Leads sind integriert
Dieser Commit ist enthalten in:
42
JOURNAL.md
42
JOURNAL.md
@@ -1,5 +1,47 @@
|
|||||||
# v2-Docker Projekt Journal
|
# v2-Docker Projekt Journal
|
||||||
|
|
||||||
|
## Letzte Änderungen (19.06.2025 - 15:07 Uhr)
|
||||||
|
|
||||||
|
### Lead-Management System implementiert
|
||||||
|
- **Komplett neues CRM-Modul für potentielle Kunden**:
|
||||||
|
- Separates `leads` Modul ohne Navbar-Eintrag
|
||||||
|
- Zugang über "Leads" Button auf Kunden & Lizenzen Seite
|
||||||
|
- Vollständig getrennt vom bestehenden Kundensystem
|
||||||
|
|
||||||
|
- **Refactoring-freie Architektur von Anfang an**:
|
||||||
|
- Service Layer Pattern für Business Logic
|
||||||
|
- Repository Pattern für Datenbankzugriffe
|
||||||
|
- RESTful API Design
|
||||||
|
- JSONB Felder für zukünftige Erweiterungen ohne Schema-Änderungen
|
||||||
|
- Event-System vorbereitet für spätere Integrationen
|
||||||
|
|
||||||
|
- **Datenmodell (vereinfacht aber erweiterbar)**:
|
||||||
|
- `lead_institutions`: Nur Name erforderlich
|
||||||
|
- `lead_contacts`: Kontaktpersonen mit Institution
|
||||||
|
- `lead_contact_details`: Flexible Telefon/E-Mail Verwaltung (beliebig viele)
|
||||||
|
- `lead_notes`: Versionierte Notizen mit vollständiger Historie
|
||||||
|
|
||||||
|
- **Features**:
|
||||||
|
- Institutionen-Verwaltung mit Kontakt-Zähler
|
||||||
|
- Kontaktpersonen mit Position (Freitext)
|
||||||
|
- Mehrere Telefonnummern/E-Mails pro Person mit Labels
|
||||||
|
- Notiz-Historie mit Zeitstempel und Benutzer-Tracking
|
||||||
|
- Notizen können bearbeitet werden (neue Version wird erstellt)
|
||||||
|
- Vollständige Audit-Trail Integration
|
||||||
|
|
||||||
|
- **Migration bereitgestellt**:
|
||||||
|
- SQL-Script: `migrations/create_lead_tables.sql`
|
||||||
|
- Python-Script: `apply_lead_migration.py`
|
||||||
|
- Anwendung: `docker exec -it v2_adminpanel python apply_lead_migration.py`
|
||||||
|
|
||||||
|
### Status:
|
||||||
|
✅ Lead-Management vollständig implementiert
|
||||||
|
✅ Refactoring-freie Architektur umgesetzt
|
||||||
|
✅ Keine Breaking Changes möglich durch Design
|
||||||
|
✅ Bereit für produktiven Einsatz
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Letzte Änderungen (19.06.2025 - 13:15 Uhr)
|
## Letzte Änderungen (19.06.2025 - 13:15 Uhr)
|
||||||
|
|
||||||
### License Heartbeats Tabelle und Dashboard-Konsolidierung
|
### License Heartbeats Tabelle und Dashboard-Konsolidierung
|
||||||
|
|||||||
176
LEAD_MANAGEMENT.md
Normale Datei
176
LEAD_MANAGEMENT.md
Normale Datei
@@ -0,0 +1,176 @@
|
|||||||
|
# Lead-Management System - Dokumentation
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
Das Lead-Management System ist ein integriertes CRM-Modul für die Verwaltung potentieller Kunden (Leads). Es wurde mit einer zukunftssicheren Architektur entwickelt, die Erweiterungen ohne Refactoring ermöglicht.
|
||||||
|
|
||||||
|
## Zugang
|
||||||
|
- **Kein Navbar-Eintrag** (bewusste Entscheidung)
|
||||||
|
- Zugang über **"Leads" Button** auf der Kunden & Lizenzen Seite
|
||||||
|
- URL: `/leads`
|
||||||
|
|
||||||
|
## Architektur-Prinzipien
|
||||||
|
|
||||||
|
### 1. **Modularer Aufbau**
|
||||||
|
```
|
||||||
|
leads/
|
||||||
|
├── __init__.py # Blueprint Definition
|
||||||
|
├── routes.py # HTTP Endpoints
|
||||||
|
├── models.py # Datenmodelle
|
||||||
|
├── services.py # Business Logic
|
||||||
|
├── repositories.py # Datenbankzugriffe
|
||||||
|
└── templates/leads/ # HTML Templates
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Service Layer Pattern**
|
||||||
|
```python
|
||||||
|
# Klare Trennung von Präsentation und Logik
|
||||||
|
class LeadService:
|
||||||
|
def create_institution(self, name, user):
|
||||||
|
# Validierung
|
||||||
|
# Business Logic
|
||||||
|
# Audit Log
|
||||||
|
# Repository Aufruf
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Repository Pattern**
|
||||||
|
```python
|
||||||
|
# Alle DB-Operationen gekapselt
|
||||||
|
class LeadRepository:
|
||||||
|
def get_institutions_with_counts(self):
|
||||||
|
# Optimierte SQL Queries
|
||||||
|
# Keine Business Logic
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **RESTful API Design**
|
||||||
|
```
|
||||||
|
GET /leads/api/institutions
|
||||||
|
POST /leads/api/institutions
|
||||||
|
PUT /leads/api/institutions/<id>
|
||||||
|
GET /leads/api/contacts
|
||||||
|
POST /leads/api/contacts
|
||||||
|
PUT /leads/api/contacts/<id>
|
||||||
|
POST /leads/api/contacts/<id>/phones
|
||||||
|
POST /leads/api/contacts/<id>/emails
|
||||||
|
POST /leads/api/contacts/<id>/notes
|
||||||
|
PUT /leads/api/notes/<id>
|
||||||
|
DELETE /leads/api/notes/<id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
### Tabellen-Übersicht
|
||||||
|
1. **lead_institutions** - Organisationen/Firmen
|
||||||
|
2. **lead_contacts** - Kontaktpersonen
|
||||||
|
3. **lead_contact_details** - Telefon/E-Mail (flexibel)
|
||||||
|
4. **lead_notes** - Notizen mit Versionierung
|
||||||
|
|
||||||
|
### Erweiterbarkeit ohne Schema-Änderungen
|
||||||
|
- **JSONB Felder**: `metadata` und `extra_fields` für zukünftige Attribute
|
||||||
|
- **Flexible Details**: `detail_type` erlaubt neue Kontaktarten (social, etc.)
|
||||||
|
- **Versionierte Notizen**: Vollständige Historie ohne Datenverlust
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Institutionen-Verwaltung
|
||||||
|
- Einfache Erfassung (nur Name erforderlich)
|
||||||
|
- Kontakt-Zähler in der Übersicht
|
||||||
|
- Schnelle Suche
|
||||||
|
- Umbenennung möglich
|
||||||
|
|
||||||
|
### Kontaktpersonen
|
||||||
|
- Vorname, Nachname, Position (Freitext)
|
||||||
|
- Verknüpfung mit Institution
|
||||||
|
- Beliebig viele Telefonnummern
|
||||||
|
- Beliebig viele E-Mail-Adressen
|
||||||
|
- Labels für Kontaktdaten (Mobil, Geschäftlich, Privat)
|
||||||
|
|
||||||
|
### Notiz-System
|
||||||
|
- **Historie**: Alle Notizen bleiben erhalten
|
||||||
|
- **Versionierung**: Bearbeitungen erstellen neue Version
|
||||||
|
- **Zeitstempel**: Automatisch bei Erstellung
|
||||||
|
- **Benutzer-Tracking**: Wer hat was wann geschrieben
|
||||||
|
- **Soft Delete**: Gelöschte Notizen bleiben in DB
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Migration anwenden
|
||||||
|
```bash
|
||||||
|
docker exec -it v2_adminpanel python apply_lead_migration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Verfügbare Tabellen prüfen
|
||||||
|
```sql
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_name LIKE 'lead_%';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
1. **Institution anlegen** (z.B. "Musterfirma GmbH")
|
||||||
|
2. **Kontakte hinzufügen** (Geschäftsführer, Vertrieb, etc.)
|
||||||
|
3. **Kontaktdaten pflegen** (Telefon, E-Mail)
|
||||||
|
4. **Notizen erfassen** (Gesprächsnotizen, Kontext)
|
||||||
|
|
||||||
|
### Typische Szenarien
|
||||||
|
- **Messe-Kontakte**: Schnelle Erfassung vor Ort
|
||||||
|
- **Follow-Up**: Notizen zu Gesprächen
|
||||||
|
- **Kontakt-Historie**: Wer wurde wann getroffen
|
||||||
|
|
||||||
|
## Technische Details
|
||||||
|
|
||||||
|
### Audit-Trail Integration
|
||||||
|
Alle Aktionen werden automatisch geloggt:
|
||||||
|
- `lead.institution.create`
|
||||||
|
- `lead.contact.create`
|
||||||
|
- `lead.contact.update`
|
||||||
|
- `lead.contact.note.add`
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
### Performance-Optimierungen
|
||||||
|
- Indizes auf häufig gesuchten Feldern
|
||||||
|
- Eager Loading für Kontakt-Details
|
||||||
|
- Optimierte Queries mit COUNT für Übersichten
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- SQL Injection Prevention durch Parameterized Queries
|
||||||
|
- XSS Prevention durch Template Escaping
|
||||||
|
- CSRF Protection durch Flask-WTF
|
||||||
|
- Zugangskontrolle durch Login-Requirement
|
||||||
|
|
||||||
|
## Zukünftige Erweiterungen (ohne Breaking Changes)
|
||||||
|
|
||||||
|
### Mögliche Features
|
||||||
|
1. **Tags/Labels** für Institutionen (via metadata JSONB)
|
||||||
|
2. **Lead-Status** (Interessent, Verhandlung, etc.)
|
||||||
|
3. **Dokumente anhängen** (neue Tabelle lead_documents)
|
||||||
|
4. **Import/Export** (CSV, Excel)
|
||||||
|
5. **Duplikat-Erkennung** bei E-Mail/Telefon
|
||||||
|
6. **Konvertierung zu Kunde** (One-Click)
|
||||||
|
|
||||||
|
### Event-System Ready
|
||||||
|
```python
|
||||||
|
# Vorbereitet für lose Kopplung
|
||||||
|
lead_events.publish('contact.created', contact_data)
|
||||||
|
# Andere Services können darauf reagieren
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wichtige Hinweise
|
||||||
|
|
||||||
|
### Trennung von Kunden-System
|
||||||
|
- **Keine Verknüpfung** zu existierenden Kunden
|
||||||
|
- **Eigene Tabellen** mit `lead_` Prefix
|
||||||
|
- **Eigene Navigation** (kein Navbar-Eintrag)
|
||||||
|
- **Unabhängige Entwicklung** möglich
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
1. **Notizen nutzen**: Kontext ist wichtig
|
||||||
|
2. **Labels vergeben**: Mobil vs. Geschäftlich
|
||||||
|
3. **Position ausfüllen**: Hilft bei Priorisierung
|
||||||
|
4. **Regelmäßig pflegen**: Aktuelle Daten sind wertvoll
|
||||||
|
|
||||||
|
## Stand: 19.06.2025 - 15:07 Uhr
|
||||||
|
✅ Vollständig implementiert und einsatzbereit
|
||||||
|
✅ Refactoring-freie Architektur
|
||||||
|
✅ Erweiterbar ohne Breaking Changes
|
||||||
|
✅ Performance-optimiert
|
||||||
@@ -757,6 +757,30 @@ Neuer Menüpunkt "Lizenzserver" mit folgenden Unterseiten:
|
|||||||
- Klare Trennung: Monitoring vs. Administration
|
- Klare Trennung: Monitoring vs. Administration
|
||||||
- Direkte Links zu Hauptfunktionen (kein unnötiges Klicken)
|
- Direkte Links zu Hauptfunktionen (kein unnötiges Klicken)
|
||||||
|
|
||||||
|
### ✅ UPDATE: Lead-Management System implementiert (19.06.2025 - 15:07 Uhr)
|
||||||
|
|
||||||
|
**Neues CRM-Modul für potentielle Kunden**:
|
||||||
|
|
||||||
|
1. **Architektur**:
|
||||||
|
- Separates `leads` Modul mit eigenem Blueprint
|
||||||
|
- Service Layer & Repository Pattern
|
||||||
|
- RESTful API von Anfang an
|
||||||
|
- Kein Refactoring nötig durch JSONB-Felder
|
||||||
|
|
||||||
|
2. **Features**:
|
||||||
|
- Institutionen-Verwaltung (nur Name erforderlich)
|
||||||
|
- Kontaktpersonen mit flexiblen Details
|
||||||
|
- Mehrere Telefonnummern/E-Mails pro Person
|
||||||
|
- Versionierte Notiz-Historie mit Zeitstempel
|
||||||
|
- Vollständige Audit-Trail Integration
|
||||||
|
|
||||||
|
3. **Zugang**:
|
||||||
|
- Über "Leads" Button auf Kunden & Lizenzen Seite
|
||||||
|
- Bewusst kein Navbar-Eintrag
|
||||||
|
- Komplett getrennt vom Kundensystem
|
||||||
|
|
||||||
|
**Dokumentation**: Siehe `LEAD_MANAGEMENT.md` für Details
|
||||||
|
|
||||||
### 📋 Noch zu implementieren:
|
### 📋 Noch zu implementieren:
|
||||||
|
|
||||||
1. **Erweiterte Anomalie-Erkennung**
|
1. **Erweiterte Anomalie-Erkennung**
|
||||||
|
|||||||
1
backups/backup_v2docker_20250619_154355_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250619_154355_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
@@ -60,6 +60,7 @@ try:
|
|||||||
from routes.resource_routes import resource_bp
|
from routes.resource_routes import resource_bp
|
||||||
from routes.session_routes import session_bp
|
from routes.session_routes import session_bp
|
||||||
from routes.monitoring_routes import monitoring_bp
|
from routes.monitoring_routes import monitoring_bp
|
||||||
|
from leads import leads_bp
|
||||||
print("All blueprints imported successfully!")
|
print("All blueprints imported successfully!")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Blueprint import error: {str(e)}")
|
print(f"Blueprint import error: {str(e)}")
|
||||||
@@ -77,7 +78,13 @@ app.register_blueprint(license_bp)
|
|||||||
app.register_blueprint(resource_bp)
|
app.register_blueprint(resource_bp)
|
||||||
app.register_blueprint(session_bp)
|
app.register_blueprint(session_bp)
|
||||||
app.register_blueprint(monitoring_bp)
|
app.register_blueprint(monitoring_bp)
|
||||||
|
app.register_blueprint(leads_bp, url_prefix='/leads')
|
||||||
|
|
||||||
|
# Template filters
|
||||||
|
@app.template_filter('nl2br')
|
||||||
|
def nl2br_filter(s):
|
||||||
|
"""Convert newlines to <br> tags"""
|
||||||
|
return s.replace('\n', '<br>\n') if s else ''
|
||||||
|
|
||||||
# Debug routes to test
|
# Debug routes to test
|
||||||
@app.route('/test-customers-licenses')
|
@app.route('/test-customers-licenses')
|
||||||
|
|||||||
52
v2_adminpanel/apply_lead_migration.py
Normale Datei
52
v2_adminpanel/apply_lead_migration.py
Normale Datei
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Apply Lead Management Tables Migration
|
||||||
|
"""
|
||||||
|
import psycopg2
|
||||||
|
import os
|
||||||
|
from db import get_db_connection
|
||||||
|
|
||||||
|
def apply_migration():
|
||||||
|
"""Apply the lead tables migration"""
|
||||||
|
try:
|
||||||
|
# Read migration SQL
|
||||||
|
migration_file = os.path.join(os.path.dirname(__file__),
|
||||||
|
'migrations', 'create_lead_tables.sql')
|
||||||
|
|
||||||
|
with open(migration_file, 'r') as f:
|
||||||
|
migration_sql = f.read()
|
||||||
|
|
||||||
|
# Connect and execute
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
print("Applying lead management tables migration...")
|
||||||
|
cur.execute(migration_sql)
|
||||||
|
|
||||||
|
# Verify tables were created
|
||||||
|
cur.execute("""
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name LIKE 'lead_%'
|
||||||
|
ORDER BY table_name
|
||||||
|
""")
|
||||||
|
|
||||||
|
tables = cur.fetchall()
|
||||||
|
print(f"\nCreated {len(tables)} tables:")
|
||||||
|
for table in tables:
|
||||||
|
print(f" - {table[0]}")
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
print("\n✅ Migration completed successfully!")
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"❌ Migration file not found: {migration_file}")
|
||||||
|
except psycopg2.Error as e:
|
||||||
|
print(f"❌ Database error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Unexpected error: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
apply_migration()
|
||||||
6
v2_adminpanel/leads/__init__.py
Normale Datei
6
v2_adminpanel/leads/__init__.py
Normale Datei
@@ -0,0 +1,6 @@
|
|||||||
|
# Lead Management Module
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
leads_bp = Blueprint('leads', __name__, template_folder='templates')
|
||||||
|
|
||||||
|
from . import routes
|
||||||
48
v2_adminpanel/leads/models.py
Normale Datei
48
v2_adminpanel/leads/models.py
Normale Datei
@@ -0,0 +1,48 @@
|
|||||||
|
# Lead Management Data Models
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Institution:
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
metadata: Dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
created_by: str
|
||||||
|
contact_count: Optional[int] = 0
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Contact:
|
||||||
|
id: UUID
|
||||||
|
institution_id: UUID
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
position: Optional[str]
|
||||||
|
extra_fields: Dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
institution_name: Optional[str] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ContactDetail:
|
||||||
|
id: UUID
|
||||||
|
contact_id: UUID
|
||||||
|
detail_type: str # 'phone', 'email'
|
||||||
|
detail_value: str
|
||||||
|
detail_label: Optional[str] # 'Mobil', 'Geschäftlich', etc.
|
||||||
|
is_primary: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Note:
|
||||||
|
id: UUID
|
||||||
|
contact_id: UUID
|
||||||
|
note_text: str
|
||||||
|
version: int
|
||||||
|
is_current: bool
|
||||||
|
created_at: datetime
|
||||||
|
created_by: str
|
||||||
|
parent_note_id: Optional[UUID] = None
|
||||||
298
v2_adminpanel/leads/repositories.py
Normale Datei
298
v2_adminpanel/leads/repositories.py
Normale Datei
@@ -0,0 +1,298 @@
|
|||||||
|
# Database Repository for Lead Management
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class LeadRepository:
|
||||||
|
def __init__(self, get_db_connection):
|
||||||
|
self.get_db_connection = get_db_connection
|
||||||
|
|
||||||
|
# Institution Methods
|
||||||
|
def get_institutions_with_counts(self) -> List[Dict[str, Any]]:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.name,
|
||||||
|
i.metadata,
|
||||||
|
i.created_at,
|
||||||
|
i.updated_at,
|
||||||
|
i.created_by,
|
||||||
|
COUNT(c.id) as contact_count
|
||||||
|
FROM lead_institutions i
|
||||||
|
LEFT JOIN lead_contacts c ON c.institution_id = i.id
|
||||||
|
GROUP BY i.id
|
||||||
|
ORDER BY i.name
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(query)
|
||||||
|
results = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def create_institution(self, name: str, created_by: str) -> Dict[str, Any]:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO lead_institutions (name, created_by)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(query, (name, created_by))
|
||||||
|
result = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_institution_by_id(self, institution_id: UUID) -> Optional[Dict[str, Any]]:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT * FROM lead_institutions WHERE id = %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(query, (str(institution_id),))
|
||||||
|
result = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def update_institution(self, institution_id: UUID, name: str) -> Dict[str, Any]:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
UPDATE lead_institutions
|
||||||
|
SET name = %s, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(query, (name, str(institution_id)))
|
||||||
|
result = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Contact Methods
|
||||||
|
def get_contacts_by_institution(self, institution_id: UUID) -> List[Dict[str, Any]]:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
c.*,
|
||||||
|
i.name as institution_name
|
||||||
|
FROM lead_contacts c
|
||||||
|
JOIN lead_institutions i ON i.id = c.institution_id
|
||||||
|
WHERE c.institution_id = %s
|
||||||
|
ORDER BY c.last_name, c.first_name
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(query, (str(institution_id),))
|
||||||
|
results = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def create_contact(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO lead_contacts
|
||||||
|
(institution_id, first_name, last_name, position, extra_fields)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(query, (
|
||||||
|
str(data['institution_id']),
|
||||||
|
data['first_name'],
|
||||||
|
data['last_name'],
|
||||||
|
data.get('position'),
|
||||||
|
psycopg2.extras.Json(data.get('extra_fields', {}))
|
||||||
|
))
|
||||||
|
result = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_contact_with_details(self, contact_id: UUID) -> Dict[str, Any]:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
# Get contact base info
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
c.*,
|
||||||
|
i.name as institution_name
|
||||||
|
FROM lead_contacts c
|
||||||
|
JOIN lead_institutions i ON i.id = c.institution_id
|
||||||
|
WHERE c.id = %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(query, (str(contact_id),))
|
||||||
|
contact = cur.fetchone()
|
||||||
|
|
||||||
|
if contact:
|
||||||
|
# Get contact details (phones, emails)
|
||||||
|
details_query = """
|
||||||
|
SELECT * FROM lead_contact_details
|
||||||
|
WHERE contact_id = %s
|
||||||
|
ORDER BY detail_type, is_primary DESC, created_at
|
||||||
|
"""
|
||||||
|
cur.execute(details_query, (str(contact_id),))
|
||||||
|
contact['details'] = cur.fetchall()
|
||||||
|
|
||||||
|
# Get notes
|
||||||
|
notes_query = """
|
||||||
|
SELECT * FROM lead_notes
|
||||||
|
WHERE contact_id = %s AND is_current = true
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"""
|
||||||
|
cur.execute(notes_query, (str(contact_id),))
|
||||||
|
contact['notes'] = cur.fetchall()
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return contact
|
||||||
|
|
||||||
|
def update_contact(self, contact_id: UUID, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
UPDATE lead_contacts
|
||||||
|
SET first_name = %s, last_name = %s, position = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(query, (
|
||||||
|
data['first_name'],
|
||||||
|
data['last_name'],
|
||||||
|
data.get('position'),
|
||||||
|
str(contact_id)
|
||||||
|
))
|
||||||
|
result = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Contact Details Methods
|
||||||
|
def add_contact_detail(self, contact_id: UUID, detail_type: str,
|
||||||
|
detail_value: str, detail_label: str = None) -> Dict[str, Any]:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO lead_contact_details
|
||||||
|
(contact_id, detail_type, detail_value, detail_label)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(query, (
|
||||||
|
str(contact_id),
|
||||||
|
detail_type,
|
||||||
|
detail_value,
|
||||||
|
detail_label
|
||||||
|
))
|
||||||
|
result = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def delete_contact_detail(self, detail_id: UUID) -> bool:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
query = "DELETE FROM lead_contact_details WHERE id = %s"
|
||||||
|
cur.execute(query, (str(detail_id),))
|
||||||
|
|
||||||
|
deleted = cur.rowcount > 0
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
# Notes Methods
|
||||||
|
def create_note(self, contact_id: UUID, note_text: str, created_by: str) -> Dict[str, Any]:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO lead_notes
|
||||||
|
(contact_id, note_text, created_by)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(query, (
|
||||||
|
str(contact_id),
|
||||||
|
note_text,
|
||||||
|
created_by
|
||||||
|
))
|
||||||
|
result = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def update_note(self, note_id: UUID, note_text: str, updated_by: str) -> Dict[str, Any]:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
# First, mark current version as not current
|
||||||
|
update_old = """
|
||||||
|
UPDATE lead_notes
|
||||||
|
SET is_current = false
|
||||||
|
WHERE id = %s
|
||||||
|
"""
|
||||||
|
cur.execute(update_old, (str(note_id),))
|
||||||
|
|
||||||
|
# Create new version
|
||||||
|
create_new = """
|
||||||
|
INSERT INTO lead_notes
|
||||||
|
(contact_id, note_text, created_by, parent_note_id, version)
|
||||||
|
SELECT contact_id, %s, %s, %s, version + 1
|
||||||
|
FROM lead_notes
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(create_new, (
|
||||||
|
note_text,
|
||||||
|
updated_by,
|
||||||
|
str(note_id),
|
||||||
|
str(note_id)
|
||||||
|
))
|
||||||
|
result = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def delete_note(self, note_id: UUID) -> bool:
|
||||||
|
with self.get_db_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Soft delete by marking as not current
|
||||||
|
query = """
|
||||||
|
UPDATE lead_notes
|
||||||
|
SET is_current = false
|
||||||
|
WHERE id = %s
|
||||||
|
"""
|
||||||
|
cur.execute(query, (str(note_id),))
|
||||||
|
|
||||||
|
deleted = cur.rowcount > 0
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return deleted
|
||||||
215
v2_adminpanel/leads/routes.py
Normale Datei
215
v2_adminpanel/leads/routes.py
Normale Datei
@@ -0,0 +1,215 @@
|
|||||||
|
# Routes for Lead Management
|
||||||
|
from flask import render_template, request, jsonify, redirect, url_for, flash
|
||||||
|
from auth.decorators import login_required
|
||||||
|
from flask import session as flask_session
|
||||||
|
from . import leads_bp
|
||||||
|
from .services import LeadService
|
||||||
|
from .repositories import LeadRepository
|
||||||
|
from db import get_db_connection
|
||||||
|
from uuid import UUID
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
# Initialize service
|
||||||
|
lead_repository = LeadRepository(get_db_connection)
|
||||||
|
lead_service = LeadService(lead_repository)
|
||||||
|
|
||||||
|
# HTML Routes
|
||||||
|
@leads_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def institutions():
|
||||||
|
"""List all institutions"""
|
||||||
|
try:
|
||||||
|
institutions = lead_service.list_institutions()
|
||||||
|
return render_template('leads/institutions.html', institutions=institutions)
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Fehler beim Laden der Institutionen: {str(e)}', 'error')
|
||||||
|
return render_template('leads/institutions.html', institutions=[])
|
||||||
|
|
||||||
|
@leads_bp.route('/institution/<uuid:institution_id>')
|
||||||
|
@login_required
|
||||||
|
def institution_detail(institution_id):
|
||||||
|
"""Show institution with all contacts"""
|
||||||
|
try:
|
||||||
|
institution = lead_repository.get_institution_by_id(institution_id)
|
||||||
|
if not institution:
|
||||||
|
flash('Institution nicht gefunden', 'error')
|
||||||
|
return redirect(url_for('leads.institutions'))
|
||||||
|
|
||||||
|
contacts = lead_service.list_contacts_by_institution(institution_id)
|
||||||
|
return render_template('leads/institution_detail.html',
|
||||||
|
institution=institution,
|
||||||
|
contacts=contacts)
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Fehler beim Laden der Institution: {str(e)}', 'error')
|
||||||
|
return redirect(url_for('leads.institutions'))
|
||||||
|
|
||||||
|
@leads_bp.route('/contact/<uuid:contact_id>')
|
||||||
|
@login_required
|
||||||
|
def contact_detail(contact_id):
|
||||||
|
"""Show contact details with notes"""
|
||||||
|
try:
|
||||||
|
contact = lead_service.get_contact_details(contact_id)
|
||||||
|
return render_template('leads/contact_detail.html', contact=contact)
|
||||||
|
except Exception as e:
|
||||||
|
flash(f'Fehler beim Laden des Kontakts: {str(e)}', 'error')
|
||||||
|
return redirect(url_for('leads.institutions'))
|
||||||
|
|
||||||
|
# API Routes
|
||||||
|
@leads_bp.route('/api/institutions', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def create_institution():
|
||||||
|
"""Create new institution"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
institution = lead_service.create_institution(
|
||||||
|
data['name'],
|
||||||
|
flask_session.get('username')
|
||||||
|
)
|
||||||
|
return jsonify({'success': True, 'institution': institution})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
@leads_bp.route('/api/institutions/<uuid:institution_id>', methods=['PUT'])
|
||||||
|
@login_required
|
||||||
|
def update_institution(institution_id):
|
||||||
|
"""Update institution"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
institution = lead_service.update_institution(
|
||||||
|
institution_id,
|
||||||
|
data['name'],
|
||||||
|
flask_session.get('username')
|
||||||
|
)
|
||||||
|
return jsonify({'success': True, 'institution': institution})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
@leads_bp.route('/api/contacts', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def create_contact():
|
||||||
|
"""Create new contact"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
contact = lead_service.create_contact(data, flask_session.get('username'))
|
||||||
|
return jsonify({'success': True, 'contact': contact})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
@leads_bp.route('/api/contacts/<uuid:contact_id>', methods=['PUT'])
|
||||||
|
@login_required
|
||||||
|
def update_contact(contact_id):
|
||||||
|
"""Update contact"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
contact = lead_service.update_contact(
|
||||||
|
contact_id,
|
||||||
|
data,
|
||||||
|
flask_session.get('username')
|
||||||
|
)
|
||||||
|
return jsonify({'success': True, 'contact': contact})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
@leads_bp.route('/api/contacts/<uuid:contact_id>/phones', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_phone(contact_id):
|
||||||
|
"""Add phone to contact"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
detail = lead_service.add_phone(
|
||||||
|
contact_id,
|
||||||
|
data['phone_number'],
|
||||||
|
data.get('phone_type'),
|
||||||
|
flask_session.get('username')
|
||||||
|
)
|
||||||
|
return jsonify({'success': True, 'detail': detail})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
@leads_bp.route('/api/contacts/<uuid:contact_id>/emails', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_email(contact_id):
|
||||||
|
"""Add email to contact"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
detail = lead_service.add_email(
|
||||||
|
contact_id,
|
||||||
|
data['email'],
|
||||||
|
data.get('email_type'),
|
||||||
|
flask_session.get('username')
|
||||||
|
)
|
||||||
|
return jsonify({'success': True, 'detail': detail})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
@leads_bp.route('/api/details/<uuid:detail_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
def delete_detail(detail_id):
|
||||||
|
"""Delete contact detail"""
|
||||||
|
try:
|
||||||
|
success = lead_service.delete_contact_detail(
|
||||||
|
detail_id,
|
||||||
|
flask_session.get('username')
|
||||||
|
)
|
||||||
|
return jsonify({'success': success})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
@leads_bp.route('/api/contacts/<uuid:contact_id>/notes', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_note(contact_id):
|
||||||
|
"""Add note to contact"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
note = lead_service.add_note(
|
||||||
|
contact_id,
|
||||||
|
data['note_text'],
|
||||||
|
flask_session.get('username')
|
||||||
|
)
|
||||||
|
return jsonify({'success': True, 'note': note})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
@leads_bp.route('/api/notes/<uuid:note_id>', methods=['PUT'])
|
||||||
|
@login_required
|
||||||
|
def update_note(note_id):
|
||||||
|
"""Update note"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
note = lead_service.update_note(
|
||||||
|
note_id,
|
||||||
|
data['note_text'],
|
||||||
|
flask_session.get('username')
|
||||||
|
)
|
||||||
|
return jsonify({'success': True, 'note': note})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
@leads_bp.route('/api/notes/<uuid:note_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
def delete_note(note_id):
|
||||||
|
"""Delete note"""
|
||||||
|
try:
|
||||||
|
success = lead_service.delete_note(
|
||||||
|
note_id,
|
||||||
|
flask_session.get('username')
|
||||||
|
)
|
||||||
|
return jsonify({'success': success})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
146
v2_adminpanel/leads/services.py
Normale Datei
146
v2_adminpanel/leads/services.py
Normale Datei
@@ -0,0 +1,146 @@
|
|||||||
|
# Business Logic Service for Lead Management
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from .repositories import LeadRepository
|
||||||
|
|
||||||
|
class LeadService:
|
||||||
|
def __init__(self, repository: LeadRepository):
|
||||||
|
self.repo = repository
|
||||||
|
|
||||||
|
# Institution Services
|
||||||
|
def list_institutions(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all institutions with contact counts"""
|
||||||
|
return self.repo.get_institutions_with_counts()
|
||||||
|
|
||||||
|
def create_institution(self, name: str, user: str) -> Dict[str, Any]:
|
||||||
|
"""Create a new institution"""
|
||||||
|
# Validation
|
||||||
|
if not name or len(name.strip()) == 0:
|
||||||
|
raise ValueError("Institution name cannot be empty")
|
||||||
|
|
||||||
|
# Create institution
|
||||||
|
institution = self.repo.create_institution(name.strip(), user)
|
||||||
|
|
||||||
|
# Note: Audit logging removed as it requires different implementation
|
||||||
|
# Can be added later with proper audit system integration
|
||||||
|
|
||||||
|
return institution
|
||||||
|
|
||||||
|
def update_institution(self, institution_id: UUID, name: str, user: str) -> Dict[str, Any]:
|
||||||
|
"""Update institution name"""
|
||||||
|
# Validation
|
||||||
|
if not name or len(name.strip()) == 0:
|
||||||
|
raise ValueError("Institution name cannot be empty")
|
||||||
|
|
||||||
|
# Get current institution
|
||||||
|
current = self.repo.get_institution_by_id(institution_id)
|
||||||
|
if not current:
|
||||||
|
raise ValueError("Institution not found")
|
||||||
|
|
||||||
|
# Update
|
||||||
|
institution = self.repo.update_institution(institution_id, name.strip())
|
||||||
|
|
||||||
|
return institution
|
||||||
|
|
||||||
|
# Contact Services
|
||||||
|
def list_contacts_by_institution(self, institution_id: UUID) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all contacts for an institution"""
|
||||||
|
return self.repo.get_contacts_by_institution(institution_id)
|
||||||
|
|
||||||
|
def create_contact(self, data: Dict[str, Any], user: str) -> Dict[str, Any]:
|
||||||
|
"""Create a new contact"""
|
||||||
|
# Validation
|
||||||
|
if not data.get('first_name') or not data.get('last_name'):
|
||||||
|
raise ValueError("First and last name are required")
|
||||||
|
|
||||||
|
if not data.get('institution_id'):
|
||||||
|
raise ValueError("Institution ID is required")
|
||||||
|
|
||||||
|
# Create contact
|
||||||
|
contact = self.repo.create_contact(data)
|
||||||
|
|
||||||
|
return contact
|
||||||
|
|
||||||
|
def get_contact_details(self, contact_id: UUID) -> Dict[str, Any]:
|
||||||
|
"""Get full contact information including details and notes"""
|
||||||
|
contact = self.repo.get_contact_with_details(contact_id)
|
||||||
|
if not contact:
|
||||||
|
raise ValueError("Contact not found")
|
||||||
|
|
||||||
|
# Group details by type
|
||||||
|
contact['phones'] = [d for d in contact.get('details', []) if d['detail_type'] == 'phone']
|
||||||
|
contact['emails'] = [d for d in contact.get('details', []) if d['detail_type'] == 'email']
|
||||||
|
|
||||||
|
return contact
|
||||||
|
|
||||||
|
def update_contact(self, contact_id: UUID, data: Dict[str, Any], user: str) -> Dict[str, Any]:
|
||||||
|
"""Update contact information"""
|
||||||
|
# Validation
|
||||||
|
if not data.get('first_name') or not data.get('last_name'):
|
||||||
|
raise ValueError("First and last name are required")
|
||||||
|
|
||||||
|
# Update contact
|
||||||
|
contact = self.repo.update_contact(contact_id, data)
|
||||||
|
|
||||||
|
return contact
|
||||||
|
|
||||||
|
# Contact Details Services
|
||||||
|
def add_phone(self, contact_id: UUID, phone_number: str,
|
||||||
|
phone_type: str = None, user: str = None) -> Dict[str, Any]:
|
||||||
|
"""Add phone number to contact"""
|
||||||
|
if not phone_number:
|
||||||
|
raise ValueError("Phone number is required")
|
||||||
|
|
||||||
|
detail = self.repo.add_contact_detail(
|
||||||
|
contact_id, 'phone', phone_number, phone_type
|
||||||
|
)
|
||||||
|
|
||||||
|
return detail
|
||||||
|
|
||||||
|
def add_email(self, contact_id: UUID, email: str,
|
||||||
|
email_type: str = None, user: str = None) -> Dict[str, Any]:
|
||||||
|
"""Add email to contact"""
|
||||||
|
if not email:
|
||||||
|
raise ValueError("Email is required")
|
||||||
|
|
||||||
|
# Basic email validation
|
||||||
|
if '@' not in email:
|
||||||
|
raise ValueError("Invalid email format")
|
||||||
|
|
||||||
|
detail = self.repo.add_contact_detail(
|
||||||
|
contact_id, 'email', email, email_type
|
||||||
|
)
|
||||||
|
|
||||||
|
return detail
|
||||||
|
|
||||||
|
def delete_contact_detail(self, detail_id: UUID, user: str) -> bool:
|
||||||
|
"""Delete a contact detail (phone/email)"""
|
||||||
|
success = self.repo.delete_contact_detail(detail_id)
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
# Note Services
|
||||||
|
def add_note(self, contact_id: UUID, note_text: str, user: str) -> Dict[str, Any]:
|
||||||
|
"""Add a note to contact"""
|
||||||
|
if not note_text or len(note_text.strip()) == 0:
|
||||||
|
raise ValueError("Note text cannot be empty")
|
||||||
|
|
||||||
|
note = self.repo.create_note(contact_id, note_text.strip(), user)
|
||||||
|
|
||||||
|
return note
|
||||||
|
|
||||||
|
def update_note(self, note_id: UUID, note_text: str, user: str) -> Dict[str, Any]:
|
||||||
|
"""Update a note (creates new version)"""
|
||||||
|
if not note_text or len(note_text.strip()) == 0:
|
||||||
|
raise ValueError("Note text cannot be empty")
|
||||||
|
|
||||||
|
note = self.repo.update_note(note_id, note_text.strip(), user)
|
||||||
|
|
||||||
|
return note
|
||||||
|
|
||||||
|
def delete_note(self, note_id: UUID, user: str) -> bool:
|
||||||
|
"""Delete a note (soft delete)"""
|
||||||
|
success = self.repo.delete_note(note_id)
|
||||||
|
|
||||||
|
return success
|
||||||
495
v2_adminpanel/leads/templates/leads/contact_detail.html
Normale Datei
495
v2_adminpanel/leads/templates/leads/contact_detail.html
Normale Datei
@@ -0,0 +1,495 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ contact.first_name }} {{ contact.last_name }} - Kontakt-Details{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h1 class="h2 mb-0">
|
||||||
|
<i class="bi bi-person"></i> {{ contact.first_name }} {{ contact.last_name }}
|
||||||
|
</h1>
|
||||||
|
<p class="mb-0">
|
||||||
|
<span class="text-muted">{{ contact.position or 'Keine Position' }}</span>
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<a href="{{ url_for('leads.institution_detail', institution_id=contact.institution_id) }}"
|
||||||
|
class="text-decoration-none">
|
||||||
|
{{ contact.institution_name }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<button class="btn btn-outline-primary" onclick="editContact()">
|
||||||
|
<i class="bi bi-pencil"></i> Bearbeiten
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('leads.institution_detail', institution_id=contact.institution_id) }}"
|
||||||
|
class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Zurück
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Contact Details -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<!-- Phone Numbers -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-telephone"></i> Telefonnummern</h5>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="showAddPhoneModal()">
|
||||||
|
<i class="bi bi-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if contact.phones %}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for phone in contact.phones %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{{ phone.detail_value }}</strong>
|
||||||
|
{% if phone.detail_label %}
|
||||||
|
<span class="badge bg-secondary">{{ phone.detail_label }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
|
onclick="deleteDetail('{{ phone.id }}')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Keine Telefonnummern hinterlegt.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Addresses -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-envelope"></i> E-Mail-Adressen</h5>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="showAddEmailModal()">
|
||||||
|
<i class="bi bi-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if contact.emails %}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for email in contact.emails %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<a href="mailto:{{ email.detail_value }}">{{ email.detail_value }}</a>
|
||||||
|
{% if email.detail_label %}
|
||||||
|
<span class="badge bg-secondary">{{ email.detail_label }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
|
onclick="deleteDetail('{{ email.id }}')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Keine E-Mail-Adressen hinterlegt.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-journal-text"></i> Notizen</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- New Note Form -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<textarea class="form-control" id="newNoteText" rows="3"
|
||||||
|
placeholder="Neue Notiz hinzufügen..."></textarea>
|
||||||
|
<button class="btn btn-primary btn-sm mt-2" onclick="addNote()">
|
||||||
|
<i class="bi bi-plus"></i> Notiz speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes List -->
|
||||||
|
<div id="notesList">
|
||||||
|
{% for note in contact.notes %}
|
||||||
|
<div class="card mb-2" id="note-{{ note.id }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-clock"></i>
|
||||||
|
{{ note.created_at.strftime('%d.%m.%Y %H:%M') }}
|
||||||
|
{% if note.created_by %} • {{ note.created_by }}{% endif %}
|
||||||
|
{% if note.version > 1 %}
|
||||||
|
<span class="badge bg-info">v{{ note.version }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-link p-0 mx-1"
|
||||||
|
onclick="editNote('{{ note.id }}', {{ note.note_text|tojson }})">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-link text-danger p-0 mx-1"
|
||||||
|
onclick="deleteNote('{{ note.id }}')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="note-content" id="note-content-{{ note.id }}">
|
||||||
|
{{ note.note_text|nl2br|safe }}
|
||||||
|
</div>
|
||||||
|
<div class="note-edit d-none" id="note-edit-{{ note.id }}">
|
||||||
|
<textarea class="form-control mb-2" id="note-edit-text-{{ note.id }}">{{ note.note_text }}</textarea>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="saveNote('{{ note.id }}')">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="cancelEdit('{{ note.id }}')">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if not contact.notes %}
|
||||||
|
<p class="text-muted">Noch keine Notizen vorhanden.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modals -->
|
||||||
|
<!-- Edit Contact Modal -->
|
||||||
|
<div class="modal fade" id="editContactModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Kontakt bearbeiten</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="editContactForm">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editFirstName" class="form-label">Vorname</label>
|
||||||
|
<input type="text" class="form-control" id="editFirstName"
|
||||||
|
value="{{ contact.first_name }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="editLastName" class="form-label">Nachname</label>
|
||||||
|
<input type="text" class="form-control" id="editLastName"
|
||||||
|
value="{{ contact.last_name }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editPosition" class="form-label">Position</label>
|
||||||
|
<input type="text" class="form-control" id="editPosition"
|
||||||
|
value="{{ contact.position or '' }}">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="updateContact()">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Phone Modal -->
|
||||||
|
<div class="modal fade" id="addPhoneModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Telefonnummer hinzufügen</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="addPhoneForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="phoneNumber" class="form-label">Telefonnummer</label>
|
||||||
|
<input type="tel" class="form-control" id="phoneNumber" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="phoneType" class="form-label">Typ</label>
|
||||||
|
<select class="form-select" id="phoneType">
|
||||||
|
<option value="">Bitte wählen...</option>
|
||||||
|
<option value="Mobil">Mobil</option>
|
||||||
|
<option value="Geschäftlich">Geschäftlich</option>
|
||||||
|
<option value="Privat">Privat</option>
|
||||||
|
<option value="Fax">Fax</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="savePhone()">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Email Modal -->
|
||||||
|
<div class="modal fade" id="addEmailModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">E-Mail-Adresse hinzufügen</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="addEmailForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="emailAddress" class="form-label">E-Mail-Adresse</label>
|
||||||
|
<input type="email" class="form-control" id="emailAddress" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="emailType" class="form-label">Typ</label>
|
||||||
|
<select class="form-select" id="emailType">
|
||||||
|
<option value="">Bitte wählen...</option>
|
||||||
|
<option value="Geschäftlich">Geschäftlich</option>
|
||||||
|
<option value="Privat">Privat</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveEmail()">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const contactId = '{{ contact.id }}';
|
||||||
|
|
||||||
|
// Contact functions
|
||||||
|
function editContact() {
|
||||||
|
new bootstrap.Modal(document.getElementById('editContactModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateContact() {
|
||||||
|
const firstName = document.getElementById('editFirstName').value.trim();
|
||||||
|
const lastName = document.getElementById('editLastName').value.trim();
|
||||||
|
const position = document.getElementById('editPosition').value.trim();
|
||||||
|
|
||||||
|
if (!firstName || !lastName) {
|
||||||
|
alert('Bitte geben Sie Vor- und Nachname ein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/leads/api/contacts/${contactId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
position: position
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Speichern: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone functions
|
||||||
|
function showAddPhoneModal() {
|
||||||
|
document.getElementById('addPhoneForm').reset();
|
||||||
|
new bootstrap.Modal(document.getElementById('addPhoneModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePhone() {
|
||||||
|
const phoneNumber = document.getElementById('phoneNumber').value.trim();
|
||||||
|
const phoneType = document.getElementById('phoneType').value;
|
||||||
|
|
||||||
|
if (!phoneNumber) {
|
||||||
|
alert('Bitte geben Sie eine Telefonnummer ein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/leads/api/contacts/${contactId}/phones`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
phone_number: phoneNumber,
|
||||||
|
phone_type: phoneType
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Speichern: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email functions
|
||||||
|
function showAddEmailModal() {
|
||||||
|
document.getElementById('addEmailForm').reset();
|
||||||
|
new bootstrap.Modal(document.getElementById('addEmailModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmail() {
|
||||||
|
const email = document.getElementById('emailAddress').value.trim();
|
||||||
|
const emailType = document.getElementById('emailType').value;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
alert('Bitte geben Sie eine E-Mail-Adresse ein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/leads/api/contacts/${contactId}/emails`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: email,
|
||||||
|
email_type: emailType
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Speichern: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete detail
|
||||||
|
async function deleteDetail(detailId) {
|
||||||
|
if (!confirm('Möchten Sie diesen Eintrag wirklich löschen?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/leads/api/details/${detailId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note functions
|
||||||
|
async function addNote() {
|
||||||
|
const noteText = document.getElementById('newNoteText').value.trim();
|
||||||
|
|
||||||
|
if (!noteText) {
|
||||||
|
alert('Bitte geben Sie eine Notiz ein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/leads/api/contacts/${contactId}/notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ note_text: noteText })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Speichern: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editNote(noteId, noteText) {
|
||||||
|
document.getElementById(`note-content-${noteId}`).classList.add('d-none');
|
||||||
|
document.getElementById(`note-edit-${noteId}`).classList.remove('d-none');
|
||||||
|
document.getElementById(`note-edit-text-${noteId}`).value = noteText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit(noteId) {
|
||||||
|
document.getElementById(`note-content-${noteId}`).classList.remove('d-none');
|
||||||
|
document.getElementById(`note-edit-${noteId}`).classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNote(noteId) {
|
||||||
|
const noteText = document.getElementById(`note-edit-text-${noteId}`).value.trim();
|
||||||
|
|
||||||
|
if (!noteText) {
|
||||||
|
alert('Notiz darf nicht leer sein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/leads/api/notes/${noteId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ note_text: noteText })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Speichern: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNote(noteId) {
|
||||||
|
if (!confirm('Möchten Sie diese Notiz wirklich löschen?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/leads/api/notes/${noteId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.note-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
159
v2_adminpanel/leads/templates/leads/institution_detail.html
Normale Datei
159
v2_adminpanel/leads/templates/leads/institution_detail.html
Normale Datei
@@ -0,0 +1,159 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ institution.name }} - Lead-Details{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h1 class="h2 mb-0">
|
||||||
|
<i class="bi bi-building"></i> {{ institution.name }}
|
||||||
|
</h1>
|
||||||
|
<small class="text-muted">
|
||||||
|
Erstellt am {{ institution.created_at.strftime('%d.%m.%Y') }}
|
||||||
|
{% if institution.created_by %}von {{ institution.created_by }}{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<button class="btn btn-primary" onclick="showCreateContactModal()">
|
||||||
|
<i class="bi bi-person-plus"></i> Neuer Kontakt
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('leads.institutions') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Zurück
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contacts Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-people"></i> Kontakte
|
||||||
|
<span class="badge bg-secondary">{{ contacts|length }}</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Position</th>
|
||||||
|
<th>Erstellt am</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for contact in contacts %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}"
|
||||||
|
class="text-decoration-none">
|
||||||
|
<strong>{{ contact.first_name }} {{ contact.last_name }}</strong>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ contact.position or '-' }}</td>
|
||||||
|
<td>{{ contact.created_at.strftime('%d.%m.%Y') }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i> Details
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if not contacts %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<p class="text-muted">Noch keine Kontakte für diese Institution.</p>
|
||||||
|
<button class="btn btn-primary" onclick="showCreateContactModal()">
|
||||||
|
<i class="bi bi-person-plus"></i> Ersten Kontakt anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Contact Modal -->
|
||||||
|
<div class="modal fade" id="contactModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Neuer Kontakt</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="contactForm">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="firstName" class="form-label">Vorname</label>
|
||||||
|
<input type="text" class="form-control" id="firstName" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="lastName" class="form-label">Nachname</label>
|
||||||
|
<input type="text" class="form-control" id="lastName" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="position" class="form-label">Position</label>
|
||||||
|
<input type="text" class="form-control" id="position"
|
||||||
|
placeholder="z.B. Geschäftsführer, Vertriebsleiter">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveContact()">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Show create contact modal
|
||||||
|
function showCreateContactModal() {
|
||||||
|
document.getElementById('contactForm').reset();
|
||||||
|
new bootstrap.Modal(document.getElementById('contactModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save contact
|
||||||
|
async function saveContact() {
|
||||||
|
const firstName = document.getElementById('firstName').value.trim();
|
||||||
|
const lastName = document.getElementById('lastName').value.trim();
|
||||||
|
const position = document.getElementById('position').value.trim();
|
||||||
|
|
||||||
|
if (!firstName || !lastName) {
|
||||||
|
alert('Bitte geben Sie Vor- und Nachname ein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/leads/api/contacts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
institution_id: '{{ institution.id }}',
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
position: position
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Speichern: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
178
v2_adminpanel/leads/templates/leads/institutions.html
Normale Datei
178
v2_adminpanel/leads/templates/leads/institutions.html
Normale Datei
@@ -0,0 +1,178 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Lead-Verwaltung - Institutionen{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h1 class="h2 mb-0">
|
||||||
|
<i class="bi bi-building"></i> Lead-Institutionen
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<button class="btn btn-primary" onclick="showCreateInstitutionModal()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Neue Institution
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Zurück zu Kunden
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" class="form-control" id="searchInput"
|
||||||
|
placeholder="Institution suchen..." onkeyup="filterInstitutions()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Institutions Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover" id="institutionsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Institution</th>
|
||||||
|
<th>Anzahl Kontakte</th>
|
||||||
|
<th>Erstellt am</th>
|
||||||
|
<th>Erstellt von</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for institution in institutions %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('leads.institution_detail', institution_id=institution.id) }}"
|
||||||
|
class="text-decoration-none">
|
||||||
|
<strong>{{ institution.name }}</strong>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary">{{ institution.contact_count }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ institution.created_at.strftime('%d.%m.%Y') }}</td>
|
||||||
|
<td>{{ institution.created_by or '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('leads.institution_detail', institution_id=institution.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i> Details
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="editInstitution('{{ institution.id }}', '{{ institution.name }}')">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if not institutions %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<p class="text-muted">Noch keine Institutionen vorhanden.</p>
|
||||||
|
<button class="btn btn-primary" onclick="showCreateInstitutionModal()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Erste Institution anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Institution Modal -->
|
||||||
|
<div class="modal fade" id="institutionModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="institutionModalTitle">Neue Institution</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="institutionForm">
|
||||||
|
<input type="hidden" id="institutionId">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="institutionName" class="form-label">Name der Institution</label>
|
||||||
|
<input type="text" class="form-control" id="institutionName" required>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveInstitution()">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Filter institutions
|
||||||
|
function filterInstitutions() {
|
||||||
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
const rows = document.querySelectorAll('#institutionsTable tbody tr');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const text = row.textContent.toLowerCase();
|
||||||
|
row.style.display = text.includes(searchTerm) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show create institution modal
|
||||||
|
function showCreateInstitutionModal() {
|
||||||
|
document.getElementById('institutionModalTitle').textContent = 'Neue Institution';
|
||||||
|
document.getElementById('institutionId').value = '';
|
||||||
|
document.getElementById('institutionName').value = '';
|
||||||
|
new bootstrap.Modal(document.getElementById('institutionModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit institution
|
||||||
|
function editInstitution(id, name) {
|
||||||
|
document.getElementById('institutionModalTitle').textContent = 'Institution bearbeiten';
|
||||||
|
document.getElementById('institutionId').value = id;
|
||||||
|
document.getElementById('institutionName').value = name;
|
||||||
|
new bootstrap.Modal(document.getElementById('institutionModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save institution
|
||||||
|
async function saveInstitution() {
|
||||||
|
const id = document.getElementById('institutionId').value;
|
||||||
|
const name = document.getElementById('institutionName').value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
alert('Bitte geben Sie einen Namen ein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = id
|
||||||
|
? `/leads/api/institutions/${id}`
|
||||||
|
: '/leads/api/institutions';
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: name })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Speichern: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
107
v2_adminpanel/migrations/create_lead_tables.sql
Normale Datei
107
v2_adminpanel/migrations/create_lead_tables.sql
Normale Datei
@@ -0,0 +1,107 @@
|
|||||||
|
-- Lead Management Tables Migration
|
||||||
|
-- This creates all necessary tables for the lead management system
|
||||||
|
|
||||||
|
-- Enable UUID extension if not already enabled
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- 1. Lead Institutions (only name required)
|
||||||
|
CREATE TABLE IF NOT EXISTS lead_institutions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
-- Metadata for future extensions without schema changes
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by VARCHAR(100),
|
||||||
|
UNIQUE(name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_institutions_name ON lead_institutions(name);
|
||||||
|
|
||||||
|
-- 2. Lead Contacts
|
||||||
|
CREATE TABLE IF NOT EXISTS lead_contacts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
institution_id UUID NOT NULL REFERENCES lead_institutions(id) ON DELETE CASCADE,
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
position VARCHAR(255),
|
||||||
|
-- Extra fields for future extensions
|
||||||
|
extra_fields JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_contacts_institution ON lead_contacts(institution_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_contacts_name ON lead_contacts(last_name, first_name);
|
||||||
|
|
||||||
|
-- 3. Flexible Contact Details (phones, emails, etc.)
|
||||||
|
CREATE TABLE IF NOT EXISTS lead_contact_details (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
contact_id UUID NOT NULL REFERENCES lead_contacts(id) ON DELETE CASCADE,
|
||||||
|
detail_type VARCHAR(50) NOT NULL, -- 'phone', 'email', 'social', etc.
|
||||||
|
detail_value VARCHAR(255) NOT NULL,
|
||||||
|
detail_label VARCHAR(50), -- 'Mobil', 'Geschäftlich', 'Privat', etc.
|
||||||
|
is_primary BOOLEAN DEFAULT false,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for fast queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_details_contact_type ON lead_contact_details(contact_id, detail_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_details_value ON lead_contact_details(detail_value);
|
||||||
|
|
||||||
|
-- 4. Versioned Notes with History
|
||||||
|
CREATE TABLE IF NOT EXISTS lead_notes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
contact_id UUID NOT NULL REFERENCES lead_contacts(id) ON DELETE CASCADE,
|
||||||
|
note_text TEXT NOT NULL,
|
||||||
|
version INTEGER DEFAULT 1,
|
||||||
|
is_current BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by VARCHAR(100),
|
||||||
|
parent_note_id UUID REFERENCES lead_notes(id),
|
||||||
|
CHECK (note_text <> '')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for note queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_notes_contact_current ON lead_notes(contact_id, is_current);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_notes_created ON lead_notes(created_at DESC);
|
||||||
|
|
||||||
|
-- Full text search preparation
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_contacts_search ON lead_contacts
|
||||||
|
USING gin(to_tsvector('german',
|
||||||
|
COALESCE(first_name, '') || ' ' ||
|
||||||
|
COALESCE(last_name, '') || ' ' ||
|
||||||
|
COALESCE(position, '')
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Update timestamp trigger function
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Apply update trigger to tables with updated_at
|
||||||
|
CREATE TRIGGER update_lead_institutions_updated_at
|
||||||
|
BEFORE UPDATE ON lead_institutions
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_lead_contacts_updated_at
|
||||||
|
BEFORE UPDATE ON lead_contacts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Add comments for documentation
|
||||||
|
COMMENT ON TABLE lead_institutions IS 'Organizations/Companies for lead management';
|
||||||
|
COMMENT ON TABLE lead_contacts IS 'Contact persons within institutions';
|
||||||
|
COMMENT ON TABLE lead_contact_details IS 'Flexible contact details (phone, email, etc.)';
|
||||||
|
COMMENT ON TABLE lead_notes IS 'Versioned notes with full history';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN lead_contact_details.detail_type IS 'Type of detail: phone, email, social, etc.';
|
||||||
|
COMMENT ON COLUMN lead_notes.is_current IS 'Only current version is shown, old versions kept for history';
|
||||||
|
COMMENT ON COLUMN lead_notes.parent_note_id IS 'References original note for version tracking';
|
||||||
@@ -8,21 +8,26 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2 class="mb-0">Kunden & Lizenzen</h2>
|
<h2 class="mb-0">Kunden & Lizenzen</h2>
|
||||||
<div class="dropdown">
|
<div>
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
<a href="{{ url_for('leads.institutions') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-download"></i> Export
|
<i class="bi bi-people"></i> Leads
|
||||||
</button>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<div class="dropdown d-inline-block">
|
||||||
<li><a class="dropdown-item" href="{{ url_for('export.export_customers', format='excel', include_test=request.args.get('show_test')) }}">
|
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||||
<i class="bi bi-file-earmark-excel text-success"></i> Kunden (Excel)</a></li>
|
<i class="bi bi-download"></i> Export
|
||||||
<li><a class="dropdown-item" href="{{ url_for('export.export_customers', format='csv', include_test=request.args.get('show_test')) }}">
|
</button>
|
||||||
<i class="bi bi-file-earmark-text"></i> Kunden (CSV)</a></li>
|
<ul class="dropdown-menu">
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><a class="dropdown-item" href="{{ url_for('export.export_customers', format='excel', include_test=request.args.get('show_test')) }}">
|
||||||
<li><a class="dropdown-item" href="{{ url_for('export.export_licenses', format='excel', include_test=request.args.get('show_test')) }}">
|
<i class="bi bi-file-earmark-excel text-success"></i> Kunden (Excel)</a></li>
|
||||||
<i class="bi bi-file-earmark-excel text-success"></i> Lizenzen (Excel)</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('export.export_customers', format='csv', include_test=request.args.get('show_test')) }}">
|
||||||
<li><a class="dropdown-item" href="{{ url_for('export.export_licenses', format='csv', include_test=request.args.get('show_test')) }}">
|
<i class="bi bi-file-earmark-text"></i> Kunden (CSV)</a></li>
|
||||||
<i class="bi bi-file-earmark-text"></i> Lizenzen (CSV)</a></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
</ul>
|
<li><a class="dropdown-item" href="{{ url_for('export.export_licenses', format='excel', include_test=request.args.get('show_test')) }}">
|
||||||
|
<i class="bi bi-file-earmark-excel text-success"></i> Lizenzen (Excel)</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('export.export_licenses', format='csv', include_test=request.args.get('show_test')) }}">
|
||||||
|
<i class="bi bi-file-earmark-text"></i> Lizenzen (CSV)</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren