nordabiz/templates/admin/gbp_audit_dashboard.html
Maciej Pienczyn 110d971dca
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: migrate prod docs to OVH VPS + UTC→Warsaw timezone in all templates
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS
(57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash
commands, memory files, architecture docs, and deploy procedures.

Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted
155 .strftime() calls across 71 templates so timestamps display
in Polish timezone regardless of server timezone.

Also includes: created_by_id tracking, abort import fix, ICS
calendar fix for missing end times, Pros Poland data cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:41:53 +02:00

967 lines
35 KiB
HTML

{% extends "base.html" %}
{% block title %}Panel Audyt GBP - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.data-source-info {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
margin-top: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--info-light, #e0f2fe);
border-radius: var(--radius);
font-size: var(--font-size-sm);
color: var(--info, #0284c7);
}
.data-source-info svg {
flex-shrink: 0;
}
.data-source-info a {
color: inherit;
font-weight: 600;
text-decoration: underline;
}
.header-actions {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
/* Summary Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
text-align: center;
}
.stat-number {
font-size: var(--font-size-2xl);
font-weight: 700;
display: block;
margin-bottom: var(--spacing-xs);
}
.stat-number.green { color: var(--success); }
.stat-number.yellow { color: var(--warning); }
.stat-number.red { color: var(--error); }
.stat-number.gray { color: var(--secondary); }
.stat-number.blue { color: var(--primary); }
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* Field Coverage Section */
.section-title {
font-size: var(--font-size-xl);
font-weight: 600;
margin-bottom: var(--spacing-md);
color: var(--text-primary);
}
.field-coverage-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.field-card {
background: white;
padding: var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.field-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-sm);
}
.field-name {
font-weight: 500;
color: var(--text-primary);
}
.field-percent {
font-weight: 700;
font-size: var(--font-size-lg);
}
.progress-bar {
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-fill.green { background: var(--success); }
.progress-fill.yellow { background: var(--warning); }
.progress-fill.red { background: var(--error); }
/* Filters */
.filters-bar {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
background: white;
padding: var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.filter-group {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.filter-group label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-weight: 500;
}
.filter-group select,
.filter-group input {
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-sm);
min-width: 150px;
}
/* Table */
.table-container {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.gbp-table {
width: 100%;
border-collapse: collapse;
}
.gbp-table th,
.gbp-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.gbp-table th {
background: var(--background);
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
text-transform: uppercase;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.gbp-table th:hover {
background: #e9ecef;
}
.gbp-table th .sort-icon {
display: inline-block;
margin-left: var(--spacing-xs);
opacity: 0.3;
}
.gbp-table th.sorted .sort-icon {
opacity: 1;
}
.gbp-table th.sorted-asc .sort-icon::after {
content: '\2191';
}
.gbp-table th.sorted-desc .sort-icon::after {
content: '\2193';
}
.gbp-table tbody tr:hover {
background: var(--background);
}
.company-name-cell {
font-weight: 500;
max-width: 250px;
}
.company-name-cell a {
color: var(--text-primary);
text-decoration: none;
}
.company-name-cell a:hover {
color: var(--primary);
}
/* Score badges */
.score-cell {
text-align: center;
font-weight: 600;
}
.score-badge {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius-sm);
min-width: 45px;
}
.score-excellent {
background: #dcfce7;
color: #166534;
}
.score-good {
background: #fef3c7;
color: #92400e;
}
.score-poor {
background: #fee2e2;
color: #991b1b;
}
.score-na {
background: var(--border);
color: var(--text-secondary);
font-style: italic;
font-weight: normal;
}
/* Rating display */
.rating-cell {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.star-icon {
color: #fbbf24;
}
/* Category badge */
.category-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
background: var(--border);
color: var(--text-secondary);
}
/* Date cell */
.date-cell {
font-size: var(--font-size-sm);
color: var(--text-secondary);
white-space: nowrap;
}
.date-old {
color: var(--warning);
}
.date-never {
color: var(--error);
font-style: italic;
}
/* Detail button */
.btn-detail {
padding: 4px 10px;
border-radius: var(--radius);
border: 1px solid var(--primary);
background: var(--primary);
color: white;
font-size: 11px;
font-weight: 600;
text-decoration: none;
transition: var(--transition);
white-space: nowrap;
}
.btn-detail:hover {
opacity: 0.9;
}
/* Action buttons */
.action-buttons {
display: flex;
gap: var(--spacing-xs);
align-items: center;
}
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
transition: var(--transition);
text-decoration: none;
color: var(--text-primary);
}
.btn-icon:hover {
background: var(--background);
border-color: var(--primary);
color: var(--primary);
}
.btn-icon.audit {
color: var(--success);
}
.btn-icon.audit:hover {
background: #dcfce7;
border-color: var(--success);
}
/* Legend */
.legend {
display: flex;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.legend-item {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 2px;
}
.legend-dot.excellent { background: #dcfce7; border: 1px solid #166534; }
.legend-dot.good { background: #fef3c7; border: 1px solid #92400e; }
.legend-dot.poor { background: #fee2e2; border: 1px solid #991b1b; }
/* Empty state */
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
/* Responsive */
@media (max-width: 1200px) {
.gbp-table {
font-size: var(--font-size-sm);
}
.hide-mobile {
display: none;
}
}
@media (max-width: 768px) {
.filters-bar {
flex-direction: column;
align-items: stretch;
}
.filter-group {
flex-direction: column;
align-items: stretch;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
}
.field-coverage-grid {
grid-template-columns: 1fr;
}
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<div>
<h1>Panel Audyt GBP</h1>
<p class="text-muted">Analiza kompletnosci profili Google Business Profile czlonkow Norda Biznes</p>
<div class="data-source-info">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Dane z <a href="https://business.google.com/" target="_blank" rel="noopener">Google Business Profile</a> (via Places API)</span>
</div>
</div>
<div class="header-actions">
<a href="{{ url_for('admin.admin_gbp_match_places') }}" class="btn btn-outline btn-sm" title="Dopasuj firmy bez Place ID do Google Maps">
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Dopasuj Place ID
</a>
<button id="gbpBatchBtn" class="btn btn-primary btn-sm" onclick="startGbpBatch()" title="Uruchom audyt GBP dla wszystkich firm">
<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
</button>
<a href="{{ url_for('api.api_gbp_audit_health') }}" class="btn btn-outline btn-sm" target="_blank">
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
API
</a>
</div>
</div>
<!-- Confirmation Modal -->
<div id="gbpBatchConfirm" style="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;">
<div style="background:white; border-radius:var(--radius-lg); padding:var(--spacing-xl); max-width:480px; width:90%; box-shadow:var(--shadow-lg);">
<h3 style="margin-bottom:var(--spacing-md);">Uruchom audyt GBP</h3>
<p style="color:var(--text-secondary); margin-bottom:var(--spacing-md);">
Audyt sprawdzi kompletnosc profili Google Business Profile dla wszystkich aktywnych firm.
Wyniki zostana zapisane automatycznie.
</p>
<div style="margin-bottom:var(--spacing-lg);">
<label style="display:flex; align-items:center; gap:var(--spacing-sm); cursor:pointer;">
<input type="checkbox" id="gbpFetchGoogle" checked>
<span style="font-size:var(--font-size-sm);">Pobierz aktualne dane z Google Places API (wolniejsze, ale dokladniejsze)</span>
</label>
</div>
<div style="display:flex; gap:var(--spacing-sm); justify-content:flex-end;">
<button class="btn btn-outline btn-sm" onclick="document.getElementById('gbpBatchConfirm').style.display='none'">Anuluj</button>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('gbpBatchConfirm').style.display='none'; doStartGbpBatch();">
<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="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
</button>
</div>
</div>
</div>
<!-- Live batch audit panel -->
<div id="gbpBatchPanel" style="display:none; margin-bottom:var(--spacing-xl); background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow-sm); overflow:hidden;">
<div style="padding:var(--spacing-md) var(--spacing-lg); background:#eff6ff; border-bottom:1px solid #bfdbfe; display:flex; align-items:center; gap:var(--spacing-md);">
<div id="gbpBatchSpinner" style="width:20px; height:20px; border:3px solid #bfdbfe; border-top-color:#2563eb; border-radius:50%; animation:spin 0.8s linear infinite;"></div>
<div style="flex:1;">
<div style="font-weight:600; color:#1e40af;" id="gbpBatchTitle">Audyt GBP w toku...</div>
<div style="font-size:var(--font-size-xs); color:#3b82f6;" id="gbpBatchSubtitle">Sprawdzanie profili Google Business Profile</div>
</div>
<div style="text-align:right;">
<div style="font-size:var(--font-size-2xl); font-weight:700; color:#1e40af;" id="gbpBatchCounter">0 / 0</div>
<div style="font-size:var(--font-size-xs); color:#3b82f6;" id="gbpBatchErrors"></div>
</div>
</div>
<div style="height:6px; background:#e0e7ff;">
<div id="gbpBatchProgressBar" style="height:100%; background:#2563eb; transition:width 0.3s; width:0%;"></div>
</div>
<div id="gbpBatchFeed" style="max-height:300px; overflow-y:auto; padding:var(--spacing-sm) var(--spacing-lg); font-size:var(--font-size-sm); font-family:monospace;"></div>
</div>
<!-- Summary Stats -->
<div class="stats-grid">
<div class="stat-card">
<span class="stat-number green">{{ stats.excellent_count }}</span>
<span class="stat-label">Doskonaly (90-100%)</span>
</div>
<div class="stat-card">
<span class="stat-number yellow">{{ stats.good_count }}</span>
<span class="stat-label">Dobry (70-89%)</span>
</div>
<div class="stat-card">
<span class="stat-number red">{{ stats.poor_count }}</span>
<span class="stat-label">Slaby (0-69%)</span>
</div>
<div class="stat-card">
<span class="stat-number gray">{{ stats.not_audited_count }}</span>
<span class="stat-label">Niezbadane</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.avg_completeness|default('-', true) }}{% if stats.avg_completeness %}<small>%</small>{% endif %}</span>
<span class="stat-label">Srednia kompletnosc</span>
</div>
<div class="stat-card">
<span class="stat-number blue">{{ stats.avg_rating|default('-', true) }}{% if stats.avg_rating %}<small>/5</small>{% endif %}</span>
<span class="stat-label">Srednia ocena Google</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.total_reviews }}</span>
<span class="stat-label">Lacznie recenzji</span>
</div>
</div>
<!-- Field Coverage Section -->
<h2 class="section-title">Pokrycie pol GBP</h2>
<div class="field-coverage-grid">
{% set fields = [
('name', 'Nazwa firmy', stats.field_coverage.name),
('address', 'Adres', stats.field_coverage.address),
('phone', 'Telefon', stats.field_coverage.phone),
('website', 'Strona WWW', stats.field_coverage.website),
('hours', 'Godziny otwarcia', stats.field_coverage.hours),
('categories', 'Kategorie', stats.field_coverage.categories),
('photos', 'Zdjecia', stats.field_coverage.photos),
('description', 'Opis', stats.field_coverage.description),
('services', 'Uslugi', stats.field_coverage.services),
('reviews', 'Recenzje', stats.field_coverage.reviews)
] %}
{% for field_id, field_name, percent in fields %}
<div class="field-card">
<div class="field-card-header">
<span class="field-name">{{ field_name }}</span>
<span class="field-percent {{ 'green' if percent >= 80 else ('yellow' if percent >= 50 else 'red') }}">{{ percent }}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill {{ 'green' if percent >= 80 else ('yellow' if percent >= 50 else 'red') }}" style="width: {{ percent }}%"></div>
</div>
</div>
{% endfor %}
</div>
<!-- Filters -->
<div class="filters-bar">
<div class="filter-group">
<label for="filterCategory">Kategoria:</label>
<select id="filterCategory" onchange="applyFilters()">
<option value="">Wszystkie</option>
{% for category in categories %}
<option value="{{ category }}">{{ category }}</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label for="filterScore">Kompletnosc:</label>
<select id="filterScore" onchange="applyFilters()">
<option value="">Wszystkie</option>
<option value="excellent">Doskonaly (90-100%)</option>
<option value="good">Dobry (70-89%)</option>
<option value="poor">Slaby (0-69%)</option>
<option value="none">Niezbadane</option>
</select>
</div>
<div class="filter-group">
<label for="filterSearch">Szukaj:</label>
<input type="text" id="filterSearch" placeholder="Nazwa firmy..." oninput="applyFilters()">
</div>
<div class="filter-group" style="margin-left: auto;">
<button class="btn btn-sm btn-outline" onclick="resetFilters()">Resetuj filtry</button>
</div>
</div>
<!-- Legend -->
<div class="legend">
<div class="legend-item">
<div class="legend-dot excellent"></div>
<span>90-100% (doskonaly)</span>
</div>
<div class="legend-item">
<div class="legend-dot good"></div>
<span>70-89% (dobry)</span>
</div>
<div class="legend-item">
<div class="legend-dot poor"></div>
<span>0-69% (slaby)</span>
</div>
</div>
<!-- Table -->
{% if companies %}
<div class="table-container">
<table class="gbp-table" id="gbpTable">
<thead>
<tr>
<th data-sort="name">
Firma <span class="sort-icon"></span>
</th>
<th data-sort="category" class="hide-mobile">
Kategoria <span class="sort-icon"></span>
</th>
<th data-sort="completeness" class="sorted sorted-desc">
Kompletnosc <span class="sort-icon"></span>
</th>
<th data-sort="rating">
Ocena <span class="sort-icon"></span>
</th>
<th data-sort="reviews" class="hide-mobile">
Recenzje <span class="sort-icon"></span>
</th>
<th data-sort="photos" class="hide-mobile">
Zdjecia <span class="sort-icon"></span>
</th>
<th data-sort="date">
Ostatni audyt <span class="sort-icon"></span>
</th>
<th>Akcje</th>
</tr>
</thead>
<tbody id="gbpTableBody">
{% for company in companies %}
<tr data-category="{{ company.category }}"
data-name="{{ company.name|lower }}"
data-completeness="{{ company.completeness_score if company.completeness_score is not none else -1 }}"
data-rating="{{ company.average_rating if company.average_rating else -1 }}"
data-reviews="{{ company.review_count }}"
data-photos="{{ company.photo_count }}"
data-date="{{ company.audit_date.isoformat() if company.audit_date else '1970-01-01' }}">
<td class="company-name-cell">
<a href="{{ url_for('company_detail', company_id=company.id) }}">{{ company.name }}</a>
</td>
<td class="hide-mobile">
<span class="category-badge">{{ company.category or 'Inne' }}</span>
</td>
<td class="score-cell">
{% if company.completeness_score is not none %}
<span class="score-badge {{ 'score-excellent' if company.completeness_score >= 90 else ('score-good' if company.completeness_score >= 70 else 'score-poor') }}">
{{ company.completeness_score }}%
</span>
{% else %}
<span class="score-badge score-na">-</span>
{% endif %}
</td>
<td>
{% if company.average_rating %}
<div class="rating-cell">
<svg class="star-icon" width="16" height="16" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<span>{{ company.average_rating }}</span>
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="hide-mobile">
{{ company.review_count }}
</td>
<td class="hide-mobile">
{{ company.photo_count }}
</td>
<td class="date-cell">
{% if company.audit_date %}
{% set days_ago = (now - company.audit_date).days %}
<span class="{{ 'date-old' if days_ago > 30 else '' }}" title="{{ company.audit_date|local_time('%Y-%m-%d %H:%M') }}">
{{ company.audit_date|local_time('%d.%m.%Y') }}
</span>
{% else %}
<span class="date-never">Nigdy</span>
{% endif %}
</td>
<td>
<div class="action-buttons">
<a href="{{ url_for('audit.gbp_audit_dashboard', slug=company.slug) }}" class="btn-detail" title="Pelne szczegoly audytu GBP">
Szczegoly
</a>
<a href="{{ url_for('company_detail', company_id=company.id) }}" class="btn-icon" title="Zobacz profil">
<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 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" opacity="0.3">
<circle cx="40" cy="40" r="30" stroke="currentColor" stroke-width="3"/>
<path d="M30 40h20M40 30v20" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
</svg>
<h3>Brak firm do wyswietlenia</h3>
<p>Nie znaleziono firm z danymi GBP.</p>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
// Sorting state
let currentSort = { column: 'completeness', direction: 'desc' };
// Sort table
function sortTable(column) {
const tbody = document.getElementById('gbpTableBody');
const rows = Array.from(tbody.querySelectorAll('tr'));
const headers = document.querySelectorAll('.gbp-table th[data-sort]');
// Toggle direction if same column
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'desc';
}
// Update header classes
headers.forEach(h => {
h.classList.remove('sorted', 'sorted-asc', 'sorted-desc');
if (h.dataset.sort === column) {
h.classList.add('sorted', `sorted-${currentSort.direction}`);
}
});
// Sort rows
rows.sort((a, b) => {
let aVal, bVal;
if (column === 'name') {
aVal = a.dataset.name || '';
bVal = b.dataset.name || '';
} else if (column === 'category') {
aVal = a.dataset.category || '';
bVal = b.dataset.category || '';
} else if (column === 'date') {
aVal = new Date(a.dataset.date).getTime();
bVal = new Date(b.dataset.date).getTime();
} else {
aVal = parseFloat(a.dataset[column]) || -1;
bVal = parseFloat(b.dataset[column]) || -1;
}
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
// Re-append rows
rows.forEach(row => tbody.appendChild(row));
}
// Setup sorting click handlers
document.querySelectorAll('.gbp-table th[data-sort]').forEach(th => {
th.addEventListener('click', () => sortTable(th.dataset.sort));
});
// Filtering
function applyFilters() {
const category = document.getElementById('filterCategory').value;
const score = document.getElementById('filterScore').value;
const search = document.getElementById('filterSearch').value.toLowerCase();
const rows = document.querySelectorAll('#gbpTableBody tr');
rows.forEach(row => {
let show = true;
// Category filter
if (category && row.dataset.category !== category) {
show = false;
}
// Score filter
if (score && show) {
const completenessScore = parseFloat(row.dataset.completeness);
if (score === 'excellent' && (completenessScore < 90 || completenessScore < 0)) show = false;
else if (score === 'good' && (completenessScore < 70 || completenessScore >= 90)) show = false;
else if (score === 'poor' && (completenessScore < 0 || completenessScore >= 70)) show = false;
else if (score === 'none' && completenessScore >= 0) show = false;
}
// Search filter
if (search && show) {
if (!row.dataset.name.includes(search)) {
show = false;
}
}
row.style.display = show ? '' : 'none';
});
}
function resetFilters() {
document.getElementById('filterCategory').value = '';
document.getElementById('filterScore').value = '';
document.getElementById('filterSearch').value = '';
applyFilters();
}
// ============================================================
// GBP Batch Audit
// ============================================================
var _gbpBatchSince = 0;
function startGbpBatch() {
document.getElementById('gbpBatchConfirm').style.display = 'flex';
}
function doStartGbpBatch() {
var btn = document.getElementById('gbpBatchBtn');
btn.disabled = true;
btn.textContent = 'Uruchamianie...';
_gbpBatchSince = 0;
var fetchGoogle = document.getElementById('gbpFetchGoogle').checked ? '1' : '0';
fetch('{{ url_for("admin.admin_gbp_audit_run_batch") }}', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': '{{ csrf_token() }}'},
body: 'fetch_google=' + fetchGoogle
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'started') {
document.getElementById('gbpBatchPanel').style.display = 'block';
document.getElementById('gbpBatchCounter').textContent = '0 / ' + data.total;
document.getElementById('gbpBatchFeed').innerHTML = '';
pollGbpBatch();
} else {
document.getElementById('gbpBatchPanel').style.display = 'block';
document.getElementById('gbpBatchTitle').textContent = data.error || 'Blad uruchamiania';
document.getElementById('gbpBatchTitle').style.color = '#dc2626';
document.getElementById('gbpBatchSpinner').style.display = 'none';
btn.disabled = false;
btn.textContent = 'Uruchom audyt';
}
})
.catch(function(e) {
document.getElementById('gbpBatchPanel').style.display = 'block';
document.getElementById('gbpBatchTitle').textContent = 'Blad polaczenia: ' + e.message;
document.getElementById('gbpBatchTitle').style.color = '#dc2626';
document.getElementById('gbpBatchSpinner').style.display = 'none';
btn.disabled = false;
btn.textContent = 'Uruchom audyt';
});
}
function pollGbpBatch() {
fetch('{{ url_for("admin.admin_gbp_audit_batch_status") }}?since=' + _gbpBatchSince)
.then(function(r) { return r.json(); })
.then(function(data) {
document.getElementById('gbpBatchCounter').textContent = data.completed + ' / ' + data.total;
document.getElementById('gbpBatchProgressBar').style.width = data.progress + '%';
if (data.errors > 0) {
document.getElementById('gbpBatchErrors').textContent = data.errors + ' bledow';
}
// Append new feed entries
var feed = document.getElementById('gbpBatchFeed');
data.results.forEach(function(r) {
var line = document.createElement('div');
line.style.padding = '3px 0';
line.style.borderBottom = '1px solid #f3f4f6';
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>' + changeInfo;
} else {
line.innerHTML = '<span style="color:#dc2626;">&#10007;</span> ' + r.company_name +
' <span style="color:#dc2626; font-size:11px;">' + (r.error || 'blad') + '</span>';
}
feed.appendChild(line);
});
feed.scrollTop = feed.scrollHeight;
_gbpBatchSince += data.results.length;
if (data.running) {
setTimeout(pollGbpBatch, 2000);
} else {
// Completed
document.getElementById('gbpBatchSpinner').style.animation = 'none';
document.getElementById('gbpBatchSpinner').style.borderColor = '#22c55e';
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;
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';
}
})
.catch(function() {
setTimeout(pollGbpBatch, 5000);
});
}
// Check if batch is already running on page load
(function() {
fetch('{{ url_for("admin.admin_gbp_audit_batch_status") }}?since=0')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.running) {
document.getElementById('gbpBatchPanel').style.display = 'block';
document.getElementById('gbpBatchCounter').textContent = data.completed + ' / ' + data.total;
document.getElementById('gbpBatchProgressBar').style.width = data.progress + '%';
document.getElementById('gbpBatchBtn').disabled = true;
document.getElementById('gbpBatchBtn').textContent = 'Audyt w toku...';
_gbpBatchSince = data.results_total;
pollGbpBatch();
}
})
.catch(function() {});
})();
{% endblock %}