nordabiz/templates/contacts/list.html
Maciej Pienczyn 21a78befad feat(contacts): Baza kontaktów zewnętrznych dla członków Norda
- Nowy model ExternalContact z pełnymi danymi kontaktowymi, social media, related_links (JSONB)
- Migracja SQL 020_external_contacts.sql z full-text search
- Routes: /kontakty (lista), /kontakty/dodaj, /kontakty/<id>, /kontakty/<id>/edytuj
- Szablony: lista z filtrami, karta szczegółów, formularz CRUD
- Nawigacja: link "Kontakty zewnętrzne" w dropdown Społeczność
- Poprawka: aktualizacja liczby firm z 80 na 111

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 08:35:06 +01:00

475 lines
14 KiB
HTML

{% extends "base.html" %}
{% block title %}Kontakty zewnetrzne - Norda Biznes Hub{% endblock %}
{% block meta_description %}Baza kontaktow zewnetrznych - urzedy, instytucje, partnerzy projektow. Dostepna dla czlonkow Norda Biznes.{% endblock %}
{% block extra_css %}
<style>
.contacts-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.contacts-header h1 {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--text-primary);
}
.contacts-filters {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
box-shadow: var(--shadow);
}
.filters-row {
display: flex;
gap: var(--spacing-md);
flex-wrap: wrap;
align-items: flex-end;
}
.filter-group {
flex: 1;
min-width: 200px;
}
.filter-group label {
display: block;
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.filter-group input,
.filter-group select {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
background: var(--background);
}
.filter-group input:focus,
.filter-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-bg);
}
.contacts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: var(--spacing-lg);
}
.contact-card {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.contact-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.contact-card-header {
padding: var(--spacing-lg);
display: flex;
gap: var(--spacing-md);
}
.contact-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
font-weight: 700;
flex-shrink: 0;
}
.contact-avatar.has-photo {
background: none;
}
.contact-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.contact-info {
flex: 1;
min-width: 0;
}
.contact-name {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.contact-name a {
color: inherit;
text-decoration: none;
}
.contact-name a:hover {
color: var(--primary);
}
.contact-position {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.contact-organization {
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.org-type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
margin-left: var(--spacing-xs);
}
.org-type-government { background: #dbeafe; color: #1e40af; }
.org-type-agency { background: #fce7f3; color: #9d174d; }
.org-type-company { background: #dcfce7; color: #166534; }
.org-type-ngo { background: #fef3c7; color: #92400e; }
.org-type-university { background: #f3e8ff; color: #6b21a8; }
.org-type-other { background: var(--surface-secondary); color: var(--text-secondary); }
.contact-card-body {
padding: 0 var(--spacing-lg) var(--spacing-lg);
}
.contact-details {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
}
.contact-detail-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
color: var(--text-secondary);
}
.contact-detail-item a {
color: var(--primary);
text-decoration: none;
}
.contact-detail-item a:hover {
text-decoration: underline;
}
.contact-project {
margin-top: var(--spacing-sm);
padding-top: var(--spacing-sm);
border-top: 1px solid var(--border);
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.contact-project strong {
color: var(--text-secondary);
}
.social-links {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
}
.social-link {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
font-size: var(--font-size-sm);
transition: transform 0.2s ease;
}
.social-link:hover {
transform: scale(1.1);
}
.social-link.linkedin { background: #0a66c2; color: white; }
.social-link.facebook { background: #1877f2; color: white; }
.social-link.twitter { background: #1da1f2; color: white; }
.empty-state {
text-align: center;
padding: var(--spacing-3xl);
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: var(--spacing-lg);
}
.empty-state h3 {
font-size: var(--font-size-xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.empty-state p {
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-sm);
margin-top: var(--spacing-xl);
}
.pagination a,
.pagination span {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
text-decoration: none;
font-weight: 500;
}
.pagination a {
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
}
.pagination a:hover {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.pagination .current {
background: var(--primary);
color: white;
}
.stats-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
@media (max-width: 768px) {
.contacts-grid {
grid-template-columns: 1fr;
}
.filter-group {
min-width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="contacts-header">
<h1>&#128101; Kontakty zewnetrzne</h1>
<a href="{{ url_for('contact_add') }}" class="btn btn-primary">
+ Dodaj kontakt
</a>
</div>
<div class="contacts-filters">
<form method="GET" action="{{ url_for('contacts_list') }}">
<div class="filters-row">
<div class="filter-group" style="flex: 2;">
<label for="search">Szukaj</label>
<input type="text" id="search" name="q" value="{{ search }}"
placeholder="Imie, nazwisko, organizacja, projekt...">
</div>
<div class="filter-group">
<label for="type">Typ organizacji</label>
<select id="type" name="type">
<option value="">Wszystkie</option>
{% for type_key in org_types %}
<option value="{{ type_key }}" {% if org_type == type_key %}selected{% endif %}>
{{ org_type_labels.get(type_key, type_key) }}
</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label for="project">Projekt</label>
<select id="project" name="project">
<option value="">Wszystkie</option>
{% for proj in project_names %}
<option value="{{ proj }}" {% if project == proj %}selected{% endif %}>
{{ proj }}
</option>
{% endfor %}
</select>
</div>
<div class="filter-group" style="flex: 0;">
<button type="submit" class="btn btn-primary">Filtruj</button>
</div>
</div>
</form>
</div>
<div class="stats-bar">
<span>Znaleziono: {{ total }} kontaktow</span>
{% if search or org_type or project %}
<a href="{{ url_for('contacts_list') }}" style="color: var(--primary);">Wyczysc filtry</a>
{% endif %}
</div>
{% if contacts %}
<div class="contacts-grid">
{% for contact in contacts %}
<div class="contact-card">
<div class="contact-card-header">
<div class="contact-avatar {% if contact.photo_url %}has-photo{% endif %}"
style="{% if not contact.photo_url %}background: hsl({{ (contact.id * 137) % 360 }}, 65%, 50%);{% endif %}">
{% if contact.photo_url %}
<img src="{{ contact.photo_url }}" alt="{{ contact.full_name }}"
onerror="this.parentElement.classList.remove('has-photo'); this.style.display='none'; this.parentElement.innerHTML='{{ contact.first_name[0]|upper }}';">
{% else %}
{{ contact.first_name[0]|upper }}
{% endif %}
</div>
<div class="contact-info">
<div class="contact-name">
<a href="{{ url_for('contact_detail', contact_id=contact.id) }}">
{{ contact.full_name }}
</a>
</div>
{% if contact.position %}
<div class="contact-position">{{ contact.position }}</div>
{% endif %}
<div class="contact-organization">
{{ contact.organization_name }}
<span class="org-type-badge org-type-{{ contact.organization_type }}">
{{ org_type_labels.get(contact.organization_type, contact.organization_type) }}
</span>
</div>
</div>
</div>
<div class="contact-card-body">
<div class="contact-details">
{% if contact.phone %}
<div class="contact-detail-item">
&#128222; <a href="tel:{{ contact.phone }}">{{ contact.phone }}</a>
</div>
{% endif %}
{% if contact.email %}
<div class="contact-detail-item">
&#9993; <a href="mailto:{{ contact.email }}">{{ contact.email }}</a>
</div>
{% endif %}
</div>
{% if contact.has_social_media %}
<div class="social-links">
{% if contact.linkedin_url %}
<a href="{{ contact.linkedin_url }}" target="_blank" rel="noopener"
class="social-link linkedin" title="LinkedIn">in</a>
{% endif %}
{% if contact.facebook_url %}
<a href="{{ contact.facebook_url }}" target="_blank" rel="noopener"
class="social-link facebook" title="Facebook">f</a>
{% endif %}
{% if contact.twitter_url %}
<a href="{{ contact.twitter_url }}" target="_blank" rel="noopener"
class="social-link twitter" title="Twitter/X">X</a>
{% endif %}
</div>
{% endif %}
{% if contact.project_name %}
<div class="contact-project">
<strong>Projekt:</strong> {{ contact.project_name }}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="{{ url_for('contacts_list', page=page-1, q=search, type=org_type, project=project) }}">&larr; Poprzednia</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="current">{{ p }}</span>
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
<a href="{{ url_for('contacts_list', page=p, q=search, type=org_type, project=project) }}">{{ p }}</a>
{% elif p == page - 3 or p == page + 3 %}
<span>...</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('contacts_list', page=page+1, q=search, type=org_type, project=project) }}">Nastepna &rarr;</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="empty-state">
<div class="empty-state-icon">&#128203;</div>
<h3>Brak kontaktow</h3>
<p>
{% if search or org_type or project %}
Nie znaleziono kontaktow pasujacych do podanych kryteriow.
{% else %}
Baza kontaktow zewnetrznych jest pusta. Dodaj pierwszy kontakt!
{% endif %}
</p>
<a href="{{ url_for('contact_add') }}" class="btn btn-primary">
+ Dodaj pierwszy kontakt
</a>
</div>
{% endif %}
</div>
{% endblock %}