diff --git a/app.py b/app.py index 2621dd6..8921053 100644 --- a/app.py +++ b/app.py @@ -14111,6 +14111,306 @@ def contact_delete(contact_id): db.close() +# ============================================================ +# AI-ASSISTED EXTERNAL CONTACT CREATION +# ============================================================ + +AI_CONTACT_PARSE_PROMPT = """Jesteś asystentem systemu NordaBiz pomagającym dodawać kontakty zewnętrzne. + +ZADANIE: +Przeanalizuj podany tekst i wyodrębnij informacje o osobach kontaktowych z zewnętrznych organizacji +(urzędy, agencje, instytucje, firmy partnerskie - osoby spoza Norda Biznes). + +DANE WEJŚCIOWE: +``` +{input_text} +``` + +TYPY ORGANIZACJI: +- government = Urząd (np. ministerstwo, urząd gminy/powiatu) +- agency = Agencja (np. ARP, PARP, agencje rozwoju) +- company = Firma (przedsiębiorstwa, spółki) +- ngo = Organizacja pozarządowa (fundacje, stowarzyszenia) +- university = Uczelnia (uniwersytety, politechniki) +- other = Inne + +INSTRUKCJE: +1. Wyodrębnij każdą osobę kontaktową z tekstu +2. Dla każdej osoby zidentyfikuj: + - imię i nazwisko (WYMAGANE) + - stanowisko/funkcja (jeśli dostępne) + - telefon (jeśli dostępny) + - email (jeśli dostępny) + - organizacja (WYMAGANE - nazwa instytucji) + - typ organizacji (government/agency/company/ngo/university/other) + - projekt/kontekst (jeśli tekst wspomina o konkretnym projekcie) + - tagi (słowa kluczowe związane z osobą/projektem) +3. Jeśli brak imienia i nazwiska - pomiń osobę +4. Jeśli brak nazwy organizacji - pomiń osobę + +ZWRÓĆ TYLKO CZYSTY JSON w dokładnie takim formacie (bez żadnego tekstu przed ani po): +{{ + "analysis": "Krótki opis znalezionych kontaktów (1-2 zdania po polsku)", + "contacts": [ + {{ + "first_name": "Imię", + "last_name": "Nazwisko", + "position": "Stanowisko lub null", + "phone": "Numer telefonu lub null", + "email": "Email lub null", + "organization_name": "Nazwa organizacji", + "organization_type": "government|agency|company|ngo|university|other", + "project_name": "Nazwa projektu lub null", + "tags": "tagi, oddzielone, przecinkami", + "warnings": [] + }} + ] +}}""" + +AI_CONTACT_IMAGE_PROMPT = """Jesteś asystentem systemu NordaBiz pomagającym dodawać kontakty zewnętrzne. + +ZADANIE: +Przeanalizuj ten obraz (screenshot) i wyodrębnij informacje o osobach kontaktowych. +Szukaj: imion i nazwisk, stanowisk, telefonów, emaili, nazw organizacji, projektów. + +TYPY ORGANIZACJI: +- government = Urząd (np. ministerstwo, urząd gminy/powiatu) +- agency = Agencja (np. ARP, PARP, agencje rozwoju) +- company = Firma (przedsiębiorstwa, spółki) +- ngo = Organizacja pozarządowa (fundacje, stowarzyszenia) +- university = Uczelnia (uniwersytety, politechniki) +- other = Inne + +INSTRUKCJE: +1. Przeczytaj cały tekst widoczny na obrazie +2. Wyodrębnij każdą osobę kontaktową +3. Dla każdej osoby zidentyfikuj: + - imię i nazwisko (WYMAGANE) + - stanowisko/funkcja + - telefon + - email + - organizacja (WYMAGANE) + - typ organizacji + - projekt/kontekst + - tagi +4. Jeśli brak imienia/nazwiska lub organizacji - pomiń osobę + +ZWRÓĆ TYLKO CZYSTY JSON w dokładnie takim formacie: +{{ + "analysis": "Krótki opis znalezionych kontaktów (1-2 zdania po polsku)", + "contacts": [ + {{ + "first_name": "Imię", + "last_name": "Nazwisko", + "position": "Stanowisko lub null", + "phone": "Numer telefonu lub null", + "email": "Email lub null", + "organization_name": "Nazwa organizacji", + "organization_type": "government|agency|company|ngo|university|other", + "project_name": "Nazwa projektu lub null", + "tags": "tagi, oddzielone, przecinkami", + "warnings": [] + }} + ] +}}""" + + +@app.route('/api/contacts/ai-parse', methods=['POST']) +@login_required +def contacts_ai_parse(): + """Parse text or image with AI to extract external contact data.""" + db = SessionLocal() + try: + # Check input type + input_type = request.form.get('input_type') or (request.get_json() or {}).get('input_type', 'text') + + if input_type == 'image': + # Handle image upload + if 'file' not in request.files: + return jsonify({'success': False, 'error': 'Brak pliku obrazu'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'success': False, 'error': 'Nie wybrano pliku'}), 400 + + # Validate file type + allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp'} + ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else '' + if ext not in allowed_extensions: + return jsonify({'success': False, 'error': 'Dozwolone formaty: PNG, JPG, JPEG, GIF, WEBP'}), 400 + + # Save temp file + import tempfile + with tempfile.NamedTemporaryFile(delete=False, suffix=f'.{ext}') as tmp: + file.save(tmp.name) + temp_path = tmp.name + + try: + # Get Gemini service and analyze image + service = gemini_service.get_gemini_service() + ai_response = service.analyze_image(temp_path, AI_CONTACT_IMAGE_PROMPT) + finally: + # Clean up temp file + import os + if os.path.exists(temp_path): + os.unlink(temp_path) + + else: + # Handle text input + data = request.get_json() or {} + content = data.get('content', '').strip() + + if not content: + return jsonify({'success': False, 'error': 'Brak treści do analizy'}), 400 + + # Get Gemini service and analyze text + service = gemini_service.get_gemini_service() + prompt = AI_CONTACT_PARSE_PROMPT.format(input_text=content) + ai_response = service.generate_text( + prompt=prompt, + feature='ai_contact_parse', + user_id=current_user.id, + temperature=0.3 + ) + + # Parse AI response as JSON + import re + json_match = re.search(r'\{[\s\S]*\}', ai_response) + if not json_match: + logger.error(f"AI contact response not valid JSON: {ai_response[:500]}") + return jsonify({ + 'success': False, + 'error': 'AI nie zwróciło prawidłowej odpowiedzi. Spróbuj ponownie.' + }), 500 + + try: + parsed = json.loads(json_match.group()) + except json.JSONDecodeError as e: + logger.error(f"JSON parse error: {e}, response: {ai_response[:500]}") + return jsonify({ + 'success': False, + 'error': 'Błąd parsowania odpowiedzi AI. Spróbuj ponownie.' + }), 500 + + # Check for potential duplicates + from database import ExternalContact + proposed_contacts = parsed.get('contacts', []) + + for contact in proposed_contacts: + first_name = contact.get('first_name', '').strip() + last_name = contact.get('last_name', '').strip() + org_name = contact.get('organization_name', '').strip() + + if first_name and last_name and org_name: + # Check for existing similar contact + existing = db.query(ExternalContact).filter( + ExternalContact.first_name.ilike(first_name), + ExternalContact.last_name.ilike(last_name), + ExternalContact.organization_name.ilike(f'%{org_name}%'), + ExternalContact.is_active == True + ).first() + + if existing: + contact['warnings'] = contact.get('warnings', []) + [ + f'Podobny kontakt może już istnieć: {existing.full_name} @ {existing.organization_name}' + ] + contact['potential_duplicate_id'] = existing.id + + logger.info(f"User {current_user.email} used AI to parse contacts: {len(proposed_contacts)} found") + + return jsonify({ + 'success': True, + 'ai_response': parsed.get('analysis', 'Analiza zakończona'), + 'proposed_contacts': proposed_contacts + }) + + except Exception as e: + logger.error(f"Error in AI contact parse: {e}") + return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500 + finally: + db.close() + + +@app.route('/api/contacts/bulk-create', methods=['POST']) +@login_required +def contacts_bulk_create(): + """Create multiple external contacts from confirmed proposals.""" + from database import ExternalContact + + db = SessionLocal() + try: + data = request.get_json() or {} + contacts_to_create = data.get('contacts', []) + + if not contacts_to_create: + return jsonify({'success': False, 'error': 'Brak kontaktów do utworzenia'}), 400 + + created = [] + failed = [] + + for contact_data in contacts_to_create: + try: + # Validate required fields + first_name = contact_data.get('first_name', '').strip() + last_name = contact_data.get('last_name', '').strip() + organization_name = contact_data.get('organization_name', '').strip() + + if not first_name or not last_name or not organization_name: + failed.append({ + 'name': f"{first_name} {last_name}", + 'error': 'Brak wymaganych danych (imię, nazwisko lub organizacja)' + }) + continue + + # Create contact + contact = ExternalContact( + first_name=first_name, + last_name=last_name, + position=contact_data.get('position', '').strip() or None, + phone=contact_data.get('phone', '').strip() or None, + email=contact_data.get('email', '').strip() or None, + organization_name=organization_name, + organization_type=contact_data.get('organization_type', 'other'), + project_name=contact_data.get('project_name', '').strip() or None, + tags=contact_data.get('tags', '').strip() or None, + source_type='ai_import', + created_by=current_user.id + ) + + db.add(contact) + db.flush() + + created.append({ + 'id': contact.id, + 'name': contact.full_name, + 'organization': contact.organization_name + }) + + except Exception as e: + failed.append({ + 'name': f"{contact_data.get('first_name', '')} {contact_data.get('last_name', '')}", + 'error': str(e) + }) + + db.commit() + + logger.info(f"User {current_user.email} bulk created {len(created)} contacts via AI") + + return jsonify({ + 'success': True, + 'created': created, + 'failed': failed, + 'message': f'Utworzono {len(created)} kontaktów' + (f', {len(failed)} błędów' if failed else '') + }) + + except Exception as e: + db.rollback() + logger.error(f"Error in contacts bulk create: {e}") + return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500 + finally: + db.close() + + # ============================================================ # HONEYPOT ENDPOINTS (trap for malicious bots) # ============================================================ diff --git a/docker-compose.yml b/docker-compose.yml index 7b3d557..a484e0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3.8' services: postgres: - image: postgres:15 + image: postgres:16 container_name: nordabiz-postgres environment: POSTGRES_DB: nordabiz diff --git a/templates/contacts/list.html b/templates/contacts/list.html index 58e4db7..1c575c0 100644 --- a/templates/contacts/list.html +++ b/templates/contacts/list.html @@ -21,6 +21,22 @@ 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); @@ -66,6 +82,56 @@ 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: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); @@ -116,6 +182,12 @@ border-radius: 50%; } + .contact-avatar.small { + width: 40px; + height: 40px; + font-size: var(--font-size-base); + } + .contact-info { flex: 1; min-width: 0; @@ -229,6 +301,214 @@ .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); @@ -253,6 +533,7 @@ margin-bottom: var(--spacing-lg); } + /* Pagination */ .pagination { display: flex; justify-content: center; @@ -286,13 +567,292 @@ color: white; } - .stats-bar { + /* 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; - margin-bottom: var(--spacing-md); + } + + .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) { @@ -303,6 +863,19 @@ .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; + } } {% endblock %} @@ -311,9 +884,14 @@

👥 Kontakty zewnetrzne

- - + Dodaj kontakt - +
+ + + + Dodaj kontakt + +
@@ -353,15 +931,110 @@
-
- Znaleziono: {{ total }} kontaktow - {% if search or org_type or project %} - Wyczysc filtry - {% endif %} +
+
+ Znaleziono: {{ total }} kontaktow + {% if search or org_type or project %} + | Wyczysc filtry + {% endif %} +
+
+ + + +
{% if contacts %} -
+ + +
+ {% 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() %} +
+
+
+ +
+

{{ org_name }}

+
+ + {{ org_type_labels.get(org_data.type, org_data.type) }} + + {{ org_data.contacts|length }} {% if org_data.contacts|length == 1 %}kontakt{% elif org_data.contacts|length < 5 %}kontakty{% else %}kontaktow{% endif %} + {% if org_data.website %} + + 🌐 Strona WWW + + {% endif %} +
+
+
+
+
+
+ {% for contact in org_data.contacts %} +
+
+
+ {% if contact.photo_url %} + {{ contact.full_name }} + {% else %} + {{ contact.first_name[0]|upper }} + {% endif %} +
+
+ + {% if contact.position %} +
{{ contact.position }}
+ {% endif %} +
+
+
+ {% if contact.phone %} + 📞 {{ contact.phone }} + {% endif %} + {% if contact.email %} + ✉ Email + {% endif %} + Szczegoly → +
+
+ {% endfor %} +
+
+ {% endfor %} +
+ + +
{% for contact in contacts %}
@@ -432,6 +1105,60 @@ {% endfor %}
+ +
+ + + + + + + + + + + + {% for contact in contacts %} + + + + + + + + {% endfor %} + +
OsobaOrganizacjaStanowiskoKontaktProjekt
+
+
+ {% if contact.photo_url %} + {{ contact.full_name }} + {% else %} + {{ contact.first_name[0]|upper }} + {% endif %} +
+ + {{ contact.full_name }} + +
+
+ {{ contact.organization_name }} + + {{ org_type_labels.get(contact.organization_type, contact.organization_type) }} + + {{ contact.position or '-' }} + {% if contact.phone %} + {{ contact.phone }} + {% endif %} + {% if contact.phone and contact.email %}
{% endif %} + {% if contact.email %} + {{ contact.email }} + {% endif %} +
{{ contact.project_name or '-' }}
+
+ {% if total_pages > 1 %} {% endif %}
+ + + +{% 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 = + `Analiza AI: ${data.analysis || 'Znaleziono ' + parsedContacts.length + ' kontaktow.'}`; + + // Display contact proposals + const proposalsHtml = parsedContacts.map((contact, index) => ` +
+
+ + + ${getOrgTypeLabel(contact.organization_type)} + +
+
+
+ Organizacja: + ${contact.organization_name || '-'} +
+ ${contact.position ? `
+ Stanowisko: + ${contact.position} +
` : ''} + ${contact.phone ? `
+ Telefon: + ${contact.phone} +
` : ''} + ${contact.email ? `
+ Email: + ${contact.email} +
` : ''} + ${contact.project_name ? `
+ Projekt: + ${contact.project_name} +
` : ''} +
+
+ `).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 %}