feat: Person profile page and improved tooltip

- New /osoba/<id> route for person detail page
- Shows company roles with links to company pages
- Displays portal data (email, phone) if user has account
- Tooltip shows all company connections (no "4 wiecej" limit)
- Click on person node navigates to profile instead of filtering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-11 14:16:05 +01:00
parent b29071ab84
commit 807e554832
3 changed files with 347 additions and 5 deletions

49
app.py
View File

@ -665,6 +665,55 @@ def company_detail_by_slug(slug):
db.close()
@app.route('/osoba/<int:person_id>')
def person_detail(person_id):
"""Person detail page - shows registry data and portal data if available"""
db = SessionLocal()
try:
# Get person with their company relationships
person = db.query(Person).filter_by(id=person_id).first()
if not person:
flash('Osoba nie znaleziona.', 'error')
return redirect(url_for('index'))
# Get company roles with company details (only active companies)
company_roles = db.query(CompanyPerson).filter_by(
person_id=person_id
).join(Company, CompanyPerson.company_id == Company.id).filter(
Company.status == 'active'
).order_by(
CompanyPerson.role_category,
Company.name
).all()
# Try to find matching user account by name (for portal data)
# This is a simple match - in production might need more sophisticated matching
portal_user = None
name_parts = person.full_name().upper().split()
if len(name_parts) >= 2:
# Try to find user where first/last name matches
potential_users = db.query(User).filter(
User.full_name.isnot(None)
).all()
for u in potential_users:
if u.full_name:
user_name_parts = u.full_name.upper().split()
# Check if at least first and last name match
if len(user_name_parts) >= 2:
if (user_name_parts[-1] in name_parts and # Last name match
any(part in user_name_parts for part in name_parts[:-1])): # First name match
portal_user = u
break
return render_template('person_detail.html',
person=person,
company_roles=company_roles,
portal_user=portal_user
)
finally:
db.close()
@app.route('/company/<slug>/recommend', methods=['GET', 'POST'])
# @login_required # Disabled - public access
def company_recommend(slug):

View File

@ -959,15 +959,16 @@ function initModalGraph(nodes, links) {
html += `<p>Powiązany z ${uniqueCompanyCount} firmami (${connected.length} ról)</p>`;
if (connected.length > 0) {
connected.slice(0, 5).forEach(l => {
// Show ALL companies, no limit
connected.forEach(l => {
const tId = typeof l.target === 'object' ? l.target.id : l.target;
const company = nodes.find(n => n.id === tId);
if (company && company.type === 'company') {
html += `<span class="role-badge ${l.category}">${l.role}</span>${company.name}<br>`;
}
});
if (connected.length > 5) html += `<p style="color: #64748b;">...i ${connected.length - 5} więcej</p>`;
}
html += `<p style="margin-top: 8px; color: #60a5fa; font-size: 11px;">Kliknij, aby zobaczyć profil</p>`;
}
tooltip.html(html)
@ -988,9 +989,11 @@ function initModalGraph(nodes, links) {
if (d.type === 'company' && d.slug) {
closeConnectionsMap();
window.location.href = `/company/${d.slug}`;
} else {
// Select this node to filter
selectNode(d.id);
} else if (d.type === 'person') {
// Navigate to person profile
const personId = d.id.replace('person_', '');
closeConnectionsMap();
window.location.href = `/osoba/${personId}`;
}
});

View File

@ -0,0 +1,290 @@
{% extends "base.html" %}
{% block title %}{{ person.full_name() }} - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.person-header {
background: white;
padding: var(--spacing-2xl);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
margin-bottom: var(--spacing-xl);
}
.person-avatar {
width: 120px;
height: 120px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: white;
font-weight: 600;
margin-bottom: var(--spacing-lg);
}
.person-name {
font-size: var(--font-size-3xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.person-subtitle {
font-size: var(--font-size-lg);
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
}
.person-section {
background: white;
padding: var(--spacing-xl);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.section-title {
font-size: var(--font-size-xl);
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 2px solid var(--border);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.section-title svg {
color: var(--primary);
}
.company-role-card {
display: flex;
align-items: flex-start;
padding: var(--spacing-lg);
background: var(--background);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-md);
border-left: 4px solid var(--primary);
transition: transform 0.2s, box-shadow 0.2s;
}
.company-role-card:hover {
transform: translateX(4px);
box-shadow: var(--shadow);
}
.company-role-card a {
text-decoration: none;
color: inherit;
flex: 1;
}
.company-name-link {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--primary);
margin-bottom: var(--spacing-xs);
}
.company-name-link:hover {
text-decoration: underline;
}
.role-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: var(--font-size-sm);
font-weight: 500;
color: white;
margin-right: var(--spacing-sm);
margin-top: var(--spacing-xs);
}
.role-badge.zarzad { background: #e74c3c; }
.role-badge.wspolnik { background: #2ecc71; }
.role-badge.prokurent { background: #f39c12; }
.role-badge.wlasciciel_jdg { background: #9b59b6; }
.portal-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
}
.portal-info-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
}
.portal-info-item svg {
color: var(--primary);
flex-shrink: 0;
}
.portal-info-item a {
color: var(--primary);
text-decoration: none;
}
.portal-info-item a:hover {
text-decoration: underline;
}
.data-source {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-lg);
padding-top: var(--spacing-md);
border-top: 1px solid var(--border);
}
.data-source a {
color: var(--primary);
}
.no-portal-data {
padding: var(--spacing-lg);
background: #fef3c7;
border-radius: var(--radius);
color: #92400e;
font-size: var(--font-size-sm);
}
.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-lg);
}
.back-link:hover {
color: var(--primary);
}
</style>
{% endblock %}
{% block content %}
<div class="main-container">
<a href="#" onclick="history.back(); return false;" 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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
Wstecz
</a>
<!-- Person Header -->
<div class="person-header">
<div class="person-avatar">
{{ person.imiona[0] }}{{ person.nazwisko[0] }}
</div>
<h1 class="person-name">{{ person.full_name() }}</h1>
<p class="person-subtitle">
Powiazany z {{ company_roles|length }} firmami w Norda Biznes
</p>
</div>
<!-- Portal Data (if available) -->
{% if portal_user %}
<div class="person-section">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
Dane kontaktowe (z konta na portalu)
</h2>
<div class="portal-info">
{% if portal_user.email %}
<div class="portal-info-item">
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<a href="mailto:{{ portal_user.email }}">{{ portal_user.email }}</a>
</div>
{% endif %}
{% if portal_user.phone %}
<div class="portal-info-item">
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/>
</svg>
<a href="tel:{{ portal_user.phone }}">{{ portal_user.phone }}</a>
</div>
{% endif %}
{% if portal_user.company %}
<div class="portal-info-item">
<svg width="20" height="20" 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>{{ portal_user.company.name if portal_user.company else '-' }}</span>
</div>
{% endif %}
</div>
</div>
{% else %}
<div class="person-section">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
Dane kontaktowe
</h2>
<div class="no-portal-data">
Ta osoba nie ma jeszcze konta na portalu Norda Biznes Hub. Dane kontaktowe pojawia sie po rejestracji.
</div>
</div>
{% endif %}
<!-- Company Roles -->
<div class="person-section">
<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="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>
Powiazania z firmami
</h2>
{% set unique_companies = {} %}
{% for role in company_roles %}
{% if role.company.id not in unique_companies %}
{% set _ = unique_companies.update({role.company.id: {'company': role.company, 'roles': []}}) %}
{% endif %}
{% set _ = unique_companies[role.company.id]['roles'].append(role) %}
{% endfor %}
{% for company_id, data in unique_companies.items() %}
<div class="company-role-card">
<a href="{{ url_for('company_detail', company_id=data.company.id) }}">
<div class="company-name-link">{{ data.company.name }}</div>
<div>
{% for role in data.roles %}
<span class="role-badge {{ role.role_category }}">{{ role.role }}</span>
{% endfor %}
</div>
{% if data.company.address_city %}
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); margin-top: var(--spacing-xs);">
{{ data.company.address_city }}
</div>
{% endif %}
</a>
</div>
{% endfor %}
<div class="data-source">
Dane urzedowe pochodza z:
<a href="https://ekrs.ms.gov.pl" target="_blank">ekrs.ms.gov.pl</a> (KRS),
<a href="https://dane.biznes.gov.pl" target="_blank">dane.biznes.gov.pl</a> (CEIDG)
</div>
</div>
</div>
{% endblock %}