nordabiz/templates/admin/competitor_dashboard.html
Maciej Pienczyn 02696c59a4
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: Add new services, scripts, and competitor dashboard
- google_places_service.py: Google Places API integration
- competitor_monitoring_service.py: Competitor tracking service
- scripts/competitor_monitor_cron.py, scripts/generate_audit_report.py
- blueprints/admin/routes_competitors.py, templates/admin/competitor_dashboard.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:00:54 +01:00

536 lines
18 KiB
HTML

{% extends "base.html" %}
{% block title %}Monitoring Konkurencji - Panel Admina{% endblock %}
{% block extra_css %}
<style>
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.dashboard-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.dashboard-header p {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-sm);
text-align: center;
}
.stat-value {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--primary);
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.section-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.company-competitor-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-sm);
margin-bottom: var(--spacing-md);
border-left: 4px solid var(--primary);
}
.company-competitor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.company-competitor-header h3 {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
}
.competitor-count-badge {
font-size: var(--font-size-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--bg-tertiary);
border-radius: var(--radius);
color: var(--text-secondary);
}
.competitors-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
}
.competitors-table th {
text-align: left;
padding: var(--spacing-sm) var(--spacing-md);
color: var(--text-secondary);
font-weight: 500;
border-bottom: 2px solid var(--border);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.competitors-table td {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border);
color: var(--text-primary);
}
.competitors-table tr:last-child td {
border-bottom: none;
}
.competitors-table tr:hover td {
background: var(--bg-tertiary);
}
.rating-stars {
color: #f59e0b;
font-weight: 600;
}
.comparison-row {
background: rgba(168, 85, 247, 0.05);
font-weight: 600;
}
.comparison-row td {
border-bottom: 2px solid var(--primary) !important;
}
.change-badge {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: var(--font-size-xs);
padding: 2px 6px;
border-radius: var(--radius-sm);
}
.change-badge.positive {
background: #dcfce7;
color: #166534;
}
.change-badge.negative {
background: #fee2e2;
color: #991b1b;
}
.change-badge.neutral {
background: var(--bg-tertiary);
color: var(--text-tertiary);
}
.timeline-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.timeline-item {
background: var(--surface);
border-radius: var(--radius);
padding: var(--spacing-md);
box-shadow: var(--shadow-sm);
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
}
.timeline-date {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
min-width: 80px;
flex-shrink: 0;
}
.timeline-content {
flex: 1;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.timeline-content strong {
color: var(--text-primary);
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.empty-state svg {
width: 64px;
height: 64px;
color: var(--text-tertiary);
margin-bottom: var(--spacing-md);
}
.empty-state h3 {
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.empty-state p {
color: var(--text-secondary);
}
.btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
font-weight: 500;
font-size: var(--font-size-sm);
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
text-decoration: none;
}
.btn-primary {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-outline {
background: transparent;
color: var(--text-secondary);
border-color: var(--border);
}
.btn-outline:hover {
background: var(--bg-tertiary);
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-xs);
}
@media (max-width: 768px) {
.competitors-table {
display: block;
overflow-x: auto;
}
}
</style>
{% endblock %}
{% block content %}
<div class="dashboard-header">
<div>
{% if detail_view and company %}
<h1>Konkurenci: {{ company.name }}</h1>
<p>Porownanie z konkurentami z Google Maps</p>
{% else %}
<h1>Monitoring Konkurencji</h1>
<p>Sledzenie zmian u konkurentow firm czlonkowskich</p>
{% endif %}
</div>
<div>
{% if detail_view and company %}
<a href="{{ url_for('admin.admin_competitors') }}" class="btn btn-outline btn-sm">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
Wszystkie firmy
</a>
{% endif %}
</div>
</div>
<!-- Stats -->
{% if not detail_view %}
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">{{ total_companies }}</div>
<div class="stat-label">Firm z monitoringiem</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ total_competitors }}</div>
<div class="stat-label">Sledzonych konkurentow</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ recent_changes|length }}</div>
<div class="stat-label">Zmian w ostatnich 30 dniach</div>
</div>
</div>
{% endif %}
{% if detail_view and company %}
<!-- DETAIL VIEW: Single company vs competitors -->
{% if competitor_data %}
<!-- Comparison Table -->
<h2 class="section-title">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
Porownanie: {{ company.name }} vs Konkurenci
</h2>
<div style="background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); overflow: hidden; margin-bottom: var(--spacing-xl);">
<table class="competitors-table">
<thead>
<tr>
<th>Firma</th>
<th>Ocena</th>
<th>Opinie</th>
<th>Zdjecia</th>
<th>Kategoria</th>
<th>Strona WWW</th>
</tr>
</thead>
<tbody>
<!-- Company's own data -->
<tr class="comparison-row">
<td>
<strong>{{ company.name }}</strong>
<span style="font-size: var(--font-size-xs); color: var(--primary); margin-left: 4px;">(Twoja firma)</span>
</td>
<td>
{% if gbp_audit and gbp_audit.average_rating %}
<span class="rating-stars">{{ gbp_audit.average_rating }}/5</span>
{% else %}
<span style="color: var(--text-tertiary);">-</span>
{% endif %}
</td>
<td>{{ gbp_audit.review_count if gbp_audit and gbp_audit.review_count else '-' }}</td>
<td>{{ gbp_audit.photo_count if gbp_audit and gbp_audit.photo_count else '-' }}</td>
<td style="font-size: var(--font-size-xs);">{{ company.category.name if company.category else '-' }}</td>
<td style="font-size: var(--font-size-xs);">{{ 'Tak' if company.website else 'Brak' }}</td>
</tr>
<!-- Competitors -->
{% for item in competitor_data %}
{% set comp = item.competitor %}
{% set snap = item.latest_snapshot %}
<tr>
<td>
{{ comp.competitor_name or 'Bez nazwy' }}
{% if comp.added_by == 'manual' %}
<span style="font-size: 9px; padding: 1px 4px; background: #dbeafe; color: #1e40af; border-radius: 2px; margin-left: 4px;">RECZNY</span>
{% endif %}
</td>
<td>
{% if comp.competitor_rating %}
<span class="rating-stars">{{ comp.competitor_rating }}/5</span>
{% if snap and snap.changes and snap.changes.get('rating_change') %}
{% set rc = snap.changes.rating_change %}
<span class="change-badge {{ 'positive' if rc > 0 else 'negative' }}">
{{ '+' if rc > 0 else '' }}{{ rc }}
</span>
{% endif %}
{% else %}
<span style="color: var(--text-tertiary);">-</span>
{% endif %}
</td>
<td>
{{ comp.competitor_review_count or '-' }}
{% if snap and snap.changes and snap.changes.get('new_reviews') %}
<span class="change-badge positive">+{{ snap.changes.new_reviews }}</span>
{% endif %}
</td>
<td>{{ snap.photo_count if snap else '-' }}</td>
<td style="font-size: var(--font-size-xs);">{{ comp.competitor_category or '-' }}</td>
<td style="font-size: var(--font-size-xs);">
{% if comp.competitor_website %}
<a href="{{ comp.competitor_website }}" target="_blank" rel="noopener" style="color: var(--primary);">Link</a>
{% else %}
Brak
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Timeline of Changes -->
{% if timeline %}
<h2 class="section-title">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Ostatnie zmiany
</h2>
<div class="timeline-list">
{% for snap in timeline %}
<div class="timeline-item">
<div class="timeline-date">{{ snap.snapshot_date.strftime('%d.%m') }}</div>
<div class="timeline-content">
<strong>{{ snap.competitor.competitor_name }}</strong>
{% if snap.changes %}
{% if snap.changes.get('new_reviews') %}
— {{ snap.changes.new_reviews }} nowych opinii
{% endif %}
{% if snap.changes.get('rating_change') %}
— ocena {{ '+' if snap.changes.rating_change > 0 else '' }}{{ snap.changes.rating_change }}
{% endif %}
{% if snap.changes.get('new_photos') %}
— {{ snap.changes.new_photos }} nowych zdjec
{% endif %}
{% else %}
— brak zmian
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% else %}
<!-- No competitors -->
<div class="empty-state">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
<h3>Brak sledzonych konkurentow</h3>
<p>Dla firmy {{ company.name }} nie dodano jeszcze konkurentow. Uruchom skrypt discovery, aby automatycznie znalezc konkurentow w okolicy.</p>
</div>
{% endif %}
{% else %}
<!-- OVERVIEW: All companies with competitors -->
{% if company_data %}
{% for item in company_data %}
<div class="company-competitor-card">
<div class="company-competitor-header">
<h3>
<a href="{{ url_for('admin.admin_competitor_detail', company_id=item.company.id) }}" style="color: var(--text-primary); text-decoration: none;">
{{ item.company.name }}
</a>
</h3>
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
<span class="competitor-count-badge">{{ item.competitor_count }} konkurentow</span>
<a href="{{ url_for('admin.admin_competitor_detail', company_id=item.company.id) }}" class="btn btn-outline btn-sm">Szczegoly</a>
</div>
</div>
{% if item.competitors %}
<table class="competitors-table">
<thead>
<tr>
<th>Konkurent</th>
<th>Ocena</th>
<th>Opinie</th>
<th>Kategoria</th>
</tr>
</thead>
<tbody>
{% for comp in item.competitors[:5] %}
<tr>
<td>{{ comp.competitor_name or 'Bez nazwy' }}</td>
<td><span class="rating-stars">{{ comp.competitor_rating or '-' }}{% if comp.competitor_rating %}/5{% endif %}</span></td>
<td>{{ comp.competitor_review_count or '-' }}</td>
<td style="font-size: var(--font-size-xs);">{{ comp.competitor_category or '-' }}</td>
</tr>
{% endfor %}
{% if item.competitors|length > 5 %}
<tr>
<td colspan="4" style="text-align: center; color: var(--text-tertiary); font-style: italic;">
...i {{ item.competitors|length - 5 }} wiecej
</td>
</tr>
{% endif %}
</tbody>
</table>
{% endif %}
</div>
{% endfor %}
{% else %}
<!-- No companies with competitors -->
<div class="empty-state">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
<h3>Brak monitorowanych konkurentow</h3>
<p>Zaden z firm czlonkowskich nie ma jeszcze sledzonych konkurentow. Uruchom skrypt <code>scripts/competitor_monitor_cron.py --discover</code>, aby automatycznie znalezc konkurentow dla firm z profilem Google Business.</p>
</div>
{% endif %}
<!-- Recent Changes -->
{% if recent_changes %}
<h2 class="section-title" style="margin-top: var(--spacing-xl);">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Ostatnie zmiany (30 dni)
</h2>
<div class="timeline-list">
{% for snap in recent_changes %}
<div class="timeline-item">
<div class="timeline-date">{{ snap.snapshot_date.strftime('%d.%m.%Y') }}</div>
<div class="timeline-content">
<strong>{{ snap.competitor.competitor_name }}</strong>
{% if snap.changes %}
{% if snap.changes.get('new_reviews') %}
— {{ snap.changes.new_reviews }} nowych opinii
{% endif %}
{% if snap.changes.get('rating_change') %}
— ocena {{ '+' if snap.changes.rating_change > 0 else '' }}{{ snap.changes.rating_change }}
{% endif %}
{% if snap.changes.get('new_photos') %}
— {{ snap.changes.new_photos }} nowych zdjec
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
{% endblock %}