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>
This commit is contained in:
Maciej Pienczyn 2026-01-27 08:35:06 +01:00
parent a172f7af49
commit 21a78befad
9 changed files with 2290 additions and 5 deletions

View File

@ -5,7 +5,7 @@
Platforma katalogowa i networkingowa dla członków stowarzyszenia Norda Biznes z Wejherowa.
- **Produkcja:** https://nordabiznes.pl
- **Status:** LIVE (od 2025-11-23)
- **Firmy:** 80 członków Norda Biznes (100% pokrycia)
- **Firmy:** 111 członków Norda Biznes
## Struktura projektu

300
app.py
View File

@ -4683,7 +4683,7 @@ def chat_send_message(conversation_id):
# Technical metadata
'tech_info': {
'model': 'gemini-2.0-flash',
'data_source': 'PostgreSQL (80 firm Norda Biznes)',
'data_source': 'PostgreSQL (111 firm Norda Biznes)',
'architecture': 'Full DB Context (wszystkie firmy w kontekście AI)',
'tokens_input': tokens_in,
'tokens_output': tokens_out,
@ -9862,7 +9862,7 @@ def release_notes():
'new': [
'Audyt GBP: Sekcja edukacyjna "Jak działa wizytówka Google?" z trzema kartami',
'Audyt GBP: Przycisk "Zobacz wizytówkę Google" prowadzący do profilu w Maps',
'Audyt GBP: Pełny audyt z Google Places API dla wszystkich 80 firm',
'Audyt GBP: Pełny audyt z Google Places API dla wszystkich 111 firm',
'Audyty: Klikalne banery wyników prowadzą do szczegółowych stron audytu',
'Audyty: Sekcje audytów inline na profilu firmy (SEO, GBP, Social Media, IT)',
],
@ -10046,7 +10046,7 @@ def release_notes():
'badges': ['new'],
'new': [
'Oficjalny start platformy Norda Biznes Hub',
'Katalog 80 firm członkowskich',
'Katalog 111 firm członkowskich',
'Wyszukiwarka firm po nazwie, kategorii, usługach',
'Profile firm z pełnymi danymi kontaktowymi',
],
@ -13817,6 +13817,300 @@ def announcement_detail(slug):
db.close()
# ============================================================
# EXTERNAL CONTACTS (Kontakty zewnętrzne)
# ============================================================
@app.route('/kontakty')
@login_required
def contacts_list():
"""
Lista kontaktów zewnętrznych - urzędy, instytucje, partnerzy.
Dostępna dla wszystkich zalogowanych członków.
"""
from database import ExternalContact, User
db = SessionLocal()
try:
page = request.args.get('page', 1, type=int)
per_page = 20
search = request.args.get('q', '').strip()
org_type = request.args.get('type', '')
project = request.args.get('project', '')
query = db.query(ExternalContact).filter(ExternalContact.is_active == True)
# Search filter
if search:
search_pattern = f'%{search}%'
query = query.filter(
or_(
ExternalContact.first_name.ilike(search_pattern),
ExternalContact.last_name.ilike(search_pattern),
ExternalContact.organization_name.ilike(search_pattern),
ExternalContact.position.ilike(search_pattern),
ExternalContact.project_name.ilike(search_pattern),
ExternalContact.tags.ilike(search_pattern)
)
)
# Organization type filter
if org_type and org_type in ExternalContact.ORGANIZATION_TYPES:
query = query.filter(ExternalContact.organization_type == org_type)
# Project filter
if project:
query = query.filter(ExternalContact.project_name.ilike(f'%{project}%'))
# Order by organization name, then last name
query = query.order_by(
ExternalContact.organization_name,
ExternalContact.last_name
)
# Pagination
total = query.count()
contacts = query.offset((page - 1) * per_page).limit(per_page).all()
total_pages = (total + per_page - 1) // per_page
# Get unique projects for filter dropdown
projects = db.query(ExternalContact.project_name).filter(
ExternalContact.is_active == True,
ExternalContact.project_name.isnot(None),
ExternalContact.project_name != ''
).distinct().order_by(ExternalContact.project_name).all()
project_names = [p[0] for p in projects if p[0]]
return render_template('contacts/list.html',
contacts=contacts,
page=page,
total_pages=total_pages,
total=total,
search=search,
org_type=org_type,
project=project,
org_types=ExternalContact.ORGANIZATION_TYPES,
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS,
project_names=project_names)
finally:
db.close()
@app.route('/kontakty/<int:contact_id>')
@login_required
def contact_detail(contact_id):
"""
Szczegóły kontaktu zewnętrznego - pełna karta osoby.
"""
from database import ExternalContact
db = SessionLocal()
try:
contact = db.query(ExternalContact).filter(
ExternalContact.id == contact_id,
ExternalContact.is_active == True
).first()
if not contact:
flash('Kontakt nie został znaleziony.', 'error')
return redirect(url_for('contacts_list'))
# Get other contacts from the same organization
related_contacts = db.query(ExternalContact).filter(
ExternalContact.organization_name == contact.organization_name,
ExternalContact.id != contact.id,
ExternalContact.is_active == True
).order_by(ExternalContact.last_name).limit(5).all()
# Check if current user can edit (creator or admin)
can_edit = (current_user.is_admin or
(contact.created_by and contact.created_by == current_user.id))
return render_template('contacts/detail.html',
contact=contact,
related_contacts=related_contacts,
can_edit=can_edit,
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS)
finally:
db.close()
@app.route('/kontakty/dodaj', methods=['GET', 'POST'])
@login_required
def contact_add():
"""
Dodawanie nowego kontaktu zewnętrznego.
Każdy zalogowany użytkownik może dodać kontakt.
"""
from database import ExternalContact
if request.method == 'POST':
db = SessionLocal()
try:
# Parse related_links from form (JSON)
related_links_json = request.form.get('related_links', '[]')
try:
related_links = json.loads(related_links_json) if related_links_json else []
except json.JSONDecodeError:
related_links = []
contact = ExternalContact(
first_name=request.form.get('first_name', '').strip(),
last_name=request.form.get('last_name', '').strip(),
position=request.form.get('position', '').strip() or None,
photo_url=request.form.get('photo_url', '').strip() or None,
phone=request.form.get('phone', '').strip() or None,
phone_secondary=request.form.get('phone_secondary', '').strip() or None,
email=request.form.get('email', '').strip() or None,
website=request.form.get('website', '').strip() or None,
linkedin_url=request.form.get('linkedin_url', '').strip() or None,
facebook_url=request.form.get('facebook_url', '').strip() or None,
twitter_url=request.form.get('twitter_url', '').strip() or None,
organization_name=request.form.get('organization_name', '').strip(),
organization_type=request.form.get('organization_type', 'other'),
organization_address=request.form.get('organization_address', '').strip() or None,
organization_website=request.form.get('organization_website', '').strip() or None,
organization_logo_url=request.form.get('organization_logo_url', '').strip() or None,
project_name=request.form.get('project_name', '').strip() or None,
project_description=request.form.get('project_description', '').strip() or None,
source_type='manual',
source_url=request.form.get('source_url', '').strip() or None,
related_links=related_links,
tags=request.form.get('tags', '').strip() or None,
notes=request.form.get('notes', '').strip() or None,
created_by=current_user.id
)
db.add(contact)
db.commit()
flash(f'Kontakt {contact.full_name} został dodany.', 'success')
return redirect(url_for('contact_detail', contact_id=contact.id))
except Exception as e:
db.rollback()
app.logger.error(f"Error adding contact: {e}")
flash('Wystąpił błąd podczas dodawania kontaktu.', 'error')
finally:
db.close()
# GET - show form
return render_template('contacts/form.html',
contact=None,
org_types=ExternalContact.ORGANIZATION_TYPES,
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS)
@app.route('/kontakty/<int:contact_id>/edytuj', methods=['GET', 'POST'])
@login_required
def contact_edit(contact_id):
"""
Edycja kontaktu zewnętrznego.
Może edytować twórca kontaktu lub admin.
"""
from database import ExternalContact
db = SessionLocal()
try:
contact = db.query(ExternalContact).filter(
ExternalContact.id == contact_id
).first()
if not contact:
flash('Kontakt nie został znaleziony.', 'error')
return redirect(url_for('contacts_list'))
# Check permissions
if not current_user.is_admin and contact.created_by != current_user.id:
flash('Nie masz uprawnień do edycji tego kontaktu.', 'error')
return redirect(url_for('contact_detail', contact_id=contact_id))
if request.method == 'POST':
# Parse related_links from form (JSON)
related_links_json = request.form.get('related_links', '[]')
try:
related_links = json.loads(related_links_json) if related_links_json else []
except json.JSONDecodeError:
related_links = contact.related_links or []
contact.first_name = request.form.get('first_name', '').strip()
contact.last_name = request.form.get('last_name', '').strip()
contact.position = request.form.get('position', '').strip() or None
contact.photo_url = request.form.get('photo_url', '').strip() or None
contact.phone = request.form.get('phone', '').strip() or None
contact.phone_secondary = request.form.get('phone_secondary', '').strip() or None
contact.email = request.form.get('email', '').strip() or None
contact.website = request.form.get('website', '').strip() or None
contact.linkedin_url = request.form.get('linkedin_url', '').strip() or None
contact.facebook_url = request.form.get('facebook_url', '').strip() or None
contact.twitter_url = request.form.get('twitter_url', '').strip() or None
contact.organization_name = request.form.get('organization_name', '').strip()
contact.organization_type = request.form.get('organization_type', 'other')
contact.organization_address = request.form.get('organization_address', '').strip() or None
contact.organization_website = request.form.get('organization_website', '').strip() or None
contact.organization_logo_url = request.form.get('organization_logo_url', '').strip() or None
contact.project_name = request.form.get('project_name', '').strip() or None
contact.project_description = request.form.get('project_description', '').strip() or None
contact.source_url = request.form.get('source_url', '').strip() or None
contact.related_links = related_links
contact.tags = request.form.get('tags', '').strip() or None
contact.notes = request.form.get('notes', '').strip() or None
contact.updated_at = datetime.now()
db.commit()
flash(f'Kontakt {contact.full_name} został zaktualizowany.', 'success')
return redirect(url_for('contact_detail', contact_id=contact.id))
# GET - show form with existing data
return render_template('contacts/form.html',
contact=contact,
org_types=ExternalContact.ORGANIZATION_TYPES,
org_type_labels=ExternalContact.ORGANIZATION_TYPE_LABELS)
finally:
db.close()
@app.route('/kontakty/<int:contact_id>/usun', methods=['POST'])
@login_required
def contact_delete(contact_id):
"""
Usuwanie kontaktu zewnętrznego (soft delete).
Może usunąć twórca kontaktu lub admin.
"""
from database import ExternalContact
db = SessionLocal()
try:
contact = db.query(ExternalContact).filter(
ExternalContact.id == contact_id
).first()
if not contact:
flash('Kontakt nie został znaleziony.', 'error')
return redirect(url_for('contacts_list'))
# Check permissions
if not current_user.is_admin and contact.created_by != current_user.id:
flash('Nie masz uprawnień do usunięcia tego kontaktu.', 'error')
return redirect(url_for('contact_detail', contact_id=contact_id))
# Soft delete
contact.is_active = False
contact.updated_at = datetime.now()
db.commit()
flash(f'Kontakt {contact.full_name} został usunięty.', 'success')
return redirect(url_for('contacts_list'))
finally:
db.close()
# ============================================================
# HONEYPOT ENDPOINTS (trap for malicious bots)
# ============================================================

View File

@ -3112,6 +3112,117 @@ class AnnouncementRead(Base):
return f"<AnnouncementRead announcement={self.announcement_id} user={self.user_id}>"
# ============================================================
# EXTERNAL CONTACTS (Kontakty zewnętrzne)
# ============================================================
class ExternalContact(Base):
"""
Baza kontaktów zewnętrznych - urzędy, instytucje, partnerzy projektów.
Dostępna dla wszystkich zalogowanych członków Norda Biznes.
"""
__tablename__ = 'external_contacts'
id = Column(Integer, primary_key=True)
# Dane osobowe
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
position = Column(String(200)) # Stanowisko (opcjonalne)
photo_url = Column(String(500)) # Zdjęcie osoby (opcjonalne)
# Dane kontaktowe
phone = Column(String(50))
phone_secondary = Column(String(50)) # Drugi numer telefonu
email = Column(String(255))
website = Column(String(500)) # Strona osobista/wizytówka
# Social Media
linkedin_url = Column(String(500))
facebook_url = Column(String(500))
twitter_url = Column(String(500))
# Organizacja
organization_name = Column(String(300), nullable=False)
organization_type = Column(String(50), default='other')
# Typy: government (urząd), agency (agencja), company (firma), ngo (organizacja), university (uczelnia), other
organization_address = Column(String(500))
organization_website = Column(String(500))
organization_logo_url = Column(String(500))
# Kontekst/Projekt
project_name = Column(String(300)) # Nazwa projektu (Tytani, EJ Choczewo, itp.)
project_description = Column(Text) # Krótki opis kontekstu
# Źródło kontaktu
source_type = Column(String(50)) # announcement, forum_post, manual
source_id = Column(Integer) # ID ogłoszenia lub wpisu (opcjonalne)
source_url = Column(String(500)) # URL do źródła
# Powiązane linki (artykuły, strony, dokumenty) - JSON array
# Format: [{"title": "Artykuł o...", "url": "https://...", "type": "article"}, ...]
related_links = Column(PG_JSONB, default=list)
# Tagi do wyszukiwania
tags = Column(String(500)) # Tagi oddzielone przecinkami
# Notatki
notes = Column(Text)
# Audyt
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Status
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False) # Zweryfikowany przez admina/moderatora
# Relationships
creator = relationship('User', foreign_keys=[created_by])
# Constants
ORGANIZATION_TYPES = ['government', 'agency', 'company', 'ngo', 'university', 'other']
ORGANIZATION_TYPE_LABELS = {
'government': 'Urząd',
'agency': 'Agencja',
'company': 'Firma',
'ngo': 'Organizacja pozarządowa',
'university': 'Uczelnia',
'other': 'Inne'
}
SOURCE_TYPES = ['announcement', 'forum_post', 'manual']
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
@property
def organization_type_label(self):
return self.ORGANIZATION_TYPE_LABELS.get(self.organization_type, self.organization_type)
@property
def tags_list(self):
"""Zwraca tagi jako listę."""
if not self.tags:
return []
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
@property
def has_social_media(self):
"""Sprawdza czy kontakt ma jakiekolwiek social media."""
return bool(self.linkedin_url or self.facebook_url or self.twitter_url)
@property
def has_contact_info(self):
"""Sprawdza czy kontakt ma dane kontaktowe."""
return bool(self.phone or self.email or self.website)
def __repr__(self):
return f"<ExternalContact {self.full_name} @ {self.organization_name}>"
# ============================================================
# ZOPK MILESTONES (Timeline)
# ============================================================

View File

@ -0,0 +1,96 @@
-- ============================================================
-- Migration: 020_external_contacts.sql
-- Description: Baza kontaktów zewnętrznych (urzędy, instytucje, partnerzy)
-- Author: Maciej Pienczyn
-- Date: 2026-01-27
-- ============================================================
-- Tabela kontaktów zewnętrznych
CREATE TABLE IF NOT EXISTS external_contacts (
id SERIAL PRIMARY KEY,
-- Dane osobowe
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
position VARCHAR(200), -- Stanowisko (opcjonalne)
photo_url VARCHAR(500), -- Zdjęcie osoby (opcjonalne)
-- Dane kontaktowe
phone VARCHAR(50),
phone_secondary VARCHAR(50), -- Drugi numer telefonu
email VARCHAR(255),
website VARCHAR(500), -- Strona osobista/wizytówka
-- Social Media
linkedin_url VARCHAR(500),
facebook_url VARCHAR(500),
twitter_url VARCHAR(500),
-- Organizacja
organization_name VARCHAR(300) NOT NULL,
organization_type VARCHAR(50) DEFAULT 'other',
-- Typy: government (urząd), agency (agencja), company (firma), ngo (organizacja), university (uczelnia), other
organization_address VARCHAR(500),
organization_website VARCHAR(500),
organization_logo_url VARCHAR(500),
-- Kontekst/Projekt
project_name VARCHAR(300), -- Nazwa projektu (Tytani, EJ Choczewo, itp.)
project_description TEXT, -- Krótki opis kontekstu
-- Źródło kontaktu
source_type VARCHAR(50), -- announcement, forum_post, manual
source_id INTEGER, -- ID ogłoszenia lub wpisu (opcjonalne)
source_url VARCHAR(500), -- URL do źródła
-- Powiązane linki (artykuły, strony, dokumenty) - JSON array
-- Format: [{"title": "Artykuł o...", "url": "https://...", "type": "article"}, ...]
related_links JSONB DEFAULT '[]',
-- Tagi do wyszukiwania
tags VARCHAR(500), -- Tagi oddzielone przecinkami
-- Notatki
notes TEXT,
-- Audyt
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Status
is_active BOOLEAN DEFAULT TRUE,
is_verified BOOLEAN DEFAULT FALSE -- Zweryfikowany przez admina/moderatora
);
-- Indeksy dla szybkiego wyszukiwania
CREATE INDEX IF NOT EXISTS idx_external_contacts_name ON external_contacts(last_name, first_name);
CREATE INDEX IF NOT EXISTS idx_external_contacts_organization ON external_contacts(organization_name);
CREATE INDEX IF NOT EXISTS idx_external_contacts_org_type ON external_contacts(organization_type);
CREATE INDEX IF NOT EXISTS idx_external_contacts_project ON external_contacts(project_name);
CREATE INDEX IF NOT EXISTS idx_external_contacts_active ON external_contacts(is_active);
-- Full-text search index (PostgreSQL)
CREATE INDEX IF NOT EXISTS idx_external_contacts_search ON external_contacts
USING gin(to_tsvector('polish',
COALESCE(first_name, '') || ' ' ||
COALESCE(last_name, '') || ' ' ||
COALESCE(organization_name, '') || ' ' ||
COALESCE(project_name, '') || ' ' ||
COALESCE(tags, '')
));
-- Indeks na JSONB dla related_links
CREATE INDEX IF NOT EXISTS idx_external_contacts_links ON external_contacts USING gin(related_links);
-- Uprawnienia
GRANT ALL ON TABLE external_contacts TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE external_contacts_id_seq TO nordabiz_app;
-- Komentarze
COMMENT ON TABLE external_contacts IS 'Baza kontaktów zewnętrznych - urzędy, instytucje, partnerzy projektów';
COMMENT ON COLUMN external_contacts.organization_type IS 'Typ: government, agency, company, ngo, university, other';
COMMENT ON COLUMN external_contacts.source_type IS 'Źródło: announcement, forum_post, manual';
COMMENT ON COLUMN external_contacts.tags IS 'Tagi do wyszukiwania, oddzielone przecinkami';
COMMENT ON COLUMN external_contacts.related_links IS 'JSON array z linkami: [{"title": "...", "url": "...", "type": "article|document|video"}]';

View File

@ -960,6 +960,7 @@
<li><a href="{{ url_for('classifieds_index') }}">Tablica B2B</a></li>
<li><a href="{{ url_for('chat') }}">NordaGPT</a></li>
<li><a href="{{ url_for('zopk_index') }}">ZOP Kaszubia</a></li>
<li><a href="{{ url_for('contacts_list') }}">Kontakty zewnętrzne</a></li>
<li><a href="#" onclick="openConnectionsMap(); return false;">Mapa Powiązań</a></li>
</ul>
</li>

View File

@ -847,7 +847,7 @@
<div class="timeline-date">Listopad 2025</div>
<div class="timeline-content">
<strong>Uruchomienie NordaGPT</strong>
<p>Premiera asystenta AI dla członków Norda Biznes. Integracja z bazą 80 firm.</p>
<p>Premiera asystenta AI dla członków Norda Biznes. Integracja z bazą 111 firm.</p>
<span class="timeline-badge launch">Premiera</span>
</div>
</div>

View File

@ -0,0 +1,732 @@
{% extends "base.html" %}
{% block title %}{{ contact.full_name }} - {{ contact.organization_name }} - Kontakty - Norda Biznes Hub{% endblock %}
{% block meta_description %}{{ contact.full_name }} - {{ contact.position or '' }} w {{ contact.organization_name }}. Kontakt zewnetrzny Norda Biznes.{% endblock %}
{% block extra_css %}
<style>
.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);
}
.contact-layout {
display: grid;
grid-template-columns: 1fr 350px;
gap: var(--spacing-2xl);
}
@media (max-width: 992px) {
.contact-layout {
grid-template-columns: 1fr;
}
}
.contact-main {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.contact-header {
background: linear-gradient(135deg, var(--primary) 0%, #6b46c1 100%);
padding: var(--spacing-2xl);
color: white;
text-align: center;
}
.contact-avatar-large {
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(255,255,255,0.2);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
font-weight: 700;
margin: 0 auto var(--spacing-lg);
border: 4px solid rgba(255,255,255,0.3);
overflow: hidden;
}
.contact-avatar-large img {
width: 100%;
height: 100%;
object-fit: cover;
}
.contact-name-large {
font-size: var(--font-size-2xl);
font-weight: 700;
margin-bottom: var(--spacing-xs);
}
.contact-position-large {
font-size: var(--font-size-lg);
opacity: 0.9;
margin-bottom: var(--spacing-sm);
}
.contact-org-large {
font-size: var(--font-size-base);
opacity: 0.8;
}
.org-type-badge-large {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
background: rgba(255,255,255,0.2);
margin-top: var(--spacing-sm);
}
.contact-verified {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 4px 12px;
border-radius: var(--radius);
background: rgba(16, 185, 129, 0.3);
color: white;
font-size: var(--font-size-sm);
margin-top: var(--spacing-sm);
}
.contact-body {
padding: var(--spacing-xl);
}
.contact-section {
margin-bottom: var(--spacing-xl);
}
.contact-section:last-child {
margin-bottom: 0;
}
.section-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 2px solid var(--primary);
}
.contact-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-md);
}
@media (max-width: 576px) {
.contact-grid {
grid-template-columns: 1fr;
}
}
.contact-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
}
.contact-item-icon {
font-size: var(--font-size-xl);
width: 32px;
text-align: center;
flex-shrink: 0;
}
.contact-item-content {
flex: 1;
min-width: 0;
}
.contact-item-label {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-bottom: 2px;
}
.contact-item-value {
font-size: var(--font-size-base);
color: var(--text-primary);
word-break: break-word;
}
.contact-item-value a {
color: var(--primary);
text-decoration: none;
}
.contact-item-value a:hover {
text-decoration: underline;
}
.social-media-grid {
display: flex;
gap: var(--spacing-md);
flex-wrap: wrap;
}
.social-media-link {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
text-decoration: none;
color: white;
font-weight: 500;
transition: transform 0.2s ease;
}
.social-media-link:hover {
transform: translateY(-2px);
}
.social-media-link.linkedin { background: #0a66c2; }
.social-media-link.facebook { background: #1877f2; }
.social-media-link.twitter { background: #1da1f2; }
.related-links-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.related-link-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
text-decoration: none;
color: var(--text-primary);
transition: background 0.2s ease;
}
.related-link-item:hover {
background: var(--primary-bg);
}
.related-link-icon {
font-size: var(--font-size-lg);
}
.related-link-content {
flex: 1;
min-width: 0;
}
.related-link-title {
font-weight: 500;
color: var(--primary);
}
.related-link-type {
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.tag {
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--primary-bg);
color: var(--primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
.notes-content {
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
white-space: pre-wrap;
color: var(--text-secondary);
line-height: 1.6;
}
.project-box {
background: linear-gradient(135deg, var(--primary-bg) 0%, var(--surface) 100%);
border: 1px solid var(--primary);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
}
.project-name {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--primary);
margin-bottom: var(--spacing-sm);
}
.project-description {
color: var(--text-secondary);
line-height: 1.6;
}
/* Sidebar */
.sidebar {
position: sticky;
top: var(--spacing-xl);
}
.sidebar-section {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.sidebar-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 2px solid var(--primary);
}
.actions-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
text-decoration: none;
font-weight: 500;
border: none;
cursor: pointer;
width: 100%;
font-size: var(--font-size-base);
}
.action-btn.primary {
background: var(--primary);
color: white;
}
.action-btn.secondary {
background: var(--surface-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
}
.action-btn.danger {
background: var(--danger-bg);
color: var(--danger);
border: 1px solid var(--danger);
}
.action-btn:hover {
opacity: 0.9;
}
.related-contact {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border);
}
.related-contact:last-child {
border-bottom: none;
padding-bottom: 0;
}
.related-contact:first-child {
padding-top: 0;
}
.related-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
font-weight: 600;
}
.related-info {
flex: 1;
min-width: 0;
}
.related-name {
font-weight: 500;
font-size: var(--font-size-sm);
}
.related-name a {
color: var(--text-primary);
text-decoration: none;
}
.related-name a:hover {
color: var(--primary);
}
.related-position {
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.source-info {
font-size: var(--font-size-sm);
color: var(--text-muted);
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
}
.source-info a {
color: var(--primary);
}
.meta-info {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-top: var(--spacing-md);
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<a href="{{ url_for('contacts_list') }}" class="back-link">
&larr; Powrot do listy kontaktow
</a>
<div class="contact-layout">
<!-- Main content -->
<div class="contact-main">
<div class="contact-header">
<div class="contact-avatar-large">
{% if contact.photo_url %}
<img src="{{ contact.photo_url }}" alt="{{ contact.full_name }}"
onerror="this.style.display='none'; this.parentElement.innerHTML='{{ contact.first_name[0]|upper }}';">
{% else %}
{{ contact.first_name[0]|upper }}
{% endif %}
</div>
<div class="contact-name-large">{{ contact.full_name }}</div>
{% if contact.position %}
<div class="contact-position-large">{{ contact.position }}</div>
{% endif %}
<div class="contact-org-large">{{ contact.organization_name }}</div>
<span class="org-type-badge-large">
{{ org_type_labels.get(contact.organization_type, contact.organization_type) }}
</span>
{% if contact.is_verified %}
<div class="contact-verified">
&#10003; Zweryfikowany
</div>
{% endif %}
</div>
<div class="contact-body">
<!-- Contact Info -->
{% if contact.has_contact_info or contact.phone_secondary %}
<div class="contact-section">
<h2 class="section-title">&#128222; Dane kontaktowe</h2>
<div class="contact-grid">
{% if contact.phone %}
<div class="contact-item">
<span class="contact-item-icon">&#128222;</span>
<div class="contact-item-content">
<div class="contact-item-label">Telefon</div>
<div class="contact-item-value">
<a href="tel:{{ contact.phone }}">{{ contact.phone }}</a>
</div>
</div>
</div>
{% endif %}
{% if contact.phone_secondary %}
<div class="contact-item">
<span class="contact-item-icon">&#128241;</span>
<div class="contact-item-content">
<div class="contact-item-label">Telefon dodatkowy</div>
<div class="contact-item-value">
<a href="tel:{{ contact.phone_secondary }}">{{ contact.phone_secondary }}</a>
</div>
</div>
</div>
{% endif %}
{% if contact.email %}
<div class="contact-item">
<span class="contact-item-icon">&#9993;</span>
<div class="contact-item-content">
<div class="contact-item-label">Email</div>
<div class="contact-item-value">
<a href="mailto:{{ contact.email }}">{{ contact.email }}</a>
</div>
</div>
</div>
{% endif %}
{% if contact.website %}
<div class="contact-item">
<span class="contact-item-icon">&#127760;</span>
<div class="contact-item-content">
<div class="contact-item-label">Strona WWW</div>
<div class="contact-item-value">
<a href="{{ contact.website }}" target="_blank" rel="noopener">{{ contact.website }}</a>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Social Media -->
{% if contact.has_social_media %}
<div class="contact-section">
<h2 class="section-title">&#128101; Media spolecznosciowe</h2>
<div class="social-media-grid">
{% if contact.linkedin_url %}
<a href="{{ contact.linkedin_url }}" target="_blank" rel="noopener"
class="social-media-link linkedin">
<span>in</span> LinkedIn
</a>
{% endif %}
{% if contact.facebook_url %}
<a href="{{ contact.facebook_url }}" target="_blank" rel="noopener"
class="social-media-link facebook">
<span>f</span> Facebook
</a>
{% endif %}
{% if contact.twitter_url %}
<a href="{{ contact.twitter_url }}" target="_blank" rel="noopener"
class="social-media-link twitter">
<span>X</span> Twitter/X
</a>
{% endif %}
</div>
</div>
{% endif %}
<!-- Organization -->
<div class="contact-section">
<h2 class="section-title">&#127970; Organizacja</h2>
<div class="contact-grid">
<div class="contact-item">
<span class="contact-item-icon">&#127970;</span>
<div class="contact-item-content">
<div class="contact-item-label">Nazwa</div>
<div class="contact-item-value">{{ contact.organization_name }}</div>
</div>
</div>
{% if contact.organization_address %}
<div class="contact-item">
<span class="contact-item-icon">&#128205;</span>
<div class="contact-item-content">
<div class="contact-item-label">Adres</div>
<div class="contact-item-value">{{ contact.organization_address }}</div>
</div>
</div>
{% endif %}
{% if contact.organization_website %}
<div class="contact-item">
<span class="contact-item-icon">&#127760;</span>
<div class="contact-item-content">
<div class="contact-item-label">Strona organizacji</div>
<div class="contact-item-value">
<a href="{{ contact.organization_website }}" target="_blank" rel="noopener">
{{ contact.organization_website }}
</a>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Project -->
{% if contact.project_name %}
<div class="contact-section">
<h2 class="section-title">&#128640; Projekt</h2>
<div class="project-box">
<div class="project-name">{{ contact.project_name }}</div>
{% if contact.project_description %}
<div class="project-description">{{ contact.project_description }}</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Related Links -->
{% if contact.related_links and contact.related_links|length > 0 %}
<div class="contact-section">
<h2 class="section-title">&#128279; Powiazane materialy</h2>
<div class="related-links-list">
{% for link in contact.related_links %}
<a href="{{ link.url }}" target="_blank" rel="noopener" class="related-link-item">
<span class="related-link-icon">
{% if link.type == 'article' %}&#128196;
{% elif link.type == 'document' %}&#128195;
{% elif link.type == 'video' %}&#127910;
{% else %}&#128279;{% endif %}
</span>
<div class="related-link-content">
<div class="related-link-title">{{ link.title }}</div>
<div class="related-link-type">{{ link.type|default('link', true) }}</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Tags -->
{% if contact.tags_list %}
<div class="contact-section">
<h2 class="section-title">&#127991; Tagi</h2>
<div class="tags-list">
{% for tag in contact.tags_list %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Notes -->
{% if contact.notes %}
<div class="contact-section">
<h2 class="section-title">&#128221; Notatki</h2>
<div class="notes-content">{{ contact.notes }}</div>
</div>
{% endif %}
<!-- Source -->
{% if contact.source_url %}
<div class="contact-section">
<h2 class="section-title">&#128214; Zrodlo</h2>
<div class="source-info">
<a href="{{ contact.source_url }}" target="_blank" rel="noopener">
{{ contact.source_url }}
</a>
</div>
</div>
{% endif %}
<div class="meta-info">
Dodano: {{ contact.created_at.strftime('%d.%m.%Y %H:%M') if contact.created_at else 'nieznana' }}
{% if contact.creator %}
przez {{ contact.creator.name or contact.creator.email }}
{% endif %}
{% if contact.updated_at and contact.updated_at != contact.created_at %}
| Zaktualizowano: {{ contact.updated_at.strftime('%d.%m.%Y %H:%M') }}
{% endif %}
</div>
</div>
</div>
<!-- Sidebar -->
<aside class="sidebar">
<!-- Actions -->
{% if can_edit %}
<div class="sidebar-section">
<h3 class="sidebar-title">Akcje</h3>
<div class="actions-list">
<a href="{{ url_for('contact_edit', contact_id=contact.id) }}" class="action-btn primary">
&#9998; Edytuj kontakt
</a>
<form action="{{ url_for('contact_delete', contact_id=contact.id) }}" method="POST"
onsubmit="return confirm('Czy na pewno chcesz usunac ten kontakt?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="action-btn danger">
&#128465; Usun kontakt
</button>
</form>
</div>
</div>
{% endif %}
<!-- Quick Contact -->
<div class="sidebar-section">
<h3 class="sidebar-title">Szybki kontakt</h3>
<div class="actions-list">
{% if contact.phone %}
<a href="tel:{{ contact.phone }}" class="action-btn secondary">
&#128222; Zadzwon
</a>
{% endif %}
{% if contact.email %}
<a href="mailto:{{ contact.email }}" class="action-btn secondary">
&#9993; Wyslij email
</a>
{% endif %}
</div>
</div>
<!-- Related contacts from same organization -->
{% if related_contacts %}
<div class="sidebar-section">
<h3 class="sidebar-title">Inni z {{ contact.organization_name|truncate(20) }}</h3>
{% for rc in related_contacts %}
<div class="related-contact">
<div class="related-avatar" style="background: hsl({{ (rc.id * 137) % 360 }}, 65%, 50%);">
{{ rc.first_name[0]|upper }}
</div>
<div class="related-info">
<div class="related-name">
<a href="{{ url_for('contact_detail', contact_id=rc.id) }}">{{ rc.full_name }}</a>
</div>
{% if rc.position %}
<div class="related-position">{{ rc.position }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Back to list -->
<div class="sidebar-section" style="text-align: center;">
<a href="{{ url_for('contacts_list') }}" class="action-btn secondary" style="width: 100%;">
&larr; Lista kontaktow
</a>
</div>
</aside>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,577 @@
{% extends "base.html" %}
{% block title %}{% if contact %}Edytuj kontakt{% else %}Dodaj kontakt{% endif %} - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.form-container {
max-width: 900px;
margin: 0 auto;
}
.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);
}
.form-header {
margin-bottom: var(--spacing-xl);
}
.form-header h1 {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.form-header p {
color: var(--text-secondary);
}
.form-card {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
overflow: hidden;
}
.form-section-header {
background: var(--primary-bg);
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--border);
}
.form-section-header h2 {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.form-section-body {
padding: var(--spacing-lg);
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-md);
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
display: block;
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.form-group label .required {
color: var(--danger);
}
.form-group input,
.form-group select,
.form-group textarea {
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);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-bg);
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.form-group .hint {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-top: var(--spacing-xs);
}
.form-group .input-with-icon {
position: relative;
}
.form-group .input-with-icon input {
padding-left: 40px;
}
.form-group .input-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
font-size: var(--font-size-lg);
}
/* Related Links Editor */
.related-links-editor {
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--spacing-md);
}
.related-links-list {
margin-bottom: var(--spacing-md);
}
.related-link-row {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--surface);
border-radius: var(--radius);
align-items: flex-start;
}
.related-link-row input,
.related-link-row select {
flex: 1;
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
.related-link-row input.link-title {
flex: 2;
}
.related-link-row input.link-url {
flex: 3;
}
.related-link-row select {
flex: 1;
min-width: 100px;
}
.remove-link-btn {
background: var(--danger-bg);
color: var(--danger);
border: none;
border-radius: var(--radius-sm);
padding: var(--spacing-xs) var(--spacing-sm);
cursor: pointer;
font-size: var(--font-size-sm);
}
.remove-link-btn:hover {
background: var(--danger);
color: white;
}
.add-link-btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
background: var(--primary-bg);
color: var(--primary);
border: 1px dashed var(--primary);
border-radius: var(--radius);
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: 500;
transition: background 0.2s ease;
}
.add-link-btn:hover {
background: var(--primary);
color: white;
}
/* Form Actions */
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.form-actions .btn-group {
display: flex;
gap: var(--spacing-sm);
}
@media (max-width: 576px) {
.form-actions {
flex-direction: column;
gap: var(--spacing-md);
}
.form-actions .btn-group {
width: 100%;
flex-direction: column;
}
.form-actions .btn {
width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="form-container">
<a href="{{ url_for('contacts_list') }}" class="back-link">
&larr; Powrot do listy kontaktow
</a>
<div class="form-header">
<h1>{% if contact %}&#9998; Edytuj kontakt{% else %}+ Dodaj nowy kontakt{% endif %}</h1>
<p>Kontakty zewnetrzne - osoby z urzedow, instytucji, agencji i partnerow projektow.</p>
</div>
<form method="POST" id="contact-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Personal Data -->
<div class="form-card">
<div class="form-section-header">
<h2>&#128100; Dane osobowe</h2>
</div>
<div class="form-section-body">
<div class="form-grid">
<div class="form-group">
<label for="first_name">Imie <span class="required">*</span></label>
<input type="text" id="first_name" name="first_name" required
value="{{ contact.first_name if contact else '' }}"
placeholder="np. Anna">
</div>
<div class="form-group">
<label for="last_name">Nazwisko <span class="required">*</span></label>
<input type="text" id="last_name" name="last_name" required
value="{{ contact.last_name if contact else '' }}"
placeholder="np. Kowalska">
</div>
<div class="form-group">
<label for="position">Stanowisko</label>
<input type="text" id="position" name="position"
value="{{ contact.position if contact else '' }}"
placeholder="np. Specjalista ds. inwestycji">
</div>
<div class="form-group">
<label for="photo_url">URL zdjecia</label>
<input type="url" id="photo_url" name="photo_url"
value="{{ contact.photo_url if contact else '' }}"
placeholder="https://...">
<div class="hint">Opcjonalnie - link do zdjecia profilowego</div>
</div>
</div>
</div>
</div>
<!-- Contact Info -->
<div class="form-card">
<div class="form-section-header">
<h2>&#128222; Dane kontaktowe</h2>
</div>
<div class="form-section-body">
<div class="form-grid">
<div class="form-group">
<label for="phone">Telefon</label>
<div class="input-with-icon">
<span class="input-icon">&#128222;</span>
<input type="tel" id="phone" name="phone"
value="{{ contact.phone if contact else '' }}"
placeholder="np. 58 32 33 160">
</div>
</div>
<div class="form-group">
<label for="phone_secondary">Telefon dodatkowy</label>
<div class="input-with-icon">
<span class="input-icon">&#128241;</span>
<input type="tel" id="phone_secondary" name="phone_secondary"
value="{{ contact.phone_secondary if contact else '' }}"
placeholder="np. 600 123 456">
</div>
</div>
<div class="form-group">
<label for="email">Email</label>
<div class="input-with-icon">
<span class="input-icon">&#9993;</span>
<input type="email" id="email" name="email"
value="{{ contact.email if contact else '' }}"
placeholder="np. a.kowalska@arp.gda.pl">
</div>
</div>
<div class="form-group">
<label for="website">Strona WWW osobista</label>
<div class="input-with-icon">
<span class="input-icon">&#127760;</span>
<input type="url" id="website" name="website"
value="{{ contact.website if contact else '' }}"
placeholder="https://...">
</div>
</div>
</div>
</div>
</div>
<!-- Social Media -->
<div class="form-card">
<div class="form-section-header">
<h2>&#128101; Media spolecznosciowe</h2>
</div>
<div class="form-section-body">
<div class="form-grid">
<div class="form-group">
<label for="linkedin_url">LinkedIn</label>
<input type="url" id="linkedin_url" name="linkedin_url"
value="{{ contact.linkedin_url if contact else '' }}"
placeholder="https://linkedin.com/in/...">
</div>
<div class="form-group">
<label for="facebook_url">Facebook</label>
<input type="url" id="facebook_url" name="facebook_url"
value="{{ contact.facebook_url if contact else '' }}"
placeholder="https://facebook.com/...">
</div>
<div class="form-group">
<label for="twitter_url">Twitter/X</label>
<input type="url" id="twitter_url" name="twitter_url"
value="{{ contact.twitter_url if contact else '' }}"
placeholder="https://twitter.com/...">
</div>
</div>
</div>
</div>
<!-- Organization -->
<div class="form-card">
<div class="form-section-header">
<h2>&#127970; Organizacja</h2>
</div>
<div class="form-section-body">
<div class="form-grid">
<div class="form-group">
<label for="organization_name">Nazwa organizacji <span class="required">*</span></label>
<input type="text" id="organization_name" name="organization_name" required
value="{{ contact.organization_name if contact else '' }}"
placeholder="np. Agencja Rozwoju Pomorza S.A.">
</div>
<div class="form-group">
<label for="organization_type">Typ organizacji</label>
<select id="organization_type" name="organization_type">
{% for type_key in org_types %}
<option value="{{ type_key }}"
{% if contact and contact.organization_type == type_key %}selected{% endif %}>
{{ org_type_labels.get(type_key, type_key) }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group full-width">
<label for="organization_address">Adres organizacji</label>
<input type="text" id="organization_address" name="organization_address"
value="{{ contact.organization_address if contact else '' }}"
placeholder="np. ul. Arkońska 6, 80-387 Gdańsk">
</div>
<div class="form-group">
<label for="organization_website">Strona organizacji</label>
<input type="url" id="organization_website" name="organization_website"
value="{{ contact.organization_website if contact else '' }}"
placeholder="https://...">
</div>
<div class="form-group">
<label for="organization_logo_url">Logo organizacji (URL)</label>
<input type="url" id="organization_logo_url" name="organization_logo_url"
value="{{ contact.organization_logo_url if contact else '' }}"
placeholder="https://...">
</div>
</div>
</div>
</div>
<!-- Project -->
<div class="form-card">
<div class="form-section-header">
<h2>&#128640; Projekt / Kontekst</h2>
</div>
<div class="form-section-body">
<div class="form-grid">
<div class="form-group">
<label for="project_name">Nazwa projektu</label>
<input type="text" id="project_name" name="project_name"
value="{{ contact.project_name if contact else '' }}"
placeholder="np. Elektrownia Jądrowa Choczewo, Tytani Przedsiębiorczości">
</div>
<div class="form-group full-width">
<label for="project_description">Opis kontekstu</label>
<textarea id="project_description" name="project_description"
placeholder="W jakim kontekscie poznalismy te osobe? Przy jakiej okazji?">{{ contact.project_description if contact else '' }}</textarea>
</div>
</div>
</div>
</div>
<!-- Related Links -->
<div class="form-card">
<div class="form-section-header">
<h2>&#128279; Powiazane materialy</h2>
</div>
<div class="form-section-body">
<div class="form-group full-width">
<label>Artykuly, dokumenty, filmy</label>
<div class="related-links-editor">
<div class="related-links-list" id="related-links-list">
{% if contact and contact.related_links %}
{% for link in contact.related_links %}
<div class="related-link-row">
<input type="text" class="link-title" placeholder="Tytul"
value="{{ link.title }}">
<input type="url" class="link-url" placeholder="URL (https://...)"
value="{{ link.url }}">
<select class="link-type">
<option value="article" {% if link.type == 'article' %}selected{% endif %}>Artykul</option>
<option value="document" {% if link.type == 'document' %}selected{% endif %}>Dokument</option>
<option value="video" {% if link.type == 'video' %}selected{% endif %}>Film</option>
<option value="other" {% if link.type == 'other' %}selected{% endif %}>Inne</option>
</select>
<button type="button" class="remove-link-btn" onclick="removeLink(this)">&#10005;</button>
</div>
{% endfor %}
{% endif %}
</div>
<button type="button" class="add-link-btn" onclick="addLink()">
+ Dodaj link
</button>
</div>
<div class="hint">Dodaj linki do artykulow, dokumentow lub filmow zwiazanych z ta osoba</div>
</div>
<input type="hidden" name="related_links" id="related-links-json"
value="{{ contact.related_links|tojson if contact and contact.related_links else '[]' }}">
</div>
</div>
<!-- Additional Info -->
<div class="form-card">
<div class="form-section-header">
<h2>&#128221; Dodatkowe informacje</h2>
</div>
<div class="form-section-body">
<div class="form-grid">
<div class="form-group full-width">
<label for="tags">Tagi</label>
<input type="text" id="tags" name="tags"
value="{{ contact.tags if contact else '' }}"
placeholder="np. energetyka, inwestycje, Choczewo (oddzielone przecinkami)">
<div class="hint">Tagi pomagaja w wyszukiwaniu - oddziel przecinkami</div>
</div>
<div class="form-group full-width">
<label for="source_url">URL zrodla</label>
<input type="url" id="source_url" name="source_url"
value="{{ contact.source_url if contact else '' }}"
placeholder="Skad pochodzi informacja o tej osobie?">
</div>
<div class="form-group full-width">
<label for="notes">Notatki</label>
<textarea id="notes" name="notes" rows="4"
placeholder="Dodatkowe informacje, uwagi...">{{ contact.notes if contact else '' }}</textarea>
</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="form-actions">
<a href="{{ url_for('contacts_list') }}" class="btn btn-secondary">
Anuluj
</a>
<div class="btn-group">
<button type="submit" class="btn btn-primary">
{% if contact %}Zapisz zmiany{% else %}Dodaj kontakt{% endif %}
</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
// Related links management
function addLink() {
const list = document.getElementById('related-links-list');
const row = document.createElement('div');
row.className = 'related-link-row';
row.innerHTML = `
<input type="text" class="link-title" placeholder="Tytul">
<input type="url" class="link-url" placeholder="URL (https://...)">
<select class="link-type">
<option value="article">Artykul</option>
<option value="document">Dokument</option>
<option value="video">Film</option>
<option value="other">Inne</option>
</select>
<button type="button" class="remove-link-btn" onclick="removeLink(this)">&#10005;</button>
`;
list.appendChild(row);
}
function removeLink(btn) {
btn.parentElement.remove();
}
// Serialize related links before form submit
document.getElementById('contact-form').addEventListener('submit', function(e) {
const links = [];
const rows = document.querySelectorAll('.related-link-row');
rows.forEach(function(row) {
const title = row.querySelector('.link-title').value.trim();
const url = row.querySelector('.link-url').value.trim();
const type = row.querySelector('.link-type').value;
if (title && url) {
links.push({ title: title, url: url, type: type });
}
});
document.getElementById('related-links-json').value = JSON.stringify(links);
});
{% endblock %}

View File

@ -0,0 +1,474 @@
{% 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 %}