From a3a5a10514d34295886d11d89aeab1743b5cce87 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 26 Jul 2026 02:35:44 +0200 Subject: [PATCH] Phase 1: Workflows UI, Dedup/Merge UI, Import/Export UI, Print/PDF - Workflows UI: full page with definitions/instances tabs, step editor, instance detail with approve/reject - Dedup/Merge UI: duplicate detection, side-by-side comparison, field-level merge dialog, merge history - Import/Export UI: import wizard (dry-run preview), export panel (CSV/XLSX), backend export route added - Print/PDF: PrintButton component, print.css, integrated in Contacts/Calendar/Reports/ContactDetail - Backend: GET /api/v1/export endpoint, export_companies_csv() service function - Routes: /workflows, /contacts/dedup, /import-export registered - Menu items: Workflows, Import/Export, Duplikate added to automation plugin manifest - IMPLEMENTATION_PLAN.md: audit-corrected plan for all 14 remaining features --- IMPLEMENTATION_PLAN.md | 534 ++++++++++++++++ app/plugins/builtins/automation/plugin.py | 34 +- app/routes/import_export.py | 76 +++ app/services/import_export_service.py | 39 ++ frontend/index.html | 1 + frontend/public/print.css | 197 ++++++ frontend/src/api/importExport.ts | 66 ++ .../src/components/common/PrintButton.tsx | 137 ++++ .../components/dedup/DuplicatePairCard.tsx | 114 ++++ frontend/src/components/dedup/MergeDialog.tsx | 287 +++++++++ .../src/components/dedup/MergeHistory.tsx | 176 ++++++ .../components/import-export/ExportPanel.tsx | 120 ++++ .../components/import-export/ImportWizard.tsx | 593 ++++++++++++++++++ .../components/workflows/StepConfigPanel.tsx | 104 +++ .../components/workflows/WorkflowEditor.tsx | 292 +++++++++ .../workflows/WorkflowInstanceDetail.tsx | 382 +++++++++++ .../workflows/WorkflowInstanceList.tsx | 192 ++++++ frontend/src/pages/Calendar.tsx | 6 +- frontend/src/pages/ContactDetailPage.tsx | 5 +- frontend/src/pages/ContactsList.tsx | 4 +- frontend/src/pages/DedupMerge.tsx | 225 +++++++ frontend/src/pages/ImportExport.tsx | 68 ++ frontend/src/pages/Reports.tsx | 4 +- frontend/src/pages/Workflows.tsx | 278 ++++++++ frontend/src/routes/index.tsx | 6 + frontend/src/styles/print.css | 197 ++++++ frontend/src/utils/print.ts | 156 +++++ 27 files changed, 4288 insertions(+), 5 deletions(-) create mode 100644 IMPLEMENTATION_PLAN.md create mode 100644 frontend/public/print.css create mode 100644 frontend/src/api/importExport.ts create mode 100644 frontend/src/components/common/PrintButton.tsx create mode 100644 frontend/src/components/dedup/DuplicatePairCard.tsx create mode 100644 frontend/src/components/dedup/MergeDialog.tsx create mode 100644 frontend/src/components/dedup/MergeHistory.tsx create mode 100644 frontend/src/components/import-export/ExportPanel.tsx create mode 100644 frontend/src/components/import-export/ImportWizard.tsx create mode 100644 frontend/src/components/workflows/StepConfigPanel.tsx create mode 100644 frontend/src/components/workflows/WorkflowEditor.tsx create mode 100644 frontend/src/components/workflows/WorkflowInstanceDetail.tsx create mode 100644 frontend/src/components/workflows/WorkflowInstanceList.tsx create mode 100644 frontend/src/pages/DedupMerge.tsx create mode 100644 frontend/src/pages/ImportExport.tsx create mode 100644 frontend/src/pages/Workflows.tsx create mode 100644 frontend/src/styles/print.css create mode 100644 frontend/src/utils/print.ts diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..938000b --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,534 @@ +# LeoCRM — Implementationsplan: Fehlende Frontend-Features + +> **Stand:** 26.07.2026 (Audit-korrigiert) | **Backend:** 352 Endpunkte | **Frontend:** 47 Pages, 38 API-Clients +> **Repo:** `/a0/usr/workdir/leocrm-fix` | **Branch:** `main` | **Deploy:** Coolify App `stvabl4vaqru7jclx4ittzr3` + +--- + +## ⚠️ Audit-Korrekturen (26.07.2026 02:17) + +### Korrektur 1: Permissions Management UI — ENTHALTEN IN SettingsRoles.tsx +**Vorher:** Plan sagte "keine Verwaltungs-Seite um Permissions pro Rolle zu konfigurieren" +**Tatsächlich:** `SettingsRoles.tsx` (531 Zeilen) hat VOLLSTÄNDIGE Permission-Verwaltung: +- ✅ Grant permissions (Checkboxen gruppiert nach system/plugin) +- ✅ Denied permissions (explizite Verweigern-Liste) +- ✅ Field-level permissions (pro Modul/Feld Sensitivität) +- ✅ Rollen erstellen/bearbeiten mit Permission-Zuweisung +- ✅ DMS `ShareDialog.tsx` nutzt bereits File-Permissions API (grant/revoke/share-link) +**Folge:** Feature 8 entfällt. Keine neue Permission-UI nötig. + +### Korrektur 2: Import/Export — Export-Route fehlt DEFINITIV +**Vorher:** Plan sagte "falls Export fehlt" +**Tatsächlich:** `export_contacts_csv()` Service-Funktion existiert, aber KEINE Route in `import_export.py`. Nur `/import` und `/import/preview` sind registriert. Export muss als Route hinzugefügt werden. + +### Korrektur 3: Activity Timeline — ActivityFeed existiert bereits +**Vorher:** Plan sagte "Dashboard hat ActivityFeed aber nur statisch" +**Tatsächlich:** Dashboard nutzt `ActivityFeed` mit Daten aus Audit-API. Komponente ist wiederverwendbar. Es fehlt nur eine eigenständige Seite mit Filterung/Pagination. + +### Korrektur 4: DMS ShareDialog — File Permissions bereits integriert +**Vorher:** Plan sah `FilePermissionDialog` als neue Komponente vor +**Tatsächlich:** `ShareDialog.tsx` (11KB) existiert bereits und nutzt `fetchFilePermissions`, `grantPermission`, `revokePermission`, `createShareLink`, `revokeShareLink` aus `permissions.ts`. + +--- + +## Übersicht: 14 verbleibende Features in 4 Phasen + +| Phase | Features | Priorität | Geschätzter Aufwand | +|-------|----------|-----------|---------------------| +| **1 — Kritisch** | Workflows UI, Dedup/Merge UI, Import/Export UI, Print/PDF | CRM-Kern | ~4-5 Tage | +| **2 — Wichtig** | Tags UI, Custom Fields UI, Notifications Dropdown | Tagesgeschäft | ~2.5-3 Tage | +| **3 — Nice-to-have** | Saved Filters UI, Entity History UI, Activity Timeline, API Docs Link | Produktivität | ~1.5-2 Tage | +| **4 — Backend+Frontend** | Webhooks, Backup/Restore UI, Onboarding/Tutorial | Erweiterungen | ~3-4 Tage | + +**Gesamtaufwand:** ~11-14 Entwicklungstage (1 Feature entfallen) + +--- + +## Architektur-Grundsätze (für alle Features) + +### Frontend-Konventionen +- **Routing:** Lazy-loaded in `frontend/src/routes/index.tsx`, explizite Routes (nicht PluginRouteRenderer) +- **API-Clients:** In `frontend/src/api/.ts`, verwenden `apiGet/apiPost/apiPatch/apiDelete` aus `client.ts` +- **Hooks:** React Query (`useQuery`/`useMutation`) mit Query-Key-Invalidierung +- **UI:** Tailwind CSS, `clsx` für Klassen, `lucide-react` für Icons +- **i18n:** `useTranslation()` mit `t('key')`, Keys in `frontend/src/i18n/` +- **Sidebar:** Plugin-Manifeste liefern Menu-Items via `usePluginStore` — neue Pages brauchen Plugin-Manifest-Einträge +- **Settings:** Hardcoded nav items in `Settings.tsx` + plugin settings_pages +- **Error Handling:** `ErrorBoundary` wrappt alle Routes + +### Backend-Konventionen +- **Routes:** `app/routes/.py`, registriert in `app/main.py` +- **Services:** `app/services/_service.py` +- **Models:** `app/models/.py`, Migrationen in `alembic/versions/` +- **Schemas:** `app/schemas/.py` (Pydantic) +- **Permissions:** `require_permission('plugin:action')` Dependency +- **Events:** `event_bus.publish()` für System-Events + +--- + +## Phase 1 — Kritisch für CRM-Betrieb + +### 1.1 Workflows UI + +**Audit-Status:** ✅ Backend vollständig (routes, model, service, execution engine). ✅ API-Client vollständig (`workflows.ts`). ❌ Keine Frontend-Seite. ❌ Kein menu_items-Eintrag im Automation-Plugin. + +**Neue Dateien:** +- `frontend/src/pages/Workflows.tsx` — Hauptseite mit Tabs: Definitionen | Instanzen +- `frontend/src/components/workflows/WorkflowEditor.tsx` — Visueller Step-Editor +- `frontend/src/components/workflows/WorkflowInstanceList.tsx` — Liste laufender/abgeschlossener Instanzen +- `frontend/src/components/workflows/WorkflowInstanceDetail.tsx` — Detail mit Step-History, Approve/Reject +- `frontend/src/components/workflows/StepConfigPanel.tsx` — Konfiguration pro Step-Typ + +**Modifizierte Dateien:** +- `frontend/src/routes/index.tsx` — Route `/workflows` + `/workflows/instances/:id` +- `app/plugins/builtins/automation/plugin.py` — menu_items Eintrag für Workflows (aktuell `menu_items=[]`) +- `frontend/src/i18n/de.json` — Workflow-Übersetzungen + +**Step-Editor:** +- Step-Typen: `action`, `approval`, `notification`, `condition` +- Drag-and-Drop Reihenfolge (oder Button-basiert nach oben/unten) +- Pro Step: Name, Typ, Config-Form +- Trigger-Event Dropdown (aus Event-Bus-Events) +- Aktiv/Inaktiv Toggle + +**Instanzen-View:** +- Status-Filter: pending, in_progress, completed, rejected, cancelled +- Pro Instanz: Workflow-Name, Status, Current Step, Timeout +- Detail: Step-History Timeline, Approve/Reject Buttons + +**Aufwand:** ~1.5 Tage + +--- + +### 1.2 Dedup/Merge UI + +**Audit-Status:** ✅ Backend vollständig (`dedup_service.py`, routes in `contacts.py`: `/duplicates`, `/merge`, `/merge-history`). ✅ API-Client vollständig (`dedup.ts`). ❌ Keine Frontend-Seite. + +**Neue Dateien:** +- `frontend/src/pages/DedupMerge.tsx` — Hauptseite mit drei Bereichen +- `frontend/src/components/dedup/DuplicatePairCard.tsx` — Side-by-side Vergleich +- `frontend/src/components/dedup/MergeDialog.tsx` — Merge-Dialog mit Feld-Auswahl +- `frontend/src/components/dedup/MergeHistory.tsx` — Verlauf der durchgeführten Merges + +**Modifizierte Dateien:** +- `frontend/src/routes/index.tsx` — Route `/contacts/dedup` +- `frontend/src/pages/ContactsList.tsx` — Button "Duplikate prüfen" im Header +- `frontend/src/i18n/de.json` — Dedup-Übersetzungen + +**Merge-Dialog:** +- Side-by-side Feld-Vergleich +- Pro Feld Radio: Quelle | Ziel | Manuell eingeben +- Vorschau des merged Kontakts +- Optionale Notiz +- Bestätigungs-Button mit Warnung + +**Aufwand:** ~1 Tag + +--- + +### 1.3 Import/Export UI + +**Audit-Status:** ✅ Backend hat `/api/v1/import` + `/api/v1/import/preview` (Routes). ✅ Service hat `import_csv()`, `export_contacts_csv()`. ❌ **Export-Route fehlt** — Service-Funktion existiert aber ist nicht als Endpoint registriert. ❌ Kein Frontend, kein API-Client. + +**Backend-Ergänzung (bestätigt nötig):** +- `app/routes/import_export.py` — `GET /api/v1/export?entity_type=contacts&format=csv` hinzufügen + - Ruft `export_contacts_csv()` auf, gibt `StreamingResponse` mit CSV zurück + - Erweiterung: `entity_type=companies` (Filter auf `Contact.type == 'company'`) + - Optional: XLSX-Format via `openpyxl` + +**Neue Frontend-Dateien:** +- `frontend/src/pages/ImportExport.tsx` — Hauptseite mit Tabs: Import | Export +- `frontend/src/components/import-export/ImportWizard.tsx` — Mehrstufiger Import-Wizard +- `frontend/src/components/import-export/ExportPanel.tsx` — Export-Auswahl +- `frontend/src/api/importExport.ts` — API-Client (neu) + +**Modifizierte Dateien:** +- `frontend/src/routes/index.tsx` — Route `/import-export` +- Plugin-Manifest — menu_items Eintrag +- `frontend/src/i18n/de.json` — Übersetzungen + +**Import-Wizard:** +``` +Step 1: Datei hochladen + Entity-Typ (Companies/Contacts) +Step 2: Dry-Run Preview — zeigt erkannte Spalten, Mapping, Fehler +Step 3: Bestätigung — Anzahl neu/aktualisiert/fehlerhaft +Step 4: Import ausführen — Progress + Ergebnis +``` + +**Export-Panel:** +- Entity: Kontakte / Firmen +- Format: CSV (XLSX optional) +- Download-Button → File-Download + +**Aufwand:** ~1.5 Tage (inkl. Backend Export-Route) + +--- + +### 1.4 Print/PDF + +**Audit-Status:** ❌ Komplett fehlend. Keine Print-Utils, keine Print-Buttons, kein `@media print` CSS. + +**Neue Dateien:** +- `frontend/src/utils/print.ts` — Print-Utility +- `frontend/src/components/common/PrintButton.tsx` — Wiederverwendbarer Print/Export-Button +- `frontend/src/styles/print.css` — Print-spezifische CSS + +**Modifizierte Dateien:** +- `frontend/src/pages/ContactsList.tsx` — Print-Button in Toolbar +- `frontend/src/pages/ContactDetailPage.tsx` — Print-Button +- `frontend/src/pages/Calendar.tsx` — Print-Button +- `frontend/src/pages/Reports.tsx` — Print-Button +- `frontend/index.html` — Print-CSS einbinden + +**Implementierung:** +- Option A: `window.print()` mit `@media print` CSS (empfohlen für Listen/Details) +- Option B: `jspdf` + `html2canvas` für echte PDF-Generierung (für Reports) +- Print-Button Dropdown: "Drucken" | "Als PDF" + +**Aufwand:** ~0.5 Tage + +--- + +## Phase 2 — Wichtig für Tagesgeschäft + +### 2.1 Tags UI + +**Audit-Status:** ✅ Backend-Plugin vollständig (`app/plugins/builtins/tags/`: models, routes, schemas). ✅ API-Client vollständig (`tags.ts`). ❌ Keine Frontend-Seite. + +**Neue Dateien:** +- `frontend/src/pages/Tags.tsx` — Tag-Verwaltung (CRUD, Farb-Auswahl, Usage-Count) +- `frontend/src/components/tags/TagBadge.tsx` — Wiederverwendbares Tag-Badge +- `frontend/src/components/tags/TagSelector.tsx` — Multi-Select Tag-Picker + +**Modifizierte Dateien:** +- `frontend/src/routes/index.tsx` — Route `/tags` +- `frontend/src/pages/ContactsList.tsx` — Tag-Spalte + Tag-Filter +- `frontend/src/pages/ContactDetailPage.tsx` — Tag-Badges + Tag-Selector +- `frontend/src/pages/Calendar.tsx` — Tag-Badges für Termine +- `frontend/src/pages/Dms.tsx` — Tag-Badges für Dateien +- Plugin-Manifest — menu_items +- `frontend/src/i18n/de.json` + +**Aufwand:** ~1 Tag + +--- + +### 2.2 Custom Fields UI + +**Audit-Status:** ✅ Backend hat Custom-Fields-Route (plugin-manifest-gesteuert, Werte in `contacts.custom` JSONB). ❌ Keine User-definierten Feld-Definitionen (nur Plugin-Definitionen). ❌ Keine Frontend-Seite. + +**Backend-Ergänzung nötig:** +- `app/models/custom_field_definition.py` — Model für User-definierte Felder +- `app/schemas/custom_field_definition.py` — Pydantic Schemas +- `app/services/custom_field_service.py` — CRUD-Service +- `app/routes/custom_fields.py` — `GET/POST/PATCH/DELETE /api/v1/custom-fields/definitions` +- Migration für `custom_field_definitions` Tabelle +- Bestehende `_collect_custom_field_definitions()` erweitern um DB-Definitionen + +**Neue Frontend-Dateien:** +- `frontend/src/pages/CustomFields.tsx` — Definitionen verwalten +- `frontend/src/components/custom-fields/FieldDefinitionForm.tsx` — Form für neue Felder +- `frontend/src/components/custom-fields/CustomFieldRenderer.tsx` — Dynamisches Feld-Rendering +- `frontend/src/api/customFieldDefinitions.ts` — API-Client für Definitionen + +**Modifizierte Dateien:** +- `frontend/src/routes/index.tsx` — Route `/settings/custom-fields` +- `frontend/src/pages/Settings.tsx` — Nav-Eintrag "Custom Fields" +- `frontend/src/pages/ContactDetailPage.tsx` — Custom Fields Section +- `frontend/src/i18n/de.json` + +**Feld-Typen:** text, number, date, select, multiselect, boolean + +**Aufwand:** ~1.5 Tage (inkl. Backend CRUD + Migration) + +--- + +### 2.3 Notifications Dropdown (Bell Icon in TopBar) + +**Audit-Status:** ✅ API-Client vollständig (`notifications.ts`). ✅ Backend vollständig. ❌ TopBar hat kein Bell-Icon (confirmed: `grep` findet nichts). Notifications nur in AISidebar. + +**Neue Dateien:** +- `frontend/src/components/layout/NotificationBell.tsx` — Bell-Icon mit Badge + Dropdown +- `frontend/src/components/notifications/NotificationDropdown.tsx` — Dropdown-Liste +- `frontend/src/components/notifications/NotificationItem.tsx` — Einzelne Notification + +**Modifizierte Dateien:** +- `frontend/src/components/layout/TopBar.tsx` — `` vor User-Menu einfügen +- `frontend/src/i18n/de.json` + +**Features:** +- Unread-Count Badge (rot) +- Polling alle 30s (refetchInterval in useQuery) +- Click: Notification als gelesen markieren +- "Alle als gelesen" Button +- Type-Icon pro Notification +- Zeitstempel (relativ: "vor 5 Min") + +**Aufwand:** ~0.5 Tage + +--- + +## Phase 3 — Nice-to-have / Produktivität + +### 3.1 Saved Filters UI + +**Audit-Status:** ✅ Backend vollständig (`saved_filters.py`: CRUD, entity_types: contacts/mail/calendar/dms). ✅ API-Client vorhanden (`savedFilters.ts`). ❌ Keine UI. + +**Neue Dateien:** +- `frontend/src/components/common/SavedFilterBar.tsx` — Filter-Leiste mit Save/Load +- `frontend/src/components/common/SaveFilterDialog.tsx` — Dialog zum Speichern + +**Modifizierte Dateien:** +- `frontend/src/pages/ContactsList.tsx` — SavedFilterBar +- `frontend/src/pages/Calendar.tsx` — SavedFilterBar +- `frontend/src/pages/Dms.tsx` — SavedFilterBar +- `frontend/src/pages/Tasks.tsx` — SavedFilterBar +- `frontend/src/i18n/de.json` + +**Aufwand:** ~0.5 Tage + +--- + +### 3.2 Entity History UI + +**Audit-Status:** ✅ Backend vollständig (`entity_history.py`: get/restore/undo). ✅ API-Client vorhanden (`entityHistory.ts`). ❌ Keine UI-Komponente. + +**Neue Dateien:** +- `frontend/src/components/common/EntityHistoryPanel.tsx` — Timeline-Komponente +- `frontend/src/components/common/HistoryDiff.tsx` — Visualisierung von Feld-Änderungen + +**Modifizierte Dateien:** +- `frontend/src/pages/ContactDetailPage.tsx` — History-Tab/Panel +- `frontend/src/pages/Dms.tsx` — History für Dateien +- `frontend/src/pages/Calendar.tsx` — History für Termine +- `frontend/src/i18n/de.json` + +**Features:** +- Timeline mit create/update/delete Events +- Diff-Anzeige: alt → neu pro Feld +- Restore-Button pro Eintrag +- Undo-Button (letzte Aktion rückgängig) + +**Aufwand:** ~0.5 Tage + +--- + +### 3.3 Activity Timeline + +**Audit-Status:** ✅ `ActivityFeed` Komponente existiert (wiederverwendbar). ✅ Dashboard nutzt sie mit Audit-Daten. ❌ Keine eigenständige Seite mit Filterung/Pagination. + +**Neue Dateien:** +- `frontend/src/pages/ActivityTimeline.tsx` — Globale Activity-Feed Seite +- `frontend/src/components/activity/ActivityFilter.tsx` — Filter (User, Entity, Action, Zeitraum) + +**Modifizierte Dateien:** +- `frontend/src/routes/index.tsx` — Route `/activity` +- `frontend/src/api/audit.ts` — Erweitern um Timeline-Query (alle Entities, Pagination) +- `frontend/src/pages/Dashboard.tsx` — Link "Alle Aktivitäten anzeigen" +- `frontend/src/i18n/de.json` + +**Features:** +- Wiederverwendung von `ActivityFeed` Komponente +- Gruppierung nach Tag +- Filter: User, Entity-Typ, Aktion, Zeitraum +- Pagination / Infinite-Scroll +- Link zu Entity-Detail bei Click + +**Aufwand:** ~0.5 Tage + +--- + +### 3.4 API Documentation Link + +**Audit-Status:** ✅ FastAPI generiert automatisch `/docs` (Swagger) und `/redoc`. ❌ Kein Link im UI. + +**Modifizierte Dateien:** +- `frontend/src/components/layout/TopBar.tsx` — "API Docs" Link im User-Menu +- `frontend/src/pages/SettingsSystem.tsx` — "API Dokumentation" Sektion +- `frontend/src/i18n/de.json` + +**Implementierung:** +- Link zu Swagger UI: `/docs` (FastAPI auto-docs) +- Link zu ReDoc: `/redoc` +- In Settings/System: Sektion "Entwickler" + +**Aufwand:** ~0.25 Tage + +--- + +## Phase 4 — Backend + Frontend (komplett neu) + +### 4.1 Webhooks + +**Audit-Status:** ❌ Komplett fehlend. Kein Backend, kein Frontend, keine Modelle. + +**Neue Backend-Dateien:** +- `app/models/webhook.py` — Webhook-Modell (url, events, secret, is_active, retry_count) +- `app/schemas/webhook.py` — Pydantic Schemas +- `app/services/webhook_service.py` — Webhook-Service (send, retry, verify HMAC) +- `app/routes/webhooks.py` — CRUD-Routes `/api/v1/webhooks` +- `app/core/webhook_dispatcher.py` — Event-Bus-Subscriber +- `alembic/versions/0036_webhooks.py` — Migration + +**Neue Frontend-Dateien:** +- `frontend/src/pages/SettingsWebhooks.tsx` — Webhook-Verwaltung +- `frontend/src/components/webhooks/WebhookForm.tsx` — Create/Edit Form +- `frontend/src/components/webhooks/WebhookDeliveryLog.tsx` — Delivery-Log +- `frontend/src/api/webhooks.ts` — API-Client + +**Modifizierte Dateien:** +- `app/main.py` — Router registrieren +- `app/core/event_bus.py` — Webhook-Dispatcher subscriben +- `frontend/src/routes/index.tsx` — Route `/settings/webhooks` +- `frontend/src/pages/Settings.tsx` — Nav-Eintrag "Webhooks" +- `frontend/src/i18n/de.json` + +**Features:** +- URL + Secret (HMAC-Signatur) +- Event-Auswahl (Multi-Select aus Event-Bus-Events) +- Aktiv/Pause Toggle +- Delivery-Log mit Status, Response-Code, Latenz +- Retry-Konfiguration +- Test-Button + +**Aufwand:** ~1.5 Tage + +--- + +### 4.2 Backup/Restore UI + +**Audit-Status:** ✅ `backup_check` Cron-Job existiert (prüft last_backup_at, published Events). ❌ Keine Backup-Routes, keine Restore-Funktionalität, keine UI. + +**Backend-Ergänzung:** +- `app/routes/backups.py` — `/api/v1/backups` (list, create, restore, delete) +- `app/services/backup_service.py` — Backup erstellen (pg_dump), Restore (pg_restore) +- `app/models/backup.py` — Backup-Modell +- `alembic/versions/0037_backups.py` — Migration + +**Neue Frontend-Dateien:** +- `frontend/src/pages/SettingsBackup.tsx` — Backup-Verwaltung +- `frontend/src/components/backup/BackupList.tsx` — Liste der Backups +- `frontend/src/components/backup/RestoreDialog.tsx` — Restore-Bestätigung +- `frontend/src/api/backups.ts` — API-Client + +**Modifizierte Dateien:** +- `app/main.py` — Router registrieren +- `frontend/src/routes/index.tsx` — Route `/settings/backup` +- `frontend/src/pages/Settings.tsx` — Nav-Eintrag "Backup & Restore" +- `frontend/src/i18n/de.json` + +**Aufwand:** ~1 Tag + +--- + +### 4.3 Onboarding/Tutorial + +**Audit-Status:** ❌ Komplett fehlend. + +**Neue Dateien:** +- `frontend/src/components/onboarding/OnboardingTour.tsx` — Guided Tour +- `frontend/src/components/onboarding/WelcomeDialog.tsx` — Willkommens-Dialog +- `frontend/src/store/onboardingStore.ts` — Zustand-Store + +**Modifizierte Dateien:** +- `frontend/src/components/layout/AppShell.tsx` — OnboardingTour einbinden +- `frontend/src/api/userPreferences.ts` — onboarding_completed flag +- `frontend/src/i18n/de.json` + +**Tour-Schritte (8):** +1. Willkommen +2. Sidebar-Navigation +3. Globale Suche +4. Kontakte erstellen +5. Kalender/Termine +6. KI Assistent +7. Einstellungen +8. Fertig + +**Bibliothek:** `react-joyride` oder Custom Implementation + +**Aufwand:** ~1 Tag + +--- + +## Implementations-Reihenfolge + +``` +Phase 1 (Kritisch) + 1.1 Workflows UI ████████████████░░░ 1.5 Tage + 1.2 Dedup/Merge UI ███████████░░░░░░░░ 1.0 Tag + 1.3 Import/Export UI ████████████████░░░ 1.5 Tage (inkl. Backend Export-Route) + 1.4 Print/PDF █████░░░░░░░░░░░░░░ 0.5 Tage + +Phase 2 (Wichtig) + 2.1 Tags UI ███████████░░░░░░░░ 1.0 Tag + 2.2 Custom Fields UI ████████████████░░░ 1.5 Tage (inkl. Backend CRUD) + 2.3 Notifications Bell █████░░░░░░░░░░░░░░ 0.5 Tage + +Phase 3 (Nice-to-have) + 3.1 Saved Filters UI █████░░░░░░░░░░░░░░ 0.5 Tage + 3.2 Entity History UI █████░░░░░░░░░░░░░░ 0.5 Tage + 3.3 Activity Timeline █████░░░░░░░░░░░░░░ 0.5 Tage + 3.4 API Docs Link ██░░░░░░░░░░░░░░░░░ 0.25 Tage + +Phase 4 (Backend + Frontend) + 4.1 Webhooks ████████████████░░░ 1.5 Tage + 4.2 Backup/Restore UI ███████████░░░░░░░░ 1.0 Tag + 4.3 Onboarding/Tutorial ███████████░░░░░░░░ 1.0 Tag +``` + +## Deployment-Strategie + +### Nach jeder Phase: +1. Frontend Build: `cd frontend && npm run build` +2. Git commit + push +3. Coolify Auto-Deploy +4. Verifikation im Browser + +## Abhängigkeiten + +``` +1.1 Workflows UI ← keine (API ready) +1.2 Dedup/Merge UI ← keine (API ready) +1.3 Import/Export UI ← Backend Export-Route hinzufügen (Service existiert) +1.4 Print/PDF ← keine + +2.1 Tags UI ← keine (API ready) +2.2 Custom Fields UI ← Backend CRUD + Migration (neu) +2.3 Notifications Bell ← keine (API ready) + +3.1 Saved Filters ← keine (API ready) +3.2 Entity History ← keine (API ready) +3.3 Activity Timeline ← Audit-API ggf. erweitern (Pagination) +3.4 API Docs Link ← keine + +4.1 Webhooks ← Backend komplett neu + Migration +4.2 Backup/Restore ← Backend komplett neu + Migration +4.3 Onboarding ← User-Preferences API ggf. erweitern +``` + +## Risiko-Bewertung + +| Feature | Risiko | Grund | +|---------|--------|-------| +| Workflows UI | Mittel | Komplexe Step-Editor UI | +| Custom Fields UI | Hoch | Backend-Ergänzung + dynamisches Rendering | +| Webhooks | Hoch | Backend komplett neu, Security (HMAC, Retry) | +| Backup/Restore | Hoch | Datenverlust-Risiko bei Fehlern | +| Import/Export | Mittel | Backend Export-Route fehlt, Datei-Handling | +| Alle anderen | Niedrig | API existiert, nur UI | + +--- + +## Entfallenes Feature + +### ~~Permissions Management UI~~ — BEREITS VORHANDEN +- `SettingsRoles.tsx` (531 Zeilen) hat vollständige Permission-Verwaltung +- `ShareDialog.tsx` (11KB) nutzt File-Permissions API +- `roles.ts` API-Client hat `usePermissions()`, `useRoles()`, `useCreateRole()`, `useUpdateRole()`, `useDeleteRole()` +- Backend `/roles/permissions` liefert alle System+Plugin-Permissions +- Backend Roles-CRUD erlaubt Permission-Zuweisung (grant/deny/field-level) + +--- + +*Plan erstellt am 26.07.2026, audit-korrigiert um 02:17 — bereit zur Umsetzung.* diff --git a/app/plugins/builtins/automation/plugin.py b/app/plugins/builtins/automation/plugin.py index 209d755..a46b870 100644 --- a/app/plugins/builtins/automation/plugin.py +++ b/app/plugins/builtins/automation/plugin.py @@ -70,7 +70,29 @@ class AutomationPlugin(BasePlugin): "agents:execute", ], is_core=False, - menu_items=[], + menu_items=[ + FrontendMenuItem( + label_key="nav.workflows", + label="Workflows", + path="/workflows", + icon="Workflow", + order=52, + ), + FrontendMenuItem( + label_key="nav.importExport", + label="Import / Export", + path="/import-export", + icon="ArrowUpDown", + order=53, + ), + FrontendMenuItem( + label_key="nav.dedupMerge", + label="Duplikate", + path="/contacts/dedup", + icon="Copy", + order=54, + ), + ], page_routes=[ FrontendPageRoute( path="/automation", @@ -82,6 +104,16 @@ class AutomationPlugin(BasePlugin): component="@/pages/AgentDashboard", order=51, ), + FrontendPageRoute( + path="/workflows", + component="@/pages/Workflows", + order=52, + ), + FrontendPageRoute( + path="/import-export", + component="@/pages/ImportExport", + order=53, + ), ], settings_pages=[ FrontendSettingsPage( diff --git a/app/routes/import_export.py b/app/routes/import_export.py index 031ecd8..36dcc1d 100644 --- a/app/routes/import_export.py +++ b/app/routes/import_export.py @@ -2,6 +2,7 @@ from __future__ import annotations +import io import uuid from fastapi import ( @@ -10,9 +11,11 @@ from fastapi import ( File, Form, HTTPException, + Query, UploadFile, ) from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import StreamingResponse from app.core.db import get_db from app.deps import require_permission @@ -73,3 +76,76 @@ async def import_csv_preview( dry_run=True, ) return result + + +@router.get("/export") +async def export_data( + entity_type: str = Query("contacts", pattern="^(contacts|companies)$"), + format: str = Query("csv", pattern="^(csv|xlsx)$"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("import_export:read")), +): + """Export contacts or companies as CSV or XLSX file download. + + Query params: + - entity_type: 'contacts' or 'companies' (default: contacts) + - format: 'csv' or 'xlsx' (default: csv) + """ + tenant_id = uuid.UUID(current_user["tenant_id"]) + + # Determine filename based on entity_type + filename = f"{entity_type}_export.csv" + + # Fetch CSV data from the appropriate service function + if entity_type == "contacts": + csv_data = await import_export_service.export_contacts_csv(db, tenant_id) + elif entity_type == "companies": + csv_data = await import_export_service.export_companies_csv(db, tenant_id) + else: + raise HTTPException(status_code=400, detail=f"Unsupported entity_type: {entity_type}") + + # XLSX format handling + if format == "xlsx": + try: + import openpyxl + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = entity_type.capitalize() + + # Parse the CSV string and populate the worksheet + import csv as _csv + + reader = _csv.reader(io.StringIO(csv_data)) + for row in reader: + ws.append(row) + + xlsx_buffer = io.BytesIO() + wb.save(xlsx_buffer) + xlsx_buffer.seek(0) + + xlsx_filename = f"{entity_type}_export.xlsx" + return StreamingResponse( + xlsx_buffer, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{xlsx_filename}"'}, + ) + except ImportError: + # openpyxl not available — fall back to CSV with a warning header + media_type = "text/csv" + headers = { + "Content-Disposition": f'attachment; filename="{filename}"', + "X-Export-Warning": "openpyxl not installed, falling back to CSV format", + } + return StreamingResponse( + iter([csv_data.encode("utf-8")]), + media_type=media_type, + headers=headers, + ) + + # Default: CSV format + return StreamingResponse( + iter([csv_data.encode("utf-8")]), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/app/services/import_export_service.py b/app/services/import_export_service.py index 1ea558f..b776b1f 100644 --- a/app/services/import_export_service.py +++ b/app/services/import_export_service.py @@ -252,3 +252,42 @@ async def export_contacts_csv( ] ) return output.getvalue() + + +async def export_companies_csv( + db: AsyncSession, + tenant_id: uuid.UUID, +) -> str: + """Export companies (Contact.type == 'company') as CSV string.""" + q = ( + select(Contact) + .where( + Contact.tenant_id == tenant_id, + Contact.deleted_at.is_(None), + Contact.type == "company", + ) + .order_by(Contact.name) + ) + result = await db.execute(q) + companies = result.scalars().all() + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow( + ["id", "type", "name", "email", "phone", "website", "city", "postalcode", "country"] + ) + for c in companies: + writer.writerow( + [ + str(c.id), + c.type or "company", + c.name or "", + c.email_1 or "", + c.phone_1 or "", + c.website or "", + c.mailing_city or "", + c.mailing_postalcode or "", + c.mailing_country or "", + ] + ) + return output.getvalue() diff --git a/frontend/index.html b/frontend/index.html index 64053bb..2e42918 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,7 @@ +
diff --git a/frontend/public/print.css b/frontend/public/print.css new file mode 100644 index 0000000..57a0b44 --- /dev/null +++ b/frontend/public/print.css @@ -0,0 +1,197 @@ +/* Print-specific styles for LeoCRM */ +/* Applied via media="print" in index.html */ + +@media print { + /* ---- Hide non-essential UI elements ---- */ + + /* Sidebars and navigation */ + [data-testid="sidebar"], + [data-testid="topbar"], + [data-testid="message-sidebar"], + [data-testid="ai-sidebar"], + aside, + nav[aria-label="Sidebar"], + nav[aria-label="Main navigation"] { + display: none !important; + } + + /* Elements marked with data-no-print attribute */ + [data-no-print] { + display: none !important; + } + + /* Toolbar, buttons, and action elements */ + [data-testid*="toolbar"], + [data-testid*="-toolbar"], + .plugin-toolbar, + button:not([data-print-keep]), + .no-print { + display: none !important; + } + + /* ---- Page setup ---- */ + @page { + margin: 1.5cm; + } + + html, body { + margin: 0 !important; + padding: 0 !important; + font-size: 12pt !important; + color: #000 !important; + background: #fff !important; + line-height: 1.4 !important; + } + + /* Remove fixed/sticky positioning that breaks print */ + * { + position: static !important; + overflow: visible !important; + } + + /* Allow content to flow naturally */ + .flex, .flex-col, .flex-1 { + display: block !important; + } + + .overflow-hidden, .overflow-y-auto, .overflow-x-auto { + overflow: visible !important; + } + + .h-full, .min-h-0 { + height: auto !important; + min-height: 0 !important; + } + + /* ---- Show print-only elements ---- */ + [data-print-only] { + display: block !important; + } + + [data-print-only].inline { + display: inline !important; + } + + /* Print header for documents */ + .print-header { + display: block !important; + border-bottom: 2px solid #000; + padding-bottom: 8px; + margin-bottom: 16px; + } + + .print-header h1 { + font-size: 18pt; + margin: 0 0 4px 0; + } + + .print-header .print-date { + font-size: 10pt; + color: #666; + } + + /* ---- Format tables for print ---- */ + table { + width: 100% !important; + border-collapse: collapse !important; + page-break-inside: auto !important; + } + + thead { + display: table-header-group !important; + } + + tr { + page-break-inside: avoid !important; + page-break-after: auto !important; + } + + th, td { + border: 1px solid #ccc !important; + padding: 4px 8px !important; + font-size: 11pt !important; + text-align: left !important; + color: #000 !important; + } + + th { + background: #f5f5f5 !important; + font-weight: 600 !important; + } + + /* ---- Format cards for print ---- */ + .card, + [class*="rounded-lg"], + [class*="shadow-sm"] { + border: 1px solid #ddd !important; + box-shadow: none !important; + border-radius: 4px !important; + margin-bottom: 12px !important; + padding: 12px !important; + } + + /* Background colors to light shades for print readability */ + [class*="bg-white"] { + background: #fff !important; + } + + [class*="bg-secondary-50"], + [class*="bg-secondary-100"] { + background: #f9f9f9 !important; + } + + [class*="bg-primary-50"], + [class*="bg-primary-100"] { + background: #f0f0f0 !important; + } + + /* ---- Typography for print ---- */ + h1, h2, h3, h4, h5, h6 { + page-break-after: avoid !important; + color: #000 !important; + } + + h1 { font-size: 18pt !important; } + h2 { font-size: 15pt !important; } + h3 { font-size: 13pt !important; } + h4 { font-size: 12pt !important; } + + p, li { + font-size: 11pt !important; + color: #000 !important; + } + + a { + color: #000 !important; + text-decoration: none !important; + } + + /* ---- Hide specific interactive elements ---- */ + input[type="search"], + input[type="text"], + select, + textarea { + border: 1px solid #ccc !important; + background: #fff !important; + } + + /* Images */ + img { + max-width: 100% !important; + height: auto !important; + } + + /* Avoid page breaks inside critical content */ + [data-testid="contact-detail"], + [data-testid="reports-content"], + [data-testid="calendar-view"] { + page-break-inside: auto !important; + } +} + +/* Screen: hide print-only elements */ +@media screen { + [data-print-only] { + display: none !important; + } +} diff --git a/frontend/src/api/importExport.ts b/frontend/src/api/importExport.ts new file mode 100644 index 0000000..530a10a --- /dev/null +++ b/frontend/src/api/importExport.ts @@ -0,0 +1,66 @@ +import { apiClient } from './client'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface ImportResult { + total?: number; + created?: number; + updated?: number; + errors?: string[]; + warnings?: string[]; + rows?: Record[]; + preview?: boolean; +} + +export type EntityType = 'companies' | 'contacts'; +export type ExportFormat = 'csv' | 'xlsx'; + +// ─── Import ───────────────────────────────────────────────────────────────── + +/** + * Import a CSV file. When dryRun is true, a preview is returned without + * writing to the database. + */ +export async function importCsv( + file: File, + entityType: string, + dryRun: boolean +): Promise { + const url = dryRun ? '/import/preview' : '/import'; + const formData = new FormData(); + formData.append('file', file); + formData.append('entity_type', entityType); + + const response = await apiClient.post(url, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; +} + +// ─── Export ───────────────────────────────────────────────────────────────── + +/** + * Export data as CSV or XLSX and trigger a browser download. + */ +export async function exportData( + entityType: string, + format: string +): Promise { + const url = `/export?entity_type=${encodeURIComponent(entityType)}&format=${encodeURIComponent(format)}`; + const response = await apiClient.get(url, { responseType: 'blob' }); + + const blob = new Blob([response.data], { + type: format === 'xlsx' + ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + : 'text/csv', + }); + + const blobUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = `${entityType}_export.${format}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(blobUrl); +} diff --git a/frontend/src/components/common/PrintButton.tsx b/frontend/src/components/common/PrintButton.tsx new file mode 100644 index 0000000..d1e1a3f --- /dev/null +++ b/frontend/src/components/common/PrintButton.tsx @@ -0,0 +1,137 @@ +/** + * PrintButton — reusable button with dropdown for Print and PDF export. + * Uses lucide-react icons and clsx for styling. + */ + +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import clsx from 'clsx'; +import { Printer, FileDown, ChevronDown } from 'lucide-react'; +import { printElement, printCurrentPage, exportToPDF } from '@/utils/print'; + +export interface PrintButtonProps { + /** Element id to print. If omitted, prints the whole page. */ + targetId?: string; + /** Filename suggestion for PDF export. */ + filename?: string; + /** Additional Tailwind classes for the trigger button. */ + className?: string; +} + +export function PrintButton({ + targetId, + filename = 'export', + className, +}: PrintButtonProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + if (!open) return; + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [open]); + + const handlePrint = useCallback(() => { + setOpen(false); + if (targetId) { + printElement(targetId); + } else { + printCurrentPage(); + } + }, [targetId]); + + const handleExportPDF = useCallback(() => { + setOpen(false); + if (targetId) { + exportToPDF(targetId, filename); + } else { + // No specific element — use whole-page print which user can save as PDF + printCurrentPage(); + } + }, [targetId, filename]); + + return ( +
+ + + {open && ( +
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/components/dedup/DuplicatePairCard.tsx b/frontend/src/components/dedup/DuplicatePairCard.tsx new file mode 100644 index 0000000..c2f32c8 --- /dev/null +++ b/frontend/src/components/dedup/DuplicatePairCard.tsx @@ -0,0 +1,114 @@ +/** + * DuplicatePairCard — Side-by-side comparison of a duplicate pair. + * Shows both contacts with key fields, similarity score badge, match reasons, + * and a "Zusammenführen" button. + */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import { GitMerge, Mail, Phone, MapPin, Calendar, User } from 'lucide-react'; +import { Badge, type BadgeVariant } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import type { DuplicatePair, DuplicateContactBrief } from '@/api/dedup'; + +export interface DuplicatePairCardProps { + pair: DuplicatePair; + onMerge: (pair: DuplicatePair) => void; +} + +function similarityVariant(score: number): BadgeVariant { + const pct = score * 100; + if (pct > 90) return 'success'; + if (pct > 75) return 'warning'; + return 'danger'; +} + +function ContactColumn({ contact, label }: { contact: DuplicateContactBrief; label: string }) { + const { t } = useTranslation(); + const fullName = + contact.firstname || contact.surname + ? `${contact.firstname ?? ''} ${contact.surname ?? ''}`.trim() + : contact.displayname || contact.name || '—'; + + const rows = [ + { icon: User, label: t('dedup.field.name', 'Name'), value: fullName }, + { icon: Mail, label: t('dedup.field.email', 'E-Mail'), value: contact.email_1 }, + { icon: Phone, label: t('dedup.field.phone', 'Telefon'), value: contact.phone_1 }, + { icon: MapPin, label: t('dedup.field.city', 'Stadt'), value: contact.mailing_city }, + { icon: MapPin, label: t('dedup.field.postalcode', 'PLZ'), value: contact.mailing_postalcode }, + { icon: Calendar, label: t('dedup.field.created', 'Erstellt'), value: contact.created_at }, + ]; + + return ( +
+

{label}

+
+ {rows.map((row) => { + const Icon = row.icon; + return ( +
+
+ ); + })} +
+
+ ); +} + +export function DuplicatePairCard({ pair, onMerge }: DuplicatePairCardProps) { + const { t } = useTranslation(); + const pct = Math.round(pair.similarity_score * 100); + + return ( +
+ {/* Header: score + reasons + merge button */} +
+
+ + {pct}% + + + {t('dedup.similarity', 'Ähnlichkeit')} + + {pair.match_reasons.length > 0 && ( +
+ {pair.match_reasons.map((reason) => ( + + {reason} + + ))} +
+ )} +
+ +
+ + {/* Body: side-by-side contacts */} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/dedup/MergeDialog.tsx b/frontend/src/components/dedup/MergeDialog.tsx new file mode 100644 index 0000000..3d01014 --- /dev/null +++ b/frontend/src/components/dedup/MergeDialog.tsx @@ -0,0 +1,287 @@ +/** + * MergeDialog — Merge confirmation dialog with per-field value selection. + * Shows both contacts side-by-side, lets user choose source/target/manual value + * for each mergeable field, then calls useMergeContacts(). + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import { AlertTriangle } from 'lucide-react'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import { useToast } from '@/components/ui/Toast'; +import { useMergeContacts, type DuplicatePair, type DuplicateContactBrief } from '@/api/dedup'; + +export interface MergeDialogProps { + open: boolean; + pair: DuplicatePair | null; + onClose: () => void; +} + +type FieldChoice = 'source' | 'target' | 'manual'; + +interface FieldConfig { + key: keyof DuplicateContactBrief; + labelKey: string; + labelFallback: string; +} + +const MERGE_FIELDS: FieldConfig[] = [ + { key: 'firstname', labelKey: 'dedup.field.firstname', labelFallback: 'Vorname' }, + { key: 'surname', labelKey: 'dedup.field.surname', labelFallback: 'Nachname' }, + { key: 'email_1', labelKey: 'dedup.field.email', labelFallback: 'E-Mail' }, + { key: 'phone_1', labelKey: 'dedup.field.phone', labelFallback: 'Telefon' }, + { key: 'mailing_city', labelKey: 'dedup.field.city', labelFallback: 'Stadt' }, + { key: 'mailing_postalcode', labelKey: 'dedup.field.postalcode', labelFallback: 'PLZ' }, +]; + +function getFieldValue(contact: DuplicateContactBrief | undefined, key: keyof DuplicateContactBrief): string { + if (!contact) return ''; + const val = contact[key]; + return val == null ? '' : String(val); +} + +export function MergeDialog({ open, pair, onClose }: MergeDialogProps) { + const { t } = useTranslation(); + const toast = useToast(); + const mergeMutation = useMergeContacts(); + + const [choices, setChoices] = useState>({}); + const [manualValues, setManualValues] = useState>({}); + const [note, setNote] = useState(''); + + // Reset state when dialog opens with a new pair + useEffect(() => { + if (open && pair) { + const initialChoices: Record = {}; + const initialManual: Record = {}; + for (const field of MERGE_FIELDS) { + initialChoices[field.key] = 'target'; + initialManual[field.key] = ''; + } + setChoices(initialChoices); + setManualValues(initialManual); + setNote(''); + } + }, [open, pair]); + + const handleChoiceChange = (fieldKey: string, choice: FieldChoice) => { + setChoices((prev) => ({ ...prev, [fieldKey]: choice })); + }; + + const handleManualChange = (fieldKey: string, value: string) => { + setManualValues((prev) => ({ ...prev, [fieldKey]: value })); + }; + + // Build field_overrides from choices + const fieldOverrides = useMemo(() => { + if (!pair) return {}; + const overrides: Record = {}; + for (const field of MERGE_FIELDS) { + const choice = choices[field.key] ?? 'target'; + if (choice === 'manual') { + overrides[field.key] = manualValues[field.key] ?? ''; + } else if (choice === 'source') { + overrides[field.key] = getFieldValue(pair.source_contact, field.key); + } + // 'target' means no override — surviving record keeps its value + } + return overrides; + }, [choices, manualValues, pair]); + + const handleMerge = () => { + if (!pair) return; + mergeMutation.mutate( + { + source_contact_id: pair.source_contact.id, + target_contact_id: pair.target_contact.id, + field_overrides: fieldOverrides, + note: note.trim() || undefined, + }, + { + onSuccess: () => { + toast.success(t('dedup.mergeSuccess', 'Kontakte wurden erfolgreich zusammengeführt.')); + onClose(); + }, + onError: (error: unknown) => { + const message = + error instanceof Error + ? error.message + : t('dedup.mergeError', 'Fehler beim Zusammenführen der Kontakte.'); + toast.error(message); + }, + }, + ); + }; + + if (!pair) return null; + + const sourceName = + pair.source_contact.firstname || pair.source_contact.surname + ? `${pair.source_contact.firstname ?? ''} ${pair.source_contact.surname ?? ''}`.trim() + : pair.source_contact.displayname || pair.source_contact.id; + + const targetName = + pair.target_contact.firstname || pair.target_contact.surname + ? `${pair.target_contact.firstname ?? ''} ${pair.target_contact.surname ?? ''}`.trim() + : pair.target_contact.displayname || pair.target_contact.id; + + return ( + +
+ {/* Warning */} +
+
+ + {/* Contact names summary */} +
+ {sourceName} + + {targetName} +
+ + {/* Field-by-field selection table */} +
+ + + + + + + + + + + {MERGE_FIELDS.map((field) => { + const sourceVal = getFieldValue(pair.source_contact, field.key); + const targetVal = getFieldValue(pair.target_contact, field.key); + const choice = choices[field.key] ?? 'target'; + + return ( + + + + + + + ); + })} + +
+ {t('dedup.field', 'Feld')} + + {t('dedup.sourceContact', 'Quell-Kontakt')} + + {t('dedup.targetContact', 'Ziel-Kontakt')} + + {t('dedup.manual', 'Manuell')} +
+ {t(field.labelKey, field.labelFallback)} + + + + + +
+ handleChoiceChange(field.key, 'manual')} + className="h-4 w-4 text-primary-600 focus:ring-primary-500" + /> + { + handleManualChange(field.key, e.target.value); + if (choice !== 'manual') handleChoiceChange(field.key, 'manual'); + }} + disabled={choice !== 'manual'} + className={clsx( + 'flex-1 rounded-md border px-2 py-1 text-sm min-h-touch', + 'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500', + choice === 'manual' + ? 'border-primary-400 text-secondary-900' + : 'border-secondary-200 text-secondary-400 bg-secondary-50', + )} + placeholder={t('dedup.manualPlaceholder', 'Eigener Wert…')} + /> +
+
+
+ + {/* Note textarea */} +
+ +