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
This commit is contained in:
@@ -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/<name>.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/<name>.py`, registriert in `app/main.py`
|
||||||
|
- **Services:** `app/services/<name>_service.py`
|
||||||
|
- **Models:** `app/models/<name>.py`, Migrationen in `alembic/versions/`
|
||||||
|
- **Schemas:** `app/schemas/<name>.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` — `<NotificationBell />` 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.*
|
||||||
@@ -70,7 +70,29 @@ class AutomationPlugin(BasePlugin):
|
|||||||
"agents:execute",
|
"agents:execute",
|
||||||
],
|
],
|
||||||
is_core=False,
|
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=[
|
page_routes=[
|
||||||
FrontendPageRoute(
|
FrontendPageRoute(
|
||||||
path="/automation",
|
path="/automation",
|
||||||
@@ -82,6 +104,16 @@ class AutomationPlugin(BasePlugin):
|
|||||||
component="@/pages/AgentDashboard",
|
component="@/pages/AgentDashboard",
|
||||||
order=51,
|
order=51,
|
||||||
),
|
),
|
||||||
|
FrontendPageRoute(
|
||||||
|
path="/workflows",
|
||||||
|
component="@/pages/Workflows",
|
||||||
|
order=52,
|
||||||
|
),
|
||||||
|
FrontendPageRoute(
|
||||||
|
path="/import-export",
|
||||||
|
component="@/pages/ImportExport",
|
||||||
|
order=53,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
settings_pages=[
|
settings_pages=[
|
||||||
FrontendSettingsPage(
|
FrontendSettingsPage(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
@@ -10,9 +11,11 @@ from fastapi import (
|
|||||||
File,
|
File,
|
||||||
Form,
|
Form,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
|
Query,
|
||||||
UploadFile,
|
UploadFile,
|
||||||
)
|
)
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.deps import require_permission
|
from app.deps import require_permission
|
||||||
@@ -73,3 +76,76 @@ async def import_csv_preview(
|
|||||||
dry_run=True,
|
dry_run=True,
|
||||||
)
|
)
|
||||||
return result
|
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}"'},
|
||||||
|
)
|
||||||
|
|||||||
@@ -252,3 +252,42 @@ async def export_contacts_csv(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
return output.getvalue()
|
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()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/print.css" media="print" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
total?: number;
|
||||||
|
created?: number;
|
||||||
|
updated?: number;
|
||||||
|
errors?: string[];
|
||||||
|
warnings?: string[];
|
||||||
|
rows?: Record<string, unknown>[];
|
||||||
|
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<ImportResult> {
|
||||||
|
const url = dryRun ? '/import/preview' : '/import';
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('entity_type', entityType);
|
||||||
|
|
||||||
|
const response = await apiClient.post<ImportResult>(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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div ref={containerRef} className="relative inline-block" data-no-print>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md',
|
||||||
|
'text-sm font-medium text-secondary-700',
|
||||||
|
'border border-secondary-300 bg-white',
|
||||||
|
'hover:bg-secondary-50 transition-colors',
|
||||||
|
'min-h-touch',
|
||||||
|
open && 'ring-2 ring-primary-500 ring-offset-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label="Drucken oder als PDF exportieren"
|
||||||
|
title="Drucken / PDF"
|
||||||
|
data-testid="print-button-trigger"
|
||||||
|
>
|
||||||
|
<Printer className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||||
|
<span className="hidden sm:inline">Drucken</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={clsx('w-3.5 h-3.5 transition-transform', open && 'rotate-180')}
|
||||||
|
aria-hidden="true"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className={clsx(
|
||||||
|
'absolute right-0 mt-1 z-50',
|
||||||
|
'min-w-[180px] rounded-md border border-secondary-200',
|
||||||
|
'bg-white shadow-lg py-1',
|
||||||
|
)}
|
||||||
|
data-testid="print-button-dropdown"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePrint}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 w-full px-3 py-2',
|
||||||
|
'text-sm text-secondary-700 hover:bg-secondary-50',
|
||||||
|
'transition-colors text-left',
|
||||||
|
)}
|
||||||
|
role="menuitem"
|
||||||
|
data-testid="print-button-print"
|
||||||
|
>
|
||||||
|
<Printer className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
|
||||||
|
<span>Drucken</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportPDF}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 w-full px-3 py-2',
|
||||||
|
'text-sm text-secondary-700 hover:bg-secondary-50',
|
||||||
|
'transition-colors text-left',
|
||||||
|
)}
|
||||||
|
role="menuitem"
|
||||||
|
data-testid="print-button-pdf"
|
||||||
|
>
|
||||||
|
<FileDown className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
|
||||||
|
<span>Als PDF</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex-1 min-w-0 rounded-md border border-secondary-200 bg-secondary-50 p-4">
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-secondary-500">{label}</p>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const Icon = row.icon;
|
||||||
|
return (
|
||||||
|
<div key={row.label} className="flex items-start gap-2">
|
||||||
|
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0 text-secondary-400" aria-hidden="true" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-xs text-secondary-500">{row.label}</dt>
|
||||||
|
<dd className={clsx('text-sm text-secondary-900 truncate', !row.value && 'text-secondary-300')}>
|
||||||
|
{row.value || '—'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DuplicatePairCard({ pair, onMerge }: DuplicatePairCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pct = Math.round(pair.similarity_score * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-secondary-200 bg-white shadow-sm">
|
||||||
|
{/* Header: score + reasons + merge button */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-secondary-200 px-5 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant={similarityVariant(pair.similarity_score)} className="text-sm font-bold">
|
||||||
|
{pct}%
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-secondary-600">
|
||||||
|
{t('dedup.similarity', 'Ähnlichkeit')}
|
||||||
|
</span>
|
||||||
|
{pair.match_reasons.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{pair.match_reasons.map((reason) => (
|
||||||
|
<Badge key={reason} variant="secondary">
|
||||||
|
{reason}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
icon={<GitMerge className="h-4 w-4" />}
|
||||||
|
onClick={() => onMerge(pair)}
|
||||||
|
>
|
||||||
|
{t('dedup.merge', 'Zusammenführen')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body: side-by-side contacts */}
|
||||||
|
<div className="flex flex-col gap-4 p-5 md:flex-row">
|
||||||
|
<ContactColumn
|
||||||
|
contact={pair.source_contact}
|
||||||
|
label={t('dedup.sourceContact', 'Quell-Kontakt')}
|
||||||
|
/>
|
||||||
|
<ContactColumn
|
||||||
|
contact={pair.target_contact}
|
||||||
|
label={t('dedup.targetContact', 'Ziel-Kontakt')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Record<string, FieldChoice>>({});
|
||||||
|
const [manualValues, setManualValues] = useState<Record<string, string>>({});
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
|
||||||
|
// Reset state when dialog opens with a new pair
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && pair) {
|
||||||
|
const initialChoices: Record<string, FieldChoice> = {};
|
||||||
|
const initialManual: Record<string, string> = {};
|
||||||
|
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<string, unknown> = {};
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('dedup.mergeDialogTitle', 'Kontakte zusammenführen')}
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="flex items-start gap-3 rounded-md border border-warning-300 bg-warning-50 p-3">
|
||||||
|
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-warning-600" aria-hidden="true" />
|
||||||
|
<p className="text-sm text-warning-800">
|
||||||
|
{t(
|
||||||
|
'dedup.mergeWarning',
|
||||||
|
'Der Quell-Kontakt wird nach dem Merge gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact names summary */}
|
||||||
|
<div className="flex items-center justify-center gap-3 text-sm text-secondary-700">
|
||||||
|
<span className="font-medium text-secondary-900">{sourceName}</span>
|
||||||
|
<span className="text-secondary-400">→</span>
|
||||||
|
<span className="font-medium text-secondary-900">{targetName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field-by-field selection table */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-secondary-200 text-left text-xs uppercase tracking-wide text-secondary-500">
|
||||||
|
<th className="pb-2 pr-4 font-semibold">
|
||||||
|
{t('dedup.field', 'Feld')}
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 pr-4 font-semibold">
|
||||||
|
{t('dedup.sourceContact', 'Quell-Kontakt')}
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 pr-4 font-semibold">
|
||||||
|
{t('dedup.targetContact', 'Ziel-Kontakt')}
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 pr-4 font-semibold">
|
||||||
|
{t('dedup.manual', 'Manuell')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr key={field.key} className="border-b border-secondary-100">
|
||||||
|
<td className="py-3 pr-4 font-medium text-secondary-700">
|
||||||
|
{t(field.labelKey, field.labelFallback)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`field-${field.key}`}
|
||||||
|
value="source"
|
||||||
|
checked={choice === 'source'}
|
||||||
|
onChange={() => handleChoiceChange(field.key, 'source')}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className={clsx('text-secondary-900', !sourceVal && 'text-secondary-300')}>
|
||||||
|
{sourceVal || '—'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`field-${field.key}`}
|
||||||
|
value="target"
|
||||||
|
checked={choice === 'target'}
|
||||||
|
onChange={() => handleChoiceChange(field.key, 'target')}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className={clsx('text-secondary-900', !targetVal && 'text-secondary-300')}>
|
||||||
|
{targetVal || '—'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`field-${field.key}`}
|
||||||
|
value="manual"
|
||||||
|
checked={choice === 'manual'}
|
||||||
|
onChange={() => handleChoiceChange(field.key, 'manual')}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manualValues[field.key] ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
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…')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Note textarea */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="merge-note" className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
{t('dedup.note', 'Notiz (optional)')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="merge-note"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className={clsx(
|
||||||
|
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
|
||||||
|
'text-secondary-900 placeholder-secondary-400 resize-y',
|
||||||
|
)}
|
||||||
|
placeholder={t('dedup.notePlaceholder', 'Grund für den Merge…')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={mergeMutation.isPending}>
|
||||||
|
{t('common.cancel', 'Abbrechen')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleMerge}
|
||||||
|
isLoading={mergeMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('dedup.executeMerge', 'Merge ausführen')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* MergeHistory — Collapsible list of past merge operations.
|
||||||
|
* Uses useMergeHistory() hook internally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ChevronDown, ChevronRight, History, GitMerge } from 'lucide-react';
|
||||||
|
import { useMergeHistory, type MergeHistoryItem } from '@/api/dedup';
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFieldName(key: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
firstname: 'Vorname',
|
||||||
|
surname: 'Nachname',
|
||||||
|
email_1: 'E-Mail',
|
||||||
|
phone_1: 'Telefon',
|
||||||
|
mailing_city: 'Stadt',
|
||||||
|
mailing_postalcode: 'PLZ',
|
||||||
|
};
|
||||||
|
return map[key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MergeHistoryRow({ item }: { item: MergeHistoryItem }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const mergedFields = item.merged_fields;
|
||||||
|
const fieldEntries = Object.entries(mergedFields || {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-secondary-100 last:border-b-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-3 py-3 text-left hover:bg-secondary-50 px-3 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 flex-shrink-0 text-secondary-400" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 flex-shrink-0 text-secondary-400" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<GitMerge className="h-4 w-4 flex-shrink-0 text-secondary-400" aria-hidden="true" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-secondary-900">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('dedup.history.source', 'Quelle')}: {item.source_contact_id.slice(0, 8)}…
|
||||||
|
</span>
|
||||||
|
<span className="text-secondary-400">→</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('dedup.history.target', 'Ziel')}: {item.target_contact_id.slice(0, 8)}…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-3 text-xs text-secondary-500">
|
||||||
|
<span>{formatDate(item.created_at)}</span>
|
||||||
|
{item.merged_by && (
|
||||||
|
<span>
|
||||||
|
{t('dedup.history.mergedBy', 'durch')}: {item.merged_by}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.note && (
|
||||||
|
<span className="truncate">„{item.note}“</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{expanded && fieldEntries.length > 0 && (
|
||||||
|
<div className="px-10 pb-3">
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-secondary-500">
|
||||||
|
{t('dedup.history.mergedFields', 'Überschriebene Felder')}:
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{fieldEntries.map(([key, val]) => (
|
||||||
|
<li key={key} className="text-sm text-secondary-700">
|
||||||
|
<span className="font-medium">{formatFieldName(key)}:</span>{' '}
|
||||||
|
{val == null ? '—' : String(val)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{expanded && fieldEntries.length === 0 && (
|
||||||
|
<div className="px-10 pb-3 text-sm text-secondary-400">
|
||||||
|
{t('dedup.history.noOverrides', 'Keine Feld-Überschreibungen.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MergeHistory() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { data, isLoading, isError, error } = useMergeHistory(1, 20);
|
||||||
|
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-secondary-200 bg-white shadow-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center justify-between px-5 py-3 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<History className="h-5 w-5 text-secondary-400" aria-hidden="true" />
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900">
|
||||||
|
{t('dedup.history.title', 'Merge-Historie')}
|
||||||
|
</h3>
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="ml-1 rounded-full bg-secondary-100 px-2 py-0.5 text-xs font-medium text-secondary-600">
|
||||||
|
{total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-5 w-5 text-secondary-400" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-5 w-5 text-secondary-400" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t border-secondary-200 px-3 py-2">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="py-8 text-center text-sm text-secondary-500">
|
||||||
|
{t('common.loading', 'Laden…')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="py-8 text-center text-sm text-danger-600">
|
||||||
|
{t('dedup.history.loadError', 'Fehler beim Laden der Historie.')}
|
||||||
|
{error instanceof Error && (
|
||||||
|
<span className="block mt-1 text-xs text-secondary-400">{error.message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && items.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-sm text-secondary-500">
|
||||||
|
{t('dedup.history.empty', 'Noch keine Merges durchgeführt.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && items.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{items.map((item) => (
|
||||||
|
<MergeHistoryRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
{total > items.length && (
|
||||||
|
<p className="mt-2 px-3 py-2 text-center text-xs text-secondary-400">
|
||||||
|
{t('dedup.history.more', 'Weitere Einträge auf Seite 2+')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Download, Loader2, FileText, CheckCircle } from 'lucide-react';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { exportData } from '@/api/importExport';
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ENTITY_OPTIONS = [
|
||||||
|
{ value: 'contacts', label: 'Kontakte' },
|
||||||
|
{ value: 'companies', label: 'Firmen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FORMAT_OPTIONS = [
|
||||||
|
{ value: 'csv', label: 'CSV' },
|
||||||
|
{ value: 'xlsx', label: 'XLSX' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ExportPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [entityType, setEntityType] = useState<string>('contacts');
|
||||||
|
const [format, setFormat] = useState<string>('csv');
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [lastExport, setLastExport] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
await exportData(entityType, format);
|
||||||
|
const timestamp = new Date().toLocaleString();
|
||||||
|
setLastExport(timestamp);
|
||||||
|
toast.success(
|
||||||
|
t('importExport.exportSuccess', 'Export erfolgreich heruntergeladen')
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(
|
||||||
|
err?.message || t('importExport.exportFailed', 'Export fehlgeschlagen')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('importExport.exportTitle', 'Daten exportieren')}
|
||||||
|
description={t('importExport.exportDescription', 'Exportieren Sie Kontakte oder Firmen als CSV- oder XLSX-Datei')}
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Entity type */}
|
||||||
|
<Select
|
||||||
|
label={t('importExport.entityType', 'Entitätstyp')}
|
||||||
|
options={ENTITY_OPTIONS}
|
||||||
|
value={entityType}
|
||||||
|
onChange={(e) => setEntityType(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Format */}
|
||||||
|
<Select
|
||||||
|
label={t('importExport.format', 'Format')}
|
||||||
|
options={FORMAT_OPTIONS}
|
||||||
|
value={format}
|
||||||
|
onChange={(e) => setFormat(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview info */}
|
||||||
|
<div className="bg-secondary-50 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<FileText className="w-5 h-5 text-secondary-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-secondary-600 space-y-1">
|
||||||
|
<p>
|
||||||
|
{t('importExport.exportSummary', 'Exportiert alle')}{' '}
|
||||||
|
<span className="font-medium text-secondary-900">
|
||||||
|
{entityType === 'contacts' ? 'Kontakte' : 'Firmen'}
|
||||||
|
</span>{' '}
|
||||||
|
als{' '}
|
||||||
|
<span className="font-medium text-secondary-900">
|
||||||
|
{format.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-secondary-500">
|
||||||
|
{t('importExport.exportHint', 'Die Datei wird nach dem Klick auf "Exportieren" heruntergeladen')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last export */}
|
||||||
|
{lastExport && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-success-700">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
{t('importExport.lastExport', 'Letzter Export')}: {lastExport}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={isExporting}
|
||||||
|
isLoading={isExporting}
|
||||||
|
icon={!isExporting ? <Download className="w-4 h-4" /> : undefined}
|
||||||
|
>
|
||||||
|
{t('importExport.export', 'Exportieren')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Upload,
|
||||||
|
RotateCcw,
|
||||||
|
ArrowLeft,
|
||||||
|
Eye,
|
||||||
|
Play,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { importCsv, type ImportResult } from '@/api/importExport';
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
const ACCEPTED_EXTENSIONS = ['.csv'];
|
||||||
|
|
||||||
|
const ENTITY_OPTIONS = [
|
||||||
|
{ value: 'contacts', label: 'Kontakte' },
|
||||||
|
{ value: 'companies', label: 'Firmen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type WizardStep = 'upload' | 'preview' | 'review' | 'result';
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ImportWizard() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<WizardStep>('upload');
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [entityType, setEntityType] = useState<string>('contacts');
|
||||||
|
const [previewResult, setPreviewResult] = useState<ImportResult | null>(null);
|
||||||
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
|
const [previewConfirmed, setPreviewConfirmed] = useState(false);
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
||||||
|
const [isLoadingImport, setIsLoadingImport] = useState(false);
|
||||||
|
const [fileError, setFileError] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// ─── File validation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const validateFile = useCallback((f: File): string | null => {
|
||||||
|
const ext = f.name.toLowerCase().substring(f.name.lastIndexOf('.'));
|
||||||
|
if (!ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||||
|
return t('importExport.onlyCsvAllowed', 'Nur CSV-Dateien sind erlaubt');
|
||||||
|
}
|
||||||
|
if (f.size > MAX_FILE_SIZE) {
|
||||||
|
return t('importExport.fileTooLarge', 'Datei ist größer als 10 MB');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback((f: File | null) => {
|
||||||
|
if (!f) return;
|
||||||
|
const error = validateFile(f);
|
||||||
|
if (error) {
|
||||||
|
setFileError(error);
|
||||||
|
setFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFileError(null);
|
||||||
|
setFile(f);
|
||||||
|
}, [validateFile]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
|
if (droppedFile) handleFileSelect(droppedFile);
|
||||||
|
}, [handleFileSelect]);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selected = e.target.files?.[0] ?? null;
|
||||||
|
handleFileSelect(selected);
|
||||||
|
}, [handleFileSelect]);
|
||||||
|
|
||||||
|
// ─── Preview ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setIsLoadingPreview(true);
|
||||||
|
try {
|
||||||
|
const result = await importCsv(file, entityType, true);
|
||||||
|
setPreviewResult(result);
|
||||||
|
setStep('preview');
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || t('importExport.previewFailed', 'Vorschau fehlgeschlagen'));
|
||||||
|
} finally {
|
||||||
|
setIsLoadingPreview(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Import ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setIsLoadingImport(true);
|
||||||
|
try {
|
||||||
|
const result = await importCsv(file, entityType, false);
|
||||||
|
setImportResult(result);
|
||||||
|
setStep('result');
|
||||||
|
toast.success(t('importExport.importSuccess', 'Import erfolgreich abgeschlossen'));
|
||||||
|
} catch (err: any) {
|
||||||
|
setImportResult({
|
||||||
|
errors: [err?.message || t('importExport.importFailed', 'Import fehlgeschlagen')],
|
||||||
|
});
|
||||||
|
setStep('result');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingImport(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Reset ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setStep('upload');
|
||||||
|
setFile(null);
|
||||||
|
setPreviewResult(null);
|
||||||
|
setImportResult(null);
|
||||||
|
setPreviewConfirmed(false);
|
||||||
|
setFileError(null);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (step === 'preview') setStep('upload');
|
||||||
|
else if (step === 'review') setStep('preview');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Step indicator ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const steps: { key: WizardStep; label: string }[] = [
|
||||||
|
{ key: 'upload', label: t('importExport.stepUpload', 'Datei hochladen') },
|
||||||
|
{ key: 'preview', label: t('importExport.stepPreview', 'Vorschau') },
|
||||||
|
{ key: 'review', label: t('importExport.stepReview', 'Überprüfung') },
|
||||||
|
{ key: 'result', label: t('importExport.stepResult', 'Ergebnis') },
|
||||||
|
];
|
||||||
|
const currentStepIndex = steps.findIndex((s) => s.key === step);
|
||||||
|
|
||||||
|
// ─── Render ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="flex items-center justify-between max-w-2xl">
|
||||||
|
{steps.map((s, idx) => (
|
||||||
|
<React.Fragment key={s.key}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium transition-colors',
|
||||||
|
idx <= currentStepIndex
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-secondary-200 text-secondary-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{idx < currentStepIndex ? (
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
idx + 1
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'text-sm font-medium hidden sm:inline',
|
||||||
|
idx <= currentStepIndex ? 'text-secondary-900' : 'text-secondary-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{idx < steps.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex-1 h-0.5 mx-2 transition-colors',
|
||||||
|
idx < currentStepIndex ? 'bg-primary-600' : 'bg-secondary-200'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step: Upload */}
|
||||||
|
{step === 'upload' && (
|
||||||
|
<Card
|
||||||
|
title={t('importExport.uploadTitle', 'Datei hochladen')}
|
||||||
|
description={t('importExport.uploadDescription', 'Wählen Sie eine CSV-Datei und den Entitätstyp für den Import')}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Entity type */}
|
||||||
|
<Select
|
||||||
|
label={t('importExport.entityType', 'Entitätstyp')}
|
||||||
|
options={ENTITY_OPTIONS}
|
||||||
|
value={entityType}
|
||||||
|
onChange={(e) => setEntityType(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drag & drop zone */}
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={clsx(
|
||||||
|
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
|
||||||
|
isDragOver
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-secondary-300 hover:border-secondary-400 bg-secondary-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{file ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<FileText className="w-10 h-10 text-primary-600" />
|
||||||
|
<p className="text-sm font-medium text-secondary-900">{file.name}</p>
|
||||||
|
<p className="text-xs text-secondary-500">
|
||||||
|
{(file.size / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Upload className="w-10 h-10 text-secondary-400" />
|
||||||
|
<p className="text-sm font-medium text-secondary-700">
|
||||||
|
{t('importExport.dropFileHere', 'CSV-Datei hierher ziehen oder klicken zum Auswählen')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-secondary-500">
|
||||||
|
{t('importExport.fileConstraints', 'Max. 10 MB, nur .csv')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fileError && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-danger-600">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{fileError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={!file || isLoadingPreview}
|
||||||
|
isLoading={isLoadingPreview}
|
||||||
|
icon={!isLoadingPreview ? <Eye className="w-4 h-4" /> : undefined}
|
||||||
|
>
|
||||||
|
{t('importExport.preview', 'Vorschau')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Preview */}
|
||||||
|
{step === 'preview' && previewResult && (
|
||||||
|
<Card
|
||||||
|
title={t('importExport.previewTitle', 'Vorschau der Daten')}
|
||||||
|
description={t('importExport.previewDescription', 'Überprüfen Sie das Ergebnis des Dry-Run-Imports')}
|
||||||
|
actions={
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleBack} icon={<ArrowLeft className="w-4 h-4" />}>
|
||||||
|
{t('common.back', 'Zurück')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
|
<StatBox
|
||||||
|
label={t('importExport.totalRows', 'Gesamtzeilen')}
|
||||||
|
value={previewResult.total ?? 0}
|
||||||
|
icon={<FileText className="w-5 h-5 text-secondary-400" />}
|
||||||
|
/>
|
||||||
|
<StatBox
|
||||||
|
label={t('importExport.created', 'Neu')}
|
||||||
|
value={previewResult.created ?? 0}
|
||||||
|
icon={<CheckCircle className="w-5 h-5 text-success-500" />}
|
||||||
|
/>
|
||||||
|
<StatBox
|
||||||
|
label={t('importExport.updated', 'Aktualisiert')}
|
||||||
|
value={previewResult.updated ?? 0}
|
||||||
|
icon={<RotateCcw className="w-5 h-5 text-accent-500" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Errors */}
|
||||||
|
{previewResult.errors && previewResult.errors.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-danger-500" />
|
||||||
|
<h4 className="text-sm font-semibold text-danger-700">
|
||||||
|
{t('importExport.errors', 'Fehler')} ({previewResult.errors.length})
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{previewResult.errors.map((err, idx) => (
|
||||||
|
<li key={idx} className="text-sm text-danger-600 bg-danger-50 rounded px-3 py-1.5">
|
||||||
|
{err}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{previewResult.warnings && previewResult.warnings.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-warning-500" />
|
||||||
|
<h4 className="text-sm font-semibold text-warning-700">
|
||||||
|
{t('importExport.warnings', 'Warnungen')} ({previewResult.warnings.length})
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{previewResult.warnings.map((warn, idx) => (
|
||||||
|
<li key={idx} className="text-sm text-warning-700 bg-warning-50 rounded px-3 py-1.5">
|
||||||
|
{warn}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview rows */}
|
||||||
|
{previewResult.rows && previewResult.rows.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-secondary-700">
|
||||||
|
{t('importExport.previewRows', 'Vorschau der ersten Zeilen')}
|
||||||
|
</h4>
|
||||||
|
<div className="overflow-x-auto border border-secondary-200 rounded-lg">
|
||||||
|
<table className="min-w-full divide-y divide-secondary-200">
|
||||||
|
<thead className="bg-secondary-50">
|
||||||
|
<tr>
|
||||||
|
{Object.keys(previewResult.rows[0]).slice(0, 6).map((key) => (
|
||||||
|
<th key={key} className="px-3 py-2 text-left text-xs font-medium text-secondary-500 uppercase tracking-wider">
|
||||||
|
{key}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-secondary-200">
|
||||||
|
{previewResult.rows.slice(0, 5).map((row, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
{Object.values(row).slice(0, 6).map((val, vidx) => (
|
||||||
|
<td key={vidx} className="px-3 py-2 text-sm text-secondary-900 whitespace-nowrap">
|
||||||
|
{String(val ?? '')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => setStep('review')} icon={<CheckCircle className="w-4 h-4" />}>
|
||||||
|
{t('importExport.continue', 'Weiter')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Review */}
|
||||||
|
{step === 'review' && (
|
||||||
|
<Card
|
||||||
|
title={t('importExport.reviewTitle', 'Überprüfung')}
|
||||||
|
description={t('importExport.reviewDescription', 'Bestätigen Sie, dass Sie die Vorschau geprüft haben')}
|
||||||
|
actions={
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleBack} icon={<ArrowLeft className="w-4 h-4" />}>
|
||||||
|
{t('common.back', 'Zurück')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="bg-secondary-50 rounded-lg p-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-secondary-600">{t('importExport.entityType', 'Entitätstyp')}</span>
|
||||||
|
<Badge variant="primary">
|
||||||
|
{entityType === 'contacts' ? 'Kontakte' : 'Firmen'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-secondary-600">{t('importExport.file', 'Datei')}</span>
|
||||||
|
<span className="text-sm font-medium text-secondary-900">{file?.name}</span>
|
||||||
|
</div>
|
||||||
|
{previewResult && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-secondary-600">{t('importExport.totalRows', 'Gesamtzeilen')}</span>
|
||||||
|
<span className="text-sm font-medium text-secondary-900">{previewResult.total ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-secondary-600">{t('importExport.created', 'Neu')}</span>
|
||||||
|
<span className="text-sm font-medium text-success-600">{previewResult.created ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-secondary-600">{t('importExport.updated', 'Aktualisiert')}</span>
|
||||||
|
<span className="text-sm font-medium text-accent-600">{previewResult.updated ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
{previewResult.errors && previewResult.errors.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-secondary-600">{t('importExport.errors', 'Fehler')}</span>
|
||||||
|
<Badge variant="danger">{previewResult.errors.length}</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{previewResult.warnings && previewResult.warnings.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-secondary-600">{t('importExport.warnings', 'Warnungen')}</span>
|
||||||
|
<Badge variant="warning">{previewResult.warnings.length}</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation checkbox */}
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={previewConfirmed}
|
||||||
|
onChange={(e) => setPreviewConfirmed(e.target.checked)}
|
||||||
|
className="mt-1 w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-secondary-700">
|
||||||
|
{t('importExport.confirmPreview', 'Ich habe die Vorschau geprüft und bin mir der Folgen bewusst')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button variant="ghost" onClick={handleReset} icon={<RotateCcw className="w-4 h-4" />}>
|
||||||
|
{t('importExport.restart', 'Neu starten')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={!previewConfirmed || isLoadingImport}
|
||||||
|
isLoading={isLoadingImport}
|
||||||
|
icon={!isLoadingImport ? <Play className="w-4 h-4" /> : undefined}
|
||||||
|
>
|
||||||
|
{t('importExport.executeImport', 'Import ausführen')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Result */}
|
||||||
|
{step === 'result' && importResult && (
|
||||||
|
<Card
|
||||||
|
title={t('importExport.resultTitle', 'Import-Ergebnis')}
|
||||||
|
actions={
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleReset} icon={<RotateCcw className="w-4 h-4" />}>
|
||||||
|
{t('importExport.newImport', 'Neuer Import')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Success / Error indicator */}
|
||||||
|
{importResult.errors && importResult.errors.length > 0 && !importResult.created && !importResult.updated ? (
|
||||||
|
<div className="flex items-center gap-3 bg-danger-50 rounded-lg p-4">
|
||||||
|
<AlertCircle className="w-6 h-6 text-danger-600" />
|
||||||
|
<span className="text-sm font-medium text-danger-700">
|
||||||
|
{t('importExport.importFailed', 'Import fehlgeschlagen')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 bg-success-50 rounded-lg p-4">
|
||||||
|
<CheckCircle className="w-6 h-6 text-success-600" />
|
||||||
|
<span className="text-sm font-medium text-success-700">
|
||||||
|
{t('importExport.importSuccess', 'Import erfolgreich abgeschlossen')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{(importResult.created !== undefined || importResult.updated !== undefined || importResult.total !== undefined) && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
|
{importResult.total !== undefined && (
|
||||||
|
<StatBox
|
||||||
|
label={t('importExport.totalRows', 'Gesamtzeilen')}
|
||||||
|
value={importResult.total}
|
||||||
|
icon={<FileText className="w-5 h-5 text-secondary-400" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{importResult.created !== undefined && (
|
||||||
|
<StatBox
|
||||||
|
label={t('importExport.created', 'Neu')}
|
||||||
|
value={importResult.created}
|
||||||
|
icon={<CheckCircle className="w-5 h-5 text-success-500" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{importResult.updated !== undefined && (
|
||||||
|
<StatBox
|
||||||
|
label={t('importExport.updated', 'Aktualisiert')}
|
||||||
|
value={importResult.updated}
|
||||||
|
icon={<RotateCcw className="w-5 h-5 text-accent-500" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Errors */}
|
||||||
|
{importResult.errors && importResult.errors.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-danger-500" />
|
||||||
|
<h4 className="text-sm font-semibold text-danger-700">
|
||||||
|
{t('importExport.errors', 'Fehler')} ({importResult.errors.length})
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{importResult.errors.map((err, idx) => (
|
||||||
|
<li key={idx} className="text-sm text-danger-600 bg-danger-50 rounded px-3 py-1.5">
|
||||||
|
{err}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{importResult.warnings && importResult.warnings.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-warning-500" />
|
||||||
|
<h4 className="text-sm font-semibold text-warning-700">
|
||||||
|
{t('importExport.warnings', 'Warnungen')} ({importResult.warnings.length})
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{importResult.warnings.map((warn, idx) => (
|
||||||
|
<li key={idx} className="text-sm text-warning-700 bg-warning-50 rounded px-3 py-1.5">
|
||||||
|
{warn}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper sub-component ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatBox({ label, value, icon }: { label: string; value: number; icon: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-secondary-50 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
{icon}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-500">{label}</p>
|
||||||
|
<p className="text-lg font-semibold text-secondary-900">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import type { WorkflowStep } from '@/api/workflows';
|
||||||
|
|
||||||
|
const stepTypeOptions = [
|
||||||
|
{ value: 'action', label: 'Action' },
|
||||||
|
{ value: 'approval', label: 'Approval' },
|
||||||
|
{ value: 'notification', label: 'Notification' },
|
||||||
|
{ value: 'condition', label: 'Condition' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const configHints: Record<string, string> = {
|
||||||
|
action: 'Config keys: action_type, target, params',
|
||||||
|
approval: 'Config keys: approver_role, timeout_hours',
|
||||||
|
notification: 'Config keys: channel, template, recipients',
|
||||||
|
condition: 'Config keys: field, operator, value',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StepConfigPanelProps {
|
||||||
|
step: WorkflowStep;
|
||||||
|
onChange: (step: WorkflowStep) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||||
|
const [configText, setConfigText] = useState('');
|
||||||
|
const [configError, setConfigError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConfigText(JSON.stringify(step.config ?? {}, null, 2));
|
||||||
|
setConfigError(undefined);
|
||||||
|
}, [step.config]);
|
||||||
|
|
||||||
|
const handleConfigChange = (value: string) => {
|
||||||
|
setConfigText(value);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
setConfigError(undefined);
|
||||||
|
onChange({ ...step, config: parsed });
|
||||||
|
} catch {
|
||||||
|
setConfigError('Ungültiges JSON');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-lg border border-secondary-200 p-4 bg-secondary-50">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Schritt-Name"
|
||||||
|
required
|
||||||
|
value={step.name}
|
||||||
|
onChange={(e) => onChange({ ...step, name: e.target.value })}
|
||||||
|
placeholder="z.B. Genehmigung einholen"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Typ"
|
||||||
|
options={stepTypeOptions}
|
||||||
|
value={step.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...step, type: e.target.value as WorkflowStep['type'] })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
Beschreibung
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={step.description ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...step, description: e.target.value || null })
|
||||||
|
}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="Optionale Beschreibung"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
Konfiguration (JSON)
|
||||||
|
</label>
|
||||||
|
{step.type && configHints[step.type] && (
|
||||||
|
<p className="text-xs text-secondary-400 mb-1">
|
||||||
|
{configHints[step.type]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={configText}
|
||||||
|
onChange={(e) => handleConfigChange(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder='{"key": "value"}'
|
||||||
|
/>
|
||||||
|
{configError && (
|
||||||
|
<p className="mt-1 text-sm text-danger-600" role="alert">
|
||||||
|
{configError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { StepConfigPanel } from './StepConfigPanel';
|
||||||
|
import {
|
||||||
|
useCreateWorkflow,
|
||||||
|
useUpdateWorkflow,
|
||||||
|
} from '@/api/workflows';
|
||||||
|
import type { Workflow, WorkflowStep, WorkflowCreateInput, WorkflowUpdateInput } from '@/api/workflows';
|
||||||
|
import { ArrowUp, ArrowDown, Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const triggerEventOptions = [
|
||||||
|
{ value: '', label: '— Kein Trigger —' },
|
||||||
|
{ value: 'contact.created', label: 'contact.created' },
|
||||||
|
{ value: 'contact.updated', label: 'contact.updated' },
|
||||||
|
{ value: 'deal.created', label: 'deal.created' },
|
||||||
|
{ value: 'deal.stage_changed', label: 'deal.stage_changed' },
|
||||||
|
{ value: 'deal.won', label: 'deal.won' },
|
||||||
|
{ value: 'deal.lost', label: 'deal.lost' },
|
||||||
|
{ value: 'task.completed', label: 'task.completed' },
|
||||||
|
{ value: 'task.overdue', label: 'task.overdue' },
|
||||||
|
{ value: 'manual', label: 'manual' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface EditorFormState {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
trigger_event: string;
|
||||||
|
is_active: boolean;
|
||||||
|
steps: WorkflowStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyStep = (): WorkflowStep => ({
|
||||||
|
name: '',
|
||||||
|
type: 'action',
|
||||||
|
config: {},
|
||||||
|
description: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyForm: EditorFormState = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
trigger_event: '',
|
||||||
|
is_active: true,
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface WorkflowEditorProps {
|
||||||
|
open: boolean;
|
||||||
|
workflow?: Workflow | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const createMutation = useCreateWorkflow();
|
||||||
|
const updateMutation = useUpdateWorkflow();
|
||||||
|
|
||||||
|
const isEdit = !!workflow;
|
||||||
|
const [form, setForm] = useState<EditorFormState>(emptyForm);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (workflow) {
|
||||||
|
setForm({
|
||||||
|
name: workflow.name,
|
||||||
|
description: workflow.description ?? '',
|
||||||
|
trigger_event: workflow.trigger_event ?? '',
|
||||||
|
is_active: workflow.is_active,
|
||||||
|
steps: workflow.steps?.length
|
||||||
|
? workflow.steps.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
type: s.type,
|
||||||
|
config: s.config ?? {},
|
||||||
|
description: s.description ?? null,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm({ ...emptyForm });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, workflow]);
|
||||||
|
|
||||||
|
const updateField = <K extends keyof EditorFormState>(
|
||||||
|
key: K,
|
||||||
|
value: EditorFormState[K]
|
||||||
|
) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addStep = () => {
|
||||||
|
setForm((prev) => ({ ...prev, steps: [...prev.steps, emptyStep()] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStep = (index: number, updated: WorkflowStep) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
const steps = [...prev.steps];
|
||||||
|
steps[index] = updated;
|
||||||
|
return { ...prev, steps };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStep = (index: number) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
steps: prev.steps.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveStep = (index: number, dir: 'up' | 'down') => {
|
||||||
|
setForm((prev) => {
|
||||||
|
const steps = [...prev.steps];
|
||||||
|
const target = dir === 'up' ? index - 1 : index + 1;
|
||||||
|
if (target < 0 || target >= steps.length) return prev;
|
||||||
|
[steps[index], steps[target]] = [steps[target], steps[index]];
|
||||||
|
return { ...prev, steps };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
toast.error('Name ist erforderlich');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEmptyStepName = form.steps.some((s) => !s.name.trim());
|
||||||
|
if (hasEmptyStepName) {
|
||||||
|
toast.error('Alle Schritte muessen einen Namen haben');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit && workflow) {
|
||||||
|
const data: WorkflowUpdateInput = {
|
||||||
|
name: form.name,
|
||||||
|
description: form.description || null,
|
||||||
|
trigger_event: form.trigger_event || null,
|
||||||
|
steps: form.steps,
|
||||||
|
is_active: form.is_active,
|
||||||
|
};
|
||||||
|
await updateMutation.mutateAsync({ id: workflow.id, data });
|
||||||
|
toast.success('Workflow aktualisiert');
|
||||||
|
} else {
|
||||||
|
const data: WorkflowCreateInput = {
|
||||||
|
name: form.name,
|
||||||
|
description: form.description || null,
|
||||||
|
trigger_event: form.trigger_event || null,
|
||||||
|
steps: form.steps,
|
||||||
|
is_active: form.is_active,
|
||||||
|
};
|
||||||
|
await createMutation.mutateAsync(data);
|
||||||
|
toast.success('Workflow erstellt');
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || 'Fehler beim Speichern');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={isEdit ? 'Workflow bearbeiten' : 'Neuer Workflow'}
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => updateField('name', e.target.value)}
|
||||||
|
placeholder="z.B. Deal-Genehmigungsprozess"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
|
Beschreibung
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => updateField('description', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="Optionale Beschreibung"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
label="Trigger-Event"
|
||||||
|
options={triggerEventOptions}
|
||||||
|
value={form.trigger_event}
|
||||||
|
onChange={(e) => updateField('trigger_event', e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.is_active}
|
||||||
|
onChange={(e) => updateField('is_active', e.target.checked)}
|
||||||
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
Aktiv
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<label className="text-sm font-medium text-secondary-700">
|
||||||
|
Schritte
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={addStep}
|
||||||
|
type="button"
|
||||||
|
icon={<Plus className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Schritt hinzufuegen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{form.steps.length === 0 && (
|
||||||
|
<p className="text-sm text-secondary-400 italic">
|
||||||
|
Keine Schritte definiert. Klicke auf Schritt hinzufuegen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{form.steps.map((step, i) => (
|
||||||
|
<div key={i} className="relative">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-medium text-secondary-500">
|
||||||
|
Schritt {i + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => moveStep(i, 'up')}
|
||||||
|
disabled={i === 0}
|
||||||
|
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
|
||||||
|
aria-label="Nach oben"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => moveStep(i, 'down')}
|
||||||
|
disabled={i === form.steps.length - 1}
|
||||||
|
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
|
||||||
|
aria-label="Nach unten"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeStep(i)}
|
||||||
|
className="text-danger-500 hover:text-danger-700 p-1"
|
||||||
|
aria-label="Schritt entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StepConfigPanel
|
||||||
|
step={step}
|
||||||
|
onChange={(updated) => updateStep(i, updated)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
|
||||||
|
<Button variant="secondary" onClick={onClose} type="button">
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isSaving}>
|
||||||
|
{isEdit ? 'Speichern' : 'Erstellen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
useWorkflowInstance,
|
||||||
|
useAdvanceWorkflowInstance,
|
||||||
|
useCancelWorkflowInstance,
|
||||||
|
} from '@/api/workflows';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Ban,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
function statusBadgeVariant(
|
||||||
|
status: string
|
||||||
|
): 'success' | 'warning' | 'danger' | 'info' | 'secondary' {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'success';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'info';
|
||||||
|
case 'pending':
|
||||||
|
return 'warning';
|
||||||
|
case 'rejected':
|
||||||
|
return 'danger';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'secondary';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pending: 'Wartend',
|
||||||
|
in_progress: 'In Bearbeitung',
|
||||||
|
completed: 'Abgeschlossen',
|
||||||
|
rejected: 'Abgelehnt',
|
||||||
|
cancelled: 'Abgebrochen',
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepTypeLabel(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
action: 'Action',
|
||||||
|
approval: 'Approval',
|
||||||
|
notification: 'Notification',
|
||||||
|
condition: 'Condition',
|
||||||
|
};
|
||||||
|
return map[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowInstanceDetailProps {
|
||||||
|
instanceId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowInstanceDetail({
|
||||||
|
instanceId,
|
||||||
|
onClose,
|
||||||
|
}: WorkflowInstanceDetailProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const { data: instance, isLoading, isError, refetch } =
|
||||||
|
useWorkflowInstance(instanceId);
|
||||||
|
const advanceMutation = useAdvanceWorkflowInstance();
|
||||||
|
const cancelMutation = useCancelWorkflowInstance();
|
||||||
|
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [showCommentField, setShowCommentField] = useState<
|
||||||
|
'approve' | 'reject' | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const handleAdvance = async (decision: 'approve' | 'reject') => {
|
||||||
|
if (!instance) return;
|
||||||
|
try {
|
||||||
|
await advanceMutation.mutateAsync({
|
||||||
|
instanceId: instance.id,
|
||||||
|
data: {
|
||||||
|
decision,
|
||||||
|
comment: comment || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
decision === 'approve'
|
||||||
|
? 'Schritt genehmigt'
|
||||||
|
: 'Schritt abgelehnt'
|
||||||
|
);
|
||||||
|
setComment('');
|
||||||
|
setShowCommentField(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || 'Fehler bei der Aktion');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!instance) return;
|
||||||
|
try {
|
||||||
|
await cancelMutation.mutateAsync(instance.id);
|
||||||
|
toast.success('Instanz abgebrochen');
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || 'Fehler beim Abbrechen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canAct =
|
||||||
|
instance?.status === 'in_progress' || instance?.status === 'pending';
|
||||||
|
const canCancel =
|
||||||
|
instance?.status === 'in_progress' || instance?.status === 'pending';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={!!instanceId}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Workflow-Instanz Details"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="flex items-center gap-3 text-danger-600 p-4">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
<span>Fehler beim Laden der Instanz</span>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="text-sm text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && instance && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header info */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<Badge variant={statusBadgeVariant(instance.status)}>
|
||||||
|
{statusLabel(instance.status)}
|
||||||
|
</Badge>
|
||||||
|
{instance.workflow_name && (
|
||||||
|
<span className="text-lg font-semibold text-secondary-900">
|
||||||
|
{instance.workflow_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!instance.workflow_name && (
|
||||||
|
<span className="text-lg font-semibold text-secondary-900">
|
||||||
|
Workflow {instance.workflow_id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-secondary-400">
|
||||||
|
ID: {instance.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key info grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-secondary-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-400 mb-1">Aktueller Schritt</p>
|
||||||
|
<p className="text-sm font-medium text-secondary-900">
|
||||||
|
{instance.current_step_index + 1}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-400 mb-1">Initiiert von</p>
|
||||||
|
<p className="text-sm font-medium text-secondary-900">
|
||||||
|
{instance.initiated_by || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-400 mb-1">Erstellt am</p>
|
||||||
|
<p className="text-sm font-medium text-secondary-900">
|
||||||
|
{instance.created_at
|
||||||
|
? new Date(instance.created_at).toLocaleString()
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-400 mb-1">Timeout</p>
|
||||||
|
<p className="text-sm font-medium text-secondary-900 flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{instance.timeout_at
|
||||||
|
? new Date(instance.timeout_at).toLocaleString()
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context JSON */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
Kontext
|
||||||
|
</h4>
|
||||||
|
<pre className="text-xs font-mono bg-secondary-900 text-secondary-100 rounded-lg p-3 overflow-x-auto max-h-40">
|
||||||
|
{JSON.stringify(instance.context ?? {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step history timeline */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-secondary-700 mb-3">
|
||||||
|
Schritt-Historie
|
||||||
|
</h4>
|
||||||
|
{instance.history && instance.history.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{instance.history.map((entry, idx) => (
|
||||||
|
<div
|
||||||
|
key={entry.id || idx}
|
||||||
|
className="flex items-start gap-3 pb-3 border-b border-secondary-100 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center flex-shrink-0">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-xs font-medium">
|
||||||
|
{entry.step_index + 1}
|
||||||
|
</div>
|
||||||
|
{idx < instance.history.length - 1 && (
|
||||||
|
<div className="w-px h-full bg-secondary-200 mt-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 pb-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{stepTypeLabel(entry.step_type)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium text-secondary-900">
|
||||||
|
{entry.action}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-secondary-400">
|
||||||
|
{entry.actor_id && (
|
||||||
|
<span>Aktor: {entry.actor_id}</span>
|
||||||
|
)}
|
||||||
|
{entry.created_at && (
|
||||||
|
<span>
|
||||||
|
{new Date(entry.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.details && (
|
||||||
|
<pre className="text-xs font-mono bg-secondary-50 rounded p-2 mt-2 overflow-x-auto max-h-24">
|
||||||
|
{JSON.stringify(entry.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-secondary-400 italic">
|
||||||
|
Keine Historie vorhanden
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
{canAct && (
|
||||||
|
<div className="space-y-3 pt-4 border-t border-secondary-200">
|
||||||
|
{showCommentField && (
|
||||||
|
<Input
|
||||||
|
label="Kommentar (optional)"
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Kommentar hinzufuegen..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{!showCommentField && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setShowCommentField('approve')}
|
||||||
|
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Genehmigen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => setShowCommentField('reject')}
|
||||||
|
icon={<XCircle className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showCommentField === 'approve' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => handleAdvance('approve')}
|
||||||
|
isLoading={advanceMutation.isPending}
|
||||||
|
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Bestaetigen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCommentField(null);
|
||||||
|
setComment('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showCommentField === 'reject' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => handleAdvance('reject')}
|
||||||
|
isLoading={advanceMutation.isPending}
|
||||||
|
icon={<XCircle className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Ablehnen bestaetigen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCommentField(null);
|
||||||
|
setComment('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zurueck
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canCancel && !showCommentField && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
isLoading={cancelMutation.isPending}
|
||||||
|
icon={<Ban className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{instance.status === 'completed' && (
|
||||||
|
<div className="flex items-center gap-2 text-success-700 p-3 bg-success-50 rounded-lg">
|
||||||
|
<CheckCircle2 className="h-5 w-5" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Dieser Workflow ist abgeschlossen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{instance.status === 'rejected' && (
|
||||||
|
<div className="flex items-center gap-2 text-danger-700 p-3 bg-danger-50 rounded-lg">
|
||||||
|
<XCircle className="h-5 w-5" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Dieser Workflow wurde abgelehnt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{instance.status === 'cancelled' && (
|
||||||
|
<div className="flex items-center gap-2 text-secondary-700 p-3 bg-secondary-100 rounded-lg">
|
||||||
|
<Ban className="h-5 w-5" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Dieser Workflow wurde abgebrochen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useWorkflowInstances } from '@/api/workflows';
|
||||||
|
import type { InstanceStatus, WorkflowInstance } from '@/api/workflows';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { AlertCircle, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
const statusFilterOptions = [
|
||||||
|
{ value: '', label: 'Alle Status' },
|
||||||
|
{ value: 'pending', label: 'Wartend' },
|
||||||
|
{ value: 'in_progress', label: 'In Bearbeitung' },
|
||||||
|
{ value: 'completed', label: 'Abgeschlossen' },
|
||||||
|
{ value: 'rejected', label: 'Abgelehnt' },
|
||||||
|
{ value: 'cancelled', label: 'Abgebrochen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusBadgeVariant(
|
||||||
|
status: string
|
||||||
|
): 'success' | 'warning' | 'danger' | 'info' | 'secondary' {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'success';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'info';
|
||||||
|
case 'pending':
|
||||||
|
return 'warning';
|
||||||
|
case 'rejected':
|
||||||
|
return 'danger';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'secondary';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pending: 'Wartend',
|
||||||
|
in_progress: 'In Bearbeitung',
|
||||||
|
completed: 'Abgeschlossen',
|
||||||
|
rejected: 'Abgelehnt',
|
||||||
|
cancelled: 'Abgebrochen',
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowInstanceListProps {
|
||||||
|
onSelectInstance: (instance: WorkflowInstance) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowInstanceList({
|
||||||
|
onSelectInstance,
|
||||||
|
}: WorkflowInstanceListProps) {
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const queryStatus =
|
||||||
|
statusFilter !== '' ? (statusFilter as InstanceStatus) : undefined;
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useWorkflowInstances(
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
const instances = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filter bar */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Select
|
||||||
|
options={statusFilterOptions}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-secondary-500">
|
||||||
|
{total} Instanz{total !== 1 ? 'en' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{isError && (
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 text-danger-600">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
<span>Fehler beim Laden der Instanzen</span>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="text-sm text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!isLoading && !isError && instances.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
title="Keine Workflow-Instanzen"
|
||||||
|
description="Es wurden keine Instanzen gefunden, die dem Filter entsprechen."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Instance list */}
|
||||||
|
{!isLoading && !isError && instances.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{instances.map((instance) => (
|
||||||
|
<button
|
||||||
|
key={instance.id}
|
||||||
|
onClick={() => onSelectInstance(instance)}
|
||||||
|
className="w-full text-left bg-white rounded-lg border border-secondary-200 hover:border-primary-300 hover:shadow-sm transition-all px-4 py-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Badge variant={statusBadgeVariant(instance.status)}>
|
||||||
|
{statusLabel(instance.status)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium text-secondary-900 truncate">
|
||||||
|
{instance.workflow_id}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-secondary-400">
|
||||||
|
Schritt {instance.current_step_index + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-secondary-400 flex-shrink-0">
|
||||||
|
{instance.initiated_by && (
|
||||||
|
<span>von {instance.initiated_by}</span>
|
||||||
|
)}
|
||||||
|
{instance.created_at && (
|
||||||
|
<span>
|
||||||
|
{new Date(instance.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{instance.timeout_at && (
|
||||||
|
<span className="text-warning-600">
|
||||||
|
Timeout: {new Date(instance.timeout_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="h-4 w-4 text-secondary-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{!isLoading && !isError && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md border border-secondary-300 hover:bg-secondary-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Zurueck
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-secondary-600">
|
||||||
|
Seite {page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md border border-secondary-300 hover:bg-secondary-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { SharingSettings } from '@/components/calendar/SharingSettings';
|
|||||||
import { useCalendarStore, type CalendarViewMode } from '@/store/calendarStore';
|
import { useCalendarStore, type CalendarViewMode } from '@/store/calendarStore';
|
||||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||||
import { ChevronLeft, ChevronRight, ExternalLink, Info, Plus } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, ExternalLink, Info, Plus } from 'lucide-react';
|
||||||
|
import { PrintButton } from '@/components/common/PrintButton';
|
||||||
import {
|
import {
|
||||||
fetchCalendars,
|
fetchCalendars,
|
||||||
createCalendar,
|
createCalendar,
|
||||||
@@ -596,8 +597,11 @@ export function CalendarPage() {
|
|||||||
data-testid="calendar-view-pane"
|
data-testid="calendar-view-pane"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-end px-3 py-1.5 border-b border-secondary-200" data-no-print>
|
||||||
|
<PrintButton targetId="calendar-view" />
|
||||||
|
</div>
|
||||||
{renderRangeControls()}
|
{renderRangeControls()}
|
||||||
<div className="flex-1 overflow-hidden">{renderCalendarView()}</div>
|
<div className="flex-1 overflow-hidden" id="calendar-view">{renderCalendarView()}</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useWindowStore } from '@/store/windowStore';
|
|||||||
import { useUnifiedContact, type UnifiedContact } from '@/api/hooks';
|
import { useUnifiedContact, type UnifiedContact } from '@/api/hooks';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { PrintButton } from '@/components/common/PrintButton';
|
||||||
|
|
||||||
export function ContactDetailPage() {
|
export function ContactDetailPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -49,8 +50,10 @@ export function ContactDetailPage() {
|
|||||||
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||||
<span>{t('contacts.title')}</span>
|
<span>{t('contacts.title')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<PrintButton targetId="contact-detail" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto" id="contact-detail">
|
||||||
<ContactDetail
|
<ContactDetail
|
||||||
contact={contact ?? null}
|
contact={contact ?? null}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
useUnifiedContact,
|
useUnifiedContact,
|
||||||
type UnifiedContact,
|
type UnifiedContact,
|
||||||
} from '@/api/hooks';
|
} from '@/api/hooks';
|
||||||
|
import { PrintButton } from '@/components/common/PrintButton';
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -353,6 +354,7 @@ export function ContactsListPage() {
|
|||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 text-primary-600" aria-hidden="true" strokeWidth={2} />
|
<Plus className="w-4 h-4 text-primary-600" aria-hidden="true" strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
|
<PrintButton targetId="contacts-table" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Saved Filters */}
|
{/* Saved Filters */}
|
||||||
@@ -371,7 +373,7 @@ export function ContactsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0" id="contacts-table">
|
||||||
<ContactList
|
<ContactList
|
||||||
contacts={filteredContacts}
|
contacts={filteredContacts}
|
||||||
selectedContactId={selectedContactId}
|
selectedContactId={selectedContactId}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* DedupMerge page — Main deduplication & merge UI.
|
||||||
|
* Search for duplicate contacts, review pairs, merge with field-level control.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Search, CopyCheck, Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { DuplicatePairCard } from '@/components/dedup/DuplicatePairCard';
|
||||||
|
import { MergeDialog } from '@/components/dedup/MergeDialog';
|
||||||
|
import { MergeHistory } from '@/components/dedup/MergeHistory';
|
||||||
|
import { useFindDuplicates, type DuplicatePair } from '@/api/dedup';
|
||||||
|
|
||||||
|
export function DedupMergePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [threshold, setThreshold] = useState(0.7);
|
||||||
|
const [limit, setLimit] = useState(50);
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
const [mergePair, setMergePair] = useState<DuplicatePair | null>(null);
|
||||||
|
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const findDuplicates = useFindDuplicates();
|
||||||
|
const duplicates = findDuplicates.data ?? [];
|
||||||
|
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
setHasSearched(true);
|
||||||
|
findDuplicates.mutate({ threshold, limit });
|
||||||
|
}, [findDuplicates, threshold, limit]);
|
||||||
|
|
||||||
|
const handleMergeClick = useCallback((pair: DuplicatePair) => {
|
||||||
|
setMergePair(pair);
|
||||||
|
setMergeDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMergeDialogClose = useCallback(() => {
|
||||||
|
setMergeDialogOpen(false);
|
||||||
|
setMergePair(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLimitChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = parseInt(e.target.value, 10);
|
||||||
|
if (isNaN(val) || val < 1) {
|
||||||
|
setLimit(1);
|
||||||
|
} else if (val > 500) {
|
||||||
|
setLimit(500);
|
||||||
|
} else {
|
||||||
|
setLimit(val);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-y-auto">
|
||||||
|
<div className="mx-auto w-full max-w-5xl space-y-6 p-6">
|
||||||
|
{/* Header section */}
|
||||||
|
<div className="rounded-lg border border-secondary-200 bg-white shadow-sm">
|
||||||
|
<div className="border-b border-secondary-200 px-6 py-4">
|
||||||
|
<h1 className="text-xl font-bold text-secondary-900">
|
||||||
|
{t('dedup.title', 'Duplikate & Zusammenführen')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-secondary-500">
|
||||||
|
{t(
|
||||||
|
'dedup.description',
|
||||||
|
'Suchen Sie nach dubletten Kontakten und führen Sie diese zusammen.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5 px-6 py-5">
|
||||||
|
{/* Threshold slider */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<label htmlFor="threshold-slider" className="text-sm font-medium text-secondary-700">
|
||||||
|
{t('dedup.threshold', 'Ähnlichkeitsschwelle')}
|
||||||
|
</label>
|
||||||
|
<span className="text-sm font-semibold text-primary-700">
|
||||||
|
{Math.round(threshold * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="threshold-slider"
|
||||||
|
type="range"
|
||||||
|
min={0.5}
|
||||||
|
max={1.0}
|
||||||
|
step={0.05}
|
||||||
|
value={threshold}
|
||||||
|
onChange={(e) => setThreshold(parseFloat(e.target.value))}
|
||||||
|
className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-secondary-200 accent-primary-600"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex justify-between text-xs text-secondary-400">
|
||||||
|
<span>50%</span>
|
||||||
|
<span>100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Limit input + search button */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
|
<div className="w-full sm:w-40">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
label={t('dedup.limit', 'Max. Ergebnisse')}
|
||||||
|
value={limit}
|
||||||
|
onChange={handleLimitChange}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
helperText={t('dedup.limitHelper', '1–500')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
isLoading={findDuplicates.isPending}
|
||||||
|
icon={<Search className="h-4 w-4" />}
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
|
{t('dedup.search', 'Duplikate suchen')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Loading state */}
|
||||||
|
{findDuplicates.isPending && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary-500" aria-hidden="true" />
|
||||||
|
<p className="mt-3 text-sm text-secondary-500">
|
||||||
|
{t('dedup.searching', 'Suche nach Duplikaten…')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{findDuplicates.isError && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<AlertCircle className="h-8 w-8 text-danger-500" aria-hidden="true" />
|
||||||
|
<p className="mt-3 text-sm font-medium text-danger-700">
|
||||||
|
{t('dedup.searchError', 'Fehler bei der Duplikatssuche.')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-secondary-400">
|
||||||
|
{findDuplicates.error instanceof Error
|
||||||
|
? findDuplicates.error.message
|
||||||
|
: t('common.unknownError', 'Unbekannter Fehler')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
|
{t('common.retry', 'Erneut versuchen')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state: no search yet */}
|
||||||
|
{!findDuplicates.isPending && !findDuplicates.isError && !hasSearched && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<CopyCheck className="h-12 w-12" />}
|
||||||
|
title={t('dedup.emptyTitle', 'Keine Suche durchgeführt')}
|
||||||
|
description={t(
|
||||||
|
'dedup.emptyDescription',
|
||||||
|
'Stellen Sie die Schwellwerte ein und klicken Sie auf „Duplikate suchen“, um mögliche Dubletten zu finden.',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty results: search done, no duplicates found */}
|
||||||
|
{!findDuplicates.isPending && !findDuplicates.isError && hasSearched && duplicates.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<CopyCheck className="h-12 w-12" />}
|
||||||
|
title={t('dedup.noDuplicates', 'Keine Duplikate gefunden')}
|
||||||
|
description={t(
|
||||||
|
'dedup.noDuplicatesDescription',
|
||||||
|
'Mit den aktuellen Einstellungen wurden keine Duplikate gefunden. Versuchen Sie eine niedrigere Schwellwert.',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results list */}
|
||||||
|
{!findDuplicates.isPending && !findDuplicates.isError && duplicates.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-secondary-900">
|
||||||
|
{t('dedup.results', 'Gefundene Duplikate')}
|
||||||
|
</h2>
|
||||||
|
<span className="rounded-full bg-secondary-100 px-3 py-0.5 text-sm font-medium text-secondary-600">
|
||||||
|
{duplicates.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{duplicates.map((pair, idx) => (
|
||||||
|
<DuplicatePairCard
|
||||||
|
key={`${pair.source_contact.id}-${pair.target_contact.id}-${idx}`}
|
||||||
|
pair={pair}
|
||||||
|
onMerge={handleMergeClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Merge History section */}
|
||||||
|
<MergeHistory />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Merge Dialog */}
|
||||||
|
<MergeDialog
|
||||||
|
open={mergeDialogOpen}
|
||||||
|
pair={mergePair}
|
||||||
|
onClose={handleMergeDialogClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Upload, Download } from 'lucide-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { ImportWizard } from '@/components/import-export/ImportWizard';
|
||||||
|
import { ExportPanel } from '@/components/import-export/ExportPanel';
|
||||||
|
|
||||||
|
type Tab = 'import' | 'export';
|
||||||
|
|
||||||
|
export function ImportExportPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('import');
|
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{
|
||||||
|
key: 'import',
|
||||||
|
label: t('importExport.tabImport', 'Import'),
|
||||||
|
icon: <Upload className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'export',
|
||||||
|
label: t('importExport.tabExport', 'Export'),
|
||||||
|
icon: <Download className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900">
|
||||||
|
{t('importExport.title', 'Import / Export')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-secondary-500 mt-1">
|
||||||
|
{t('importExport.subtitle', 'Daten importieren oder exportieren')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab navigation */}
|
||||||
|
<div className="border-b border-secondary-200">
|
||||||
|
<nav className="flex gap-1" aria-label="Tabs">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'border-primary-600 text-primary-700'
|
||||||
|
: 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300'
|
||||||
|
)}
|
||||||
|
aria-current={activeTab === tab.key ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div>
|
||||||
|
{activeTab === 'import' && <ImportWizard />}
|
||||||
|
{activeTab === 'export' && <ExportPanel />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { PrintButton } from '@/components/common/PrintButton';
|
||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import {
|
import {
|
||||||
useReportTemplates,
|
useReportTemplates,
|
||||||
@@ -213,6 +214,7 @@ export function ReportsPage() {
|
|||||||
<BarChart3 className="w-6 h-6 text-primary-600" />
|
<BarChart3 className="w-6 h-6 text-primary-600" />
|
||||||
<h1 className="text-2xl font-bold text-secondary-900">{t('reports.title', 'Reports')}</h1>
|
<h1 className="text-2xl font-bold text-secondary-900">{t('reports.title', 'Reports')}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<PrintButton targetId="reports-content" filename="leocrm-report" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preset Quick Actions */}
|
{/* Preset Quick Actions */}
|
||||||
@@ -260,7 +262,7 @@ export function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main 3-column layout */}
|
{/* Main 3-column layout */}
|
||||||
<div className="flex flex-1 gap-4 min-h-0">
|
<div className="flex flex-1 gap-4 min-h-0" id="reports-content">
|
||||||
{/* Left: Template List */}
|
{/* Left: Template List */}
|
||||||
<div className="w-64 flex-shrink-0 bg-white rounded-lg shadow-sm border border-secondary-200 flex flex-col" data-testid="template-list-panel">
|
<div className="w-64 flex-shrink-0 bg-white rounded-lg shadow-sm border border-secondary-200 flex flex-col" data-testid="template-list-panel">
|
||||||
<div className="flex items-center justify-between p-3 border-b border-secondary-200">
|
<div className="flex items-center justify-between p-3 border-b border-secondary-200">
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
useWorkflows,
|
||||||
|
useDeleteWorkflow,
|
||||||
|
useUpdateWorkflow,
|
||||||
|
} from '@/api/workflows';
|
||||||
|
import type { Workflow, WorkflowInstance } from '@/api/workflows';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { WorkflowEditor } from '@/components/workflows/WorkflowEditor';
|
||||||
|
import { WorkflowInstanceList } from '@/components/workflows/WorkflowInstanceList';
|
||||||
|
import { WorkflowInstanceDetail } from '@/components/workflows/WorkflowInstanceDetail';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Settings2,
|
||||||
|
Trash2,
|
||||||
|
Zap,
|
||||||
|
AlertCircle,
|
||||||
|
Workflow as WorkflowIcon,
|
||||||
|
ListOrdered,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
type Tab = 'definitions' | 'instances';
|
||||||
|
|
||||||
|
function activeBadgeVariant(isActive: boolean): 'success' | 'secondary' {
|
||||||
|
return isActive ? 'success' : 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const { data, isLoading, isError, refetch } = useWorkflows(1, 50);
|
||||||
|
const deleteMutation = useDeleteWorkflow();
|
||||||
|
const updateMutation = useUpdateWorkflow();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('definitions');
|
||||||
|
const [showEditor, setShowEditor] = useState(false);
|
||||||
|
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<Workflow | null>(null);
|
||||||
|
const [selectedInstanceId, setSelectedInstanceId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const workflows = data?.items ?? [];
|
||||||
|
|
||||||
|
const handleToggleActive = async (workflow: Workflow) => {
|
||||||
|
try {
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
id: workflow.id,
|
||||||
|
data: { is_active: !workflow.is_active },
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
workflow.is_active ? 'Workflow deaktiviert' : 'Workflow aktiviert'
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || 'Fehler beim Umschalten');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(confirmDelete.id);
|
||||||
|
toast.success('Workflow geloescht');
|
||||||
|
setConfirmDelete(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || 'Fehler beim Loeschen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingWorkflow(null);
|
||||||
|
setShowEditor(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (workflow: Workflow) => {
|
||||||
|
setEditingWorkflow(workflow);
|
||||||
|
setShowEditor(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto p-6" data-testid="workflows-page">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900">
|
||||||
|
{t('workflows.title', 'Workflows')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-secondary-500 mt-1">
|
||||||
|
Definieren und verwalten Sie automatisierte Workflows
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{activeTab === 'definitions' && (
|
||||||
|
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
|
||||||
|
Neu
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex items-center gap-1 mb-6 border-b border-secondary-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('definitions')}
|
||||||
|
className={
|
||||||
|
activeTab === 'definitions'
|
||||||
|
? 'px-4 py-2 text-sm font-medium text-primary-600 border-b-2 border-primary-600 -mb-px'
|
||||||
|
: 'px-4 py-2 text-sm font-medium text-secondary-500 hover:text-secondary-700'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<WorkflowIcon className="h-4 w-4" />
|
||||||
|
Definitionen
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('instances')}
|
||||||
|
className={
|
||||||
|
activeTab === 'instances'
|
||||||
|
? 'px-4 py-2 text-sm font-medium text-primary-600 border-b-2 border-primary-600 -mb-px'
|
||||||
|
: 'px-4 py-2 text-sm font-medium text-secondary-500 hover:text-secondary-700'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ListOrdered className="h-4 w-4" />
|
||||||
|
Instanzen
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Definitions Tab */}
|
||||||
|
{activeTab === 'definitions' && (
|
||||||
|
<>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-24 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 text-danger-600">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
<span>Fehler beim Laden der Workflows</span>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => refetch()}>
|
||||||
|
Erneut versuchen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && workflows.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
title="Keine Workflows"
|
||||||
|
description="Erstellen Sie Ihren ersten Workflow, um automatisierte Prozesse zu definieren."
|
||||||
|
icon={<WorkflowIcon className="h-8 w-8" />}
|
||||||
|
action={
|
||||||
|
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
|
||||||
|
Workflow erstellen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && workflows.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{workflows.map((wf) => (
|
||||||
|
<Card key={wf.id} className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900">
|
||||||
|
{wf.name}
|
||||||
|
</h3>
|
||||||
|
<Badge variant={activeBadgeVariant(wf.is_active)}>
|
||||||
|
{wf.is_active ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
</Badge>
|
||||||
|
{wf.trigger_event && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-secondary-400">
|
||||||
|
<Zap className="h-3 w-3" />
|
||||||
|
<span>{wf.trigger_event}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{wf.description && (
|
||||||
|
<p className="text-sm text-secondary-500 mb-2">
|
||||||
|
{wf.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-secondary-400">
|
||||||
|
<span>{wf.steps?.length || 0} Schritte</span>
|
||||||
|
{wf.created_at && (
|
||||||
|
<span>
|
||||||
|
Erstellt: {new Date(wf.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={wf.is_active}
|
||||||
|
onChange={() => handleToggleActive(wf)}
|
||||||
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Aktiv</span>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => openEdit(wf)}
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setConfirmDelete(wf)}
|
||||||
|
title="Loeschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-danger-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Instances Tab */}
|
||||||
|
{activeTab === 'instances' && (
|
||||||
|
<WorkflowInstanceList
|
||||||
|
onSelectInstance={(inst: WorkflowInstance) =>
|
||||||
|
setSelectedInstanceId(inst.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Workflow Editor Modal */}
|
||||||
|
<WorkflowEditor
|
||||||
|
open={showEditor}
|
||||||
|
workflow={editingWorkflow}
|
||||||
|
onClose={() => {
|
||||||
|
setShowEditor(false);
|
||||||
|
setEditingWorkflow(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Instance Detail Modal */}
|
||||||
|
{selectedInstanceId && (
|
||||||
|
<WorkflowInstanceDetail
|
||||||
|
instanceId={selectedInstanceId}
|
||||||
|
onClose={() => setSelectedInstanceId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDelete}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Workflow loeschen"
|
||||||
|
message={`Moechten Sie den Workflow "${confirmDelete?.name}" wirklich loeschen?`}
|
||||||
|
confirmLabel="Loeschen"
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,6 +52,9 @@ const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettin
|
|||||||
const ReportsPage = React.lazy(() => import('@/pages/Reports').then(m => ({ default: m.ReportsPage })));
|
const ReportsPage = React.lazy(() => import('@/pages/Reports').then(m => ({ default: m.ReportsPage })));
|
||||||
const TasksPage = React.lazy(() => import('@/pages/Tasks').then(m => ({ default: m.TasksPage })));
|
const TasksPage = React.lazy(() => import('@/pages/Tasks').then(m => ({ default: m.TasksPage })));
|
||||||
const CommunicationPage = React.lazy(() => import('@/pages/Communication').then(m => ({ default: m.CommunicationPage })));
|
const CommunicationPage = React.lazy(() => import('@/pages/Communication').then(m => ({ default: m.CommunicationPage })));
|
||||||
|
const WorkflowsPage = React.lazy(() => import('@/pages/Workflows').then(m => ({ default: m.WorkflowsPage })));
|
||||||
|
const DedupMergePage = React.lazy(() => import('@/pages/DedupMerge').then(m => ({ default: m.DedupMergePage })));
|
||||||
|
const ImportExportPage = React.lazy(() => import('@/pages/ImportExport').then(m => ({ default: m.ImportExportPage })));
|
||||||
|
|
||||||
/** Centered spinner fallback for lazy-loaded routes */
|
/** Centered spinner fallback for lazy-loaded routes */
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
@@ -125,6 +128,9 @@ const router = createBrowserRouter([
|
|||||||
{ path: '/reports', element: withSuspense(<ReportsPage />) },
|
{ path: '/reports', element: withSuspense(<ReportsPage />) },
|
||||||
{ path: '/tasks', element: withSuspense(<TasksPage />) },
|
{ path: '/tasks', element: withSuspense(<TasksPage />) },
|
||||||
{ path: '/communication', element: withSuspense(<CommunicationPage />) },
|
{ path: '/communication', element: withSuspense(<CommunicationPage />) },
|
||||||
|
{ path: '/workflows', element: withSuspense(<WorkflowsPage />) },
|
||||||
|
{ path: '/contacts/dedup', element: withSuspense(<DedupMergePage />) },
|
||||||
|
{ path: '/import-export', element: withSuspense(<ImportExportPage />) },
|
||||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Print / PDF utility functions.
|
||||||
|
* Uses the browser's native print-to-PDF capability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print a specific DOM element by cloning it into a new window.
|
||||||
|
* Copies all stylesheets from the current document so the clone looks identical.
|
||||||
|
*/
|
||||||
|
export function printElement(elementId: string): void {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) {
|
||||||
|
console.warn(`printElement: element with id "${elementId}" not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank', 'width=900,height=700');
|
||||||
|
if (!printWindow) {
|
||||||
|
alert('Bitte erlauben Sie Pop-up-Fenster zum Drucken.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the target element so we don't disturb the live DOM
|
||||||
|
const clone = element.cloneNode(true) as HTMLElement;
|
||||||
|
|
||||||
|
// Write the HTML document
|
||||||
|
printWindow.document.open();
|
||||||
|
printWindow.document.write('<!DOCTYPE html>');
|
||||||
|
printWindow.document.write('<html lang="de"><head><meta charset="UTF-8">');
|
||||||
|
printWindow.document.write('<title>Druckansicht</title>');
|
||||||
|
|
||||||
|
// Copy all stylesheets from the current document
|
||||||
|
const styleSheets = document.querySelectorAll('style, link[rel="stylesheet"]');
|
||||||
|
styleSheets.forEach((sheet) => {
|
||||||
|
if (sheet.tagName === 'STYLE') {
|
||||||
|
const styleEl = sheet.cloneNode(true) as HTMLElement;
|
||||||
|
printWindow.document.head.appendChild(styleEl);
|
||||||
|
} else if (sheet.tagName === 'LINK') {
|
||||||
|
const linkEl = sheet.cloneNode(true) as HTMLElement;
|
||||||
|
printWindow.document.head.appendChild(linkEl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add print-specific CSS
|
||||||
|
printWindow.document.write(
|
||||||
|
'<style>' +
|
||||||
|
'@media print {' +
|
||||||
|
'body { margin: 1cm; font-size: 12pt; color: #000; background: #fff; }' +
|
||||||
|
'img { max-width: 100%; }' +
|
||||||
|
'table { width: 100%; border-collapse: collapse; }' +
|
||||||
|
'th, td { border: 1px solid #ccc; padding: 4px 8px; font-size: 11pt; }' +
|
||||||
|
'th { background: #f5f5f5; font-weight: 600; }' +
|
||||||
|
'}' +
|
||||||
|
'@media screen {' +
|
||||||
|
'body { padding: 20px; font-family: sans-serif; }' +
|
||||||
|
'}' +
|
||||||
|
'</style>',
|
||||||
|
);
|
||||||
|
|
||||||
|
printWindow.document.write('</head><body>');
|
||||||
|
printWindow.document.write(clone.outerHTML);
|
||||||
|
printWindow.document.write('</body></html>');
|
||||||
|
printWindow.document.close();
|
||||||
|
|
||||||
|
// Wait for stylesheets to load before printing
|
||||||
|
printWindow.onload = () => {
|
||||||
|
printWindow.focus();
|
||||||
|
printWindow.print();
|
||||||
|
// Close the window after print dialog (most browsers do this automatically)
|
||||||
|
setTimeout(() => {
|
||||||
|
printWindow.close();
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the current page using window.print().
|
||||||
|
* Relies on @media print CSS (print.css) to hide non-printable elements.
|
||||||
|
*/
|
||||||
|
export function printCurrentPage(): void {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export an element to PDF using the browser's native print-to-PDF.
|
||||||
|
* Opens the print dialog where the user can choose "Save as PDF".
|
||||||
|
*
|
||||||
|
* @param elementId - The id of the DOM element to export
|
||||||
|
* @param filename - Suggested filename (used in the print window title)
|
||||||
|
*/
|
||||||
|
export function exportToPDF(elementId: string, filename: string): void {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) {
|
||||||
|
console.warn(`exportToPDF: element with id "${elementId}" not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank', 'width=900,height=700');
|
||||||
|
if (!printWindow) {
|
||||||
|
alert('Bitte erlauben Sie Pop-up-Fenster zum Exportieren als PDF.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = element.cloneNode(true) as HTMLElement;
|
||||||
|
|
||||||
|
printWindow.document.open();
|
||||||
|
printWindow.document.write('<!DOCTYPE html>');
|
||||||
|
printWindow.document.write('<html lang="de"><head><meta charset="UTF-8">');
|
||||||
|
printWindow.document.write(`<title>${filename}</title>`);
|
||||||
|
|
||||||
|
// Copy all stylesheets
|
||||||
|
const styleSheets = document.querySelectorAll('style, link[rel="stylesheet"]');
|
||||||
|
styleSheets.forEach((sheet) => {
|
||||||
|
if (sheet.tagName === 'STYLE') {
|
||||||
|
const styleEl = sheet.cloneNode(true) as HTMLElement;
|
||||||
|
printWindow.document.head.appendChild(styleEl);
|
||||||
|
} else if (sheet.tagName === 'LINK') {
|
||||||
|
const linkEl = sheet.cloneNode(true) as HTMLElement;
|
||||||
|
printWindow.document.head.appendChild(linkEl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Print-specific styles for PDF export
|
||||||
|
printWindow.document.write(
|
||||||
|
'<style>' +
|
||||||
|
'@media print {' +
|
||||||
|
`@page { margin: 1.5cm; }` +
|
||||||
|
'body { margin: 0; font-size: 12pt; color: #000; background: #fff; line-height: 1.4; }' +
|
||||||
|
'img { max-width: 100%; height: auto; }' +
|
||||||
|
'table { width: 100%; border-collapse: collapse; page-break-inside: auto; }' +
|
||||||
|
'tr { page-break-inside: avoid; page-break-after: auto; }' +
|
||||||
|
'thead { display: table-header-group; }' +
|
||||||
|
'th, td { border: 1px solid #ccc; padding: 4px 8px; font-size: 11pt; text-align: left; }' +
|
||||||
|
'th { background: #f5f5f5; font-weight: 600; }' +
|
||||||
|
'h1, h2, h3 { page-break-after: avoid; }' +
|
||||||
|
'.card, [class*="rounded"] { border: 1px solid #ddd !important; box-shadow: none !important; border-radius: 4px; }' +
|
||||||
|
'}' +
|
||||||
|
'@media screen {' +
|
||||||
|
'body { padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }' +
|
||||||
|
'}' +
|
||||||
|
'</style>',
|
||||||
|
);
|
||||||
|
|
||||||
|
printWindow.document.write('</head><body>');
|
||||||
|
printWindow.document.write(clone.outerHTML);
|
||||||
|
printWindow.document.write('</body></html>');
|
||||||
|
printWindow.document.close();
|
||||||
|
|
||||||
|
printWindow.onload = () => {
|
||||||
|
printWindow.focus();
|
||||||
|
printWindow.print();
|
||||||
|
setTimeout(() => {
|
||||||
|
printWindow.close();
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user