feat(social-audit): add activity, completeness & engagement metrics to dashboard
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
Display existing but unused model fields: last_post_date activity indicator, profile_completeness_score progress bar, engagement rate, posts count. Add KPI cards for avg completeness, engagement, inactive companies. Add activity filter. Fix Polish diacritics throughout template. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cb5c599d24
commit
c1df642aad
@ -165,7 +165,12 @@ def admin_social_audit():
|
||||
CompanySocialMedia.followers_count,
|
||||
CompanySocialMedia.verified_at,
|
||||
CompanySocialMedia.is_valid,
|
||||
CompanySocialMedia.check_status
|
||||
CompanySocialMedia.check_status,
|
||||
CompanySocialMedia.profile_completeness_score,
|
||||
CompanySocialMedia.engagement_rate,
|
||||
CompanySocialMedia.last_post_date,
|
||||
CompanySocialMedia.posts_count_30d,
|
||||
CompanySocialMedia.posting_frequency_score
|
||||
).filter(
|
||||
or_(
|
||||
CompanySocialMedia.is_valid == True,
|
||||
@ -183,7 +188,12 @@ def admin_social_audit():
|
||||
'url': sm.url,
|
||||
'followers': sm.followers_count or 0,
|
||||
'verified_at': sm.verified_at,
|
||||
'needs_verification': sm.check_status == 'needs_verification'
|
||||
'needs_verification': sm.check_status == 'needs_verification',
|
||||
'completeness': sm.profile_completeness_score or 0,
|
||||
'engagement_rate': sm.engagement_rate or 0,
|
||||
'last_post_date': sm.last_post_date,
|
||||
'posts_30d': sm.posts_count_30d or 0,
|
||||
'posting_freq': sm.posting_frequency_score or 0
|
||||
}
|
||||
if sm.check_status == 'needs_verification':
|
||||
needs_verification_items.append({
|
||||
@ -223,6 +233,19 @@ def admin_social_audit():
|
||||
if 'youtube' not in sm_data:
|
||||
recommendations.append('Brak YouTube')
|
||||
|
||||
# Aggregate engagement & completeness across platforms
|
||||
completeness_scores = [p.get('completeness', 0) for p in sm_data.values() if p.get('completeness')]
|
||||
avg_completeness = round(sum(completeness_scores) / len(completeness_scores)) if completeness_scores else 0
|
||||
|
||||
engagement_rates = [p.get('engagement_rate', 0) for p in sm_data.values() if p.get('engagement_rate')]
|
||||
avg_engagement = round(sum(engagement_rates) / len(engagement_rates), 2) if engagement_rates else 0
|
||||
|
||||
total_posts_30d = sum(p.get('posts_30d', 0) for p in sm_data.values())
|
||||
|
||||
# Last post date across all platforms
|
||||
post_dates = [p.get('last_post_date') for p in sm_data.values() if p.get('last_post_date')]
|
||||
last_post = max(post_dates) if post_dates else None
|
||||
|
||||
companies.append({
|
||||
'id': row.id,
|
||||
'name': row.name,
|
||||
@ -240,7 +263,11 @@ def admin_social_audit():
|
||||
'has_twitter': 'twitter' in sm_data,
|
||||
'has_tiktok': 'tiktok' in sm_data,
|
||||
'has_needs_verification': len(needs_verify_platforms) > 0,
|
||||
'recommendations': recommendations
|
||||
'recommendations': recommendations,
|
||||
'avg_completeness': avg_completeness,
|
||||
'avg_engagement': avg_engagement,
|
||||
'total_posts_30d': total_posts_30d,
|
||||
'last_post': last_post
|
||||
})
|
||||
|
||||
# Platform statistics
|
||||
@ -270,6 +297,18 @@ def admin_social_audit():
|
||||
for item in needs_verification_items:
|
||||
item['company_name'] = company_names.get(item['company_id'], 'Nieznana')
|
||||
|
||||
# Aggregate engagement & activity stats
|
||||
all_completeness = [c['avg_completeness'] for c in companies if c['avg_completeness'] > 0]
|
||||
avg_completeness_global = round(sum(all_completeness) / len(all_completeness)) if all_completeness else 0
|
||||
|
||||
all_engagement = [c['avg_engagement'] for c in companies if c['avg_engagement'] > 0]
|
||||
avg_engagement_global = round(sum(all_engagement) / len(all_engagement), 2) if all_engagement else 0
|
||||
|
||||
now = datetime.now()
|
||||
inactive_30d = len([c for c in companies if c['platform_count'] > 0 and (
|
||||
not c['last_post'] or (now - c['last_post']).days > 30
|
||||
)])
|
||||
|
||||
stats = {
|
||||
'total_companies': total_companies,
|
||||
'companies_with_sm': companies_with_sm,
|
||||
@ -277,7 +316,10 @@ def admin_social_audit():
|
||||
'total_profiles': total_profiles,
|
||||
'total_followers': total_followers,
|
||||
'needs_verification_count': len(needs_verification_items),
|
||||
'platform_stats': platform_stats
|
||||
'platform_stats': platform_stats,
|
||||
'avg_completeness': avg_completeness_global,
|
||||
'avg_engagement': avg_engagement_global,
|
||||
'inactive_30d': inactive_30d
|
||||
}
|
||||
|
||||
# Get unique categories
|
||||
|
||||
@ -420,6 +420,52 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Activity indicator */
|
||||
.activity-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--font-size-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.activity-dot::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.activity-dot.active::before { background: #22c55e; }
|
||||
.activity-dot.warning::before { background: #f59e0b; }
|
||||
.activity-dot.inactive::before { background: #ef4444; }
|
||||
.activity-dot.unknown::before { background: #d1d5db; }
|
||||
|
||||
/* Completeness bar (mini) */
|
||||
.completeness-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.completeness-track {
|
||||
width: 60px;
|
||||
height: 6px;
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.completeness-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.completeness-fill.high { background: #22c55e; }
|
||||
.completeness-fill.medium { background: #f59e0b; }
|
||||
.completeness-fill.low { background: #ef4444; }
|
||||
.completeness-value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
@ -459,12 +505,12 @@
|
||||
<div class="admin-header">
|
||||
<div>
|
||||
<h1>Panel Audyt Social Media</h1>
|
||||
<p class="text-muted">Analiza obecnosci w mediach spolecznosciowych czlonkow Norda Biznes</p>
|
||||
<p class="text-muted">Analiza obecności w mediach społecznościowych członków Norda Biznes</p>
|
||||
<div class="data-source-info">
|
||||
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span>Dane z Facebook, Instagram, LinkedIn, YouTube, Twitter/X, TikTok</span>
|
||||
<span>Dane z: Facebook, Instagram, LinkedIn, YouTube, Twitter/X, TikTok</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@ -472,7 +518,7 @@
|
||||
<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 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
Szczegoly
|
||||
Szczegóły
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -493,12 +539,26 @@
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number blue">{{ stats.total_profiles }}</span>
|
||||
<span class="stat-label">Lacznie profili</span>
|
||||
<span class="stat-label">Łącznie profili</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number">{{ "{:,}".format(stats.total_followers).replace(",", " ") }}</span>
|
||||
<span class="stat-label">Lacznie obserwujacych</span>
|
||||
<span class="stat-label">Łącznie obserwujących</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number {{ 'green' if stats.avg_completeness >= 60 else ('yellow' if stats.avg_completeness >= 30 else 'red') }}">{{ stats.avg_completeness }}%</span>
|
||||
<span class="stat-label">Śr. kompletność profilu</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number">{{ stats.avg_engagement }}%</span>
|
||||
<span class="stat-label">Śr. engagement</span>
|
||||
</div>
|
||||
{% if stats.inactive_30d > 0 %}
|
||||
<div class="stat-card" style="border-top: 4px solid var(--error);">
|
||||
<span class="stat-number red">{{ stats.inactive_30d }}</span>
|
||||
<span class="stat-label">Nieaktywne >30 dni</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if stats.needs_verification_count > 0 %}
|
||||
<div class="stat-card" style="border-top: 4px solid #f59e0b;">
|
||||
<span class="stat-number yellow">{{ stats.needs_verification_count }}</span>
|
||||
@ -514,7 +574,7 @@
|
||||
<svg width="20" height="20" fill="none" stroke="#f59e0b" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
Profile do recznej weryfikacji
|
||||
Profile do ręcznej weryfikacji
|
||||
</h2>
|
||||
<div style="display: grid; gap: var(--spacing-sm);">
|
||||
{% for item in needs_verification %}
|
||||
@ -565,7 +625,7 @@
|
||||
<!-- Top Followers Section -->
|
||||
{% if top_followers %}
|
||||
<div class="top-followers">
|
||||
<h2 class="section-title">Top 10 - Najwiecej obserwujacych</h2>
|
||||
<h2 class="section-title">Top 10 — Najwięcej obserwujących</h2>
|
||||
<div class="top-followers-list">
|
||||
{% for company in top_followers %}
|
||||
<div class="top-follower-item">
|
||||
@ -574,7 +634,7 @@
|
||||
<div class="top-company-name">
|
||||
<a href="{{ url_for('company_detail', company_id=company.id) }}">{{ company.name }}</a>
|
||||
</div>
|
||||
<div class="top-followers-count">{{ "{:,}".format(company.total_followers).replace(",", " ") }} obserwujacych</div>
|
||||
<div class="top-followers-count">{{ "{:,}".format(company.total_followers).replace(",", " ") }} obserwujących</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@ -606,6 +666,16 @@
|
||||
<option value="none">Bez profili</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filterActivity">Aktywność:</label>
|
||||
<select id="filterActivity" onchange="applyFilters()">
|
||||
<option value="">Wszystkie</option>
|
||||
<option value="active">Aktywne (≤7 dni)</option>
|
||||
<option value="warning">Spadek (≤30 dni)</option>
|
||||
<option value="inactive">Nieaktywne (>30 dni)</option>
|
||||
<option value="unknown">Brak danych</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filterSearch">Szukaj:</label>
|
||||
<input type="text" id="filterSearch" placeholder="Nazwa firmy..." oninput="applyFilters()">
|
||||
@ -632,7 +702,13 @@
|
||||
Liczba <span class="sort-icon"></span>
|
||||
</th>
|
||||
<th data-sort="followers">
|
||||
Obserwujacy <span class="sort-icon"></span>
|
||||
Obserwujący <span class="sort-icon"></span>
|
||||
</th>
|
||||
<th data-sort="activity" class="hide-mobile">
|
||||
Aktywność <span class="sort-icon"></span>
|
||||
</th>
|
||||
<th data-sort="completeness" class="hide-mobile">
|
||||
Kompletność <span class="sort-icon"></span>
|
||||
</th>
|
||||
<th class="hide-mobile">Zalecenia</th>
|
||||
<th data-sort="date" class="hide-mobile">
|
||||
@ -643,10 +719,13 @@
|
||||
</thead>
|
||||
<tbody id="socialTableBody">
|
||||
{% for company in companies %}
|
||||
{% set activity_days = (now - company.last_post).days if company.last_post else 9999 %}
|
||||
<tr data-category="{{ company.category }}"
|
||||
data-name="{{ company.name|lower }}"
|
||||
data-platforms="{{ company.platform_count }}"
|
||||
data-followers="{{ company.total_followers }}"
|
||||
data-activity="{{ activity_days }}"
|
||||
data-completeness="{{ company.avg_completeness }}"
|
||||
data-date="{{ company.last_verified.isoformat() if company.last_verified else '1970-01-01' }}"
|
||||
data-has-facebook="{{ 'true' if company.has_facebook else 'false' }}"
|
||||
data-has-instagram="{{ 'true' if company.has_instagram else 'false' }}"
|
||||
@ -692,6 +771,40 @@
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="hide-mobile">
|
||||
{% if company.platform_count > 0 %}
|
||||
{% if company.last_post %}
|
||||
{% set days = (now - company.last_post).days %}
|
||||
{% if days <= 7 %}
|
||||
<span class="activity-dot active" title="Ostatni post: {{ company.last_post.strftime('%d.%m.%Y') }}">{{ days }}d</span>
|
||||
{% elif days <= 30 %}
|
||||
<span class="activity-dot warning" title="Ostatni post: {{ company.last_post.strftime('%d.%m.%Y') }}">{{ days }}d</span>
|
||||
{% else %}
|
||||
<span class="activity-dot inactive" title="Ostatni post: {{ company.last_post.strftime('%d.%m.%Y') }}">{{ days }}d</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="activity-dot unknown">b/d</span>
|
||||
{% endif %}
|
||||
{% if company.total_posts_30d > 0 %}
|
||||
<span style="font-size: 11px; color: var(--text-secondary); margin-left: 2px;" title="Posty w ostatnich 30 dniach">({{ company.total_posts_30d }})</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="hide-mobile">
|
||||
{% if company.avg_completeness > 0 %}
|
||||
<div class="completeness-bar">
|
||||
<div class="completeness-track">
|
||||
<div class="completeness-fill {{ 'high' if company.avg_completeness >= 60 else ('medium' if company.avg_completeness >= 30 else 'low') }}"
|
||||
style="width: {{ company.avg_completeness }}%"></div>
|
||||
</div>
|
||||
<span class="completeness-value">{{ company.avg_completeness }}%</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="hide-mobile" style="font-size: 11px; max-width: 200px;">
|
||||
{% if company.recommendations %}
|
||||
{% for rec in company.recommendations %}
|
||||
@ -731,8 +844,8 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>Brak firm do wyswietlenia</h3>
|
||||
<p>Nie znaleziono firm z danymi Social Media.</p>
|
||||
<h3>Brak firm do wyświetlenia</h3>
|
||||
<p>Nie znaleziono firm z danymi social media.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@ -776,6 +889,10 @@ function sortTable(column) {
|
||||
} else if (column === 'date') {
|
||||
aVal = new Date(a.dataset.date).getTime();
|
||||
bVal = new Date(b.dataset.date).getTime();
|
||||
} else if (column === 'activity') {
|
||||
// Lower days = more active, sort ascending by default
|
||||
aVal = parseFloat(a.dataset.activity) || 9999;
|
||||
bVal = parseFloat(b.dataset.activity) || 9999;
|
||||
} else {
|
||||
aVal = parseFloat(a.dataset[column]) || -1;
|
||||
bVal = parseFloat(b.dataset[column]) || -1;
|
||||
@ -799,6 +916,7 @@ document.querySelectorAll('.social-table th[data-sort]').forEach(th => {
|
||||
function applyFilters() {
|
||||
const category = document.getElementById('filterCategory').value;
|
||||
const platform = document.getElementById('filterPlatform').value;
|
||||
const activity = document.getElementById('filterActivity').value;
|
||||
const search = document.getElementById('filterSearch').value.toLowerCase();
|
||||
|
||||
const rows = document.querySelectorAll('#socialTableBody tr');
|
||||
@ -821,6 +939,15 @@ function applyFilters() {
|
||||
}
|
||||
}
|
||||
|
||||
// Activity filter
|
||||
if (activity && show) {
|
||||
const days = parseInt(row.dataset.activity) || 9999;
|
||||
if (activity === 'active' && days > 7) show = false;
|
||||
else if (activity === 'warning' && (days <= 7 || days > 30)) show = false;
|
||||
else if (activity === 'inactive' && days <= 30) show = false;
|
||||
else if (activity === 'unknown' && days !== 9999) show = false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (search && show) {
|
||||
if (!row.dataset.name.includes(search)) {
|
||||
@ -835,6 +962,7 @@ function applyFilters() {
|
||||
function resetFilters() {
|
||||
document.getElementById('filterCategory').value = '';
|
||||
document.getElementById('filterPlatform').value = '';
|
||||
document.getElementById('filterActivity').value = '';
|
||||
document.getElementById('filterSearch').value = '';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user