nordabiz/templates/admin/data_quality_dashboard.html
Maciej Pienczyn 19c31876b2
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: show rejected domains per company in discovery dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 10:29:43 +01:00

1372 lines
54 KiB
HTML

{% extends "base.html" %}
{% block title %}Jakość danych - Admin{% endblock %}
{% block extra_css %}
<style>
.dq-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.dq-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.dq-header p {
color: var(--text-secondary);
}
.dq-timestamp {
text-align: right;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-md) var(--spacing-lg);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* --- Stat Cards --- */
.dq-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.dq-stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: var(--spacing-lg);
text-align: center;
position: relative;
overflow: hidden;
}
.dq-stat-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 4px;
}
.dq-stat-card.total::before { background: linear-gradient(90deg, #3b82f6, #8b5cf6); }
.dq-stat-card.avg::before { background: linear-gradient(90deg, #10b981, #14b8a6); }
.dq-stat-card.complete::before { background: linear-gradient(90deg, #22c55e, #16a34a); }
.dq-stat-card.incomplete::before { background: linear-gradient(90deg, #f59e0b, #f97316); }
.dq-stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--text-primary);
line-height: 1;
margin-bottom: var(--spacing-xs);
}
.dq-stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* --- Coverage Bars --- */
.dq-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
}
.dq-section-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
}
.dq-bar-row {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.dq-bar-label {
width: 140px;
font-size: var(--font-size-sm);
color: var(--text-secondary);
text-align: right;
flex-shrink: 0;
}
.dq-bar-track {
flex: 1;
height: 24px;
background: var(--background);
border-radius: var(--radius);
overflow: hidden;
position: relative;
}
.dq-bar-fill {
height: 100%;
border-radius: var(--radius);
transition: width 0.5s ease;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: var(--spacing-sm);
font-size: var(--font-size-xs);
font-weight: 600;
color: white;
min-width: 40px;
}
.dq-bar-fill.high { background: linear-gradient(90deg, #22c55e, #16a34a); }
.dq-bar-fill.medium { background: linear-gradient(90deg, #f59e0b, #d97706); }
.dq-bar-fill.low { background: linear-gradient(90deg, #ef4444, #dc2626); }
.dq-bar-count {
width: 80px;
font-size: var(--font-size-sm);
color: var(--text-secondary);
flex-shrink: 0;
}
/* --- Distribution --- */
.dq-dist-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-md);
}
.dq-dist-card {
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
text-align: center;
}
.dq-dist-card.basic { background: #fef2f2; border: 1px solid #fecaca; }
.dq-dist-card.enhanced { background: #fffbeb; border: 1px solid #fde68a; }
.dq-dist-card.complete { background: #f0fdf4; border: 1px solid #bbf7d0; }
.dq-dist-value {
font-size: var(--font-size-2xl);
font-weight: 700;
}
.dq-dist-card.basic .dq-dist-value { color: #dc2626; }
.dq-dist-card.enhanced .dq-dist-value { color: #d97706; }
.dq-dist-card.complete .dq-dist-value { color: #16a34a; }
.dq-dist-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
/* --- Score Distribution --- */
.dq-score-dist {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-md);
margin-top: var(--spacing-lg);
}
.dq-score-bucket {
text-align: center;
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius-lg);
}
.dq-score-bucket-value {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--text-primary);
}
.dq-score-bucket-label {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
/* --- Companies Table --- */
.dq-table-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.dq-filter-select {
padding: var(--spacing-xs) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
font-size: var(--font-size-sm);
color: var(--text-primary);
}
.dq-table {
width: 100%;
border-collapse: collapse;
}
.dq-table th {
text-align: left;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-xs);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--border);
cursor: pointer;
user-select: none;
}
.dq-table th:hover {
color: var(--text-primary);
}
.dq-table td {
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-sm);
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.dq-table tr:hover {
background: var(--background);
}
.dq-score-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 2px 10px;
border-radius: 999px;
font-size: var(--font-size-xs);
font-weight: 600;
}
.dq-score-badge.high { background: #dcfce7; color: #166534; }
.dq-score-badge.medium { background: #fef9c3; color: #854d0e; }
.dq-score-badge.low { background: #fee2e2; color: #991b1b; }
.dq-quality-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius);
font-size: var(--font-size-xs);
font-weight: 500;
}
.dq-quality-badge.basic { background: #fee2e2; color: #991b1b; }
.dq-quality-badge.enhanced { background: #fef9c3; color: #854d0e; }
.dq-quality-badge.complete { background: #dcfce7; color: #166534; }
.dq-field-dots {
display: flex;
gap: 3px;
flex-wrap: wrap;
}
.dq-field-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dq-field-dot.filled { background: #22c55e; }
.dq-field-dot.empty { background: #e5e7eb; }
.dq-company-link {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.dq-company-link:hover {
text-decoration: underline;
}
.dq-bulk-bar {
display: none;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
background: var(--primary);
color: white;
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-md);
}
.dq-bulk-bar.active {
display: flex;
}
.dq-bulk-btn {
padding: var(--spacing-xs) var(--spacing-md);
background: white;
color: var(--primary);
border: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
}
.dq-bulk-btn:hover {
background: #f0f0f0;
}
/* Pagination */
.dq-pagination {
display: flex;
justify-content: center;
gap: var(--spacing-xs);
margin-top: var(--spacing-lg);
}
.dq-page-btn {
padding: var(--spacing-xs) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text-primary);
font-size: var(--font-size-sm);
cursor: pointer;
}
.dq-page-btn.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.dq-page-btn:hover:not(.active) {
background: var(--background);
}
/* Bar row click & active state */
.dq-bar-row:hover {
background: var(--background);
border-radius: var(--radius);
}
.dq-bar-row.dq-bar-active {
background: var(--background);
border-radius: var(--radius);
box-shadow: inset 3px 0 0 var(--primary);
}
/* Stale data badge */
.dq-stale-badge {
background: #fef9c3;
color: #854d0e;
font-size: var(--font-size-xs);
padding: 1px 6px;
border-radius: var(--radius);
}
/* Quick action buttons */
.dq-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text-secondary);
cursor: pointer;
padding: 0;
transition: all 0.15s;
}
.dq-action-btn:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
background: #eff6ff;
}
.dq-action-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.dq-action-btn.loading {
animation: dq-spin 1s linear infinite;
}
@keyframes dq-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.dq-actions-cell {
display: flex;
gap: 4px;
}
/* Field filter reset */
.dq-field-filter-info {
display: none;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: var(--radius);
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm);
color: #1e40af;
}
.dq-field-filter-info.active {
display: flex;
}
.dq-field-filter-reset {
margin-left: auto;
padding: 2px 8px;
border: 1px solid #93c5fd;
border-radius: var(--radius);
background: white;
color: #2563eb;
cursor: pointer;
font-size: var(--font-size-xs);
}
.dq-field-filter-reset:hover {
background: #dbeafe;
}
/* Discovery badges */
.disc-badge {
display: inline-block;
padding: 1px 6px;
border-radius: var(--radius);
font-size: var(--font-size-xs);
font-weight: 500;
}
.disc-badge.disc-match {
background: #dcfce7;
color: #166534;
}
.disc-badge.disc-miss {
background: #f3f4f6;
color: #9ca3af;
}
.disc-snippet-row td {
border-bottom: 1px solid var(--border);
}
.disc-snippet {
font-size: var(--font-size-xs);
color: var(--text-secondary);
background: var(--background);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
line-height: 1.4;
max-height: 60px;
overflow: hidden;
cursor: pointer;
transition: max-height 0.3s;
}
.disc-snippet.expanded {
max-height: 300px;
overflow-y: auto;
}
/* Responsive */
@media (max-width: 768px) {
.dq-bar-label { width: 100px; font-size: var(--font-size-xs); }
.dq-stats-grid { grid-template-columns: repeat(2, 1fr); }
.dq-score-dist { grid-template-columns: repeat(2, 1fr); }
.dq-table { font-size: var(--font-size-xs); }
.dq-table td, .dq-table th { padding: var(--spacing-xs); }
}
</style>
{% endblock %}
{% block content %}
<div class="dq-header">
<div>
<h1>Jakość danych firm</h1>
<p>Przegląd kompletności i jakości danych {{ total }} firm w katalogu</p>
</div>
<div class="dq-timestamp">
Stan na {{ now.strftime('%d.%m.%Y, %H:%M') }}
</div>
</div>
<!-- Stat Cards -->
<div class="dq-stats-grid">
<div class="dq-stat-card total">
<div class="dq-stat-value">{{ total }}</div>
<div class="dq-stat-label">Firm w katalogu</div>
</div>
<div class="dq-stat-card avg">
<div class="dq-stat-value">{{ avg_score }}%</div>
<div class="dq-stat-label">Średnia kompletność</div>
</div>
<div class="dq-stat-card complete">
<div class="dq-stat-value">{{ quality_dist.get('complete', 0) }}</div>
<div class="dq-stat-label">Kompletnych (67%+)</div>
</div>
<div class="dq-stat-card incomplete">
<div class="dq-stat-value">{{ quality_dist.get('basic', 0) }}</div>
<div class="dq-stat-label">Podstawowych (&lt;34%)</div>
</div>
</div>
<!-- Field Coverage -->
<div class="dq-section">
<div class="dq-section-title">Pokrycie danych per pole</div>
{% for field_name, stats in field_stats.items() %}
<div class="dq-bar-row" data-field="{{ field_name }}" onclick="filterByField('{{ field_name }}')" style="cursor: pointer;" title="Kliknij aby filtrować firmy bez tego pola">
<div class="dq-bar-label">{{ field_name }}</div>
<div class="dq-bar-track">
<div class="dq-bar-fill {% if stats.pct >= 70 %}high{% elif stats.pct >= 40 %}medium{% else %}low{% endif %}"
style="width: {{ stats.pct }}%">
{{ stats.pct }}%
</div>
</div>
<div class="dq-bar-count">{{ stats.count }}/{{ total }}</div>
</div>
{% endfor %}
</div>
<!-- Quality Distribution -->
<div class="dq-section">
<div class="dq-section-title">Rozkład jakości danych</div>
<div class="dq-dist-grid">
<div class="dq-dist-card basic">
<div class="dq-dist-value">{{ quality_dist.get('basic', 0) }}</div>
<div class="dq-dist-label">Podstawowe (&lt;34%)</div>
</div>
<div class="dq-dist-card enhanced">
<div class="dq-dist-value">{{ quality_dist.get('enhanced', 0) }}</div>
<div class="dq-dist-label">Rozszerzone (34-66%)</div>
</div>
<div class="dq-dist-card complete">
<div class="dq-dist-value">{{ quality_dist.get('complete', 0) }}</div>
<div class="dq-dist-label">Kompletne (67%+)</div>
</div>
</div>
<div class="dq-score-dist">
<div class="dq-score-bucket">
<div class="dq-score-bucket-value">{{ score_dist.get('0-25', 0) }}</div>
<div class="dq-score-bucket-label">0-25%</div>
</div>
<div class="dq-score-bucket">
<div class="dq-score-bucket-value">{{ score_dist.get('26-50', 0) }}</div>
<div class="dq-score-bucket-label">26-50%</div>
</div>
<div class="dq-score-bucket">
<div class="dq-score-bucket-value">{{ score_dist.get('51-75', 0) }}</div>
<div class="dq-score-bucket-label">51-75%</div>
</div>
<div class="dq-score-bucket">
<div class="dq-score-bucket-value">{{ score_dist.get('76-100', 0) }}</div>
<div class="dq-score-bucket-label">76-100%</div>
</div>
</div>
</div>
<!-- Available Data Section -->
{% if available_data %}
<div class="dq-section">
<div class="dq-section-title">Dane gotowe do uzupełnienia ({{ available_data|length }})</div>
<p style="color: var(--text-secondary); font-size: var(--font-size-sm); margin-bottom: var(--spacing-md);">
Poniższe dane zostały znalezione w Google Business Profile, ale nie są jeszcze w profilu firmy.
</p>
<div style="margin-bottom: var(--spacing-md);">
<button class="dq-bulk-btn" onclick="applyAllAvailableHints()"
style="background: var(--primary); color: white; padding: var(--spacing-sm) var(--spacing-lg); border-radius: var(--radius);">
Uzupełnij wszystkie ({{ available_data|length }})
</button>
</div>
<table class="dq-table" id="availableDataTable">
<thead>
<tr>
<th>Firma</th>
<th>Pole</th>
<th>Źródło</th>
<th>Wartość</th>
<th style="width: 100px">Akcja</th>
</tr>
</thead>
<tbody>
{% for item in available_data %}
<tr id="avail-row-{{ loop.index }}">
<td><a href="{{ url_for('admin.admin_company_detail', company_id=item.company_id) }}" class="dq-company-link">{{ item.company_name }}</a></td>
<td>{{ item.field }}</td>
<td>
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">{{ item.source }}</span>
{% if item.google_name and item.google_name.lower() != item.company_name.lower() %}
<br><span style="font-size: var(--font-size-xs); color: #d97706;" title="Nazwa profilu Google — zweryfikuj dopasowanie">Profil: {{ item.google_name[:40] }}</span>
{% endif %}
</td>
<td style="font-size: var(--font-size-sm);">{{ item.value[:50] }}</td>
<td>
<button class="hint-apply-btn" onclick="applyAvailableHint({{ item.company_id }}, '{{ item.field }}', '{{ item.value|e }}', 'avail-row-{{ loop.index }}')"
style="padding: 2px 10px; font-size: var(--font-size-xs); background: var(--primary); color: white; border: none; border-radius: var(--radius); cursor: pointer;">
Uzupełnij
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Website Discovery Section -->
<div class="dq-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-lg);">
<div class="dq-section-title" style="margin-bottom: 0;">
Odkryte strony WWW
{% if discovery_data %}
<span style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 400;">({{ discovery_data|length }} kandydatów)</span>
{% endif %}
</div>
<div style="display: flex; gap: var(--spacing-sm); align-items: center;">
{% if companies_without_website > 0 %}
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">{{ companies_without_website }} firm bez WWW</span>
{% endif %}
<button onclick="discoverWebsitesBulk()" class="dq-bulk-btn"
style="background: var(--primary); color: white; padding: var(--spacing-sm) var(--spacing-lg); border-radius: var(--radius);">
Szukaj WWW
</button>
</div>
</div>
{% if discovery_data %}
<table class="dq-table" id="discoveryTable">
<thead>
<tr>
<th style="width: 5px;"></th>
<th>Firma</th>
<th>Strona</th>
<th>Dopasowania</th>
<th style="width: 80px;">Akcja</th>
</tr>
</thead>
<tbody>
{% for d in discovery_data %}
<tr id="disc-row-{{ d.id }}" style="border-left: 3px solid {% if d.confidence == 'high' %}#22c55e{% elif d.confidence == 'medium' %}#f59e0b{% else %}#d1d5db{% endif %};">
<td></td>
<td>
<a href="{{ url_for('admin.admin_company_detail', company_id=d.company_id) }}" class="dq-company-link">{{ d.company_name }}</a>
{% if d.brave_description %}
<br><span style="font-size: var(--font-size-xs); color: var(--text-secondary);">{{ d.brave_description }}</span>
{% endif %}
</td>
<td>
<a href="{{ d.url }}" target="_blank" style="color: var(--primary); text-decoration: none; font-weight: 500;">{{ d.domain }}</a>
{% if d.title %}
<br><span style="font-size: var(--font-size-xs); color: var(--text-secondary);">{{ d.title[:80] }}</span>
{% endif %}
</td>
<td>
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
{% if d.has_nip %}
<span class="disc-badge {% if d.match_nip %}disc-match{% else %}disc-miss{% endif %}">NIP</span>
{% endif %}
{% if d.has_regon %}
<span class="disc-badge {% if d.match_regon %}disc-match{% else %}disc-miss{% endif %}">REGON</span>
{% endif %}
{% if d.has_krs %}
<span class="disc-badge {% if d.match_krs %}disc-match{% else %}disc-miss{% endif %}">KRS</span>
{% endif %}
{% if d.has_phone %}
<span class="disc-badge {% if d.match_phone %}disc-match{% else %}disc-miss{% endif %}">Tel</span>
{% endif %}
{% if d.has_email %}
<span class="disc-badge {% if d.match_email %}disc-match{% else %}disc-miss{% endif %}">Email</span>
{% endif %}
{% if d.has_city %}
<span class="disc-badge {% if d.match_city %}disc-match{% else %}disc-miss{% endif %}">Miasto</span>
{% endif %}
{% if d.has_owner %}
<span class="disc-badge {% if d.match_owner %}disc-match{% else %}disc-miss{% endif %}">Właściciel</span>
{% endif %}
<span class="disc-badge {% if d.match_domain %}disc-match{% else %}disc-miss{% endif %}">Domena</span>
{% if d.match_geo == 'wejherowo' %}
<span class="disc-badge disc-match">Wejherowo</span>
{% elif d.match_geo == 'powiat' %}
<span class="disc-badge disc-match" style="background: #fef3c7; color: #92400e;">Powiat</span>
{% elif d.match_geo == 'pomorskie' %}
<span class="disc-badge disc-match" style="background: #e0e7ff; color: #3730a3;">Pomorskie</span>
{% else %}
<span class="disc-badge disc-miss">Lokalizacja</span>
{% endif %}
</div>
</td>
<td>
<div style="display: flex; gap: 4px;">
<button class="dq-action-btn" style="background: #dcfce7; border-color: #86efac; color: #166534;" title="Zatwierdź" onclick="acceptDiscovery({{ d.id }}, 'disc-row-{{ d.id }}')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>
</button>
<button class="dq-action-btn" style="background: #fee2e2; border-color: #fca5a5; color: #991b1b;" title="Odrzuć" onclick="rejectDiscovery({{ d.id }}, 'disc-row-{{ d.id }}')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</td>
</tr>
{% if d.snippet %}
<tr class="disc-snippet-row" style="border-left: 3px solid {% if d.confidence == 'high' %}#22c55e{% elif d.confidence == 'medium' %}#f59e0b{% else %}#d1d5db{% endif %};">
<td></td>
<td colspan="4" style="padding-top: 0;">
<div class="disc-snippet">{{ d.snippet }}</div>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-secondary); font-size: var(--font-size-sm);">Brak kandydatów. Kliknij "Szukaj WWW" aby uruchomić wyszukiwanie.</p>
{% endif %}
{% if rejected_companies %}
<div style="margin-top: var(--spacing-lg); padding: var(--spacing-md); background: #fef2f2; border: 1px solid #fecaca; border-radius: var(--radius);">
<div style="font-size: var(--font-size-sm); color: #991b1b; font-weight: 500; margin-bottom: 8px;">
Odrzucone przez admina ({{ rejected_companies|length }} firm):
</div>
<div style="font-size: var(--font-size-xs); color: #b91c1c;">
{% for rc in rejected_companies %}
<div style="margin-bottom: 4px;">
<strong>{{ rc.company_name }}</strong>
{% if rc.domains %}
<span style="color: #9ca3af;"> &rarr; </span>
<span style="color: #6b7280;">{{ rc.domains|join(', ') }}</span>
{% endif %}
</div>
{% endfor %}
</div>
<div style="font-size: var(--font-size-xs); color: #991b1b; margin-top: 8px;">
Odrzucone domeny nie pojawią się ponownie w propozycjach.
</div>
</div>
{% endif %}
</div>
<!-- Bulk Discovery Modal -->
<div id="discoveryModal" 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: var(--surface); border-radius: var(--radius-xl); padding: var(--spacing-xl); max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto;">
<h3 style="margin-bottom: var(--spacing-lg);">Wyszukiwanie stron WWW</h3>
<p style="color: var(--text-secondary); margin-bottom: var(--spacing-lg);">
Szukam stron internetowych dla firm bez uzupełnionej strony WWW...
</p>
<div style="display: flex; justify-content: space-between; margin-bottom: var(--spacing-sm);">
<span style="font-weight: 600;">Postęp</span>
<span id="discProgressText">0/0</span>
</div>
<div style="height: 8px; background: var(--background); border-radius: 4px; overflow: hidden;">
<div id="discProgressBar" style="height: 100%; background: var(--primary); border-radius: 4px; transition: width 0.3s; width: 0%;"></div>
</div>
<div id="discProgressLog" style="margin-top: var(--spacing-md); max-height: 250px; overflow-y: auto; font-size: var(--font-size-xs); font-family: monospace; color: var(--text-secondary);"></div>
<div style="margin-top: var(--spacing-lg); text-align: right;">
<button id="discCloseBtn" onclick="closeDiscoveryModal()" style="display: none; padding: var(--spacing-sm) var(--spacing-lg); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); cursor: pointer;">Zamknij</button>
</div>
</div>
</div>
<!-- Companies Table -->
<div class="dq-section">
<div class="dq-section-title">Firmy wg kompletności danych</div>
<!-- Bulk action bar -->
<div class="dq-bulk-bar" id="bulkBar">
<span id="selectedCount">0</span> zaznaczonych
<button class="dq-bulk-btn" onclick="openBulkEnrich()">Uzbrój zaznaczone</button>
<button class="dq-bulk-btn" onclick="clearSelection()" style="background: transparent; color: white; border: 1px solid rgba(255,255,255,0.5);">Odznacz</button>
</div>
<div class="dq-field-filter-info" id="fieldFilterInfo">
<span>Filtr pola: <strong id="fieldFilterName"></strong> — firmy bez tego pola</span>
<button class="dq-field-filter-reset" onclick="resetFieldFilter()">Pokaż wszystkie</button>
</div>
<div class="dq-table-controls">
<div>
<select class="dq-filter-select" id="qualityFilter" onchange="filterTable()">
<option value="all">Wszystkie poziomy</option>
<option value="basic">Podstawowe</option>
<option value="enhanced">Rozszerzone</option>
<option value="complete">Kompletne</option>
</select>
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">
Pokazano <span id="shownCount">{{ companies_table|length }}</span> z {{ total }} firm
</div>
</div>
<table class="dq-table" id="companiesTable">
<thead>
<tr>
<th style="width: 30px"><input type="checkbox" id="selectAll" onchange="toggleSelectAll()"></th>
<th onclick="sortTable(1)">Firma</th>
<th onclick="sortTable(2)" style="width: 100px">Score</th>
<th onclick="sortTable(3)" style="width: 80px">Pola</th>
<th style="width: 130px">Kompletność</th>
<th onclick="sortTable(6)" style="width: 100px">Jakość</th>
<th style="width: 90px">Akcje</th>
</tr>
</thead>
<tbody>
{% for c in companies_table %}
<tr data-quality="{{ c.label }}" data-fields='{{ c.fields|tojson }}' data-nip="{{ c.nip }}" data-website="{{ c.website }}" data-id="{{ c.id }}">
<td><input type="checkbox" class="company-cb" value="{{ c.id }}"></td>
<td>
<a href="{{ url_for('admin.admin_company_detail', company_id=c.id) }}" class="dq-company-link">
{{ c.name }}
</a>
</td>
<td>
<span class="dq-score-badge {% if c.score >= 67 %}high{% elif c.score >= 34 %}medium{% else %}low{% endif %}">
{{ c.score }}%
</span>
{% if c.registry_stale %}
<span class="dq-stale-badge" title="Dane z rejestru starsze niż 6 mcy">Dane stare</span>
{% endif %}
</td>
<td>{{ c.filled }}/{{ c.total }}</td>
<td>
<div class="dq-field-dots" title="{% for fname, fval in c.fields.items() %}{{ fname }}: {{ 'tak' if fval else 'nie' }}&#10;{% endfor %}">
{% for fname, fval in c.fields.items() %}
<span class="dq-field-dot {{ 'filled' if fval else 'empty' }}" title="{{ fname }}" data-field="{{ fname }}"></span>
{% endfor %}
</div>
</td>
<td>
<span class="dq-quality-badge {{ c.label }}">
{% if c.label == 'basic' %}Podstawowe{% elif c.label == 'enhanced' %}Rozszerzone{% else %}Kompletne{% endif %}
</span>
</td>
<td>
<div class="dq-actions-cell">
{% if not c.fields['Dane urzędowe'] and c.nip %}
<button class="dq-action-btn" title="Pobierz dane z rejestru" onclick="quickAction(this, 'registry', {{ c.id }})">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</button>
{% else %}
<button class="dq-action-btn" disabled title="Rejestr {% if c.fields['Dane urzędowe'] %}wykonany{% else %}brak NIP{% endif %}">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</button>
{% endif %}
{% if not c.fields['Audyt SEO'] and c.website %}
<button class="dq-action-btn" title="Uruchom audyt SEO" onclick="quickAction(this, 'seo', {{ c.id }})">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</button>
{% else %}
<button class="dq-action-btn" disabled title="SEO {% if c.fields['Audyt SEO'] %}wykonany{% else %}brak strony{% endif %}">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</button>
{% endif %}
{% if not c.fields['Audyt GBP'] %}
<button class="dq-action-btn" title="Uruchom audyt GBP" onclick="quickAction(this, 'gbp', {{ c.id }})">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path 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 d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</button>
{% else %}
<button class="dq-action-btn" disabled title="GBP wykonany">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path 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 d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Bulk Enrich Modal -->
<div id="bulkModal" 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: var(--surface); border-radius: var(--radius-xl); padding: var(--spacing-xl); max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto;">
<h3 style="margin-bottom: var(--spacing-lg);">Uzbrój zaznaczone firmy</h3>
<p style="color: var(--text-secondary); margin-bottom: var(--spacing-lg);">
Wybierz kroki enrichmentu do wykonania dla <strong id="modalCount">0</strong> firm:
</p>
<div style="display: flex; flex-direction: column; gap: var(--spacing-sm); margin-bottom: var(--spacing-xl);">
<label style="display: flex; align-items: center; gap: var(--spacing-sm); cursor: pointer;">
<input type="checkbox" id="step-registry" checked> Dane z rejestrów (CEIDG/KRS)
</label>
<label style="display: flex; align-items: center; gap: var(--spacing-sm); cursor: pointer;">
<input type="checkbox" id="step-seo" checked> Audyt SEO
</label>
<label style="display: flex; align-items: center; gap: var(--spacing-sm); cursor: pointer;">
<input type="checkbox" id="step-social" checked> Audyt Social Media
</label>
<label style="display: flex; align-items: center; gap: var(--spacing-sm); cursor: pointer;">
<input type="checkbox" id="step-gbp" checked> Audyt GBP
</label>
<label style="display: flex; align-items: center; gap: var(--spacing-sm); cursor: pointer;">
<input type="checkbox" id="step-logo"> Pobierz logo
</label>
</div>
<div style="display: flex; gap: var(--spacing-md); justify-content: flex-end;">
<button onclick="closeBulkModal()" style="padding: var(--spacing-sm) var(--spacing-lg); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); cursor: pointer;">Anuluj</button>
<button onclick="startBulkEnrich()" style="padding: var(--spacing-sm) var(--spacing-lg); border: none; border-radius: var(--radius); background: var(--primary); color: white; font-weight: 600; cursor: pointer;">Rozpocznij</button>
</div>
<!-- Progress section -->
<div id="bulkProgress" style="display: none; margin-top: var(--spacing-xl); padding-top: var(--spacing-lg); border-top: 1px solid var(--border);">
<div style="display: flex; justify-content: space-between; margin-bottom: var(--spacing-sm);">
<span style="font-weight: 600;">Postęp</span>
<span id="progressText">0/0</span>
</div>
<div style="height: 8px; background: var(--background); border-radius: 4px; overflow: hidden;">
<div id="progressBar" style="height: 100%; background: var(--primary); border-radius: 4px; transition: width 0.3s; width: 0%;"></div>
</div>
<div id="progressLog" style="margin-top: var(--spacing-md); max-height: 200px; overflow-y: auto; font-size: var(--font-size-xs); font-family: monospace; color: var(--text-secondary);"></div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
// Data Quality Dashboard JS
function filterTable() {
applyFilters();
}
function sortTable(colIdx) {
var table = document.getElementById('companiesTable');
var tbody = table.querySelector('tbody');
var rows = Array.from(tbody.querySelectorAll('tr'));
var asc = table.dataset.sortCol == colIdx && table.dataset.sortDir !== 'asc';
table.dataset.sortCol = colIdx;
table.dataset.sortDir = asc ? 'asc' : 'desc';
rows.sort(function(a, b) {
var aVal = a.cells[colIdx].textContent.trim().replace('%', '');
var bVal = b.cells[colIdx].textContent.trim().replace('%', '');
var aNum = parseFloat(aVal);
var bNum = parseFloat(bVal);
if (!isNaN(aNum) && !isNaN(bNum)) {
return asc ? aNum - bNum : bNum - aNum;
}
return asc ? aVal.localeCompare(bVal, 'pl') : bVal.localeCompare(aVal, 'pl');
});
rows.forEach(function(row) { tbody.appendChild(row); });
}
// Checkbox selection
function toggleSelectAll() {
var checked = document.getElementById('selectAll').checked;
document.querySelectorAll('.company-cb').forEach(function(cb) {
var row = cb.closest('tr');
if (row.style.display !== 'none') {
cb.checked = checked;
}
});
updateBulkBar();
}
document.addEventListener('change', function(e) {
if (e.target.classList.contains('company-cb')) {
updateBulkBar();
}
});
function updateBulkBar() {
var selected = document.querySelectorAll('.company-cb:checked').length;
var bar = document.getElementById('bulkBar');
document.getElementById('selectedCount').textContent = selected;
if (selected > 0) {
bar.classList.add('active');
} else {
bar.classList.remove('active');
}
}
function clearSelection() {
document.querySelectorAll('.company-cb').forEach(function(cb) { cb.checked = false; });
document.getElementById('selectAll').checked = false;
updateBulkBar();
}
// Bulk enrich modal
function openBulkEnrich() {
var selected = document.querySelectorAll('.company-cb:checked').length;
document.getElementById('modalCount').textContent = selected;
document.getElementById('bulkModal').style.display = 'flex';
document.getElementById('bulkProgress').style.display = 'none';
}
function closeBulkModal() {
document.getElementById('bulkModal').style.display = 'none';
}
function startBulkEnrich() {
var companyIds = [];
document.querySelectorAll('.company-cb:checked').forEach(function(cb) {
companyIds.push(parseInt(cb.value));
});
var steps = [];
if (document.getElementById('step-registry').checked) steps.push('registry');
if (document.getElementById('step-seo').checked) steps.push('seo');
if (document.getElementById('step-social').checked) steps.push('social');
if (document.getElementById('step-gbp').checked) steps.push('gbp');
if (document.getElementById('step-logo').checked) steps.push('logo');
if (companyIds.length === 0 || steps.length === 0) return;
document.getElementById('bulkProgress').style.display = 'block';
document.getElementById('progressText').textContent = '0/' + companyIds.length;
document.getElementById('progressLog').innerHTML = '';
fetch('/admin/data-quality/bulk-enrich', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''},
body: JSON.stringify({company_ids: companyIds, steps: steps})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.job_id) {
pollProgress(data.job_id, companyIds.length);
}
})
.catch(function(err) {
document.getElementById('progressLog').innerHTML += '<div style="color: #ef4444;">Błąd: ' + err.message + '</div>';
});
}
// --- A1: Filter by field ---
var activeFieldFilter = null;
function filterByField(fieldName) {
// Toggle: if same field clicked again, reset
if (activeFieldFilter === fieldName) {
resetFieldFilter();
return;
}
activeFieldFilter = fieldName;
// Highlight active bar
document.querySelectorAll('.dq-bar-row').forEach(function(row) {
row.classList.toggle('dq-bar-active', row.dataset.field === fieldName);
});
// Show filter info
document.getElementById('fieldFilterName').textContent = fieldName;
document.getElementById('fieldFilterInfo').classList.add('active');
applyFilters();
}
function resetFieldFilter() {
activeFieldFilter = null;
document.querySelectorAll('.dq-bar-row').forEach(function(row) {
row.classList.remove('dq-bar-active');
});
document.getElementById('fieldFilterInfo').classList.remove('active');
applyFilters();
}
function applyFilters() {
var qualityFilter = document.getElementById('qualityFilter').value;
var rows = document.querySelectorAll('#companiesTable tbody tr');
var shown = 0;
rows.forEach(function(row) {
var qualityMatch = (qualityFilter === 'all' || row.dataset.quality === qualityFilter);
var fieldMatch = true;
if (activeFieldFilter) {
try {
var fields = JSON.parse(row.dataset.fields);
// Show only companies MISSING this field
fieldMatch = !fields[activeFieldFilter];
} catch(e) { fieldMatch = true; }
}
if (qualityMatch && fieldMatch) {
row.style.display = '';
shown++;
} else {
row.style.display = 'none';
}
});
document.getElementById('shownCount').textContent = shown;
}
// --- A2: Quick action buttons ---
function quickAction(btn, type, companyId) {
if (btn.disabled || btn.classList.contains('loading')) return;
var originalHTML = btn.innerHTML;
btn.classList.add('loading');
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4m-7.07-3.93l2.83-2.83m8.48-8.48l2.83-2.83M2 12h4m12 0h4m-3.93 7.07l-2.83-2.83M7.76 7.76L4.93 4.93"/></svg>';
var csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
var url, body;
if (type === 'registry') {
url = '/api/company/' + companyId + '/enrich-registry';
body = null;
} else if (type === 'seo') {
url = '/api/seo/audit';
body = JSON.stringify({company_id: companyId});
} else if (type === 'gbp') {
url = '/api/gbp/audit';
body = JSON.stringify({company_id: companyId});
}
var opts = {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf}
};
if (body) opts.body = body;
fetch(url, opts)
.then(function(r) { return r.json().then(function(d) { return {ok: r.ok, data: d}; }); })
.then(function(result) {
btn.classList.remove('loading');
if (result.ok) {
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>';
btn.disabled = true;
btn.title = 'Wykonano';
// Update corresponding dot
var row = btn.closest('tr');
var fieldName = type === 'registry' ? 'Dane urzędowe' : (type === 'seo' ? 'Audyt SEO' : 'Audyt GBP');
var dot = row.querySelector('.dq-field-dot[data-field="' + fieldName + '"]');
if (dot) {
dot.classList.remove('empty');
dot.classList.add('filled');
}
} else {
btn.innerHTML = originalHTML;
btn.title = 'Błąd: ' + (result.data.error || 'nieznany');
}
})
.catch(function(err) {
btn.classList.remove('loading');
btn.innerHTML = originalHTML;
btn.title = 'Błąd: ' + err.message;
});
}
function pollProgress(jobId, total) {
fetch('/admin/data-quality/bulk-enrich/status?job_id=' + jobId)
.then(function(r) { return r.json(); })
.then(function(data) {
var processed = data.processed || 0;
var pct = Math.round(processed / total * 100);
document.getElementById('progressBar').style.width = pct + '%';
document.getElementById('progressText').textContent = processed + '/' + total;
if (data.latest_result) {
var log = document.getElementById('progressLog');
log.innerHTML += '<div>' + data.latest_result + '</div>';
log.scrollTop = log.scrollHeight;
}
if (data.status === 'running') {
setTimeout(function() { pollProgress(jobId, total); }, 2000);
} else {
document.getElementById('progressLog').innerHTML += '<div style="color: #22c55e; font-weight: 600;">Zakończono!</div>';
}
});
}
function applyAvailableHint(companyId, field, value, rowId) {
var btn = event.target;
btn.disabled = true;
btn.textContent = '...';
fetch('/api/company/' + companyId + '/apply-hint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''
},
body: JSON.stringify({field: field, value: value})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
var row = document.getElementById(rowId);
if (row) row.style.opacity = '0.3';
btn.textContent = 'OK';
btn.style.background = '#22c55e';
} else {
btn.textContent = 'Błąd';
btn.style.background = '#ef4444';
}
})
.catch(function() {
btn.textContent = 'Błąd';
btn.style.background = '#ef4444';
});
}
function applyAllAvailableHints() {
if (!confirm('Uzupełnić wszystkie dane z Google Business?')) return;
var rows = document.querySelectorAll('#availableDataTable tbody tr');
rows.forEach(function(row) {
var btn = row.querySelector('.hint-apply-btn');
if (btn && !btn.disabled) btn.click();
});
}
// --- Website Discovery ---
function discoverWebsite(companyId, btn) {
if (btn.disabled) return;
var originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '...';
var csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
fetch('/admin/discover-website/' + companyId, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf}
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.status === 'found') {
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>';
btn.title = 'Znaleziono: ' + (data.url || '');
} else {
btn.innerHTML = originalHTML;
btn.disabled = false;
btn.title = data.error || 'Brak wyników';
}
})
.catch(function(err) {
btn.innerHTML = originalHTML;
btn.disabled = false;
btn.title = 'Błąd: ' + err.message;
});
}
function discoverWebsitesBulk() {
document.getElementById('discoveryModal').style.display = 'flex';
document.getElementById('discCloseBtn').style.display = 'none';
document.getElementById('discProgressBar').style.width = '0%';
document.getElementById('discProgressText').textContent = 'Uruchamiam...';
document.getElementById('discProgressLog').innerHTML = '';
var csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
fetch('/admin/discover-websites-bulk', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf}
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.job_id) {
// Wait 3s before first poll to let the thread start
setTimeout(function() { pollDiscoveryProgress(data.job_id); }, 3000);
}
})
.catch(function(err) {
document.getElementById('discProgressLog').innerHTML = '<div style="color: #ef4444;">Błąd: ' + err.message + '</div>';
document.getElementById('discCloseBtn').style.display = 'inline-block';
});
}
var _discLogOffset = 0;
function pollDiscoveryProgress(jobId) {
fetch('/admin/discover-websites-status?job_id=' + jobId + '&log_offset=' + _discLogOffset)
.then(function(r) { return r.json(); })
.then(function(data) {
var total = data.total || 0;
var processed = data.processed || 0;
var pct = total > 0 ? Math.round(processed / total * 100) : 0;
document.getElementById('discProgressBar').style.width = pct + '%';
document.getElementById('discProgressText').textContent = processed + '/' + total;
if (data.log_entries && data.log_entries.length > 0) {
var log = document.getElementById('discProgressLog');
data.log_entries.forEach(function(entry) {
log.innerHTML += '<div>' + entry + '</div>';
});
log.scrollTop = log.scrollHeight;
_discLogOffset = data.log_offset;
}
if (data.status === 'running') {
setTimeout(function() { pollDiscoveryProgress(jobId); }, 3000);
} else {
var msg = processed > 0
? 'Zakończono (' + processed + '/' + total + '). Odśwież stronę aby zobaczyć wyniki.'
: (total === 0
? 'Brak nowych firm do wyszukania (wszystkie mają już kandydatów).'
: 'Zakończono. Odśwież stronę aby zobaczyć wyniki.');
document.getElementById('discProgressLog').innerHTML += '<div style="color: #22c55e; font-weight: 600;">' + msg + '</div>';
document.getElementById('discCloseBtn').style.display = 'inline-block';
}
});
}
function closeDiscoveryModal() {
document.getElementById('discoveryModal').style.display = 'none';
location.reload();
}
// Toggle snippet expansion
document.querySelectorAll('.disc-snippet').forEach(function(el) {
el.addEventListener('click', function() {
this.classList.toggle('expanded');
});
});
function acceptDiscovery(candidateId, rowId) {
var csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
fetch('/admin/discovery/' + candidateId + '/accept', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf}
})
.then(function(r) { return r.json(); })
.then(function(data) {
var row = document.getElementById(rowId);
if (data.success) {
if (row) row.style.opacity = '0.3';
} else {
alert('Błąd: ' + (data.error || 'nieznany'));
}
})
.catch(function(err) { alert('Błąd: ' + err.message); });
}
function rejectDiscovery(candidateId, rowId) {
var csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
fetch('/admin/discovery/' + candidateId + '/reject', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf}
})
.then(function(r) { return r.json(); })
.then(function(data) {
var row = document.getElementById(rowId);
if (data.success) {
if (row) row.remove();
} else {
alert('Błąd: ' + (data.error || 'nieznany'));
}
})
.catch(function(err) { alert('Błąd: ' + err.message); });
}
{% endblock %}