feat: enhance data quality dashboard with filters, hints, weighted scores and contact scraping
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

- Add clickable field coverage bars to filter companies missing specific data
- Add quick-action buttons (Registry/SEO/GBP) per company in dashboard table
- Add stale data detection (>6 months) with yellow badges
- Implement weighted priority score (contacts 34%, audits 17%)
- Add data hints in admin company detail showing where to find missing data
- Add "Available data" section showing Google Business data ready to apply
- Add POST /api/company/<id>/apply-hint endpoint for one-click data fill
- Extend website content updater with phone/email extraction (AI + regex)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-21 07:25:39 +01:00
parent 93e90b2c72
commit e0bb6b718a
7 changed files with 636 additions and 21 deletions

View File

@ -703,6 +703,8 @@ def admin_company_detail(company_id):
GBPAudit.company_id == company_id GBPAudit.company_id == company_id
).order_by(GBPAudit.audit_date.desc()).first() ).order_by(GBPAudit.audit_date.desc()).first()
registry_stale = registry_done and registry_date and (datetime.now() - registry_date).days > 180
enrichment = { enrichment = {
'registry': { 'registry': {
'done': registry_done, 'done': registry_done,
@ -710,6 +712,7 @@ def admin_company_detail(company_id):
'date': registry_date, 'date': registry_date,
'has_krs': bool(company.krs), 'has_krs': bool(company.krs),
'has_nip': bool(company.nip), 'has_nip': bool(company.nip),
'stale': registry_stale,
}, },
'logo': { 'logo': {
'done': logo_exists, 'done': logo_exists,
@ -735,6 +738,38 @@ def admin_company_detail(company_id):
# --- Completeness score (12 fields) --- # --- Completeness score (12 fields) ---
completeness = compute_data_quality_score(company, db) completeness = compute_data_quality_score(company, db)
# --- Hints: where to find missing data ---
hints = {}
analysis = seo_analysis # CompanyWebsiteAnalysis object or None
if not company.phone:
if analysis and analysis.google_phone:
hints['Telefon'] = {'source': 'Google Business', 'value': analysis.google_phone, 'action': 'apply'}
elif analysis and analysis.nap_on_website:
nap = analysis.nap_on_website if isinstance(analysis.nap_on_website, dict) else {}
if nap.get('phone'):
hints['Telefon'] = {'source': 'Strona WWW (NAP)', 'value': nap['phone'], 'action': 'apply'}
elif company.nip:
hints['Telefon'] = {'source': 'CEIDG/KRS', 'value': None, 'action': 'fetch_registry'}
if not company.email:
if analysis and analysis.nap_on_website:
nap = analysis.nap_on_website if isinstance(analysis.nap_on_website, dict) else {}
if nap.get('email'):
hints['Email'] = {'source': 'Strona WWW (NAP)', 'value': nap['email'], 'action': 'apply'}
if not company.website:
if analysis and analysis.google_website:
hints['Strona WWW'] = {'source': 'Google Business', 'value': analysis.google_website, 'action': 'apply'}
if not company.address_city:
if analysis and analysis.google_address:
hints['Adres'] = {'source': 'Google Business', 'value': analysis.google_address, 'action': 'apply'}
if not company.description_short:
if analysis and analysis.content_summary:
hints['Opis'] = {'source': 'Analiza strony WWW', 'value': analysis.content_summary[:200], 'action': 'apply'}
logger.info(f"Admin {current_user.email} viewed company detail: {company.name} (ID: {company_id})") logger.info(f"Admin {current_user.email} viewed company detail: {company.name} (ID: {company_id})")
return render_template( return render_template(
@ -743,6 +778,7 @@ def admin_company_detail(company_id):
enrichment=enrichment, enrichment=enrichment,
completeness=completeness, completeness=completeness,
users=users, users=users,
hints=hints,
) )
finally: finally:
db.close() db.close()

View File

@ -19,6 +19,7 @@ from database import (
CompanySocialMedia, GBPAudit, SystemRole CompanySocialMedia, GBPAudit, SystemRole
) )
from utils.decorators import role_required from utils.decorators import role_required
from utils.data_quality import compute_weighted_score
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -118,7 +119,7 @@ def admin_data_quality():
} }
filled = sum(fields.values()) filled = sum(fields.values())
score = int(filled / len(fields) * 100) score = compute_weighted_score(fields)
# Update counters # Update counters
for field_name, has_value in fields.items(): for field_name, has_value in fields.items():
@ -146,6 +147,13 @@ def admin_data_quality():
score_sum += score score_sum += score
# Stale data detection
registry_done = fields['Dane urzędowe']
registry_date = c.krs_fetched_at or c.ceidg_fetched_at
registry_stale = registry_done and (
(not registry_date) or ((now - registry_date).days > 180)
)
companies_table.append({ companies_table.append({
'id': c.id, 'id': c.id,
'name': c.name, 'name': c.name,
@ -157,6 +165,10 @@ def admin_data_quality():
'data_quality': c.data_quality or 'basic', 'data_quality': c.data_quality or 'basic',
'fields': fields, 'fields': fields,
'status': c.status, 'status': c.status,
'nip': c.nip or '',
'website': c.website or '',
'registry_stale': registry_stale,
'registry_date': registry_date,
}) })
# Sort by score ascending (most incomplete first) # Sort by score ascending (most incomplete first)
@ -170,6 +182,31 @@ def admin_data_quality():
avg_score = round(score_sum / total) if total > 0 else 0 avg_score = round(score_sum / total) if total > 0 else 0
# Available data: companies where Google has data but company profile is empty
available_data = []
analyses = db.query(CompanyWebsiteAnalysis).all()
company_map = {c.id: c for c in companies}
for a in analyses:
comp = company_map.get(a.company_id)
if not comp:
continue
if a.google_phone and not comp.phone:
available_data.append({
'company_id': comp.id, 'company_name': comp.name, 'company_slug': comp.slug,
'field': 'Telefon', 'source': 'Google Business', 'value': a.google_phone
})
if a.google_website and not comp.website:
available_data.append({
'company_id': comp.id, 'company_name': comp.name, 'company_slug': comp.slug,
'field': 'Strona WWW', 'source': 'Google Business', 'value': a.google_website
})
if a.google_address and not comp.address_city:
available_data.append({
'company_id': comp.id, 'company_name': comp.name, 'company_slug': comp.slug,
'field': 'Adres', 'source': 'Google Business', 'value': a.google_address
})
return render_template( return render_template(
'admin/data_quality_dashboard.html', 'admin/data_quality_dashboard.html',
total=total, total=total,
@ -178,6 +215,7 @@ def admin_data_quality():
score_dist=score_dist, score_dist=score_dist,
avg_score=avg_score, avg_score=avg_score,
companies_table=companies_table, companies_table=companies_table,
available_data=available_data,
now=now, now=now,
) )
finally: finally:

View File

@ -1346,3 +1346,53 @@ def test_sanitization():
except Exception as e: except Exception as e:
logger.error(f"Error testing sanitization: {e}") logger.error(f"Error testing sanitization: {e}")
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/company/<int:company_id>/apply-hint', methods=['POST'])
@login_required
def api_apply_hint(company_id):
"""Apply a data hint to fill a missing company field."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Tylko administrator'}), 403
db = SessionLocal()
try:
company = db.query(Company).filter_by(id=company_id).first()
if not company:
return jsonify({'success': False, 'error': 'Firma nie znaleziona'}), 404
data = request.get_json() or {}
field = data.get('field', '')
value = data.get('value', '').strip()
if not field or not value:
return jsonify({'success': False, 'error': 'Brak pola lub wartości'}), 400
# Map display names to model attributes
FIELD_MAP = {
'Telefon': 'phone',
'Email': 'email',
'Strona WWW': 'website',
'Adres': 'address_city',
'Opis': 'description_short',
}
attr = FIELD_MAP.get(field)
if not attr:
return jsonify({'success': False, 'error': f'Niedozwolone pole: {field}'}), 400
setattr(company, attr, value)
db.commit()
update_company_data_quality(company, db)
db.commit()
logger.info(f"Hint applied: {field}={value[:50]} for company {company.id} by {current_user.email}")
return jsonify({'success': True, 'message': f'Pole "{field}" uzupełnione'})
except Exception as e:
db.rollback()
logger.error(f"Error applying hint for company {company_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()

View File

@ -54,7 +54,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Load .env from project root # Load .env from project root
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
from database import Company, CompanyWebsiteAnalysis, SessionLocal from database import Company, CompanyWebsiteAnalysis, CompanyContact, SessionLocal
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -205,7 +205,9 @@ Zwróć odpowiedź w formacie JSON (tylko JSON, bez markdown):
"specializations": ["specjalizacja 1", "specjalizacja 2", ...], "specializations": ["specjalizacja 1", "specjalizacja 2", ...],
"target_customers": ["klient docelowy 1", "klient docelowy 2", ...], "target_customers": ["klient docelowy 1", "klient docelowy 2", ...],
"regions": ["region 1", "region 2", ...], "regions": ["region 1", "region 2", ...],
"summary": "Szczegółowe podsumowanie działalności firmy (2-3 zdania)" "summary": "Szczegółowe podsumowanie działalności firmy (2-3 zdania)",
"contact_phone": "numer telefonu firmy jeśli widoczny na stronie, w formacie +48XXXXXXXXX lub oryginalnym",
"contact_email": "adres email kontaktowy firmy jeśli widoczny na stronie"
}} }}
ZASADY - WYODRĘBNIJ WSZYSTKO, BEZ LIMITÓW: ZASADY - WYODRĘBNIJ WSZYSTKO, BEZ LIMITÓW:
@ -217,6 +219,8 @@ ZASADY - WYODRĘBNIJ WSZYSTKO, BEZ LIMITÓW:
6. target_customers: Typy klientów (np. "MŚP", "korporacje", "sektor publiczny") 6. target_customers: Typy klientów (np. "MŚP", "korporacje", "sektor publiczny")
7. regions: Obszar działania geograficzny (miasta, regiony) 7. regions: Obszar działania geograficzny (miasta, regiony)
8. summary: Pełne podsumowanie czym zajmuje się firma 8. summary: Pełne podsumowanie czym zajmuje się firma
9. contact_phone: Numer telefonu firmy (najlepiej główny/biurowy)
10. contact_email: Adres email firmy (najlepiej ogólny/biurowy, nie osobisty)
WAŻNE: WAŻNE:
- Wyodrębnij WSZYSTKIE informacje bez ograniczeń ilościowych - Wyodrębnij WSZYSTKIE informacje bez ograniczeń ilościowych
@ -261,10 +265,15 @@ ODPOWIEDŹ (tylko JSON):"""
# Merge keywords + brands + target_customers + regions into main_keywords # Merge keywords + brands + target_customers + regions into main_keywords
merged_keywords = list(dict.fromkeys(all_keywords + all_brands + all_target_customers + all_regions)) merged_keywords = list(dict.fromkeys(all_keywords + all_brands + all_target_customers + all_regions))
contact_phone = data.get('contact_phone', '')
contact_email = data.get('contact_email', '')
return { return {
'services': merged_services, # No limit 'services': merged_services, # No limit
'keywords': merged_keywords, # No limit 'keywords': merged_keywords, # No limit
'summary': data.get('summary', '')[:1000] if data.get('summary') else None, 'summary': data.get('summary', '')[:1000] if data.get('summary') else None,
'contact_phone': contact_phone,
'contact_email': contact_email,
'raw_data': { 'raw_data': {
'services': all_services, 'services': all_services,
'products': all_products, 'products': all_products,
@ -284,6 +293,41 @@ ODPOWIEDŹ (tylko JSON):"""
logger.error(f"Gemini extraction error: {e}") logger.error(f"Gemini extraction error: {e}")
return {'services': [], 'keywords': [], 'summary': None, 'error': str(e)[:100]} return {'services': [], 'keywords': [], 'summary': None, 'error': str(e)[:100]}
def extract_contacts_regex(self, html_text: str) -> Dict[str, List[str]]:
"""Extract phone numbers and emails from raw website text using regex."""
contacts = {'phones': [], 'emails': []}
# Email extraction
email_pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
emails = re.findall(email_pattern, html_text)
# Filter out common non-contact emails
skip_domains = {'example.com', 'sentry.io', 'wixpress.com', 'wordpress.org', 'w3.org', 'schema.org', 'googleapis.com'}
contacts['emails'] = list(dict.fromkeys(
e.lower() for e in emails
if not any(d in e.lower() for d in skip_domains)
))[:5] # Max 5 emails
# Phone extraction - Polish patterns
phone_patterns = [
r'(?:\+48\s?)?\d{2}[\s-]?\d{3}[\s-]?\d{2}[\s-]?\d{2}', # +48 XX XXX XX XX
r'(?:\+48\s?)?\d{3}[\s-]?\d{3}[\s-]?\d{3}', # +48 XXX XXX XXX
r'\(\d{2}\)\s?\d{3}[\s-]?\d{2}[\s-]?\d{2}', # (XX) XXX XX XX
r'(?:tel|phone|telefon)[.:]\s*[\+]?\d[\d\s\-]{7,14}', # tel: +48...
]
for pattern in phone_patterns:
matches = re.findall(pattern, html_text, re.IGNORECASE)
for m in matches:
# Clean up
clean = re.sub(r'(?:tel|phone|telefon)[.:]?\s*', '', m, flags=re.IGNORECASE).strip()
digits = re.sub(r'\D', '', clean)
if 9 <= len(digits) <= 12:
contacts['phones'].append(clean)
contacts['phones'] = list(dict.fromkeys(contacts['phones']))[:5]
return contacts
def update_company(self, company: Company) -> bool: def update_company(self, company: Company) -> bool:
""" """
Aktualizuje dane jednej firmy. Aktualizuje dane jednej firmy.
@ -366,6 +410,65 @@ ODPOWIEDŹ (tylko JSON):"""
self.db.commit() self.db.commit()
self.stats['updated'] += 1 self.stats['updated'] += 1
# --- Contact extraction ---
all_phones = []
all_emails = []
# From Gemini
if extracted.get('contact_phone'):
all_phones.append(extracted['contact_phone'])
if extracted.get('contact_email'):
all_emails.append(extracted['contact_email'])
# From regex fallback
regex_contacts = self.extract_contacts_regex(text)
all_phones.extend(regex_contacts.get('phones', []))
all_emails.extend(regex_contacts.get('emails', []))
# Deduplicate
all_phones = list(dict.fromkeys(all_phones))
all_emails = list(dict.fromkeys(all_emails))
# Save to CompanyContact (source='website')
contacts_added = 0
for phone in all_phones[:3]: # Max 3 phones
existing = self.db.query(CompanyContact).filter_by(
company_id=company.id, contact_type='phone', value=phone
).first()
if not existing:
self.db.add(CompanyContact(
company_id=company.id,
contact_type='phone',
value=phone,
source='website',
source_url=company.website,
source_date=datetime.now().date(),
is_verified=False,
))
contacts_added += 1
logger.info(f" [{company.id}] Found phone: {phone}")
for email in all_emails[:3]: # Max 3 emails
existing = self.db.query(CompanyContact).filter_by(
company_id=company.id, contact_type='email', value=email
).first()
if not existing:
self.db.add(CompanyContact(
company_id=company.id,
contact_type='email',
value=email,
source='website',
source_url=company.website,
source_date=datetime.now().date(),
is_verified=False,
))
contacts_added += 1
logger.info(f" [{company.id}] Found email: {email}")
if contacts_added > 0:
self.db.commit()
logger.info(f" [{company.id}] Saved {contacts_added} new contacts")
logger.info(f"[{company.id}] {company.name}: ✓ Zaktualizowano") logger.info(f"[{company.id}] {company.name}: ✓ Zaktualizowano")
return True return True

View File

@ -473,6 +473,18 @@
.check-ok { color: var(--success); } .check-ok { color: var(--success); }
.check-missing { color: var(--error); } .check-missing { color: var(--error); }
.hint-apply-btn {
padding: 1px 8px;
font-size: var(--font-size-xs);
background: var(--primary);
color: white;
border: none;
border-radius: var(--radius);
cursor: pointer;
white-space: nowrap;
}
.hint-apply-btn:hover { opacity: 0.9; }
/* Toast */ /* Toast */
.toast-container { .toast-container {
position: fixed; position: fixed;
@ -701,6 +713,11 @@
Nie wykonano Nie wykonano
{% endif %} {% endif %}
</div> </div>
{% if enrichment.registry.stale %}
<div style="background: #fef9c3; color: #854d0e; padding: 6px 10px; border-radius: var(--radius); font-size: var(--font-size-xs); margin-top: var(--spacing-xs);">
Dane z rejestru pobrane ponad 6 mcy temu — odśwież
</div>
{% endif %}
{% if enrichment.registry.source %} {% if enrichment.registry.source %}
<div class="action-score">Źródło: {{ enrichment.registry.source }}</div> <div class="action-score">Źródło: {{ enrichment.registry.source }}</div>
{% endif %} {% endif %}
@ -815,10 +832,24 @@
<div class="checklist-item"> <div class="checklist-item">
{% if is_filled %} {% if is_filled %}
<svg class="check-ok" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="check-ok" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span>{{ field_name }}</span>
{% else %} {% else %}
<svg class="check-missing" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="check-missing" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% endif %}
<span>{{ field_name }}</span> <span>{{ field_name }}</span>
{% if hints and hints.get(field_name) %}
<div class="hint-badge" style="margin-left: auto; display: flex; align-items: center; gap: 4px;">
<span style="font-size: var(--font-size-xs); color: var(--primary);">
{{ hints[field_name].source }}{% if hints[field_name].value %}: {{ hints[field_name].value[:40] }}{% endif %}
</span>
{% if hints[field_name].action == 'apply' and hints[field_name].value %}
<button onclick="applyHint({{ company.id }}, '{{ field_name }}', '{{ hints[field_name].value|e }}')"
class="hint-apply-btn" title="Uzupełnij to pole">Uzupełnij</button>
{% elif hints[field_name].action == 'fetch_registry' %}
<button onclick="window.location.href='#enrichment'" class="hint-apply-btn" title="Pobierz z rejestru">Pobierz</button>
{% endif %}
</div>
{% endif %}
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -847,6 +878,29 @@
}, 5000); }, 5000);
} }
function applyHint(companyId, fieldName, value) {
if (!confirm('Uzupełnić pole "' + fieldName + '" wartością: ' + value + '?')) return;
fetch('/api/company/' + companyId + '/apply-hint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({field: fieldName, value: value})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('Pole "' + fieldName + '" uzupełnione', 'success');
setTimeout(function() { location.reload(); }, 1000);
} else {
showToast('Błąd: ' + data.error, 'error');
}
})
.catch(function(err) { showToast('Błąd: ' + err.message, 'error'); });
}
var _skipReload = false; var _skipReload = false;
function runEnrichAction(btn, url, body) { function runEnrichAction(btn, url, body) {

View File

@ -360,6 +360,101 @@
background: var(--background); 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;
}
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.dq-bar-label { width: 100px; font-size: var(--font-size-xs); } .dq-bar-label { width: 100px; font-size: var(--font-size-xs); }
@ -406,7 +501,7 @@
<div class="dq-section"> <div class="dq-section">
<div class="dq-section-title">Pokrycie danych per pole</div> <div class="dq-section-title">Pokrycie danych per pole</div>
{% for field_name, stats in field_stats.items() %} {% for field_name, stats in field_stats.items() %}
<div class="dq-bar-row"> <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-label">{{ field_name }}</div>
<div class="dq-bar-track"> <div class="dq-bar-track">
<div class="dq-bar-fill {% if stats.pct >= 70 %}high{% elif stats.pct >= 40 %}medium{% else %}low{% endif %}" <div class="dq-bar-fill {% if stats.pct >= 70 %}high{% elif stats.pct >= 40 %}medium{% else %}low{% endif %}"
@ -457,6 +552,51 @@
</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></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 %}
<!-- Companies Table --> <!-- Companies Table -->
<div class="dq-section"> <div class="dq-section">
<div class="dq-section-title">Firmy wg kompletności danych</div> <div class="dq-section-title">Firmy wg kompletności danych</div>
@ -468,6 +608,11 @@
<button class="dq-bulk-btn" onclick="clearSelection()" style="background: transparent; color: white; border: 1px solid rgba(255,255,255,0.5);">Odznacz</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>
<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 class="dq-table-controls">
<div> <div>
<select class="dq-filter-select" id="qualityFilter" onchange="filterTable()"> <select class="dq-filter-select" id="qualityFilter" onchange="filterTable()">
@ -490,12 +635,13 @@
<th onclick="sortTable(2)" style="width: 100px">Score</th> <th onclick="sortTable(2)" style="width: 100px">Score</th>
<th onclick="sortTable(3)" style="width: 80px">Pola</th> <th onclick="sortTable(3)" style="width: 80px">Pola</th>
<th style="width: 130px">Kompletność</th> <th style="width: 130px">Kompletność</th>
<th onclick="sortTable(5)" style="width: 100px">Jakość</th> <th onclick="sortTable(6)" style="width: 100px">Jakość</th>
<th style="width: 90px">Akcje</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for c in companies_table %} {% for c in companies_table %}
<tr data-quality="{{ c.label }}"> <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><input type="checkbox" class="company-cb" value="{{ c.id }}"></td>
<td> <td>
<a href="{{ url_for('admin.admin_company_detail', company_id=c.id) }}" class="dq-company-link"> <a href="{{ url_for('admin.admin_company_detail', company_id=c.id) }}" class="dq-company-link">
@ -506,12 +652,15 @@
<span class="dq-score-badge {% if c.score >= 67 %}high{% elif c.score >= 34 %}medium{% else %}low{% endif %}"> <span class="dq-score-badge {% if c.score >= 67 %}high{% elif c.score >= 34 %}medium{% else %}low{% endif %}">
{{ c.score }}% {{ c.score }}%
</span> </span>
{% if c.registry_stale %}
<span class="dq-stale-badge" title="Dane z rejestru starsze niż 6 mcy">Dane stare</span>
{% endif %}
</td> </td>
<td>{{ c.filled }}/{{ c.total }}</td> <td>{{ c.filled }}/{{ c.total }}</td>
<td> <td>
<div class="dq-field-dots" title="{% for fname, fval in c.fields.items() %}{{ fname }}: {{ 'tak' if fval else 'nie' }}&#10;{% endfor %}"> <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() %} {% for fname, fval in c.fields.items() %}
<span class="dq-field-dot {{ 'filled' if fval else 'empty' }}" title="{{ fname }}"></span> <span class="dq-field-dot {{ 'filled' if fval else 'empty' }}" title="{{ fname }}" data-field="{{ fname }}"></span>
{% endfor %} {% endfor %}
</div> </div>
</td> </td>
@ -520,6 +669,37 @@
{% if c.label == 'basic' %}Podstawowe{% elif c.label == 'enhanced' %}Rozszerzone{% else %}Kompletne{% endif %} {% if c.label == 'basic' %}Podstawowe{% elif c.label == 'enhanced' %}Rozszerzone{% else %}Kompletne{% endif %}
</span> </span>
</td> </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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -574,18 +754,7 @@
// Data Quality Dashboard JS // Data Quality Dashboard JS
function filterTable() { function filterTable() {
var filter = document.getElementById('qualityFilter').value; applyFilters();
var rows = document.querySelectorAll('#companiesTable tbody tr');
var shown = 0;
rows.forEach(function(row) {
if (filter === 'all' || row.dataset.quality === filter) {
row.style.display = '';
shown++;
} else {
row.style.display = 'none';
}
});
document.getElementById('shownCount').textContent = shown;
} }
function sortTable(colIdx) { function sortTable(colIdx) {
@ -692,6 +861,115 @@
}); });
} }
// --- 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) { function pollProgress(jobId, total) {
fetch('/admin/data-quality/bulk-enrich/status?job_id=' + jobId) fetch('/admin/data-quality/bulk-enrich/status?job_id=' + jobId)
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
@ -714,4 +992,44 @@
} }
}); });
} }
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();
});
}
{% endblock %} {% endblock %}

View File

@ -11,6 +11,22 @@ import os
from database import CompanyWebsiteAnalysis, CompanySocialMedia, GBPAudit from database import CompanyWebsiteAnalysis, CompanySocialMedia, GBPAudit
FIELD_WEIGHTS = {
'NIP': 10, 'Adres': 8, 'Telefon': 12, 'Email': 12,
'Strona WWW': 10, 'Opis': 10, 'Kategoria': 5,
'Logo': 8, 'Dane urzędowe': 8,
'Audyt SEO': 5, 'Audyt Social': 5, 'Audyt GBP': 7,
}
MAX_WEIGHT = sum(FIELD_WEIGHTS.values())
def compute_weighted_score(fields):
"""Compute weighted score from fields dict. Returns int 0-100."""
weighted = sum(FIELD_WEIGHTS.get(f, 0) for f, v in fields.items() if v)
return int(weighted / MAX_WEIGHT * 100)
def compute_data_quality_score(company, db): def compute_data_quality_score(company, db):
"""Compute data quality score for a company. """Compute data quality score for a company.
@ -58,7 +74,7 @@ def compute_data_quality_score(company, db):
filled = sum(fields.values()) filled = sum(fields.values())
total = len(fields) total = len(fields)
score = int(filled / total * 100) score = compute_weighted_score(fields)
return { return {
'score': score, 'score': score,