nordabiz/templates/contacts/list.html
2026-01-27 08:59:57 +01:00

1570 lines
46 KiB
HTML

{% extends "base.html" %}
{% block title %}Kontakty zewnetrzne - Norda Biznes Hub{% endblock %}
{% block meta_description %}Baza kontaktow zewnetrznych - urzedy, instytucje, partnerzy projektow. Dostepna dla czlonkow Norda Biznes.{% endblock %}
{% block extra_css %}
<style>
.contacts-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.contacts-header h1 {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.btn-ai {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
color: white;
border: none;
}
.btn-ai:hover {
background: linear-gradient(135deg, #7c3aed, #4f46e5);
}
.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);
}
/* View toggle */
.view-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.stats-bar {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.view-toggle {
display: flex;
background: var(--surface);
border-radius: var(--radius);
border: 1px solid var(--border);
overflow: hidden;
}
.view-toggle button {
padding: var(--spacing-sm) var(--spacing-md);
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: var(--font-size-sm);
display: flex;
align-items: center;
gap: var(--spacing-xs);
transition: all 0.2s ease;
}
.view-toggle button:hover {
background: var(--surface-secondary);
}
.view-toggle button.active {
background: var(--primary);
color: white;
}
.view-toggle button + button {
border-left: 1px solid var(--border);
}
/* Card view */
.contacts-grid {
display: none;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: var(--spacing-lg);
}
.contacts-grid.active {
display: grid;
}
.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-avatar.small {
width: 40px;
height: 40px;
font-size: var(--font-size-base);
}
.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; }
/* Table view */
.contacts-table-wrapper {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
display: none;
}
.contacts-table-wrapper.active {
display: block;
}
.contacts-table {
width: 100%;
border-collapse: collapse;
}
.contacts-table th,
.contacts-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.contacts-table th {
background: var(--surface-secondary);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.contacts-table tr:hover {
background: var(--surface-secondary);
}
.contacts-table .contact-cell {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.contacts-table .contact-cell a {
color: var(--text-primary);
text-decoration: none;
font-weight: 500;
}
.contacts-table .contact-cell a:hover {
color: var(--primary);
}
/* Organization group view */
.contacts-groups {
display: none;
}
.contacts-groups.active {
display: block;
}
.org-group {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-lg);
overflow: hidden;
}
.org-group-header {
padding: var(--spacing-lg);
background: var(--surface-secondary);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s ease;
}
.org-group-header:hover {
background: var(--border);
}
.org-group-info {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.org-logo {
width: 48px;
height: 48px;
border-radius: var(--radius);
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-lg);
font-weight: 700;
}
.org-logo img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: var(--radius);
}
.org-details h3 {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.org-details .org-meta {
font-size: var(--font-size-sm);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.org-group-toggle {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--surface);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.org-group.expanded .org-group-toggle {
transform: rotate(180deg);
}
.org-group-contacts {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.org-group.expanded .org-group-contacts {
max-height: 2000px;
}
.org-contact-item {
padding: var(--spacing-md) var(--spacing-lg);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-md);
}
.org-contact-item:hover {
background: var(--surface-secondary);
}
.org-contact-main {
display: flex;
align-items: center;
gap: var(--spacing-md);
flex: 1;
}
.org-contact-details {
flex: 1;
}
.org-contact-details .name {
font-weight: 500;
color: var(--text-primary);
}
.org-contact-details .name a {
color: inherit;
text-decoration: none;
}
.org-contact-details .name a:hover {
color: var(--primary);
}
.org-contact-details .position {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.org-contact-actions {
display: flex;
gap: var(--spacing-sm);
font-size: var(--font-size-sm);
}
.org-contact-actions a {
color: var(--primary);
text-decoration: none;
}
.org-contact-actions a:hover {
text-decoration: underline;
}
/* Empty state */
.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 */
.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;
}
/* Modal styles */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
padding: var(--spacing-lg);
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--surface);
border-radius: var(--radius-lg);
max-width: 700px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.modal-close {
background: none;
border: none;
font-size: var(--font-size-2xl);
color: var(--text-secondary);
cursor: pointer;
line-height: 1;
}
.modal-close:hover {
color: var(--text-primary);
}
.modal-body {
padding: var(--spacing-lg);
}
.modal-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
}
/* AI form styles */
.ai-input-section {
margin-bottom: var(--spacing-lg);
}
.ai-input-section label {
display: block;
font-weight: 500;
margin-bottom: var(--spacing-sm);
color: var(--text-primary);
}
.ai-input-section .help-text {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.ai-textarea {
width: 100%;
min-height: 150px;
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: inherit;
font-size: var(--font-size-base);
resize: vertical;
}
.ai-textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-bg);
}
.ai-divider {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin: var(--spacing-lg) 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.ai-divider::before,
.ai-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.file-upload-area {
border: 2px dashed var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
}
.file-upload-area:hover {
border-color: var(--primary);
background: var(--primary-bg);
}
.file-upload-area.dragover {
border-color: var(--primary);
background: var(--primary-bg);
}
.file-upload-area input[type="file"] {
display: none;
}
.file-upload-icon {
font-size: 2.5rem;
margin-bottom: var(--spacing-sm);
}
.file-upload-text {
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.file-upload-text strong {
color: var(--primary);
}
.file-upload-hint {
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.image-preview {
margin-top: var(--spacing-md);
display: none;
}
.image-preview.active {
display: block;
}
.image-preview img {
max-width: 100%;
max-height: 200px;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.image-preview .remove-image {
display: inline-block;
margin-top: var(--spacing-sm);
color: var(--danger);
cursor: pointer;
font-size: var(--font-size-sm);
}
/* AI Results */
.ai-results {
display: none;
}
.ai-results.active {
display: block;
}
.ai-analysis {
background: var(--surface-secondary);
padding: var(--spacing-md);
border-radius: var(--radius);
margin-bottom: var(--spacing-lg);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.ai-contact-proposal {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-md);
overflow: hidden;
}
.ai-contact-proposal-header {
padding: var(--spacing-md);
background: var(--surface-secondary);
display: flex;
justify-content: space-between;
align-items: center;
}
.ai-contact-proposal-header label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
cursor: pointer;
font-weight: 500;
}
.ai-contact-proposal-header label input {
width: 18px;
height: 18px;
}
.ai-contact-proposal-body {
padding: var(--spacing-md);
font-size: var(--font-size-sm);
}
.ai-contact-proposal-body .field {
display: flex;
margin-bottom: var(--spacing-xs);
}
.ai-contact-proposal-body .field-label {
color: var(--text-secondary);
min-width: 120px;
}
.ai-contact-proposal-body .field-value {
color: var(--text-primary);
font-weight: 500;
}
/* Loading state */
.loading-overlay {
display: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--spacing-md);
z-index: 10;
}
.loading-overlay.active {
display: flex;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.contacts-grid {
grid-template-columns: 1fr;
}
.filter-group {
min-width: 100%;
}
.view-controls {
flex-direction: column;
align-items: flex-start;
}
.contacts-table-wrapper {
overflow-x: auto;
}
.contacts-table {
min-width: 600px;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="contacts-header">
<h1>&#128101; Kontakty zewnetrzne</h1>
<div class="header-actions">
<button type="button" class="btn btn-ai" onclick="openAiModal()">
&#10024; Dodaj z AI
</button>
<a href="{{ url_for('contact_add') }}" class="btn btn-primary">
+ Dodaj kontakt
</a>
</div>
</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="view-controls">
<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>
<div class="view-toggle">
<button type="button" data-view="groups" class="active" title="Pogrupowane po organizacji">
&#127970; Organizacje
</button>
<button type="button" data-view="cards" title="Widok kart">
&#128196; Karty
</button>
<button type="button" data-view="table" title="Widok tabeli">
&#128203; Tabela
</button>
</div>
</div>
{% if contacts %}
<!-- View: Organization Groups (default) -->
<div class="contacts-groups active" id="view-groups">
{% set contacts_by_org = {} %}
{% for contact in contacts %}
{% set org = contact.organization_name %}
{% if org not in contacts_by_org %}
{% set _ = contacts_by_org.update({org: {'contacts': [], 'type': contact.organization_type, 'logo': contact.organization_logo_url, 'website': contact.organization_website}}) %}
{% endif %}
{% set _ = contacts_by_org[org]['contacts'].append(contact) %}
{% endfor %}
{% for org_name, org_data in contacts_by_org.items() %}
<div class="org-group expanded">
<div class="org-group-header" onclick="toggleOrgGroup(this)">
<div class="org-group-info">
<div class="org-logo"
style="{% if not org_data.logo %}background: hsl({{ (org_name|length * 37) % 360 }}, 65%, 50%);{% endif %}">
{% if org_data.logo %}
<img src="{{ org_data.logo }}" alt="{{ org_name }}" onerror="this.style.display='none'; this.parentElement.textContent='{{ org_name[0]|upper }}';">
{% else %}
{{ org_name[0]|upper }}
{% endif %}
</div>
<div class="org-details">
<h3>{{ org_name }}</h3>
<div class="org-meta">
<span class="org-type-badge org-type-{{ org_data.type }}">
{{ org_type_labels.get(org_data.type, org_data.type) }}
</span>
<span>{{ org_data.contacts|length }} {% if org_data.contacts|length == 1 %}kontakt{% elif org_data.contacts|length < 5 %}kontakty{% else %}kontaktow{% endif %}</span>
{% if org_data.website %}
<a href="{{ org_data.website }}" target="_blank" rel="noopener" onclick="event.stopPropagation();" style="color: var(--primary);">
&#127760; Strona WWW
</a>
{% endif %}
</div>
</div>
</div>
<div class="org-group-toggle">&#9660;</div>
</div>
<div class="org-group-contacts">
{% for contact in org_data.contacts %}
<div class="org-contact-item">
<div class="org-contact-main">
<div class="contact-avatar small {% 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="org-contact-details">
<div class="name">
<a href="{{ url_for('contact_detail', contact_id=contact.id) }}">
{{ contact.full_name }}
</a>
</div>
{% if contact.position %}
<div class="position">{{ contact.position }}</div>
{% endif %}
</div>
</div>
<div class="org-contact-actions">
{% if contact.phone %}
<a href="tel:{{ contact.phone }}">&#128222; {{ contact.phone }}</a>
{% endif %}
{% if contact.email %}
<a href="mailto:{{ contact.email }}">&#9993; Email</a>
{% endif %}
<a href="{{ url_for('contact_detail', contact_id=contact.id) }}">Szczegoly &rarr;</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<!-- View: Cards -->
<div class="contacts-grid" id="view-cards">
{% 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>
<!-- View: Table -->
<div class="contacts-table-wrapper" id="view-table">
<table class="contacts-table">
<thead>
<tr>
<th>Osoba</th>
<th>Organizacja</th>
<th>Stanowisko</th>
<th>Kontakt</th>
<th>Projekt</th>
</tr>
</thead>
<tbody>
{% for contact in contacts %}
<tr>
<td>
<div class="contact-cell">
<div class="contact-avatar small {% 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>
<a href="{{ url_for('contact_detail', contact_id=contact.id) }}">
{{ contact.full_name }}
</a>
</div>
</td>
<td>
{{ contact.organization_name }}
<span class="org-type-badge org-type-{{ contact.organization_type }}">
{{ org_type_labels.get(contact.organization_type, contact.organization_type) }}
</span>
</td>
<td>{{ contact.position or '-' }}</td>
<td>
{% if contact.phone %}
<a href="tel:{{ contact.phone }}">{{ contact.phone }}</a>
{% endif %}
{% if contact.phone and contact.email %}<br>{% endif %}
{% if contact.email %}
<a href="mailto:{{ contact.email }}">{{ contact.email }}</a>
{% endif %}
</td>
<td>{{ contact.project_name or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</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>
<div class="header-actions" style="justify-content: center;">
<button type="button" class="btn btn-ai" onclick="openAiModal()">
&#10024; Dodaj z AI
</button>
<a href="{{ url_for('contact_add') }}" class="btn btn-primary">
+ Dodaj pierwszy kontakt
</a>
</div>
</div>
{% endif %}
</div>
<!-- Modal: Dodaj z AI -->
<div class="modal-overlay" id="aiModal">
<div class="modal" style="position: relative;">
<div class="loading-overlay" id="aiLoading">
<div class="loading-spinner"></div>
<div>Analizuje z AI...</div>
</div>
<div class="modal-header">
<h2>&#10024; Dodaj kontakty z AI</h2>
<button type="button" class="modal-close" onclick="closeAiModal()">&times;</button>
</div>
<div class="modal-body">
<!-- Input section -->
<div id="aiInputSection">
<div class="ai-input-section">
<label for="aiText">Wklej tekst z danymi kontaktowymi</label>
<p class="help-text">
Wklej email, wizytowke, tekst ze strony internetowej lub dowolny tekst zawierajacy dane kontaktowe.
AI wyodrębni informacje o osobach.
</p>
<textarea id="aiText" class="ai-textarea"
placeholder="Przyklad:
Anna Kowalska
Dyrektor ds. Rozwoju
Agencja Rozwoju Pomorza S.A.
tel. 58 32 33 160
anna.kowalska@arp.gda.pl"></textarea>
</div>
<div class="ai-divider">lub</div>
<div class="ai-input-section">
<label>Dodaj zrzut ekranu / zdjecie wizytowki</label>
<div class="file-upload-area" id="fileUploadArea">
<input type="file" id="aiImage" accept="image/*">
<div class="file-upload-icon">&#128247;</div>
<div class="file-upload-text">
<strong>Kliknij aby wybrac</strong> lub przeciagnij plik
</div>
<div class="file-upload-hint">PNG, JPG, WebP (max 10MB)</div>
</div>
<div class="image-preview" id="imagePreview">
<img id="previewImg" src="" alt="Podglad">
<div class="remove-image" onclick="removeImage()">&#10006; Usun obrazek</div>
</div>
</div>
</div>
<!-- Results section -->
<div class="ai-results" id="aiResults">
<div class="ai-analysis" id="aiAnalysis"></div>
<div id="aiProposals"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeAiModal()">Anuluj</button>
<button type="button" class="btn btn-ai" id="aiParseBtn" onclick="parseWithAi()">
&#10024; Analizuj z AI
</button>
<button type="button" class="btn btn-primary" id="aiSaveBtn" onclick="saveSelectedContacts()" style="display: none;">
Zapisz wybrane kontakty
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
// View toggle
const viewToggleBtns = document.querySelectorAll('.view-toggle button');
const viewContainers = {
'groups': document.getElementById('view-groups'),
'cards': document.getElementById('view-cards'),
'table': document.getElementById('view-table')
};
// Load saved view preference
const savedView = localStorage.getItem('contacts_view') || 'groups';
switchView(savedView);
viewToggleBtns.forEach(btn => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
switchView(view);
localStorage.setItem('contacts_view', view);
});
});
function switchView(view) {
// Update buttons
viewToggleBtns.forEach(b => b.classList.remove('active'));
document.querySelector(`[data-view="${view}"]`)?.classList.add('active');
// Update containers
Object.keys(viewContainers).forEach(v => {
if (viewContainers[v]) {
viewContainers[v].classList.remove('active');
if (v === view) {
viewContainers[v].classList.add('active');
}
}
});
}
// Organization group toggle
function toggleOrgGroup(header) {
const group = header.closest('.org-group');
group.classList.toggle('expanded');
}
// AI Modal
const aiModal = document.getElementById('aiModal');
const aiLoading = document.getElementById('aiLoading');
const aiInputSection = document.getElementById('aiInputSection');
const aiResults = document.getElementById('aiResults');
const aiParseBtn = document.getElementById('aiParseBtn');
const aiSaveBtn = document.getElementById('aiSaveBtn');
let parsedContacts = [];
function openAiModal() {
aiModal.classList.add('active');
resetAiModal();
}
function closeAiModal() {
aiModal.classList.remove('active');
resetAiModal();
}
function resetAiModal() {
document.getElementById('aiText').value = '';
removeImage();
aiInputSection.style.display = 'block';
aiResults.classList.remove('active');
aiParseBtn.style.display = 'inline-flex';
aiSaveBtn.style.display = 'none';
parsedContacts = [];
}
// File upload handling
const fileUploadArea = document.getElementById('fileUploadArea');
const aiImageInput = document.getElementById('aiImage');
const imagePreview = document.getElementById('imagePreview');
const previewImg = document.getElementById('previewImg');
fileUploadArea.addEventListener('click', () => aiImageInput.click());
fileUploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
fileUploadArea.classList.add('dragover');
});
fileUploadArea.addEventListener('dragleave', () => {
fileUploadArea.classList.remove('dragover');
});
fileUploadArea.addEventListener('drop', (e) => {
e.preventDefault();
fileUploadArea.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
handleImageFile(file);
}
});
aiImageInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
handleImageFile(file);
}
});
function handleImageFile(file) {
if (file.size > 10 * 1024 * 1024) {
alert('Plik jest za duzy. Maksymalny rozmiar to 10MB.');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
previewImg.src = e.target.result;
imagePreview.classList.add('active');
};
reader.readAsDataURL(file);
}
function removeImage() {
aiImageInput.value = '';
previewImg.src = '';
imagePreview.classList.remove('active');
}
// AI parsing
async function parseWithAi() {
const text = document.getElementById('aiText').value.trim();
const imageData = previewImg.src && previewImg.src.startsWith('data:') ? previewImg.src : null;
if (!text && !imageData) {
alert('Wklej tekst lub dodaj obrazek do analizy.');
return;
}
aiLoading.classList.add('active');
try {
const response = await fetch('/api/contacts/ai-parse', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
text: text || null,
image_data: imageData
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Blad analizy AI');
}
displayAiResults(data);
} catch (error) {
alert('Blad: ' + error.message);
} finally {
aiLoading.classList.remove('active');
}
}
function displayAiResults(data) {
parsedContacts = data.contacts || [];
if (parsedContacts.length === 0) {
alert('AI nie znalazlo zadnych kontaktow w podanych danych.');
return;
}
// Show results section
aiInputSection.style.display = 'none';
aiResults.classList.add('active');
aiParseBtn.style.display = 'none';
aiSaveBtn.style.display = 'inline-flex';
// Display analysis
document.getElementById('aiAnalysis').innerHTML =
`<strong>Analiza AI:</strong> ${data.analysis || 'Znaleziono ' + parsedContacts.length + ' kontaktow.'}`;
// Display contact proposals
const proposalsHtml = parsedContacts.map((contact, index) => `
<div class="ai-contact-proposal">
<div class="ai-contact-proposal-header">
<label>
<input type="checkbox" checked data-index="${index}">
<strong>${contact.first_name} ${contact.last_name}</strong>
</label>
<span class="org-type-badge org-type-${contact.organization_type || 'other'}">
${getOrgTypeLabel(contact.organization_type)}
</span>
</div>
<div class="ai-contact-proposal-body">
<div class="field">
<span class="field-label">Organizacja:</span>
<span class="field-value">${contact.organization_name || '-'}</span>
</div>
${contact.position ? `<div class="field">
<span class="field-label">Stanowisko:</span>
<span class="field-value">${contact.position}</span>
</div>` : ''}
${contact.phone ? `<div class="field">
<span class="field-label">Telefon:</span>
<span class="field-value">${contact.phone}</span>
</div>` : ''}
${contact.email ? `<div class="field">
<span class="field-label">Email:</span>
<span class="field-value">${contact.email}</span>
</div>` : ''}
${contact.project_name ? `<div class="field">
<span class="field-label">Projekt:</span>
<span class="field-value">${contact.project_name}</span>
</div>` : ''}
</div>
</div>
`).join('');
document.getElementById('aiProposals').innerHTML = proposalsHtml;
}
function getOrgTypeLabel(type) {
const labels = {
'government': 'Urzad',
'agency': 'Agencja',
'company': 'Firma',
'ngo': 'NGO',
'university': 'Uczelnia',
'other': 'Inne'
};
return labels[type] || type || 'Inne';
}
async function saveSelectedContacts() {
const checkboxes = document.querySelectorAll('#aiProposals input[type="checkbox"]:checked');
const selectedIndices = Array.from(checkboxes).map(cb => parseInt(cb.dataset.index));
if (selectedIndices.length === 0) {
alert('Wybierz co najmniej jeden kontakt do zapisania.');
return;
}
const contactsToSave = selectedIndices.map(i => parsedContacts[i]);
aiLoading.classList.add('active');
try {
const response = await fetch('/api/contacts/bulk-create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ contacts: contactsToSave })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Blad zapisywania kontaktow');
}
alert(`Zapisano ${data.created} kontaktow!`);
closeAiModal();
window.location.reload();
} catch (error) {
alert('Blad: ' + error.message);
} finally {
aiLoading.classList.remove('active');
}
}
// Close modal on overlay click
aiModal.addEventListener('click', (e) => {
if (e.target === aiModal) {
closeAiModal();
}
});
// Close modal on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && aiModal.classList.contains('active')) {
closeAiModal();
}
});
{% endblock %}