nordabiz/templates/admin_seo_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

1289 lines
43 KiB
HTML

{% extends "base.html" %}
{% block title %}Panel SEO - 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;
}
.data-source-info a:hover {
text-decoration: none;
}
.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-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* 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;
}
.filter-group select:focus,
.filter-group input:focus {
outline: none;
border-color: var(--primary);
}
/* Table Container */
.table-container {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.seo-table {
width: 100%;
border-collapse: collapse;
}
.seo-table th,
.seo-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.seo-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;
}
.seo-table th:hover {
background: #e9ecef;
}
.seo-table th .sort-icon {
display: inline-block;
margin-left: var(--spacing-xs);
opacity: 0.3;
}
.seo-table th.sorted .sort-icon {
opacity: 1;
}
.seo-table th.sorted-asc .sort-icon::after {
content: '\2191';
}
.seo-table th.sorted-desc .sort-icon::after {
content: '\2193';
}
.seo-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);
}
.company-website {
font-size: var(--font-size-sm);
color: var(--text-secondary);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 200px;
display: block;
}
/* Score Cells */
.score-cell {
text-align: center;
font-weight: 600;
font-size: var(--font-size-base);
}
.score-badge {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius-sm);
min-width: 45px;
}
.score-good {
background: #dcfce7;
color: #166534;
}
.score-medium {
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;
}
/* Overall Score - larger */
.overall-score {
font-size: var(--font-size-lg);
font-weight: 700;
}
/* 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;
}
/* Action buttons */
.action-buttons {
display: flex;
gap: var(--spacing-xs);
}
.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);
}
.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;
}
/* 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);
}
/* Empty state */
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
/* 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.good { background: #dcfce7; border: 1px solid #166534; }
.legend-dot.medium { background: #fef3c7; border: 1px solid #92400e; }
.legend-dot.poor { background: #fee2e2; border: 1px solid #991b1b; }
.column-descriptions {
display: flex;
gap: var(--spacing-lg);
flex-wrap: wrap;
margin-bottom: var(--spacing-md);
font-size: var(--font-size-xs);
color: var(--text-secondary);
padding: var(--spacing-sm) var(--spacing-md);
background: #f0f9ff;
border-radius: var(--radius);
border: 1px solid #bae6fd;
}
.column-descriptions strong {
color: var(--text-primary);
}
/* Problem tags */
.problems-cell {
max-width: 160px;
}
.problem-tag {
display: inline-block;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
background: #fee2e2;
color: #991b1b;
margin: 1px 2px;
}
.warn-tag {
display: inline-block;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
background: #fef3c7;
color: #92400e;
}
.ok-tag {
display: inline-block;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
background: #dcfce7;
color: #166534;
}
.na-tag {
display: inline-block;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
background: var(--border);
color: var(--text-secondary);
font-style: italic;
}
/* Responsive */
@media (max-width: 1200px) {
.seo-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;
}
.filter-group select,
.filter-group input {
min-width: 100%;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
}
}
/* Modal styles */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
max-width: 480px;
width: 90%;
box-shadow: var(--shadow-lg);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.modal-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.modal-icon.warning {
background: #fef3c7;
color: #d97706;
}
.modal-icon.success {
background: #dcfce7;
color: #16a34a;
}
.modal-icon.info {
background: #dbeafe;
color: #2563eb;
}
.modal-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
}
.modal-body {
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: var(--spacing-lg);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
}
/* Progress Section */
.progress-section {
background: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: var(--spacing-xl);
display: none;
}
.progress-section.active {
display: block;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.progress-title {
font-size: var(--font-size-lg);
font-weight: 600;
}
.progress-bar-container {
height: 24px;
background: var(--border);
border-radius: 12px;
overflow: hidden;
margin-bottom: var(--spacing-sm);
}
.progress-bar-fill {
height: 100%;
background: var(--primary);
border-radius: 12px;
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: var(--font-size-sm);
}
.progress-message {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.progress-log {
max-height: 250px;
overflow-y: auto;
font-family: monospace;
font-size: var(--font-size-sm);
background: var(--background);
padding: var(--spacing-md);
border-radius: var(--radius);
margin-top: var(--spacing-md);
}
.progress-log-entry {
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--border);
}
.progress-log-entry.success { color: var(--success); font-weight: 600; }
.progress-log-entry.error { color: var(--error); font-weight: 600; }
.progress-log-entry.skipped { color: var(--warning, #f59e0b); }
.progress-log-entry.detail { color: var(--text-secondary); font-size: 0.9em; padding-left: 1em; border-bottom: none; }
.progress-log-entry.info { color: var(--text-secondary); font-style: italic; }
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<div>
<h1>Panel SEO</h1>
<p class="text-muted">Analiza jakosci SEO stron internetowych 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://pagespeed.web.dev/" target="_blank" rel="noopener">Google PageSpeed Insights</a> (Lighthouse)</span>
</div>
</div>
<div class="header-actions">
{% if current_user.can_access_admin_panel() %}
<button class="btn btn-primary btn-sm" onclick="runBatchAudit()" id="batchAuditBtn">
<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 wszystkich
</button>
{% endif %}
</div>
</div>
<!-- Summary Stats -->
{% set total_companies = stats.good_count + stats.medium_count + stats.poor_count + stats.not_audited_count %}
<div class="stats-grid">
<div class="stat-card">
<span class="stat-number">{{ total_companies }}</span>
<span class="stat-label">Firm ogolnie</span>
</div>
<div class="stat-card">
<span class="stat-number green">{{ stats.good_count }}</span>
<span class="stat-label">Dobre SEO (90+)</span>
</div>
<div class="stat-card">
<span class="stat-number red">{{ stats.poor_count }}</span>
<span class="stat-label">Slabe SEO (ponizej 50)</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_score|default('-', true) }}{% if stats.avg_score %}<small>/100</small>{% endif %}</span>
<span class="stat-label">Sredni wynik</span>
</div>
</div>
<!-- Progress Section (hidden by default) -->
<div class="progress-section" id="progressSection">
<div class="progress-header">
<span class="progress-title">Audyt SEO w toku...</span>
<button class="btn btn-sm btn-outline" onclick="cancelAudit()">Anuluj</button>
</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="progressBar" style="width: 0%">0%</div>
</div>
<div class="progress-message" id="progressMessage">Przygotowywanie...</div>
<div class="progress-log" id="progressLog"></div>
</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">Wynik SEO:</label>
<select id="filterScore" onchange="applyFilters()">
<option value="">Wszystkie</option>
<option value="good">Dobry (90-100)</option>
<option value="medium">Sredni (50-89)</option>
<option value="poor">Slaby (0-49)</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 & Column Descriptions -->
<div class="legend">
<div class="legend-item">
<div class="legend-dot good"></div>
<span>90-100 (dobry)</span>
</div>
<div class="legend-item">
<div class="legend-dot medium"></div>
<span>50-89 (sredni)</span>
</div>
<div class="legend-item">
<div class="legend-dot poor"></div>
<span>0-49 (slaby)</span>
</div>
</div>
<div class="column-descriptions">
<span><strong>SEO</strong> — widocznosc w wyszukiwarkach</span>
<span><strong>Performance</strong> — szybkosc ladowania</span>
<span><strong>Accessibility</strong> — dostepnosc dla osob z niepelnosprawnosciami</span>
<span><strong>Best Practices</strong> — bezpieczenstwo i standardy</span>
<span><strong>Szczegoly</strong> — pelne informacje i zalecenia co poprawic</span>
</div>
<!-- Table -->
{% if companies %}
<div class="table-container">
<table class="seo-table" id="seoTable">
<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="overall" class="sorted sorted-asc" title="Ogolna ocena SEO strony: meta tagi, struktura, indeksowalnosc">
SEO <span class="sort-icon"></span>
</th>
<th data-sort="performance" class="hide-mobile" title="Performance — szybkosc ladowania strony, czas do interakcji">
Performance <span class="sort-icon"></span>
</th>
<th data-sort="accessibility" class="hide-mobile" title="Accessibility — dostepnosc dla osob z niepelnosprawnosciami (kontrast, opisy, nawigacja klawiatura)">
Accessibility <span class="sort-icon"></span>
</th>
<th data-sort="best_practices" class="hide-mobile" title="Best Practices — bezpieczenstwo (HTTPS), nowoczesne standardy, brak bledow w konsoli">
Best Practices <span class="sort-icon"></span>
</th>
<th>Problemy</th>
<th data-sort="date">
Audyt <span class="sort-icon"></span>
</th>
<th>Akcje</th>
</tr>
</thead>
<tbody id="seoTableBody">
{% for company in companies %}
<tr data-company-id="{{ company.id }}"
data-slug="{{ company.slug }}"
data-company-name="{{ company.name }}"
data-website="{{ company.website or '' }}"
data-category="{{ company.category }}"
data-name="{{ company.name|lower }}"
data-overall="{{ company.seo_score if company.seo_score is not none else -1 }}"
data-performance="{{ company.performance_score if company.performance_score is not none else -1 }}"
data-accessibility="{{ company.accessibility_score if company.accessibility_score is not none else -1 }}"
data-best_practices="{{ company.best_practices_score if company.best_practices_score is not none else -1 }}"
data-date="{{ company.seo_audited_at.isoformat() if company.seo_audited_at else '1970-01-01' }}">
<td class="company-name-cell">
<a href="{{ url_for('company_detail', company_id=company.id) }}">{{ company.name }}</a>
{% if company.website %}
<span class="company-website" title="{{ company.website }}">{{ company.website }}</span>
{% endif %}
</td>
<td class="hide-mobile">
<span class="category-badge">{{ company.category or 'Inne' }}</span>
</td>
<td class="score-cell">
{% if company.seo_score is not none %}
<span class="score-badge overall-score {{ 'score-good' if company.seo_score >= 90 else ('score-medium' if company.seo_score >= 50 else 'score-poor') }}">
{{ company.seo_score }}
</span>
{% else %}
<span class="score-badge score-na">-</span>
{% endif %}
</td>
<td class="score-cell hide-mobile">
{% if company.performance_score is not none %}
<span class="score-badge {{ 'score-good' if company.performance_score >= 90 else ('score-medium' if company.performance_score >= 50 else 'score-poor') }}">
{{ company.performance_score }}
</span>
{% else %}
<span class="score-badge score-na">-</span>
{% endif %}
</td>
<td class="score-cell hide-mobile">
{% if company.accessibility_score is not none %}
<span class="score-badge {{ 'score-good' if company.accessibility_score >= 90 else ('score-medium' if company.accessibility_score >= 50 else 'score-poor') }}">
{{ company.accessibility_score }}
</span>
{% else %}
<span class="score-badge score-na">-</span>
{% endif %}
</td>
<td class="score-cell hide-mobile">
{% if company.best_practices_score is not none %}
<span class="score-badge {{ 'score-good' if company.best_practices_score >= 90 else ('score-medium' if company.best_practices_score >= 50 else 'score-poor') }}">
{{ company.best_practices_score }}
</span>
{% else %}
<span class="score-badge score-na">-</span>
{% endif %}
</td>
<td class="problems-cell">
{% if company.seo_score is not none %}
{% set problems = [] %}
{% if company.seo_score is not none and company.seo_score < 50 %}
{% set _ = problems.append('Slabe SEO') %}
{% endif %}
{% if company.performance_score is not none and company.performance_score < 50 %}
{% set _ = problems.append('Wolna strona') %}
{% endif %}
{% if company.accessibility_score is not none and company.accessibility_score < 50 %}
{% set _ = problems.append('Niska dostepnosc') %}
{% endif %}
{% if company.best_practices_score is not none and company.best_practices_score < 50 %}
{% set _ = problems.append('Brak standardow') %}
{% endif %}
{% if problems %}
{% for p in problems %}
<span class="problem-tag">{{ p }}</span>
{% endfor %}
{% elif company.seo_score >= 90 and (company.performance_score is none or company.performance_score >= 90) %}
<span class="ok-tag">Wszystko OK</span>
{% else %}
<span class="warn-tag">Do poprawy</span>
{% endif %}
{% elif not company.website %}
<span class="na-tag">Brak strony WWW</span>
{% else %}
<span class="na-tag">Brak danych</span>
{% endif %}
</td>
<td class="date-cell">
{% if company.seo_audited_at %}
{% set days_ago = (now - company.seo_audited_at).days %}
<span class="{{ 'date-old' if days_ago > 30 else '' }}" title="{{ company.seo_audited_at|local_time('%Y-%m-%d %H:%M') }}">
{{ company.seo_audited_at|local_time('%d.%m.%Y') }}
</span>
{% else %}
<span class="date-never">Nigdy</span>
{% endif %}
</td>
<td>
<div class="action-buttons">
<a href="{{ url_for('admin.admin_seo_detail', company_id=company.id) }}" class="btn-detail" title="Pelne szczegoly audytu">
Szczegoly
</a>
{% if current_user.can_access_admin_panel() %}
<button class="btn-icon audit" onclick="runSingleAudit('{{ company.slug }}')" title="Uruchom audyt SEO">
<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>
</button>
{% endif %}
</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 SEO.</p>
</div>
{% endif %}
<!-- Confirmation Modal -->
<div class="modal" id="confirmModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-icon warning">
<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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="modal-title" id="modalTitle">Potwierdz operacje</div>
</div>
<div class="modal-body" id="modalBody">
Czy na pewno chcesz wykonac te operacje?
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeModal()">Anuluj</button>
<button class="btn btn-primary" onclick="confirmModalAction()">Potwierdz</button>
</div>
</div>
</div>
<!-- Info Modal -->
<div class="modal" id="infoModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-icon info">
<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>
<div class="modal-title" id="infoModalTitle">Informacja</div>
</div>
<div class="modal-body" id="infoModalBody">
Tresc informacji.
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="closeInfoModal()">OK</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
// Modal functions
let pendingModalAction = null;
function showModal(title, body, onConfirm) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalBody').textContent = body;
pendingModalAction = onConfirm;
document.getElementById('confirmModal').classList.add('active');
}
function closeModal() {
document.getElementById('confirmModal').classList.remove('active');
pendingModalAction = null;
}
function confirmModalAction() {
if (pendingModalAction) {
pendingModalAction();
}
closeModal();
}
function showInfoModal(title, body) {
document.getElementById('infoModalTitle').textContent = title;
document.getElementById('infoModalBody').textContent = body;
document.getElementById('infoModal').classList.add('active');
}
function closeInfoModal() {
document.getElementById('infoModal').classList.remove('active');
}
// Close modal on backdrop click
document.getElementById('confirmModal')?.addEventListener('click', (e) => {
if (e.target.id === 'confirmModal') closeModal();
});
document.getElementById('infoModal')?.addEventListener('click', (e) => {
if (e.target.id === 'infoModal') closeInfoModal();
});
// Sorting state
let currentSort = { column: 'overall', direction: 'asc' };
// Sort table
function sortTable(column) {
const tbody = document.getElementById('seoTableBody');
const rows = Array.from(tbody.querySelectorAll('tr'));
const headers = document.querySelectorAll('.seo-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 (unaudited companies always at the bottom)
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]);
bVal = parseFloat(b.dataset[column]);
// Push unaudited (-1) to the bottom regardless of sort direction
if (aVal < 0 && bVal >= 0) return 1;
if (bVal < 0 && aVal >= 0) return -1;
if (aVal < 0 && bVal < 0) return 0;
}
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('.seo-table th[data-sort]').forEach(th => {
th.addEventListener('click', () => sortTable(th.dataset.sort));
});
// Initial sort — worst scores first
sortTable('overall');
// 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('#seoTableBody tr');
rows.forEach(row => {
let show = true;
// Category filter
if (category && row.dataset.category !== category) {
show = false;
}
// Score filter
if (score && show) {
const overallScore = parseFloat(row.dataset.overall);
if (score === 'good' && (overallScore < 90 || overallScore < 0)) show = false;
else if (score === 'medium' && (overallScore < 50 || overallScore >= 90)) show = false;
else if (score === 'poor' && (overallScore < 0 || overallScore >= 50)) show = false;
else if (score === 'none' && overallScore >= 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();
}
// Helper functions
let auditInProgress = false;
function addLogEntry(logElement, message, type) {
const entry = document.createElement('div');
entry.className = 'progress-log-entry ' + type;
entry.textContent = message;
logElement.appendChild(entry);
logElement.scrollTop = logElement.scrollHeight;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getScoreClass(score) {
if (score >= 90) return '🟢';
if (score >= 50) return '🟡';
return '🔴';
}
function cancelAudit() {
auditInProgress = false;
document.getElementById('progressSection').classList.remove('active');
document.getElementById('batchAuditBtn').disabled = false;
document.getElementById('batchAuditBtn').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
`;
}
// Audit functions
function runSingleAudit(slug) {
showModal(
'Uruchom audyt SEO',
'Czy na pewno chcesz uruchomić audyt SEO dla tej firmy? Analiza może potrwać kilka sekund.',
async () => {
try {
const response = await fetch('/api/seo/audit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ slug: slug })
});
const data = await response.json();
if (response.ok && data.success) {
showInfoModal('Audyt zakończony', 'Audyt SEO zakończony pomyślnie! Strona zostanie odświeżona.');
setTimeout(() => location.reload(), 1500);
} else {
showInfoModal('Błąd', 'Wystąpił błąd: ' + (data.error || 'Nieznany błąd'));
}
} catch (error) {
showInfoModal('Błąd połączenia', 'Nie udało się połączyć z serwerem: ' + error.message);
}
}
);
}
async function runBatchAudit() {
showModal(
'Uruchom audyt wszystkich firm',
'Czy chcesz uruchomić audyt SEO dla wszystkich firm z adresem WWW? Każda firma będzie analizowana osobno.',
async () => {
auditInProgress = true;
const btn = document.getElementById('batchAuditBtn');
btn.disabled = true;
btn.innerHTML = '<span>Audyt w toku...</span>';
const progressSection = document.getElementById('progressSection');
progressSection.classList.add('active');
const progressBar = document.getElementById('progressBar');
const progressMessage = document.getElementById('progressMessage');
const progressLog = document.getElementById('progressLog');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressMessage.textContent = 'Pobieranie listy firm...';
progressLog.innerHTML = '';
// Get all companies with website from the table
const rows = document.querySelectorAll('#seoTableBody tr[data-slug]');
const companies = [];
rows.forEach(row => {
const website = row.dataset.website;
if (website && website.trim() !== '') {
companies.push({
slug: row.dataset.slug,
name: row.dataset.companyName || row.dataset.name,
website: website
});
}
});
if (companies.length === 0) {
progressSection.classList.remove('active');
showInfoModal('Brak firm', 'Nie znaleziono firm z adresem WWW do audytu.');
auditInProgress = false;
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
`;
return;
}
addLogEntry(progressLog, `Znaleziono ${companies.length} firm z adresem WWW`, 'info');
let success = 0;
let failed = 0;
let skipped = 0;
for (let i = 0; i < companies.length; i++) {
if (!auditInProgress) {
addLogEntry(progressLog, 'Audyt anulowany przez użytkownika', 'error');
break;
}
const company = companies[i];
const percent = Math.round(((i + 1) / companies.length) * 100);
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
progressMessage.textContent = `[${i + 1}/${companies.length}] Analizuję: ${company.name}...`;
try {
const response = await fetch('/api/seo/audit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ slug: company.slug })
});
const data = await response.json();
if (response.ok && data.success) {
success++;
const scores = data.seo_audit?.pagespeed || {};
const seo = scores.seo_score ?? '-';
const perf = scores.performance_score ?? '-';
const acc = scores.accessibility_score ?? '-';
const bp = scores.best_practices_score ?? '-';
addLogEntry(progressLog, ` ${company.name}`, 'success');
addLogEntry(progressLog, ` SEO: ${seo !== '-' ? getScoreClass(seo) + ' ' + seo : '-'} | Perf: ${perf !== '-' ? getScoreClass(perf) + ' ' + perf : '-'} | Dostępność: ${acc !== '-' ? getScoreClass(acc) + ' ' + acc : '-'} | BP: ${bp !== '-' ? getScoreClass(bp) + ' ' + bp : '-'}`, 'detail');
} else {
if (data.error && data.error.includes('Brak strony WWW')) {
skipped++;
addLogEntry(progressLog, ` ${company.name} - Brak WWW`, 'skipped');
} else {
failed++;
addLogEntry(progressLog, ` ${company.name} - ${data.error || 'Błąd'}`, 'error');
}
}
} catch (error) {
failed++;
addLogEntry(progressLog, ` ${company.name} - Błąd połączenia`, 'error');
}
// Delay between requests to avoid overwhelming the API
await sleep(500);
}
progressBar.style.width = '100%';
progressBar.textContent = '100%';
progressMessage.textContent = `Audyt zakończony! Sukces: ${success}, Błędy: ${failed}, Pominięte: ${skipped}`;
addLogEntry(progressLog, ``, 'info');
addLogEntry(progressLog, `PODSUMOWANIE: Sukces: ${success}, Błędy: ${failed}, Pominięte: ${skipped}`, 'info');
showInfoModal(
'Audyt zakończony',
`Przetworzono ${companies.length} firm.\n\nSukces: ${success}\nBłędy: ${failed}\nPominięte: ${skipped}`
);
auditInProgress = false;
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
`;
// Refresh page after a delay
setTimeout(() => location.reload(), 3000);
}
);
}
{% endblock %}