feat: Nowa sekcja Raporty w menu głównym

- Dodano link Raporty w menu nawigacyjnym (dla zalogowanych)
- Utworzono 3 raporty generowane w czasie rzeczywistym:
  - Staż członkostwa w Izbie NORDA
  - Pokrycie Social Media (6 platform)
  - Struktura branżowa (kategorie firm)
- Dodano dokumentację strategii monetyzacji 3-tier pricing
- Release notes v1.18.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-14 07:25:57 +01:00
parent 3c1e05baf5
commit 1b6e698d87
7 changed files with 1615 additions and 0 deletions

View File

@ -962,3 +962,87 @@ membership_fees (
- Dostęp do funduszy na rozwój platformy
- Możliwość świadczenia płatnych usług
- Elastyczność finansowa
## Strategia monetyzacji
### Model 3-tier Pricing (Kotwiczenie ceny)
**Strategia:** Trzy poziomy cenowe z zastosowaniem psychologii kotwiczenia ceny (Price Anchoring).
Najwyższy poziom służy jako **kotwica** - sprawia że środkowy wydaje się atrakcyjny i jest docelowy.
| Poziom | Nazwa | Cena/mies. | Cel strategiczny |
|--------|-------|------------|------------------|
| **1** | Basic | ~49 zł | Entry point, ograniczone funkcje |
| **2** | Premium | ~99 zł | **DOCELOWY** - rekomendowany, najlepsza wartość |
| **3** | Enterprise | ~199 zł | **KOTWICA** - premium, pełny dostęp |
**Wyjątek:** Członkowie Izby NORDA płacący składki (~200 zł/mies.) mają specjalny status - dostęp Premium za symboliczne 1 zł.
### Psychologia 3-tier Pricing
- Ludzie naturalnie wybierają środkową opcję (efekt kompromisu)
- Wysoka cena kotwicy sprawia że środkowa wydaje się "okazją"
- Niska cena Basic sprawia że użytkownik czuje "upgrade jest wart dopłaty"
- Firmy stosujące 3-tier widzą ~30% wzrost przychodów
- Slack: dodanie Enterprise tier zwiększyło konwersję na Professional o 28%
### Matryca dostępu do funkcji
| Funkcja | Basic | Premium | Enterprise |
|---------|:-----:|:-------:|:----------:|
| Katalog firm | ✅ | ✅ | ✅ |
| Profil firmy | ✅ | ✅ | ✅ |
| Forum | ❌ | ✅ | ✅ |
| Kalendarz wydarzeń | ❌ | ✅ | ✅ |
| Chat AI (NordaGPT) | ❌ | ✅ | ✅ |
| **Raporty podstawowe** | ❌ | ✅ | ✅ |
| **Raporty zaawansowane** | ❌ | ❌ | ✅ |
| Eksport danych (CSV/PDF) | ❌ | ❌ | ✅ |
| API dostęp | ❌ | ❌ | ✅ |
| Priorytetowe wsparcie | ❌ | ❌ | ✅ |
### Implementacja techniczna (przyszłość)
```python
# Model User - nowe pola
class User(Base):
# ...
subscription_tier = Column(String(20), default='basic') # basic, premium, enterprise
subscription_expires_at = Column(DateTime)
is_norda_member = Column(Boolean, default=False) # Członek Izby = specjalny status
# Dekorator kontroli dostępu
def requires_tier(min_tier):
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
tiers = ['basic', 'premium', 'enterprise']
user_tier_idx = tiers.index(current_user.subscription_tier)
required_idx = tiers.index(min_tier)
if user_tier_idx < required_idx:
flash(f'Ta funkcja wymaga konta {min_tier.title()}.', 'warning')
return redirect(url_for('pricing'))
return f(*args, **kwargs)
return wrapped
return decorator
# Użycie
@app.route('/raporty/zaawansowane')
@login_required
@requires_tier('enterprise')
def advanced_reports():
...
```
### Raporty - podział według poziomu
**Raporty podstawowe (Premium+):**
- Staż członkostwa w Izbie NORDA
- Pokrycie Social Media
- Struktura branżowa
**Raporty zaawansowane (Enterprise only):**
- Ranking SEO
- Mapa lokalizacji
- Sieć rekomendacji
- Aktywność w wydarzeniach

184
app.py
View File

@ -8541,6 +8541,178 @@ def api_it_audit_export():
db.close()
# ============================================================
# RAPORTY
# ============================================================
@app.route('/raporty')
@login_required
def reports_index():
"""Lista dostępnych raportów."""
reports = [
{
'id': 'staz-czlonkostwa',
'title': 'Staż członkostwa w Izbie NORDA',
'description': 'Zestawienie firm według daty przystąpienia do Izby. Pokazuje historię i lojalność członków.',
'icon': '🏆',
'url': url_for('report_membership')
},
{
'id': 'social-media',
'title': 'Pokrycie Social Media',
'description': 'Analiza obecności firm w mediach społecznościowych: Facebook, Instagram, LinkedIn, YouTube, TikTok, X.',
'icon': '📱',
'url': url_for('report_social_media')
},
{
'id': 'struktura-branzowa',
'title': 'Struktura branżowa',
'description': 'Rozkład firm według kategorii działalności: IT, Budownictwo, Usługi, Produkcja, Handel.',
'icon': '🏢',
'url': url_for('report_categories')
},
]
return render_template('reports/index.html', reports=reports)
@app.route('/raporty/staz-czlonkostwa')
@login_required
def report_membership():
"""Raport: Staż członkostwa w Izbie NORDA."""
from datetime import date
db = SessionLocal()
try:
# Firmy z member_since, posortowane od najstarszego
companies = db.query(Company).filter(
Company.member_since.isnot(None)
).order_by(Company.member_since.asc()).all()
# Statystyki
today = date.today()
stats = {
'total_with_date': len(companies),
'total_without_date': db.query(Company).filter(
Company.member_since.is_(None)
).count(),
'oldest': companies[0] if companies else None,
'newest': companies[-1] if companies else None,
'avg_years': sum(
(today - c.member_since).days / 365.25
for c in companies
) / len(companies) if companies else 0
}
# Dodaj obliczony staż do każdej firmy
for c in companies:
c.membership_years = int((today - c.member_since).days / 365.25)
# Dodaj też do oldest i newest
if stats['oldest']:
stats['oldest'].membership_years = int((today - stats['oldest'].member_since).days / 365.25)
return render_template(
'reports/membership.html',
companies=companies,
stats=stats,
generated_at=datetime.now()
)
finally:
db.close()
@app.route('/raporty/social-media')
@login_required
def report_social_media():
"""Raport: Pokrycie Social Media."""
from sqlalchemy.orm import joinedload
db = SessionLocal()
try:
# Wszystkie firmy z ich profilami social media
companies = db.query(Company).options(
joinedload(Company.social_media_profiles)
).order_by(Company.name).all()
platforms = ['facebook', 'instagram', 'linkedin', 'youtube', 'tiktok', 'twitter']
# Statystyki platform
platform_stats = {}
for platform in platforms:
count = db.query(CompanySocialMedia).filter_by(
platform=platform
).count()
platform_stats[platform] = {
'count': count,
'percent': round(count / len(companies) * 100, 1) if companies else 0
}
# Firmy z min. 1 profilem
companies_with_social = [
c for c in companies if c.social_media_profiles
]
stats = {
'total_companies': len(companies),
'with_social': len(companies_with_social),
'without_social': len(companies) - len(companies_with_social),
'coverage_percent': round(
len(companies_with_social) / len(companies) * 100, 1
) if companies else 0
}
return render_template(
'reports/social_media.html',
companies=companies,
platforms=platforms,
platform_stats=platform_stats,
stats=stats,
generated_at=datetime.now()
)
finally:
db.close()
@app.route('/raporty/struktura-branzowa')
@login_required
def report_categories():
"""Raport: Struktura branżowa."""
from sqlalchemy import func
db = SessionLocal()
try:
# Grupowanie po kategoriach
category_counts = db.query(
Company.category,
func.count(Company.id).label('count')
).group_by(Company.category).all()
total = sum(c.count for c in category_counts)
categories = []
for cat in category_counts:
cat_name = cat.category or 'Brak kategorii'
examples = db.query(Company.name).filter_by(
category=cat.category
).limit(3).all()
categories.append({
'name': cat_name,
'count': cat.count,
'percent': round(cat.count / total * 100, 1) if total else 0,
'examples': [e.name for e in examples]
})
# Sortuj od największej
categories.sort(key=lambda x: x['count'], reverse=True)
return render_template(
'reports/categories.html',
categories=categories,
total=total,
generated_at=datetime.now()
)
finally:
db.close()
# ============================================================
# RELEASE NOTES
# ============================================================
@ -8549,6 +8721,18 @@ def api_it_audit_export():
def release_notes():
"""Historia zmian platformy."""
releases = [
{
'version': 'v1.18.0',
'date': '14 stycznia 2026',
'badges': ['new'],
'new': [
'Nowa sekcja: Raporty - dostępna z menu głównego dla zalogowanych',
'Raport: Staż członkostwa w Izbie NORDA (sortowanie od najstarszego członka)',
'Raport: Pokrycie Social Media (analiza 6 platform dla wszystkich firm)',
'Raport: Struktura branżowa (rozkład firm wg kategorii działalności)',
'Raporty generowane w czasie rzeczywistym z informacją o źródle danych',
],
},
{
'version': 'v1.17.0',
'date': '14 stycznia 2026',

View File

@ -932,6 +932,9 @@
</ul>
</li>
<!-- Raporty -->
<li><a href="{{ url_for('reports_index') }}" class="nav-link {% if request.endpoint and request.endpoint.startswith('report') %}active{% endif %}">Raporty</a></li>
<!-- Notifications -->
<li class="notifications-dropdown">
<button class="notifications-trigger nav-link-with-badge" onclick="toggleNotifications(event)" aria-label="Powiadomienia">

View File

@ -0,0 +1,457 @@
{% extends "base.html" %}
{% block title %}Struktura branżowa - Raporty - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.report-header {
margin-bottom: var(--spacing-xl);
}
.report-header .back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-md);
}
.report-header .back-link:hover {
color: var(--primary);
}
.report-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.report-header h1 span {
font-size: 2rem;
}
.report-meta {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border: 1px solid #86efac;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.report-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
}
.report-meta-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--font-size-sm);
color: #166534;
}
.report-meta-item svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
text-align: center;
}
.stat-card.highlight {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-color: #fbbf24;
}
.stat-icon {
font-size: 1.5rem;
margin-bottom: var(--spacing-sm);
}
.stat-value {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--text-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-lg);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.categories-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
.category-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
position: relative;
overflow: hidden;
}
.category-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--primary);
}
.category-card.rank-1::before {
background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
}
.category-card.rank-2::before {
background: linear-gradient(180deg, #a855f7 0%, #9333ea 100%);
}
.category-card.rank-3::before {
background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
}
.category-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-md);
}
.category-name {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
}
.category-count-badge {
background: var(--primary);
color: white;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 600;
}
.category-card.rank-1 .category-count-badge {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
}
.category-card.rank-2 .category-count-badge {
background: linear-gradient(135deg, #a855f7 0%, #9333ea 100%);
}
.category-card.rank-3 .category-count-badge {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
}
.category-bar {
height: 8px;
background: var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: var(--spacing-md);
}
.category-bar-fill {
height: 100%;
background: var(--primary);
border-radius: var(--radius);
transition: width 0.5s ease;
}
.category-card.rank-1 .category-bar-fill {
background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%);
}
.category-card.rank-2 .category-bar-fill {
background: linear-gradient(90deg, #a855f7 0%, #9333ea 100%);
}
.category-card.rank-3 .category-bar-fill {
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
}
.category-stats {
display: flex;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.category-stat {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.category-examples {
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.6;
}
.category-examples strong {
color: var(--text-primary);
}
.data-table-container {
background: var(--surface);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.data-table tbody tr:hover {
background: var(--background);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
font-weight: 600;
font-size: var(--font-size-sm);
}
.rank-badge.gold {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: white;
}
.rank-badge.silver {
background: linear-gradient(135deg, #a855f7 0%, #9333ea 100%);
color: white;
}
.rank-badge.bronze {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
}
.rank-badge.regular {
background: var(--background);
color: var(--text-secondary);
}
.percent-bar {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.percent-bar-bg {
flex: 1;
height: 8px;
background: var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.percent-bar-fill {
height: 100%;
background: var(--primary);
border-radius: var(--radius);
}
.percent-value {
min-width: 50px;
text-align: right;
font-weight: 500;
color: var(--text-primary);
}
@media (max-width: 768px) {
.categories-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="report-header">
<a href="{{ url_for('reports_index') }}" class="back-link">
<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="M15 19l-7-7 7-7"/>
</svg>
Powrót do raportów
</a>
<h1><span>&#x1F3E2;</span> Struktura branżowa</h1>
</div>
<div class="report-meta">
<div class="report-meta-grid">
<div class="report-meta-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span>Wygenerowano: {{ generated_at.strftime('%d.%m.%Y, %H:%M:%S') }}</span>
</div>
<div class="report-meta-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
</svg>
<span>Źródło: baza danych nordabiznes.pl</span>
</div>
<div class="report-meta-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
<span>Firm w raporcie: {{ total }}</span>
</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-card highlight">
<div class="stat-icon">&#x1F3C6;</div>
<div class="stat-value">{{ categories[0].name if categories else '-' }}</div>
<div class="stat-label">Największa kategoria</div>
</div>
<div class="stat-card">
<div class="stat-icon">&#x1F4CA;</div>
<div class="stat-value">{{ categories|length }}</div>
<div class="stat-label">Kategorii łącznie</div>
</div>
<div class="stat-card">
<div class="stat-icon">&#x1F3E2;</div>
<div class="stat-value">{{ total }}</div>
<div class="stat-label">Firm łącznie</div>
</div>
</div>
<h2 class="section-title">&#x1F4CA; Przegląd kategorii</h2>
<div class="categories-grid">
{% for category in categories[:6] %}
<div class="category-card {% if loop.index == 1 %}rank-1{% elif loop.index == 2 %}rank-2{% elif loop.index == 3 %}rank-3{% endif %}">
<div class="category-header">
<div class="category-name">{{ category.name }}</div>
<div class="category-count-badge">{{ category.count }} firm</div>
</div>
<div class="category-bar">
<div class="category-bar-fill" style="width: {{ category.percent }}%"></div>
</div>
<div class="category-stats">
<div class="category-stat">
<span>&#x1F4C8;</span>
<span>{{ category.percent }}% udziału</span>
</div>
</div>
<div class="category-examples">
<strong>Przykłady:</strong> {{ category.examples|join(', ') }}{% if category.count > 3 %}...{% endif %}
</div>
</div>
{% endfor %}
</div>
<h2 class="section-title">&#x1F4CB; Pełna tabela</h2>
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th style="width: 60px;">#</th>
<th>Kategoria</th>
<th style="width: 100px;">Liczba firm</th>
<th>Udział</th>
<th>Przykłady firm</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr>
<td>
{% if loop.index == 1 %}
<span class="rank-badge gold">1</span>
{% elif loop.index == 2 %}
<span class="rank-badge silver">2</span>
{% elif loop.index == 3 %}
<span class="rank-badge bronze">3</span>
{% else %}
<span class="rank-badge regular">{{ loop.index }}</span>
{% endif %}
</td>
<td><strong>{{ category.name }}</strong></td>
<td>{{ category.count }}</td>
<td>
<div class="percent-bar">
<div class="percent-bar-bg">
<div class="percent-bar-fill" style="width: {{ category.percent }}%"></div>
</div>
<span class="percent-value">{{ category.percent }}%</span>
</div>
</td>
<td>{{ category.examples|join(', ') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,174 @@
{% extends "base.html" %}
{% block title %}Raporty - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.reports-header {
margin-bottom: var(--spacing-xl);
}
.reports-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.reports-header p {
color: var(--text-secondary);
font-size: var(--font-size-lg);
}
.reports-info-banner {
background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
border: 1px solid #7dd3fc;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
}
.reports-info-banner svg {
width: 24px;
height: 24px;
color: #0284c7;
flex-shrink: 0;
margin-top: 2px;
}
.reports-info-banner .info-content {
flex: 1;
}
.reports-info-banner .info-title {
font-weight: 600;
color: #0c4a6e;
margin-bottom: var(--spacing-xs);
}
.reports-info-banner .info-text {
color: #0369a1;
font-size: var(--font-size-sm);
}
.reports-grid {
display: grid;
gap: var(--spacing-lg);
}
.report-card {
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
transition: all 0.2s ease;
display: flex;
gap: var(--spacing-lg);
}
.report-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.report-icon {
font-size: 2.5rem;
flex-shrink: 0;
}
.report-content {
flex: 1;
}
.report-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.report-description {
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
line-height: 1.6;
}
.report-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
color: var(--primary);
text-decoration: none;
font-weight: 500;
font-size: var(--font-size-sm);
}
.report-link:hover {
color: var(--primary-dark);
}
.report-link svg {
width: 16px;
height: 16px;
transition: transform 0.2s;
}
.report-card:hover .report-link svg {
transform: translateX(4px);
}
@media (max-width: 640px) {
.report-card {
flex-direction: column;
text-align: center;
}
.report-link {
justify-content: center;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="reports-header">
<h1>Raporty</h1>
<p>Analizy i statystyki członków Izby NORDA</p>
</div>
<div class="reports-info-banner">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="info-content">
<div class="info-title">Raporty generowane w czasie rzeczywistym</div>
<div class="info-text">
Wszystkie raporty są generowane na bieżąco z danych systemu nordabiznes.pl.
Każdy raport zawiera informację o czasie wygenerowania i źródle danych.
</div>
</div>
</div>
<div class="reports-grid">
{% for report in reports %}
<div class="report-card">
<div class="report-icon">{{ report.icon }}</div>
<div class="report-content">
<div class="report-title">{{ report.title }}</div>
<div class="report-description">{{ report.description }}</div>
<a href="{{ report.url }}" class="report-link">
Zobacz raport
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
</svg>
</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,324 @@
{% extends "base.html" %}
{% block title %}Staż członkostwa - Raporty - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.report-header {
margin-bottom: var(--spacing-xl);
}
.report-header .back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-md);
}
.report-header .back-link:hover {
color: var(--primary);
}
.report-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.report-header h1 span {
font-size: 2rem;
}
.report-meta {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border: 1px solid #86efac;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.report-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
}
.report-meta-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--font-size-sm);
color: #166534;
}
.report-meta-item svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
text-align: center;
}
.stat-card.highlight {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-color: #fbbf24;
}
.stat-icon {
font-size: 1.5rem;
margin-bottom: var(--spacing-sm);
}
.stat-value {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--text-primary);
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.stat-detail {
font-size: var(--font-size-xs);
color: var(--text-muted, #9ca3af);
margin-top: var(--spacing-xs);
}
.data-table-container {
background: var(--surface);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.data-table tbody tr:hover {
background: var(--background);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.company-link {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.company-link:hover {
text-decoration: underline;
}
.years-badge {
display: inline-block;
background: var(--primary);
color: white;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
min-width: 60px;
text-align: center;
}
.years-badge.veteran {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
}
.years-badge.senior {
background: linear-gradient(135deg, #a855f7 0%, #9333ea 100%);
}
.years-badge.regular {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
}
.years-badge.new {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.category-badge {
display: inline-block;
background: var(--background);
color: var(--text-secondary);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius);
font-size: var(--font-size-xs);
}
.row-number {
color: var(--text-muted, #9ca3af);
font-size: var(--font-size-sm);
width: 40px;
}
@media (max-width: 768px) {
.data-table-container {
overflow-x: auto;
}
.data-table {
min-width: 600px;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="report-header">
<a href="{{ url_for('reports_index') }}" class="back-link">
<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="M15 19l-7-7 7-7"/>
</svg>
Powrót do raportów
</a>
<h1><span>&#x1F3C6;</span> Staż członkostwa w Izbie NORDA</h1>
</div>
<div class="report-meta">
<div class="report-meta-grid">
<div class="report-meta-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span>Wygenerowano: {{ generated_at.strftime('%d.%m.%Y, %H:%M:%S') }}</span>
</div>
<div class="report-meta-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
</svg>
<span>Źródło: baza danych nordabiznes.pl</span>
</div>
<div class="report-meta-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
<span>Firm w raporcie: {{ stats.total_with_date }}</span>
</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-card highlight">
<div class="stat-icon">&#x1F451;</div>
<div class="stat-value">{{ stats.oldest.name if stats.oldest else '-' }}</div>
<div class="stat-label">Najstarszy członek</div>
<div class="stat-detail">
{% if stats.oldest %}
od {{ stats.oldest.member_since.strftime('%d.%m.%Y') }} ({{ stats.oldest.membership_years }} lat)
{% endif %}
</div>
</div>
<div class="stat-card">
<div class="stat-icon">&#x1F195;</div>
<div class="stat-value">{{ stats.newest.name if stats.newest else '-' }}</div>
<div class="stat-label">Najnowszy członek</div>
<div class="stat-detail">
{% if stats.newest %}
od {{ stats.newest.member_since.strftime('%d.%m.%Y') }}
{% endif %}
</div>
</div>
<div class="stat-card">
<div class="stat-icon">&#x1F4CA;</div>
<div class="stat-value">{{ "%.1f"|format(stats.avg_years) }} lat</div>
<div class="stat-label">Średni staż</div>
<div class="stat-detail">wszystkich członków</div>
</div>
<div class="stat-card">
<div class="stat-icon">&#x2753;</div>
<div class="stat-value">{{ stats.total_without_date }}</div>
<div class="stat-label">Bez daty</div>
<div class="stat-detail">firmy do uzupełnienia</div>
</div>
</div>
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Firma</th>
<th>Data przystąpienia</th>
<th>Staż</th>
<th>Kategoria</th>
</tr>
</thead>
<tbody>
{% for company in companies %}
<tr>
<td class="row-number">{{ loop.index }}</td>
<td>
<a href="{{ url_for('company_detail', slug=company.slug) }}" class="company-link">
{{ company.name }}
</a>
</td>
<td>{{ company.member_since.strftime('%d.%m.%Y') }}</td>
<td>
{% if company.membership_years >= 20 %}
<span class="years-badge veteran">{{ company.membership_years }} lat</span>
{% elif company.membership_years >= 10 %}
<span class="years-badge senior">{{ company.membership_years }} lat</span>
{% elif company.membership_years >= 5 %}
<span class="years-badge regular">{{ company.membership_years }} lat</span>
{% else %}
<span class="years-badge new">{{ company.membership_years }} lat</span>
{% endif %}
</td>
<td>
{% if company.category %}
<span class="category-badge">{{ company.category }}</span>
{% else %}
<span class="category-badge">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,389 @@
{% extends "base.html" %}
{% block title %}Pokrycie Social Media - Raporty - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.report-header {
margin-bottom: var(--spacing-xl);
}
.report-header .back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-md);
}
.report-header .back-link:hover {
color: var(--primary);
}
.report-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.report-header h1 span {
font-size: 2rem;
}
.report-meta {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border: 1px solid #86efac;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.report-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
}
.report-meta-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--font-size-sm);
color: #166534;
}
.report-meta-item svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
text-align: center;
}
.stat-card.highlight {
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
border-color: #3b82f6;
}
.stat-icon {
font-size: 1.5rem;
margin-bottom: var(--spacing-sm);
}
.stat-value {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--text-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-lg);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.platforms-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-2xl);
}
.platform-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
text-align: center;
}
.platform-icon {
font-size: 2rem;
margin-bottom: var(--spacing-sm);
}
.platform-name {
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
text-transform: capitalize;
}
.platform-count {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--primary);
}
.platform-percent {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.data-table-container {
background: var(--surface);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.data-table th.platform-col {
text-align: center;
width: 50px;
}
.data-table tbody tr:hover {
background: var(--background);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.company-link {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.company-link:hover {
text-decoration: underline;
}
.check-icon {
display: inline-flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: var(--font-size-sm);
}
.check-icon.has {
background: #dcfce7;
color: #16a34a;
}
.check-icon.missing {
background: #fee2e2;
color: #dc2626;
}
.platform-cell {
text-align: center;
}
.total-badge {
display: inline-block;
background: var(--primary);
color: white;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
min-width: 30px;
text-align: center;
}
.total-badge.high {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.total-badge.medium {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
}
.total-badge.low {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
.total-badge.none {
background: var(--secondary);
}
@media (max-width: 768px) {
.data-table-container {
overflow-x: auto;
}
.data-table {
min-width: 700px;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="report-header">
<a href="{{ url_for('reports_index') }}" class="back-link">
<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="M15 19l-7-7 7-7"/>
</svg>
Powrót do raportów
</a>
<h1><span>&#x1F4F1;</span> Pokrycie Social Media</h1>
</div>
<div class="report-meta">
<div class="report-meta-grid">
<div class="report-meta-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span>Wygenerowano: {{ generated_at.strftime('%d.%m.%Y, %H:%M:%S') }}</span>
</div>
<div class="report-meta-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
</svg>
<span>Źródło: baza danych nordabiznes.pl</span>
</div>
<div class="report-meta-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
<span>Firm w raporcie: {{ stats.total_companies }}</span>
</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-card highlight">
<div class="stat-icon">&#x2705;</div>
<div class="stat-value">{{ stats.with_social }}</div>
<div class="stat-label">Firmy z Social Media</div>
</div>
<div class="stat-card">
<div class="stat-icon">&#x274C;</div>
<div class="stat-value">{{ stats.without_social }}</div>
<div class="stat-label">Bez Social Media</div>
</div>
<div class="stat-card">
<div class="stat-icon">&#x1F4C8;</div>
<div class="stat-value">{{ stats.coverage_percent }}%</div>
<div class="stat-label">Pokrycie</div>
</div>
</div>
<h2 class="section-title">&#x1F4CA; Statystyki platform</h2>
<div class="platforms-grid">
{% for platform in platforms %}
<div class="platform-card">
<div class="platform-icon">
{% if platform == 'facebook' %}&#x1F4D8;
{% elif platform == 'instagram' %}&#x1F4F7;
{% elif platform == 'linkedin' %}&#x1F4BC;
{% elif platform == 'youtube' %}&#x1F4FA;
{% elif platform == 'tiktok' %}&#x1F3B5;
{% elif platform == 'twitter' %}&#x1F426;
{% endif %}
</div>
<div class="platform-name">{{ platform }}</div>
<div class="platform-count">{{ platform_stats[platform].count }}</div>
<div class="platform-percent">{{ platform_stats[platform].percent }}%</div>
</div>
{% endfor %}
</div>
<h2 class="section-title">&#x1F4CB; Szczegółowa tabela</h2>
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th>Firma</th>
<th class="platform-col" title="Facebook">FB</th>
<th class="platform-col" title="Instagram">IG</th>
<th class="platform-col" title="LinkedIn">LI</th>
<th class="platform-col" title="YouTube">YT</th>
<th class="platform-col" title="TikTok">TT</th>
<th class="platform-col" title="Twitter/X">X</th>
<th>Razem</th>
</tr>
</thead>
<tbody>
{% for company in companies %}
{% set profile_count = company.social_media_profiles|length %}
<tr>
<td>
<a href="{{ url_for('company_detail', slug=company.slug) }}" class="company-link">
{{ company.name }}
</a>
</td>
{% for platform in platforms %}
<td class="platform-cell">
{% set has_platform = company.social_media_profiles|selectattr('platform', 'equalto', platform)|list|length > 0 %}
{% if has_platform %}
<span class="check-icon has">&#x2713;</span>
{% else %}
<span class="check-icon missing">&#x2717;</span>
{% endif %}
</td>
{% endfor %}
<td>
{% if profile_count >= 4 %}
<span class="total-badge high">{{ profile_count }}</span>
{% elif profile_count >= 2 %}
<span class="total-badge medium">{{ profile_count }}</span>
{% elif profile_count == 1 %}
<span class="total-badge low">{{ profile_count }}</span>
{% else %}
<span class="total-badge none">0</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}