feat(dashboard): Add onboarding progress widget
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

Visual timeline showing company profile completion status:
- 6 steps computed from existing DB data (no new tables)
- Color-coded badges: member/office/auto responsibility
- Collapsible with localStorage persistence
- Green "complete" state when all steps done
- Action links for incomplete member-owned steps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-13 15:50:25 +01:00
parent 05a09812a5
commit b76d2752c6
3 changed files with 310 additions and 1 deletions

View File

@ -658,6 +658,10 @@ def dashboard():
for mgr, cid in managers:
company_managers_map.setdefault(cid, []).append({'name': mgr.name or 'Brak imienia', 'email': mgr.email or ''})
# Onboarding progress widget
from utils.onboarding import compute_onboarding_steps
onboarding = compute_onboarding_steps(current_user, user_companies, current_app.root_path)
# Widget 1: Upcoming events (3 nearest future events)
upcoming_events = db.query(NordaEvent).filter(
NordaEvent.event_date >= date.today()
@ -715,7 +719,8 @@ def dashboard():
recent_forum_topics=recent_forum_topics,
recent_classifieds=recent_classifieds,
new_companies=new_companies,
company_managers_map=company_managers_map
company_managers_map=company_managers_map,
onboarding=onboarding
)
finally:
db.close()

View File

@ -471,6 +471,134 @@
white-space: nowrap;
}
/* Onboarding widget */
.onboarding-widget {
background: var(--surface, #fff);
border-radius: 12px;
padding: 20px 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
margin-bottom: 24px;
}
.onboarding-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.onboarding-header h3 {
margin: 0;
font-size: 1rem;
display: flex;
align-items: center;
gap: 8px;
}
.onboarding-pct {
font-size: 0.85rem;
color: var(--text-secondary, #666);
font-weight: 400;
}
.onboarding-chevron {
transition: transform 0.2s;
color: var(--text-secondary, #666);
}
.onboarding-chevron.collapsed {
transform: rotate(-90deg);
}
.onboarding-progress {
height: 6px;
background: var(--border, #e5e7eb);
border-radius: 3px;
margin: 12px 0 0;
overflow: hidden;
}
.onboarding-progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.onboarding-steps {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 0;
}
.onboarding-step {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 0;
position: relative;
}
.onboarding-step:not(:last-child)::after {
content: '';
position: absolute;
left: 11px;
top: 34px;
bottom: -2px;
width: 2px;
background: var(--border, #e5e7eb);
}
.onboarding-step.done:not(:last-child)::after {
background: #22c55e;
}
.step-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 12px;
z-index: 1;
}
.step-icon.done {
background: #22c55e;
color: #fff;
}
.step-icon.pending {
background: var(--border, #e5e7eb);
color: var(--text-secondary, #999);
}
.step-content {
flex: 1;
min-width: 0;
}
.step-label {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.step-label.done {
color: var(--text-secondary, #666);
}
.step-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.step-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 1px 8px;
border-radius: 10px;
}
.step-badge.auto { background: #f3f4f6; color: #6b7280; }
.step-badge.member { background: #dbeafe; color: #1e40af; }
.step-badge.office { background: #fef3c7; color: #92400e; }
.step-badge.both { background: #ede9fe; color: #6d28d9; }
.step-action {
font-size: 0.8rem;
color: var(--primary, #0066cc);
text-decoration: none;
font-weight: 500;
}
.step-action:hover {
text-decoration: underline;
}
/* Forum topic item */
.topic-category-badge {
padding: 2px 8px;
@ -622,6 +750,70 @@
</div>
{% endif %}
{# Onboarding progress widget #}
{% if onboarding and not onboarding.all_complete %}
<div class="onboarding-widget">
<div class="onboarding-header" onclick="toggleOnboarding()">
<h3>
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
Postęp profilu firmy
<span class="onboarding-pct">{{ onboarding.completed }}/{{ onboarding.total }}</span>
</h3>
<svg class="onboarding-chevron" id="onboardingChevron" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
</div>
<div class="onboarding-progress">
<div class="onboarding-progress-fill" style="width: {{ onboarding.percentage }}%; background: {% if onboarding.percentage == 100 %}#22c55e{% elif onboarding.percentage >= 50 %}#3b82f6{% else %}#f59e0b{% endif %};"></div>
</div>
<div class="onboarding-steps" id="onboardingSteps">
{% for step in onboarding.steps %}
<div class="onboarding-step {{ 'done' if step.completed else '' }}">
<div class="step-icon {{ 'done' if step.completed else 'pending' }}">
{% if step.completed %}
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
{% else %}
{{ loop.index }}
{% endif %}
</div>
<div class="step-content">
<div class="step-label {{ 'done' if step.completed else '' }}">{{ step.label }}</div>
<div class="step-meta">
<span class="step-badge {{ step.responsible }}">{{ step.responsible_label }}</span>
{% if not step.completed and step.action_url %}
<a href="{{ step.action_url }}" class="step-action">Uzupełnij →</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% elif onboarding and onboarding.all_complete %}
<div class="onboarding-widget" style="border-left: 4px solid #22c55e;">
<div class="onboarding-header" onclick="toggleOnboarding()">
<h3 style="color: #15803d;">
<svg width="18" height="18" fill="none" stroke="#22c55e" 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>
Profil firmy kompletny!
</h3>
<svg class="onboarding-chevron collapsed" id="onboardingChevron" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
</div>
<div class="onboarding-progress">
<div class="onboarding-progress-fill" style="width: 100%; background: #22c55e;"></div>
</div>
<div class="onboarding-steps" id="onboardingSteps" style="display: none;">
{% for step in onboarding.steps %}
<div class="onboarding-step done">
<div class="step-icon done">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
</div>
<div class="step-content">
<div class="step-label done">{{ step.label }}</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# Membership CTA for users without company #}
{% if not user_companies %}
{% if has_pending_application %}
@ -1078,5 +1270,27 @@ function showEditPermissionModal(companyId) {
document.getElementById('editPermissionModal').addEventListener('click', function(e) {
if (e.target === this) this.classList.remove('active');
});
// Onboarding widget toggle
function toggleOnboarding() {
const steps = document.getElementById('onboardingSteps');
const chevron = document.getElementById('onboardingChevron');
if (!steps) return;
const hidden = steps.style.display === 'none';
steps.style.display = hidden ? '' : 'none';
chevron.classList.toggle('collapsed', !hidden);
try { localStorage.setItem('onboarding_collapsed', hidden ? '0' : '1'); } catch(e) {}
}
// Restore collapsed state
(function() {
try {
if (localStorage.getItem('onboarding_collapsed') === '1') {
const s = document.getElementById('onboardingSteps');
const c = document.getElementById('onboardingChevron');
if (s) { s.style.display = 'none'; }
if (c) { c.classList.add('collapsed'); }
}
} catch(e) {}
})();
</script>
{% endblock %}

90
utils/onboarding.py Normal file
View File

@ -0,0 +1,90 @@
"""Onboarding progress helper — computes steps from existing DB data."""
import os
def compute_onboarding_steps(user, user_companies, app_root):
"""Compute onboarding progress for dashboard widget.
Args:
user: Current User object (Flask-Login)
user_companies: List of UserCompany objects (with .company loaded)
app_root: current_app.root_path for file checks
Returns:
dict with steps, completed, total, percentage, all_complete
"""
company = user_companies[0].company if user_companies else None
steps = [
{
'key': 'registration',
'label': 'Rejestracja konta',
'completed': True,
'responsible': 'auto',
'responsible_label': 'Automatyczne',
},
{
'key': 'email_verified',
'label': 'Weryfikacja adresu e-mail',
'completed': getattr(user, 'is_verified', False),
'responsible': 'member',
'responsible_label': 'Ty',
},
{
'key': 'company_linked',
'label': 'Powiązanie z firmą',
'completed': len(user_companies) > 0,
'responsible': 'office',
'responsible_label': 'Sekretariat',
},
{
'key': 'basic_data',
'label': 'Dane podstawowe firmy',
'completed': bool(
company
and company.name
and getattr(company, 'nip', None)
and getattr(company, 'address_city', None)
),
'responsible': 'office',
'responsible_label': 'Sekretariat',
},
{
'key': 'description',
'label': 'Opis i oferta firmy',
'completed': bool(
company
and getattr(company, 'description_full', None)
and getattr(company, 'services_offered', None)
),
'responsible': 'member',
'responsible_label': 'Ty',
'action_url': f'/company/{company.id}/edit' if company else None,
},
{
'key': 'logo',
'label': 'Logo firmy',
'completed': bool(
company
and getattr(company, 'slug', None)
and os.path.exists(
os.path.join(app_root, 'static', 'img', 'companies', f'{company.slug}.webp')
)
),
'responsible': 'both',
'responsible_label': 'Ty / Sekretariat',
'action_url': f'/company/{company.id}/edit' if company else None,
},
]
completed = sum(1 for s in steps if s['completed'])
total = len(steps)
return {
'steps': steps,
'completed': completed,
'total': total,
'percentage': round(completed / total * 100) if total else 0,
'all_complete': completed == total,
}