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
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:
parent
93e90b2c72
commit
e0bb6b718a
@ -703,6 +703,8 @@ def admin_company_detail(company_id):
|
||||
GBPAudit.company_id == company_id
|
||||
).order_by(GBPAudit.audit_date.desc()).first()
|
||||
|
||||
registry_stale = registry_done and registry_date and (datetime.now() - registry_date).days > 180
|
||||
|
||||
enrichment = {
|
||||
'registry': {
|
||||
'done': registry_done,
|
||||
@ -710,6 +712,7 @@ def admin_company_detail(company_id):
|
||||
'date': registry_date,
|
||||
'has_krs': bool(company.krs),
|
||||
'has_nip': bool(company.nip),
|
||||
'stale': registry_stale,
|
||||
},
|
||||
'logo': {
|
||||
'done': logo_exists,
|
||||
@ -735,6 +738,38 @@ def admin_company_detail(company_id):
|
||||
# --- Completeness score (12 fields) ---
|
||||
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})")
|
||||
|
||||
return render_template(
|
||||
@ -743,6 +778,7 @@ def admin_company_detail(company_id):
|
||||
enrichment=enrichment,
|
||||
completeness=completeness,
|
||||
users=users,
|
||||
hints=hints,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@ -19,6 +19,7 @@ from database import (
|
||||
CompanySocialMedia, GBPAudit, SystemRole
|
||||
)
|
||||
from utils.decorators import role_required
|
||||
from utils.data_quality import compute_weighted_score
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -118,7 +119,7 @@ def admin_data_quality():
|
||||
}
|
||||
|
||||
filled = sum(fields.values())
|
||||
score = int(filled / len(fields) * 100)
|
||||
score = compute_weighted_score(fields)
|
||||
|
||||
# Update counters
|
||||
for field_name, has_value in fields.items():
|
||||
@ -146,6 +147,13 @@ def admin_data_quality():
|
||||
|
||||
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({
|
||||
'id': c.id,
|
||||
'name': c.name,
|
||||
@ -157,6 +165,10 @@ def admin_data_quality():
|
||||
'data_quality': c.data_quality or 'basic',
|
||||
'fields': fields,
|
||||
'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)
|
||||
@ -170,6 +182,31 @@ def admin_data_quality():
|
||||
|
||||
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(
|
||||
'admin/data_quality_dashboard.html',
|
||||
total=total,
|
||||
@ -178,6 +215,7 @@ def admin_data_quality():
|
||||
score_dist=score_dist,
|
||||
avg_score=avg_score,
|
||||
companies_table=companies_table,
|
||||
available_data=available_data,
|
||||
now=now,
|
||||
)
|
||||
finally:
|
||||
|
||||
@ -1346,3 +1346,53 @@ def test_sanitization():
|
||||
except Exception as e:
|
||||
logger.error(f"Error testing sanitization: {e}")
|
||||
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()
|
||||
|
||||
@ -54,7 +54,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# Load .env from project root
|
||||
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
|
||||
logging.basicConfig(
|
||||
@ -205,7 +205,9 @@ Zwróć odpowiedź w formacie JSON (tylko JSON, bez markdown):
|
||||
"specializations": ["specjalizacja 1", "specjalizacja 2", ...],
|
||||
"target_customers": ["klient docelowy 1", "klient docelowy 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:
|
||||
@ -217,6 +219,8 @@ ZASADY - WYODRĘBNIJ WSZYSTKO, BEZ LIMITÓW:
|
||||
6. target_customers: Typy klientów (np. "MŚP", "korporacje", "sektor publiczny")
|
||||
7. regions: Obszar działania geograficzny (miasta, regiony)
|
||||
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:
|
||||
- Wyodrębnij WSZYSTKIE informacje bez ograniczeń ilościowych
|
||||
@ -261,10 +265,15 @@ ODPOWIEDŹ (tylko JSON):"""
|
||||
# Merge keywords + brands + target_customers + regions into main_keywords
|
||||
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 {
|
||||
'services': merged_services, # No limit
|
||||
'keywords': merged_keywords, # No limit
|
||||
'summary': data.get('summary', '')[:1000] if data.get('summary') else None,
|
||||
'contact_phone': contact_phone,
|
||||
'contact_email': contact_email,
|
||||
'raw_data': {
|
||||
'services': all_services,
|
||||
'products': all_products,
|
||||
@ -284,6 +293,41 @@ ODPOWIEDŹ (tylko JSON):"""
|
||||
logger.error(f"Gemini extraction error: {e}")
|
||||
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:
|
||||
"""
|
||||
Aktualizuje dane jednej firmy.
|
||||
@ -366,6 +410,65 @@ ODPOWIEDŹ (tylko JSON):"""
|
||||
self.db.commit()
|
||||
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")
|
||||
return True
|
||||
|
||||
|
||||
@ -473,6 +473,18 @@
|
||||
.check-ok { color: var(--success); }
|
||||
.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-container {
|
||||
position: fixed;
|
||||
@ -701,6 +713,11 @@
|
||||
Nie wykonano
|
||||
{% endif %}
|
||||
</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 %}
|
||||
<div class="action-score">Źródło: {{ enrichment.registry.source }}</div>
|
||||
{% endif %}
|
||||
@ -815,10 +832,24 @@
|
||||
<div class="checklist-item">
|
||||
{% 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>
|
||||
<span>{{ field_name }}</span>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@ -847,6 +878,29 @@
|
||||
}, 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;
|
||||
|
||||
function runEnrichAction(btn, url, body) {
|
||||
|
||||
@ -360,6 +360,101 @@
|
||||
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 */
|
||||
@media (max-width: 768px) {
|
||||
.dq-bar-label { width: 100px; font-size: var(--font-size-xs); }
|
||||
@ -406,7 +501,7 @@
|
||||
<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">
|
||||
<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 %}"
|
||||
@ -457,6 +552,51 @@
|
||||
</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 -->
|
||||
<div class="dq-section">
|
||||
<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>
|
||||
</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()">
|
||||
@ -490,12 +635,13 @@
|
||||
<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(5)" style="width: 100px">Jakość</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 }}">
|
||||
<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">
|
||||
@ -506,12 +652,15 @@
|
||||
<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' }} {% endfor %}">
|
||||
{% 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 %}
|
||||
</div>
|
||||
</td>
|
||||
@ -520,6 +669,37 @@
|
||||
{% 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>
|
||||
@ -574,18 +754,7 @@
|
||||
// Data Quality Dashboard JS
|
||||
|
||||
function filterTable() {
|
||||
var filter = document.getElementById('qualityFilter').value;
|
||||
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;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
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) {
|
||||
fetch('/admin/data-quality/bulk-enrich/status?job_id=' + jobId)
|
||||
.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 %}
|
||||
|
||||
@ -11,6 +11,22 @@ import os
|
||||
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):
|
||||
"""Compute data quality score for a company.
|
||||
|
||||
@ -58,7 +74,7 @@ def compute_data_quality_score(company, db):
|
||||
|
||||
filled = sum(fields.values())
|
||||
total = len(fields)
|
||||
score = int(filled / total * 100)
|
||||
score = compute_weighted_score(fields)
|
||||
|
||||
return {
|
||||
'score': score,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user