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

300
app.py
View File

@ -4683,7 +4683,7 @@ def chat_send_message(conversation_id):
# Technical metadata # Technical metadata
'tech_info': { 'tech_info': {
'model': 'gemini-2.0-flash', '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)', 'architecture': 'Full DB Context (wszystkie firmy w kontekście AI)',
'tokens_input': tokens_in, 'tokens_input': tokens_in,
'tokens_output': tokens_out, 'tokens_output': tokens_out,
@ -9862,7 +9862,7 @@ def release_notes():
'new': [ 'new': [
'Audyt GBP: Sekcja edukacyjna "Jak działa wizytówka Google?" z trzema kartami', '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: 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: Klikalne banery wyników prowadzą do szczegółowych stron audytu',
'Audyty: Sekcje audytów inline na profilu firmy (SEO, GBP, Social Media, IT)', 'Audyty: Sekcje audytów inline na profilu firmy (SEO, GBP, Social Media, IT)',
], ],
@ -10046,7 +10046,7 @@ def release_notes():
'badges': ['new'], 'badges': ['new'],
'new': [ 'new': [
'Oficjalny start platformy Norda Biznes Hub', 'Oficjalny start platformy Norda Biznes Hub',
'Katalog 80 firm członkowskich', 'Katalog 111 firm członkowskich',
'Wyszukiwarka firm po nazwie, kategorii, usługach', 'Wyszukiwarka firm po nazwie, kategorii, usługach',
'Profile firm z pełnymi danymi kontaktowymi', 'Profile firm z pełnymi danymi kontaktowymi',
], ],
@ -13817,6 +13817,300 @@ def announcement_detail(slug):
db.close() 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) # 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}>" 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) # 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('classifieds_index') }}">Tablica B2B</a></li>
<li><a href="{{ url_for('chat') }}">NordaGPT</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('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> <li><a href="#" onclick="openConnectionsMap(); return false;">Mapa Powiązań</a></li>
</ul> </ul>
</li> </li>

View File

@ -847,7 +847,7 @@
<div class="timeline-date">Listopad 2025</div> <div class="timeline-date">Listopad 2025</div>
<div class="timeline-content"> <div class="timeline-content">
<strong>Uruchomienie NordaGPT</strong> <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> <span class="timeline-badge launch">Premiera</span>
</div> </div>
</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 %}