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:
parent
a172f7af49
commit
21a78befad
@ -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
300
app.py
@ -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)
|
||||
# ============================================================
|
||||
|
||||
111
database.py
111
database.py
@ -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)
|
||||
# ============================================================
|
||||
|
||||
96
database/migrations/020_external_contacts.sql
Normal file
96
database/migrations/020_external_contacts.sql
Normal 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"}]';
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
732
templates/contacts/detail.html
Normal file
732
templates/contacts/detail.html
Normal 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">
|
||||
← 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">
|
||||
✓ 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">📞 Dane kontaktowe</h2>
|
||||
<div class="contact-grid">
|
||||
{% if contact.phone %}
|
||||
<div class="contact-item">
|
||||
<span class="contact-item-icon">📞</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">📱</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">✉</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">🌐</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">👥 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">🏢 Organizacja</h2>
|
||||
<div class="contact-grid">
|
||||
<div class="contact-item">
|
||||
<span class="contact-item-icon">🏢</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">📍</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">🌐</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">🚀 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">🔗 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' %}📄
|
||||
{% elif link.type == 'document' %}📃
|
||||
{% elif link.type == 'video' %}🎦
|
||||
{% else %}🔗{% 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">🏷 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">📝 Notatki</h2>
|
||||
<div class="notes-content">{{ contact.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Source -->
|
||||
{% if contact.source_url %}
|
||||
<div class="contact-section">
|
||||
<h2 class="section-title">📖 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">
|
||||
✎ 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">
|
||||
🗑 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">
|
||||
📞 Zadzwon
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if contact.email %}
|
||||
<a href="mailto:{{ contact.email }}" class="action-btn secondary">
|
||||
✉ 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%;">
|
||||
← Lista kontaktow
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
577
templates/contacts/form.html
Normal file
577
templates/contacts/form.html
Normal 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">
|
||||
← Powrot do listy kontaktow
|
||||
</a>
|
||||
|
||||
<div class="form-header">
|
||||
<h1>{% if contact %}✎ 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>👤 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>📞 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">📞</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">📱</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">✉</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">🌐</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>👥 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>🏢 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>🚀 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>🔗 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)">✕</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>📝 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)">✕</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 %}
|
||||
474
templates/contacts/list.html
Normal file
474
templates/contacts/list.html
Normal 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>👥 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">
|
||||
📞 <a href="tel:{{ contact.phone }}">{{ contact.phone }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if contact.email %}
|
||||
<div class="contact-detail-item">
|
||||
✉ <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) }}">← 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 →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📋</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 %}
|
||||
Loading…
Reference in New Issue
Block a user