8 Commits

Author SHA1 Message Date
Agent Zero ec81940178 Phase 0 Complete: Tasks 0.7-0.20
- 0.7: UI-Design-Richtlinien (docs/ui-design-guidelines.md, 535 lines)
- 0.8: Theme-Customization Backend (4 theme fields, migration 0023)
- 0.9: Theme-Customization Frontend (SettingsTheme.tsx, themeStore.ts, live preview)
- 0.10: RBAC-Audit (4 plugins secured, 53 routes with require_permission)
- 0.11: LiteLLM-Cleanup (llm_client.py migrated from httpx to litellm)
- 0.12: KI-Agent-Framework docs (plugin-development-guide.md, agent_capabilities field)
- 0.13: Heartbeat configurable (ProactiveSettings, migration 0024, frontend UI)
- 0.14: Unified Search Field-Level RBAC (resolve_permissions + filter_fields_by_permission)
- 0.15: Undo/History-System (EntityHistory model, service, routes, migration 0025, HistoryViewer)
- 0.16: Storage Backend (LocalStorage + S3Storage, DMS/attachments/mail updated)
- 0.17: Import/Export unified Contact fields (firstname, surname, email_1, phone_1)
- 0.18: .gitignore & Config-Cleanup (webui→frontend, python-jose removed, .env untracked)
- 0.19: Mail-Salt Security-Fix (per-account random salt, migration 0026)
- 0.20: AGPL replaced (PyMuPDF→pypdf, OnlyOffice→Collabora, LICENSE + THIRD_PARTY_LICENSES.md)
2026-07-23 08:42:26 +02:00
Agent Zero 3d06cb2353 Phase 0.5+0.6: consolidate store dirs and save frontend gap analysis
- 0.5: Moved calendarStore.ts from stores/ to store/, removed stores/ dir, updated import
- 0.6: Saved complete frontend gap analysis as frontend-gap-analysis.md (176 lines)
- All stores now in src/store/ (5 stores: authStore, uiStore, commStore, pluginToolbarStore, calendarStore)
2026-07-23 05:12:24 +02:00
Agent Zero 75d2f884da Phase 0.4: split hooks.ts into separate API modules 2026-07-23 05:11:16 +02:00
Agent Zero 57d18c1381 Phase 0.3: migrate date formatting to date-fns 2026-07-23 05:05:08 +02:00
Agent Zero 241850fddd Phase 0.2: mark task 0.2 as done in PROGRESS.md 2026-07-23 04:55:58 +02:00
Agent Zero 4f8cda1566 Phase 0.2: migrate remaining SVGs to lucide-react icons 2026-07-23 03:21:05 +02:00
Agent Zero e9a5eee524 Phase 0.2: migrate UI component SVGs to lucide-react
- Button.tsx: Loader2 spinner
- EmptyState.tsx: FolderOpen default icon
- Modal.tsx: X close icon
- Pagination.tsx: ChevronLeft/ChevronRight
- Table.tsx: Loader2 loading spinner
- Toast.tsx: Check/X/AlertTriangle/Info + X close
- lucide-react v1.25.0 installed
- All 85 UI tests pass
- 2 pre-existing TSC errors in Dms.tsx (unrelated)
2026-07-23 00:47:23 +02:00
Agent Zero 3f2307ab54 Phase 0.1: update planning docs to current state
- codebase-vs-requirements.md: rewritten to reflect actual IST-stand (PostgreSQL, React, 12 plugins, unified contacts)
- security-review-phase2.md: added resolution summary for M-01/M-02/M-03/M-05/m-03/m-08
- architecture.md: added section 11 'Implementation Status' with what's built and what's missing
- MASTER-PLAN.md: comprehensive 754-line plan with 8 phases + Phase 3.5 (~590h total)
- PROGRESS.md: progress tracking file for agent work
2026-07-23 00:35:40 +02:00
139 changed files with 5921 additions and 2636 deletions
-6
View File
@@ -1,6 +0,0 @@
DATABASE_URL=postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm
REDIS_URL=redis://localhost:6379/0
ENVIRONMENT=development
LOG_LEVEL=INFO
BCRYPT_ROUNDS=12
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
+2 -4
View File
@@ -21,7 +21,7 @@ POSTGRES_DB=crm_db
DATABASE_URL=postgresql+asyncpg://crm_user:STRONG_PASSWORD_HERE@postgres:5432/crm_db DATABASE_URL=postgresql+asyncpg://crm_user:STRONG_PASSWORD_HERE@postgres:5432/crm_db
# --- AUTH_SECRET (REQUIRED, min 32 chars) ------------------------------------ # --- AUTH_SECRET (REQUIRED, min 32 chars) ------------------------------------
# JWT signing secret. MUST be at least 32 characters. # Session signing secret. MUST be at least 32 characters.
# Generate with: # Generate with:
# python -c "import secrets; print(secrets.token_urlsafe(48))" # python -c "import secrets; print(secrets.token_urlsafe(48))"
AUTH_SECRET=MIN_32_CHARS_GENERATE_WITH_secrets_token_urlsafe_32_xxxxxxxxxxxx AUTH_SECRET=MIN_32_CHARS_GENERATE_WITH_secrets_token_urlsafe_32_xxxxxxxxxxxx
@@ -33,7 +33,5 @@ CORS_ORIGINS=http://localhost:8000,http://localhost:5173
ENVIRONMENT=production ENVIRONMENT=production
LOG_LEVEL=INFO LOG_LEVEL=INFO
# --- JWT / bcrypt tuning (keep aligned with .env.example) --------------------- # --- bcrypt tuning (keep aligned with .env.example) --------------------------
JWT_ALGORITHM=HS256
JWT_EXPIRY_HOURS=24
BCRYPT_ROUNDS=12 BCRYPT_ROUNDS=12
+9
View File
@@ -36,6 +36,15 @@ SECRET_KEY=change-me-in-production-use-a-secure-random-string
# Storage (file uploads, DMS) # Storage (file uploads, DMS)
STORAGE_PATH=/tmp STORAGE_PATH=/tmp
# Storage backend: local (default) or s3
STORAGE_BACKEND=local
# S3-compatible storage (when STORAGE_BACKEND=s3)
S3_ENDPOINT=
S3_BUCKET=
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=us-east-1
S3_SECURE=true
# SMTP / Email # SMTP / Email
SMTP_HOST=localhost SMTP_HOST=localhost
+3 -3
View File
@@ -52,9 +52,9 @@ logs/
# Alembic (autogenerated migrations excluded, but keep 0001) # Alembic (autogenerated migrations excluded, but keep 0001)
alembic/versions/__pycache__/ alembic/versions/__pycache__/
# Frontend build artifacts (Phase 4c) # Frontend build artifacts
webui/node_modules/ frontend/node_modules/
webui/dist/ frontend/dist/
# Docker # Docker
.docker-data/ .docker-data/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 LeoCRM
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+754
View File
@@ -0,0 +1,754 @@
# LeoCRM — Master Plan: Umbau & Vollendung
**Erstellt:** 2026-07-22
**Status:** Draft — zur Freigabe
**Letzte Revision:** 2026-07-22 (gründliche Überprüfung nach Code-Tiefenanalyse)
---
## Ausgangslage
### Was bereits gut ist
- Backend: ~35.800 Zeilen, 12 Plugins, Multi-Tenant mit RLS, Rate Limiting, Audit Log
- Unified Contact Model: **BEREITS implementiert** (Migration 0021) — Contact mit type='company'|'person', ContactPerson als 1:N child (wie Rentman)
- Frontend: ~30.000 Zeilen, 27 Pages, 70 Components, i18n DE/EN, TanStack Query, TipTap
- Tests: ~17.300 Zeilen Backend-Tests, 38 Vitest-Dateien
- Docker: Multi-Stage-Build (Frontend+Backend in einem Container)
- Datenbank: PostgreSQL 16 als separater docker-compose Service
- WebSocket-Infrastruktur: Bereits im `kommunikation` Plugin vorhanden (`/api/v1/comm/ws`) — kann als Referenz für KI-UI-Steuerung dienen
### Was fehlt oder nicht stimmt
- Frontend nutzt unified Contact Model nicht vollständig (keine Contact-Detail-Route, ContactPerson-Verwaltung fehlt in UI)
- **'company' als entity_type ist in 6 Plugins verankert** — muss zu 'contact' vereinheitlicht werden
- Plugin-UI-System fehlt (hartkodierte Routes statt dynamische Registry)
- Code-Splitting fehlt (alle 27 Pages im Main Bundle)
- E2E Tests fehlen komplett
- KI-UI-Steuerung fehlt
- Virtual Scrolling fehlt
- React Hook Form + Zod nicht überall
- hooks.ts ist Monolith (1.298 Zeilen)
- Fehlende Dependencies (lucide-react, date-fns)
- Plugin-Richtlinien fehlen
### Wichtige Unterscheidung: 'company' hat zwei Bedeutungen
1. **entity_type='company'** in Plugins (entity_links, calendar, tags, mail) → referenziert eine Firma als Entität → **MUSS zu 'contact' werden**
2. **system_settings.company_*** Felder (company_name, company_street etc.) → CRM-Besitzer-Firmeninfo für Rechnungen → **BLEIBT wie es ist**
3. **CalendarType='company'** → Kalender-Typ (Firmenkalender) → kann bleiben oder zu 'organization' umbenannt werden (kosmetisch)
---
## Architektur-Entscheidungen (freigegeben 2026-07-22)
1. **KI-UI-Steuerung:** Keine Mausbewegung nötig. KI muss zu Kontakten springen und einen Kontakt öffnen können. Die UI muss das Ergebnis zeigen — Kontaktliste und spezieller Kontakt ausgewählt. Implementierungsweg (WebSocket, postMessage, etc.) ist offen, Hauptsache das Ergebnis wird in der UI sichtbar.
2. **Company-Routes:** Komplett entfernen. Keine deprecated-Routes, keine Redirects. Kontakte wie in Rentman — ein unified Contact-Modell, kein separates Company-Modell mehr. **Alle Plugin-Referenzen auf entity_type='company' müssen zu 'contact' migriert werden.**
3. **PostgreSQL:** Aktuell egal (Coolify-managed oder docker-compose). Reine Docker-Lösung soll später möglich sein. Keine Code-Änderung nötig — nur Konfiguration.
4. **S3-Storage:** Provider egal. Wichtig ist nur dass die Architektur es später ermöglicht. Bereits vorbereitet in config.py (STORAGE_BACKEND=s3).
---
## Phasen-Plan
### PHASE 0: Vorbereitung & Cleanup
**Ziel:** Codebasis bereinigen, Dependencies installieren, veraltete Dokumente aktualisieren
| # | Aufgabe | Aufwand | Details |
|---|---|---|---|
| 0.1 | Veraltete Planungsdokumente aktualisieren | 2h | `codebase-vs-requirements.md` neu schreiben (beschreibt alten Stand), `architecture.md` um Implementation-Status erweitern, `security-review-phase2.md` um 'Resolved' Markierungen ergänzen |
| 0.2 | `lucide-react` installieren + Icons migrieren | 4h | Inline SVGs durch lucide-react Icons ersetzen. Konsistente Icon-Bibliothek. |
| 0.3 | `date-fns` installieren + Datum-Formatierung | 3h | Alle `toLocaleDateString()` etc. durch date-fns ersetzen. Konsistente Datum-Formatierung. |
| 0.4 | `hooks.ts` aufteilen | 3h | 1.298 Zeilen aufteilen in `api/auth.ts`, `api/contacts.ts`, `api/settings.ts` etc. Generische Hooks bleiben in `hooks.ts`. Company-Hooks werden in Phase 1 entfernt, nicht aufgeteilt. |
| 0.5 | Store-Verzeichnis konsolidieren | 1h | `store/` und `stores/` zusammenführen. |
| 0.6 | Frontend-Bestandsanalyse als Dokument speichern | 1h | `frontend-gap-analysis.md` mit vollständiger Analyse. |
| 0.7 | UI-Design-Richtlinien erstellen | 6h | `docs/ui-design-guidelines.md` basierend auf bestehenden Plugin-Patterns (siehe unten). |
| 0.8 | Theme-Customization Backend | 4h | `system_settings` um Theme-Felder erweitern (primary_color, accent_color, font_family, border_radius). Neue Alembic-Migration. API-Endpoints zum Lesen/Schreiben der Theme-Settings. |
| 0.9 | Theme-Customization Frontend | 6h | `SettingsTheme.tsx` Seite mit Color-Picker, Font-Auswahl, Live-Preview. Tailwind-CSS-Variablen dynamisch aus API-Settings überschreiben. Dark-Mode-Toggle. Theme wird beim App-Start geladen und angewendet. |
| 0.10 | RBAC-Audit & Plugin-Permissions nachrüsten | 6h | 4 Plugins haben `permissions=[]` (calendar, dms, entity_links, tags) → keine Rechte-Prüfung! Pro Plugin passende Permissions definieren und in Manifest eintragen. Routes mit `require_permission()` absichern. Siehe Details unten. |
| 0.11 | LiteLLM-Cleanup & alte llm_client.py migrieren | 3h | LiteLLM ist **BEREITS** in ai_assistant und ai_proactive integriert (`litellm.acompletion()`). Nur die alte `llm_client.py` (Copilot) nutzt noch httpx direkt. Diese auf LiteLLM umstellen oder entfernen. System-Prompt in llm_client.py referenziert noch `/api/v1/companies` → auf Contacts umstellen. |
| 0.12 | KI-Agent-Framework in Plugin-Richtlinien dokumentieren | 2h | PydanticAI + tool_registry existieren bereits. In `docs/plugin-development-guide.md` dokumentieren: Wie Plugins KI-Agenten, Tools und LLM-Funktionen nutzen. Plugin-Manifest um `agent_capabilities` Feld erweitern. |
| 0.13 | Heartbeat konfigurierbar machen | 3h | Heartbeat-Intervall, Aktivierung, Ziel-Room in ProactiveSettings (DB) speichern. Settings-UI für Heartbeat-Konfiguration. |
| 0.14 | Unified Search: Field-Level RBAC nachrüsten | 4h | Search-Provider prüfen aktuell KEINE Feld-Level-Permissions. Nutzer mit `search:read` sieht alle Felder. Provider müssen `resolved_perms` prüfen und `hidden` Felder ausblenden. `to_search_result()` um Permission-Filter ergänzen. |
| 0.15 | Undo/History-System für CRUD-Operationen | 8h | Globale Undo-History: Jede CRUD-Aktion (Create/Update/Delete) wird mit Snapshot in `entity_history` Tabelle gespeichert. User kann Änderungen rückgängig machen oder zu früherer Version zurückkehren. Nutzt bestehenden Audit-Log als Basis. Frontend: Undo-Button + History-Viewer pro Entity. |
| 0.16 | Storage Backend implementieren (S3-Support) | 8h | Architecture.md beschreibt abstract StorageBackend (local/S3), aber **existiert NICHT im Code**. Attachments nutzen hardcoded `/data/uploads`. Storage-Klasse erstellen: `LocalStorage` + `S3Storage`. Config um `STORAGE_BACKEND`, `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` erweitern. DMS und Attachments auf Storage-Backend umstellen. .env.example um S3-Variablen ergänzen. |
| 0.17 | Import/Export an unified Contact Model anpassen | 4h | Import/Export nutzt alte Feldnamen (`first_name`, `last_name`, `mobile`, `position`, `department`). Auf unified Contact-Felder umstellen (`firstname`, `surname`, `phone_1`, `email_1`, etc.). Company-Import auf Contact mit type='company' umstellen. |
| 0.18 | .gitignore & Config-Cleanup | 2h | `.gitignore` hat `webui/` statt `frontend/` — frontend/node_modules und frontend/dist werden nicht ignoriert! Korrigieren. `python-jose` (JWT) aus requirements.txt entfernen — Code nutzt Session-Auth. `pyproject.toml` Python-Version auf 3.12 aktualisieren. `.env.docker.example` JWT-Variablen entfernen. **.env aus Git entfernen** (ist committet aber sollte nicht sein). `dump.rdb` und `test.txt` aus Repo löschen. `frontend/dist/` aus Git entfernen (sollte nicht committet sein). |
| 0.19 | Mail-Salt Security-Fix | 2h | `mail/services.py` hat hardcoded salt `b"leocrm-mail-salt"` für Passwort-Verschlüsselung. Salt sollte random pro Account sein. Fix: Random salt generieren und mit encrypted_password zusammen speichern. DB-Migration für bestehende Accounts. |
| 0.20 | AGPL-Lizenzen durch kommerziell nutzbare Alternativen ersetzen | 6h | **PyMuPDF** (AGPL-3.0) → ersetzen durch `pypdf` (BSD). Text-Extraktion in unified_search anpassen. **OnlyOffice** (AGPL-3.0) → ersetzen durch **Collabora Online** (LGPL/MPL). DMS Edit-Sessions auf Collabora umstellen. `requirements.txt`, `Dockerfile`, `docker-compose.yml`, `architecture.md` aktualisieren. DMS Plugin `OnlyOfficeConfig` → `CollaboraConfig`. Frontend DMS-Komponenten anpassen. Lizenz-Datei (`LICENSE`) und `THIRD_PARTY_LICENSES.md` erstellen. |
**Phase 0 Gesamt: ~77h**
### UI-Design-Richtlinien (Task 0.7)
Basierend auf Analyse der bestehenden Plugins (Calendar, Mail, DMS, Contacts):
**Layout-Patterns:**
- **3-Spalten-Explorer-Layout** (Tree | Liste/Explorer | Detail) — verwendet von Calendar, Mail, DMS
- **ResizablePanel** für drag-to-resize Spalten — bereits implementiert
- **PluginToolbar** für Plugin-Aktionen (oben) — bereits implementiert
- **Modal** für Formulare (Create/Edit/Delete-Bestätigung) — bereits implementiert
- **EmptyState** für leere Listen — bereits implementiert
- **LoadingState/Skeleton** für Lade-Zustände — bereits implementiert
**Farbsystem (Tailwind Design Tokens):**
- `primary` (Blau #2563eb) — Hauptaktionen, aktive Zustände
- `secondary` (Slate #64748b) — Text, Borders, Hintergründe
- `accent` (Fuchsia #d946ef) — Hervorhebungen, Info-Badges
- `danger` (Rot #dc2626) — Löschen, Fehler
- `warning` (Amber #f59e0b) — Warnungen
- `success` (Grün #16a34a) — Erfolg, Bestätigungen
- Jede Farbe mit 50-900 Schattierungen
- **Dark Mode** via `darkMode: 'class'` — CSS-Variablen in `:root` und `.dark`
**Typografie:**
- Font: `Inter` (system-ui fallback)
- Mono: `JetBrains Mono` für Code/Daten
- Größen: xs (0.75rem) bis 4xl (2.25rem)
- Zeilenhöhen definiert pro Größe
**Komponenten-Konventionen:**
- **Button**: 4 Varianten (primary/secondary/danger/ghost), 3 Größen (sm/md/lg), `min-h-touch` (44px), `focus-visible:ring-2`
- **Card**: Titel + Beschreibung + Actions (header), Body, optional Footer (bg-secondary-50)
- **Badge**: 7 Varianten (default/primary/success/warning/danger/info/secondary), optional dot
- **Input/Select**: `focus-ring` Klasse, `border-secondary-200`, `rounded-md`
- **Modal**: `size` prop (sm/md/lg/xl), `ConfirmDialog` für Bestätigungen
- **Table/DataGrid**: TanStack Table, ARIA-labels auf sortierbare Headers
- **Toast**: `useToast()` Hook für Benachrichtigungen
**Spacing & Layout:**
- Standard-Padding: `px-6 py-4` (Card body), `p-4` (Panel)
- Gap: `gap-2` (Buttons), `gap-4` (Sections), `gap-6` (Columns)
- Border-Radius: `rounded-md` (0.5rem) Standard, `rounded-lg` (0.75rem) für Cards
- Shadow: `shadow-sm` (Cards), `shadow-md` (Dropdowns), `shadow-lg` (Modals)
**Accessibility (bereits implementiert):**
- `focus-ring` Klasse: `focus-visible:ring-2 focus-visible:ring-primary-500`
- `btn-touch` Klasse: `min-h-touch min-w-touch` (44px)
- `sr-only` und `sr-only-focusable` Klassen
- `prefers-reduced-motion` Media Query
- `aria-hidden="true"` auf dekorativen SVGs
- `aria-label` auf interaktiven Elementen ohne sichtbaren Text
**Plugin-UI-Patterns (für neue Plugins):**
- Jede Plugin-Seite folgt dem 3-Spalten-Layout (wenn anwendbar)
- PluginToolbar für Aktionen (Create, Import, Export, etc.)
- Plugin-Settings als eigene Settings-Sub-Seite
- Plugin-Detail-Tabs (z.B. "Dateien" bei Contact-Detail)
- Konsistente EmptyState-Komponente wenn keine Daten
- Konsistente LoadingState/Skeleton-Komponente beim Laden
- Toast für Erfolg/Fehler-Meldungen nach Aktionen
- ConfirmDialog vor destruktiven Aktionen
**Was im Design-Guide dokumentiert wird:**
1. Farbsystem mit Verwendungsregeln (wann welche Farbe)
2. Typografie-Hierarchie (Überschriften, Body-Text, Labels)
3. Layout-Patterns (3-Spalten, Modal, Settings-Tree)
4. Komponenten-Verwendung (welche Komponente für was)
5. Spacing & Sizing Konventionen
6. Accessibility-Regeln
7. Dark-Mode-Regeln
8. Plugin-UI-Patterns für neue Plugins
9. Do's & Don'ts
10. Code-Beispiele aus bestehenden Plugins
### RBAC-Audit & Plugin-Permissions (Task 0.10)
**Problem:** 4 Plugins haben `permissions=[]` im Manifest → keine Rechte-Prüfung auf ihren Routes:
| Plugin | Aktuell | Muss definiert werden |
|---|---|---|
| **calendar** | `permissions=[]` | `calendar:read`, `calendar:write`, `calendar:delete`, `calendar:share`, `calendar:admin` |
| **dms** | `permissions=[]` | `dms:read`, `dms:write`, `dms:delete`, `dms:share`, `dms:admin` |
| **entity_links** | `permissions=[]` | `entity_links:read`, `entity_links:write`, `entity_links:delete` |
| **tags** | `permissions=[]` | `tags:read`, `tags:write`, `tags:delete`, `tags:admin` |
**Was zu tun ist:**
1. Pro Plugin passende Permissions im Manifest definieren
2. Alle Plugin-Routes mit `require_permission()` absichern
3. Permission-Registry registriert Plugin-Permissions automatisch beim Aktivieren
4. Admin kann Permissions in Rollen-Editor zuweisen
5. Tests: User ohne Permission → 403, User mit Permission → 200
**Zusätzlich in Phase 1 (Permission-Registry-Cleanup):**
- `companies:read/write/delete` aus `CORE_PERMISSIONS` entfernen (wird zu `contacts:read/write/delete`)
- `CORE_FIELD_DEFINITIONS` aktualisieren: alte Felder (`first_name`, `last_name`, `mobile`, `position`, `department`, `linkedin_url`) durch unified Contact-Felder ersetzen (`firstname`, `surname`, `phone_1`, `email_1`, etc.)
- `companies` Field-Definitions entfernen
### LiteLLM-Integration (Task 0.11)
**Problem:** Aktuelle `llm_client.py` spricht nur OpenAI-compatible API direkt via httpx. Keine Unterstützung für Anthropic, Google, lokale Modelle etc.
**Lösung:** LiteLLM als unified LLM-Interface integrieren.
**Was LiteLLM bietet:**
- 100+ LLM-Provider über eine einheitliche API (OpenAI, Anthropic, Google, Azure, AWS Bedrock, Ollama, etc.)
- Konsistente Request/Response-Formate
- Streaming-Support
- Fallback/Routing-Regeln
- Cost-Tracking
- Rate-Limiting
**Was zu tun ist:**
1. `litellm` als Python-Dependency hinzufügen
2. `llm_client.py` auf LiteLLM umstellen: `litellm.acompletion()` statt direktem httpx-Call
3. Konfiguration via Env-Vars: `AI_MODEL`, `AI_API_KEY`, `AI_API_BASE` (bleiben gleich), plus `AI_PROVIDER` (neu: openai/anthropic/google/ollama/etc.)
4. AI Assistant Plugin nutzt LiteLLM für Multi-Provider-Support
5. AI Proactive Plugin nutzt LiteLLM für Suggestions
6. Zukünftige Plugins können LiteLLM einfach nutzen — einheitliches Interface
7. Mock-Mode für Tests beibehalten (wenn kein API-Key gesetzt)
8. Plugin-Entwickler-Richtlinien: Wie man LiteLLM in neuen Plugins nutzt
**Architektur:**
```
Plugin (ai_assistant, ai_proactive, zukünftige)
↓
LiteLLM (unified LLM interface)
↓
Provider (OpenAI, Anthropic, Google, Ollama, ...)
```
**Vorteil für zukünftige Plugins:**
- Ein Plugin kann LLM-Funktionen nutzen ohne sich um den Provider zu kümmern
- Admin kann Provider in Settings konfigurieren
- KI-Modelle können ausgetauscht werden ohne Code-Änderung
---
### PHASE 1: Unified Contact Model — Vollendung (Backend + Frontend)
**Ziel:** 'company' als separates Konzept komplett entfernen. Alles ist 'contact' mit type='company'|'person'. Wie Rentman.
#### 1A: Backend — Company-Routes & Services entfernen
| # | Aufgabe | Aufwand | Details |
|---|---|---|---|
| 1.1 | `app/routes/companies.py` entfernen | 1h | 303 Zeilen. Router aus `main.py`/`routes/__init__.py` austragen. |
| 1.2 | `app/services/company_service.py` entfernen | 1h | 273 Zeilen. Importe aus `services/__init__.py` entfernen. |
| 1.3 | `app/models/company.py` entfernen | 1h | Backward-compat shim. Importe überall auf `Contact` umstellen. |
| 1.4 | `app/schemas/company.py` entfernen | 1h | CompanyCreate, CompanyUpdate, CompanyResponse etc. |
| 1.5 | `app/ai/action_mapper.py` aktualisieren | 3h | Company-Intents (create_company, delete_company, update_company, list_company) auf Contact-API umstellen. Regex-Patterns anpassen. |
| 1.6 | `app/workflows/engine.py` aktualisieren | 1h | Event `company.created` → `contact.created`. Workflow-Trigger anpassen. |
| 1.7 | `app/core/worker.py` aktualisieren | 1h | `index_company` Referenzen → `index_contact`. |
| 1.8 | `app/core/seeds.py` prüfen/aktualisieren | 1h | Falls Company-Seed-Daten existieren, auf Contact mit type='company' umstellen. |
**1A Gesamt: ~10h**
#### 1B: Backend — Plugins von entity_type='company' befreien
| # | Aufgabe | Aufwand | Details |
|---|---|---|---|
| 1.9 | **entity_links Plugin** aktualisieren | 4h | `entity_type` Pattern von `^(company|contact)$` → `^contact$`. `company_router` entfernen. `on_company_deleted` → `on_contact_deleted`. Event `company.deleted` → `contact.deleted`. DB-Migration: bestehende EntityLinks mit entity_type='company' auf 'contact' migrieren. |
| 1.10 | **unified_search Plugin** aktualisieren | 6h | `CompanySearchProvider` → wird zu `ContactSearchProvider` oder bleibt als Provider für type='company' Kontakte. `index_company` → `index_contact`. Events `company.created/updated` → `contact.created/updated`. `search_engine.py` Mapping `"company" → "contacts"` anpassen. `jobs.py` aktualisieren. |
| 1.11 | **calendar Plugin** aktualisieren | 3h | `entity_type` Pattern von `^(company|contact)$` → `^contact$`. CalendarEntryLink entity_type anpassen. DB-Migration: bestehende Links migrieren. CalendarType='company' kann bleiben (Kalender-Typ, nicht Entity-Referenz). |
| 1.12 | **tags Plugin** aktualisieren | 3h | `entity_type` Pattern von `^(company|contact|file|folder)$` → `^(contact|file|folder)$`. DB-Migration: bestehende Tag-Assignments mit entity_type='company' auf 'contact' migrieren. |
| 1.13 | **mail Plugin** aktualisieren | 4h | `mail.company_id` Spalte → `mail.contact_id` (DB-Migration). Routes, Schemas, Services aktualisieren. `company_id` Referenzen in Frontend-API-Modul. |
| 1.14 | **test_sample Plugin** aktualisieren | 1h | `company.created` Event → `contact.created`. Test-Plugin ist Referenz für Plugin-Entwicklung. |
| 1.15 | **Event-Namen vereinheitlichen** | 2h | Alle `company.created/updated/deleted` Events → `contact.created/updated/deleted`. Event-Publisher in contact_service.py prüfen. |
| 1.16 | **DB-Migration: entity_type 'company' → 'contact'** | 3h | Alembic-Migration: UPDATE entity_links SET entity_type='contact' WHERE entity_type='company'. UPDATE tag_assignments SET entity_type='contact' WHERE entity_type='company'. UPDATE calendar_entry_links SET entity_type='contact' WHERE entity_type='company'. ALTER TABLE mails RENAME COLUMN company_id TO contact_id. |
| 1.17 | **Backend-Tests aktualisieren** | 4h | Alle Tests die Company-Routes oder entity_type='company' referenzieren umstellen. `test_companies.py` entfernen oder zu Contact-Tests umschreiben. |
| 1.18 | **Permission-Registry-Cleanup** | 3h | `companies:read/write/delete` aus `CORE_PERMISSIONS` entfernen. `CORE_FIELD_DEFINITIONS` aktualisieren: alte Felder durch unified Contact-Felder ersetzen. `companies` Field-Definitions entfernen. |
| 1.19 | **Addresses entity_type='company' → 'contact'** | 2h | `address_service.py` `VALID_ENTITY_TYPES` von `{"company", "contact"}` → `{"contact"}`. `address.py` Model anpassen. DB-Migration: bestehende Adressen mit entity_type='company' auf 'contact' migrieren. |
| 1.20 | **conftest.py aktualisieren** | 2h | `conftest.py` importiert `Company` und `CompanyContact` aus alten Modellen. Auf unified Contact Model umstellen. Test-Fixtures anpassen. |
**1B Gesamt: ~33h**
#### 1C: Frontend — Unified Contact UI
| # | Aufgabe | Aufwand | Details |
|---|---|---|---|
| 1.18 | Contact-Detail-Route hinzufügen | 2h | Route `/contacts/:id` in `routes/index.tsx`. `ContactDetail.tsx` (372 Zeilen) existiert bereits als Komponente. |
| 1.19 | ContactList mit Type-Filter (company/person) | 4h | `ContactsList.tsx` (445 Zeilen) um Type-Filter erweitern. Tabs oder Toggle: "Alle | Firmen | Personen". |
| 1.20 | ContactDetail um ContactPerson-Verwaltung erweitern | 8h | Bei type='company': Ansprechpartner-Liste, Ansprechpartner hinzufügen/bearbeiten/löschen. ContactPerson API-Hooks in Frontend. |
| 1.21 | ContactEditModal für beide Types | 6h | Formular je nach type unterschiedlich: company → name, person → firstname/surname. Adressen (mailing/visit/invoice). |
| 1.22 | Company-Hooks aus `hooks.ts` entfernen | 2h | `useCompanies`, `useCompany`, `useCreateCompany`, `useUpdateCompany`, `useDeleteCompany`, `useCompanyExport`, `useCompanyImport` entfernen. Company-Interface entfernen. |
| 1.23 | Frontend Type-Definitions aktualisieren | 2h | `calendar.ts`: entity_type 'company' → 'contact'. `tags.ts`: EntityType 'company' entfernen. `search.ts`: type 'company' → 'contact'. `mail.ts`: company_id → contact_id. |
| 1.24 | Dashboard.tsx aktualisieren | 1h | `useUnifiedContacts(1, 1, undefined, 'company')` → `useUnifiedContacts(1, 1, undefined, 'company')` (type-Filter bleibt, ist jetzt Contact type nicht Company entity). |
| 1.25 | GlobalSearchResults.tsx aktualisieren | 2h | Search result type 'company' → 'contact'. Grouping, Icons, Labels anpassen. |
| 1.26 | ContactFolderTree in ContactList integrieren | 4h | Ordner-Baum links, Kontaktliste rechts. Drag & Drop Kontakte in Ordner. |
| 1.27 | React Hook Form + Zod in ContactEditModal | 3h | Strukturierte Validierung für alle Contact-Felder. |
| 1.28 | Frontend-Tests aktualisieren | 4h | Tests für Contact-Detail, ContactEditModal, ContactPerson-Verwaltung. Company-Test-Referenzen entfernen. |
**1C Gesamt: ~38h**
**Phase 1 Gesamt: ~81h** (vorher 33h — unterschätzt um 48h!)
---
### PHASE 2: Code-Splitting & Performance
**Ziel:** Frontend lädt nur was nötig ist. Virtual Scrolling überall.
| # | Aufgabe | Aufwand | Details |
|---|---|---|---|
| 2.1 | React.lazy + Suspense für alle Routes | 4h | Alle Page-Imports in `routes/index.tsx` auf `React.lazy()` umstellen. `<Suspense>` mit Loading-Fallback. |
| 2.2 | `@tanstack/react-virtual` installieren | 1h | Dependency hinzufügen. |
| 2.3 | Virtual Scrolling in DataGrid | 6h | `DataGrid.tsx` um Virtual Scrolling erweitern. Nur sichtbare Zeilen rendern. |
| 2.4 | Virtual Scrolling in MailList | 4h | `MailList.tsx` um Virtual Scrolling erweitern. |
| 2.5 | Virtual Scrolling in ContactList | 4h | `ContactList.tsx` um Virtual Scrolling erweitern. |
| 2.6 | Virtual Scrolling in allen anderen Listen | 4h | AuditLog, Calendar Entries, DMS FileGrid, etc. |
| 2.7 | Bundle-Analyse & Optimierung | 2h | `vite-bundle-visualizer` prüfen, manuelle Chunks für große Dependencies. |
**Phase 2 Gesamt: ~25h**
---
### PHASE 3: Plugin-UI-System (WordPress-Style)
**Ziel:** Dynamisches Plugin-UI-Loading. Plugins registrieren sich selbst.
| # | Aufgabe | Aufwand | Details |
|---|---|---|---|
| 3.1 | Plugin-Manifest-Frontend-Endpoint | 4h | Backend-Endpoint `GET /api/v1/plugins/active-manifests` liefert alle aktiven Plugin-Manifeste mit UI-Definitionen (routes, menu_items, detail_tabs, settings_pages, dashboard_widgets). |
| 3.2 | `PluginRegistry.tsx` erstellen | 8h | Fetcht aktive Plugin-Manifeste beim App-Start. Registriert Routes, Menu-Items, Detail-Tabs, Settings-Pages dynamisch. |
| 3.3 | `PluginLoader.tsx` erstellen | 6h | Lazy-loaded Plugin-Komponenten via `React.lazy()`. Suspense-Boundaries pro Plugin. Error-Boundary falls Plugin nicht lädt. |
| 3.4 | Sidebar dynamisch aus Plugin-Manifesten | 4h | Sidebar rendert Menu-Items aus Plugin-Registry statt hartkodierte Items. |
| 3.5 | Settings-Baum dynamisch aus Plugin-Manifesten | 4h | Settings-Pages werden dynamisch aus Plugin-Manifesten generiert. |
| 3.6 | Detail-Tabs dynamisch (Contact-Detail) | 4h | Plugin-Detail-Tabs (z.B. "Dateien", "E-Mails", "Kalender") werden dynamisch gerendert. |
| 3.7 | Plugin-Routen aus hartkodiertem Router entfernen | 4h | Statische Plugin-Imports aus `routes/index.tsx` entfernen. Alles über PluginRegistry. |
| 3.8 | Plugin-Entwickler-Richtlinien erstellen | 8h | `docs/plugin-development-guide.md`: Manifest-Format, Lifecycle, UI-Registrierung, Event-Bus, Migration-Runner, Service-Container, Beispiele, Do's & Don'ts, Testing-Guide. |
| 3.9 | Plugin-Templates / Boilerplate | 4h | `templates/plugin-template/`: Minimal-Plugin als Startpunkt für neue Plugins. Mit Manifest, Routes, Models, Schemas, Migration, Tests. |
| 3.10 | Tests für Plugin-UI-System | 4h | Vitest-Tests für PluginRegistry, PluginLoader, dynamische Sidebar/Settings. |
| 3.10b | Plugin-Install-System | 8h | Plugins einfach installierbar machen: ZIP-Upload, URL-Install, Plugin-Marketplace-Integration. Plugin-Upload-Endpoint, Validierung (Manifest prüfen, tenant_id-Check, Security-Scan), automatische Migration bei Install. Install-UI in SettingsPlugins.tsx. |
**Phase 3 Gesamt: ~58h**
---
### PHASE 3.5: Automation & Agents Plugin
**Ziel:** Zentrale Oberfläche für Automatisierungen und selbst-arbeitende KI-Agenten. Plugins können Agenten und Automation-Templates mitbringen.
**Architektur:**
```
┌─────────────────────────────────────────────┐
│ Automation & Agents UI │
│ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Automation │ │ Agent Builder │ │
│ │ Builder │ │ - Agent definieren │ │
│ │ - Trigger │ │ - Tools auswählen │ │
│ │ - Schedule │ │ - LLM-Modell wählen │ │
│ │ - Conditions │ │ - Heartbeat setzen │ │
│ │ - Actions │ │ - Proaktiv/Reaktiv │ │
│ └─────────────┘ └─────────────────────┘ │
├─────────────────────────────────────────────┤
│ Cron-Scheduler │ Workflow-Timeouts │ HB │
├─────────────────────────────────────────────┤
│ Plugins bringen mit: │
│ - agent_definitions (Agent-Templates) │
│ - automation_templates (Automation-Tpl) │
│ - cron_jobs (periodische Tasks) │
│ - heartbeat_configs │
└─────────────────────────────────────────────┘
```
| # | Aufgabe | Aufwand | Details |
|---|---|---|---|
| 3.11 | Plugin-Manifest um Agent/Automation-Felder erweitern | 4h | Manifest um `agent_definitions`, `automation_templates`, `cron_jobs`, `heartbeat_configs` erweitern. Plugins deklarieren was sie mitbringen. |
| 3.12 | Cron-Scheduler Backend | 6h | ARQ-basierter Scheduler für periodische Tasks. Cron-Expressions (z.B. `0 8 * * *` = täglich 8 Uhr). Scheduler liest aktive Cron-Jobs aus DB und enqueued sie. Ersetzt hartkodierten Heartbeat. |
| 3.13 | Workflow-Timeout-Worker | 4h | ARQ-Job der regelmäßig Workflow-Instanzen mit abgelaufenem `timeout_at` prüft. Bei Timeout: Status auf `cancelled`, Notification an Initiator. |
| 3.14 | Agent Builder Backend | 8h | API für Agent-Definitionen: Name, Beschreibung, LLM-Modell, Tools (aus tool_registry), System-Prompt, Heartbeat-Intervall, Proaktiv/Reaktiv-Modus. Agent-Definitionen in DB gespeichert. |
| 3.15 | Automation Builder Backend | 6h | API für Automation-Definitionen: Trigger (Event/Schedule/Manual), Conditions, Actions (API-Call/Notification/Workflow-Start). Automation-Definitionen in DB gespeichert. |
| 3.16 | Automation Execution Engine | 6h | Engine die Automations ausführt: Event-Trigger → Conditions prüfen → Actions ausführen. Nutzt Event-Bus für Event-Trigger, Cron-Scheduler für Schedule-Trigger. |
| 3.17 | Agent Runner | 8h | Führt Agenten aus: Proaktiv (Heartbeat-getriggert, sammelt Kontext, generiert Vorschläge) oder Reaktiv (auf Event/Message, reagiert). Nutzt LiteLLM + tool_registry + PydanticAI. |
| 3.18 | Automation & Agents UI — Automation Builder | 8h | Visueller Builder für Automations: Trigger auswählen, Conditions definieren, Actions zusammenstellen. Drag & Drop oder Form-basiert. Live-Preview. |
| 3.19 | Automation & Agents UI — Agent Builder | 8h | Visueller Builder für Agenten: Name, Modell, Tools, System-Prompt, Heartbeat. Test-Run Button. Agent-Liste mit Status (aktiv/inaktiv). |
| 3.20 | Automation & Agents UI — Dashboard | 4h | Übersicht: Aktive Automations, Aktive Agenten, Letzte Ausführungen, Logs, Fehler. Heartbeat-Status pro Agent. |
| 3.21 | Plugin-Beiträge registrieren | 4h | Wenn Plugin aktiviert wird: Agent-Definitionen, Automation-Templates, Cron-Jobs aus Manifest registrieren. Bei Deaktivierung: entfernen. |
| 3.22 | Heartbeat-Verwaltung migrieren | 3h | Hartkodierten Heartbeat aus ai_proactive in Automation & Agents Plugin migrieren. Heartbeat wird zu einem konfigurierbaren Cron-Job. |
| 3.23 | Settings für Automation & Agents | 3h | Einstellungen: Default-LLM-Modell für Agenten, Heartbeat-Default-Intervall, Max-Concurrent-Agents, Log-Level. |
| 3.24 | Tests für Automation & Agents | 6h | Tests für Cron-Scheduler, Workflow-Timeouts, Agent Runner, Automation Engine, Plugin-Beiträge. |
| 3.25 | Agent- & Automation-Logs | 4h | Jede Agent-Ausführung und Automation-Ausführung wird geloggt: Start, Ende, Status, Dauer, Ergebnis, Fehler. Log-Viewer in Dashboard UI. Historie pro Agent/Automation. |
| 3.26 | RBAC für Automation & Agents | 3h | Permissions definieren: `automation:read`, `automation:write`, `automation:delete`, `automation:execute`, `agents:read`, `agents:write`, `agents:delete`, `agents:execute`. Nur Admin/Editor dürfen Agenten/Automations erstellen. |
| 3.27 | Dry-Run / Test-Modus | 3h | Automations und Agenten können im Dry-Run getestet werden: Führt Conditions aus, zeigt was passieren würde, aber führt keine destruktiven Actions aus. Test-Button in Builder UI. |
| 3.28 | Agent Rate-Limiting & Safety | 3h | Max-Ausführungen pro Agent pro Stunde. Max-Dauer pro Ausführung. Auto-Stop bei Endlosschleife (wenn Agent dieselbe Action 5x hintereinander ausführt). Budget-Limit pro Agent (LiteLLM Cost-Tracking). |
| 3.29 | Plugin-Beitrags-Konfliktlösung | 2h | Wenn zwei Plugins denselben Agent-Namen/Templat-Namen mitbringen: Plugin-Name als Prefix (`mail.mail_sorter` statt `mail_sorter`). Dedup-Logik bei Registrierung. |
| 3.30 | Agent-zu-Agent-Kommunikation | 8h | Agenten können Nachrichten an andere Agenten senden. Nutzt kommunikation Plugin-Infrastruktur (WebSocket, Rooms). Agent-Message-Router: Agent A sendet `{to: 'mail_sorter', message: 'Neuer Termin gefunden'}`. Empfänger-Agent reagiert. Agent-Chatrooms in Dashboard sichtbar. |
| 3.31 | Versionshistorie für Agenten & Automations | 4h | Jede Änderung an Agent/Automation erstellt neue Version. Alte Versionen können wiederhergestellt werden. Versions-Diff in UI. `agent_versions` und `automation_versions` Tabellen. |
| 3.32 | MiniApps: Plugin-MiniApps im Chat | 6h | **Bereits implementiert:** `MiniAppRegistry`, `MiniAppDef`, Routes (`GET /miniapps`, `POST /conversations/{id}/miniapps`), `MiniAppBlock.tsx` Frontend. **Was fehlt:** Plugin-Manifest um `miniapps` Feld erweitern (Plugins deklarieren welche MiniApps sie mitbringen). MiniApp-Builder UI (visuell MiniApps erstellen). MiniApp-Store in Settings. Dokumentation in Plugin-Entwickler-Richtlinien. |
**Phase 3.5 Gesamt: ~105h**
**Was Plugins mitbringen können:**
- **Agent-Definitionen:** Ein Plugin kann vordefinierte Agenten mitbringen (z.B. Mail-Plugin bringt "E-Mail-Sortier-Agent" mit)
- **Automation-Templates:** Ein Plugin kann Automation-Vorlagen mitbringen (z.B. Calendar-Plugin bringt "Terminerinnerung 24h vorher" mit)
- **Cron-Jobs:** Ein Plugin kann periodische Tasks deklarieren (z.B. Mail-Plugin: "IMAP-Sync alle 15 Minuten")
- **Heartbeat-Configs:** Ein Plugin kann Heartbeat-Konfigurationen mitbringen
**Beispiel: Mail-Plugin bringt Agent mit**
```json
{
"agent_definitions": [{
"name": "mail_sorter",
"display_name": "E-Mail-Sortier-Assistent",
"description": "Sortiert eingehende E-Mails automatisch nach Regeln",
"model": "ollama/deepseek-v4-flash",
"tools": ["mail.read", "mail.move", "mail.label"],
"system_prompt": "Du sortierst E-Mails...",
"mode": "reactive",
"trigger_event": "mail.received"
}]
}
```
**Beispiel: Calendar-Plugin bringt Automation mit**
```json
{
"automation_templates": [{
"name": "appointment_reminder",
"display_name": "Terminerinnerung 24h vorher",
"trigger": {"type": "schedule", "cron": "0 8 * * *"},
"conditions": [{"field": "entry.start_at", "operator": "lt", "value": "now + 24h"}],
"actions": [{"type": "notification", "title": "Terminerinnerung", "body": "Morgen: ${entry.title}"}]
}]
}
```
---
### PHASE 4: KI-UI-Steuerung
**Ziel:** KI-Agent kann UI steuern — Kontakte öffnen, Filter setzen, navigieren. User sieht das Ergebnis in der UI.
**Wichtig:** Bestehende WebSocket-Infrastruktur im `kommunikation` Plugin (`/api/v1/comm/ws`, `websocket_manager.py`) kann als Referenz dienen.
| # | Aufgabe | Aufwand | Details |
|---|---|---|---|
| 4.1 | UI-Command-Protokoll definieren | 4h | JSON-Protokoll für UI-Befehle: `{action: 'navigate', path: '/contacts/123'}`, `{action: 'filter', entity: 'contacts', filter: {type: 'company'}}`, `{action: 'open_contact', id: '...'}`. |
| 4.2 | WebSocket-Endpoint für KI-UI-Steuerung | 6h | Backend-WebSocket `/ws/ai-ui-control`. Authentifiziert via Session. KI-Agent sendet Commands, Frontend empfängt. Basiert auf bewährter WebSocket-Infrastruktur aus kommunikation Plugin. |
| 4.3 | Frontend `useAIUIControl` Hook | 6h | WebSocket-Client im Frontend. Empfängt Commands und führt sie aus. Nutzt React Router, TanStack Query, Zustand Stores. |
| 4.4 | Command: Navigate | 2h | `useNavigate()` für Route-Wechsel. KI kann zu jeder Seite navigieren. |
| 4.5 | Command: Filter setzen | 4h | URL-Search-Params setzen für Listen-Filter. KI kann Filter setzen (z.B. "Zeige nur Firmen in Berlin"). |
| 4.6 | Command: Contact öffnen | 3h | Navigate zu `/contacts/:id` + Detail-Daten laden. KI kann Kontakt öffnen und User sieht ihn. |
| 4.7 | Command: Modal öffnen/schließen | 3h | EditModal, CreateModal etc. per Command steuerbar. |
| 4.8 | Command: Tab wechseln | 2h | Detail-Tabs (Dateien, E-Mails, Kalender) per Command wechseln. |
| 4.9 | Command: Settings ändern | 3h | System-Settings, User-Preferences per UI-Command ändern. Wird in UI sichtbar. |
| 4.10 | UI-Action-Feedback an KI | 4h | Frontend sendet Bestätigung zurück: `{action: 'navigate', status: 'success', current_path: '/contacts/123'}`. KI weiß, dass Command ausgeführt wurde. |
| 4.11 | Visuelle KI-Indikation | 3h | Wenn KI eine Aktion ausführt: kurzer Highlight-Effekt oder Toast "KI führt Aktion aus...". User sieht dass KI agiert. |
| 4.12 | Tests für KI-UI-Steuerung | 4h | Vitest-Tests für Command-Protokoll, useAIUIControl Hook, Command-Ausführung. |
**Phase 4 Gesamt: ~44h**
---
### PHASE 5: API-Vollständigkeit & KI-Testbarkeit
**Ziel:** App komplett per API steuerbar. KI kann selbstständig testen und Updates einspielen.
| # | Aufgabe | Aufwand | Details |
|---|---|---|---|
| 5.1 | API-Audit: Alle UI-Funktionen per API erreichbar | 8h | Systematische Prüfung: Jede UI-Aktion hat einen API-Endpoint. Fehlende Endpoints identifizieren und implementieren. Sidebar-Zustand, Tab-Auswahl, Filter-Zustand per API speichern/laden. |
| 5.2 | User-Preferences-API erweitern | 4h | UI-Einstellungen (Sidebar collapsed, theme, language, active tab, sort preferences) per API speichern/laden. |
| 5.3 | Workflow-API-Frontend-Modul | 4h | `api/workflows.ts` erstellen. Workflow-Definitions CRUD, Instances, Step-History. |
| 5.4 | Playwright E2E-Tests: Setup | 4h | `@playwright/test` installieren. `playwright.config.ts`. Test-Helper für Login, API-Calls. |
| 5.5 | Playwright: auth.spec.ts | 3h | Login → Logout E2E-Test. |
| 5.6 | Playwright: contact-crud.spec.ts | 4h | Contact erstellen → bearbeiten → Ansprechpartner hinzufügen → löschen. |
| 5.7 | Playwright: search.spec.ts | 3h | Globale Suche, Filter, Ergebnisse prüfen. |
| 5.8 | Playwright: plugin-toggle.spec.ts | 3h | Plugin aktivieren/deaktivieren, UI-Änderung prüfen. |
| 5.9 | Playwright: mail.spec.ts | 4h | Mail-Konto anlegen, Ordner anzeigen, Mail öffnen. |
| 5.10 | Playwright: dms.spec.ts | 4h | Ordner erstellen, Datei hochladen, Vorschau, teilen. |
| 5.11 | Playwright: calendar.spec.ts | 4h | Termin erstellen, Kalender wechseln, Kanban-View. |
| 5.12 | API-Health-Check-Script für KI | 4h | `scripts/ai_health_check.py`: Prüft alle API-Endpunkte, gibt strukturierten Report. KI kann das vor/nach Updates laufen lassen. |
| 5.13 | CI/CD-Pipeline für KI-Updates | 6h | `scripts/ai_deploy.py`: KI kann Build erstellen, Tests laufen, bei Erfolg deployen. Rollback bei Fehler. |
| 5.14 | API-Dokumentation vervollständigen | 4h | OpenAPI/Swagger prüfen. Alle Endpoints dokumentiert. Beispiele für KI. |
| 5.15 | Automatisiertes Backup-System | 8h | `pg_dump` + Storage-Backup als Cron-Job (nutzt Cron-Scheduler aus Phase 3.5). Backup-Konfiguration in Settings (Intervall, Aufbewahrung, Ziel: lokal/S3/Nextcloud). Restore-Script. Backup-Status in Dashboard. Notification bei Backup-Fehler. |
| 5.16 | MCP-Server Integration | 10h | LeoCRM als MCP-Server: Externe Tools (Claude Desktop, andere KI-Clients) können auf LeoCRM-Daten zugreifen. MCP-Tools für Contacts, Calendar, Mail, DMS. Authentifiziert via API-Token. MCP-Config-Endpoint `GET /api/v1/mcp/tools`. |
| 5.17 | MCP-Client Integration | 6h | LeoCRM-Agenten können externe MCP-Server nutzen (z.B. Web-Search, Code-Execution, externe Datenquellen). MCP-Client in tool_registry integriert. Admin kann MCP-Server in Settings konfigurieren. Agenten nutzen MCP-Tools wie native Tools. |
| 5.18 | Report Generator: PDF-Support & Druck-Funktionen | 8h | Backend: WeasyPrint für PDF-Generierung aus Jinja2-Templates. Vorgefertigte Berichte: Kontaktliste, Kalender (Woche/Monat), Firmenliste, Audit-Log. Druck-Optimierte Templates (A4, Landscape). `output_format` um `pdf` und `print` erweitern. |
| 5.19 | Report Generator: Frontend-Oberfläche | 10h | `Reports.tsx` Seite: Template-Liste, Template-Editor (Code-Editor für Jinja2), Report-Generierung mit Live-Preview, Download-History. Vorgefertigte Berichte als Buttons ("Kontakt-Liste drucken", "Kalender drucken"). Druck-Dialog mit Format-Auswahl (A4/A5/Landscape). |
| 5.20 | Custom Fields: Plugin-Felder in UI | 6h | Plugins sollen Custom Fields mitbringen können. Plugin-Manifest um `custom_fields` Definition erweitern. Frontend: Dynamische Custom-Field-Renderer in Contact-Detail, ContactEditModal. Feld-Typen: text, number, date, select, multiselect, boolean. Felder werden in `contacts.custom` JSONB gespeichert. |
| 5.21 | Tasks-Plugin | 12h | Eigenes Tasks-Plugin: Freie Aufgaben/Aktivitäten verwalten (Anruf protokollieren, Notiz, Besuch). Verknüpfung mit Kontakten. Tasks haben Status (open/in_progress/done), Priorität, Fälligkeitsdatum, Zuweisung an Nutzer. Tasks-Liste mit Filter. ARQ-Reminder für fällige Tasks. Plugin-Manifest, Models, Routes, Schemas, Frontend-Seite. |
| 5.22 | Saved Searches / Smart Lists | 6h | Jede Listen-Ansicht (Contacts, Mail, Calendar, DMS) bekommt Filter-Funktionalität. Filter können gespeichert werden (Name, Filter-Kriterien). Gespeicherte Filter erscheinen als Tabs oder Sidebar-Einträge. `saved_filters` Tabelle (tenant-scoped, user-scoped). Frontend: Filter-Builder UI, Save-Button, Load-Gespeicherte-Filter. |
| 5.23 | Deduplication / Merge (über KI/Automatisierung) | 6h | Contacts-Plugin bietet Dubletten-Erkennung: KI-gestützter Vergleich von Kontakten (Name, E-Mail, Telefon). Automation-Template: "Dubletten finden und zusammenführen". Merge-UI: Zwei Kontakte vergleichen, Felder auswählen, zusammenführen. `contact_merge_history` Tabelle. |
| 5.24 | PWA (Progressive Web App) | 6h | Frontend als PWA planen: `manifest.json`, Service Worker, Offline-Caching für statische Assets, Add-to-Home-Screen, App-Icon. Vite PWA Plugin installieren. Push-Notifications vorbereiten (Notification API). |
| 5.25 | Dashboard-System ausbauen | 8h | Plugins bringen Dashboard-Komponenten mit und melden diese an. Plugin-Manifest um `dashboard_widgets` erweitern (bereits in Architektur definiert aber nicht implementiert). Dashboard lädt Widgets dynamisch aus Plugin-Registry. Widget-Typen: Stat-Cards, Charts, Recent-Activity, Quick-Actions. Frontend: Dashboard-Grid mit drag-and-drop Widget-Positionierung. |
**Phase 5 Gesamt: ~145h**
---
### PHASE 6: React Hook Form + Zod überall
**Ziel:** Konsistente Form-Validierung in allen Formularen
| # | Aufgabe | Aufwand | Details |
|---|---|---|---|
| 6.1 | ComposeModal (Mail) auf RHF + Zod | 4h | E-Mail-Validierung, Pflichtfelder, CC/BCC. |
| 6.2 | AppointmentModal (Calendar) auf RHF + Zod | 4h | Datum-Validierung, Pflichtfelder, Recurrence. |
| 6.3 | SettingsForms auf RHF + Zod | 6h | SettingsUsers, SettingsRoles, SettingsGroups, SettingsCurrencies, SettingsTaxes, SettingsSequences, SettingsSystem. |
| 6.4 | DMS-Forms (Folder create, Share) auf RHF + Zod | 3h | |
| 6.5 | Tag-Forms auf RHF + Zod | 2h | |
| 6.6 | Mail-Settings-Forms auf RHF + Zod | 4h | Account-Erstellung, Rules, Signatures, Templates. |
**Phase 6 Gesamt: ~23h**
---
### PHASE 7: Test-Vollendung & Wartbarkeit
**Ziel:** Vollständige Test-Abdeckung für KI-Wartbarkeit
| # | Aufgabe | Aufwand | Details |
|---|---|---|---|
| 7.1 | Tests für ungetestete Settings-Pages | 6h | SettingsGroups, SettingsSystem, SettingsCurrencies, SettingsTaxes, SettingsSequences, SettingsNotifications, SettingsPlugins. |
| 7.2 | Tests für AI-Komponenten | 4h | ChatWindow, SessionList, SuggestionSidebar, AISettings, ProactiveAISettings. |
| 7.3 | Tests für Calendar-Page | 3h | Calendar.tsx (717 Zeilen), CalendarKanban.tsx. |
| 7.4 | Tests für DMS-Sub-Komponenten | 4h | FileExplorer, SourceTree, FileGrid, FileDetails, BulkActions. |
| 7.5 | Tests für Contact-Sub-Komponenten | 3h | ContactDetail, ContactEditModal, ContactFolderTree. |
| 7.6 | Tests für Comm-Blocks | 3h | BlockRenderer und alle Block-Typen. |
| 7.7 | Tests für Stores | 2h | authStore, uiStore, commStore, pluginToolbarStore, calendarStore. |
| 7.8 | Backend-Test-Lücken schließen | 8h | Tests für fehlende Plugin-Routes, Edge-Cases, Multi-Tenant-Szenarien. |
| 7.9 | Test-Runner-Script für KI | 3h | `scripts/ai_run_tests.py`: Führt alle Tests aus (Backend + Frontend + E2E), gibt strukturierten Report. |
**Phase 7 Gesamt: ~36h**
---
## Zusammenfassung: Aufwandsschätzung (korrigiert)
| Phase | Thema | Aufwand | Vorher | Änderung |
|---|---|---|---|---|
| 0 | Vorbereitung & Cleanup | ~77h | ~14h | **+63h** (Design, Theme, RBAC, LiteLLM, Search-RBAC, Undo, Storage, Import/Export, Config-Cleanup, Mail-Salt, PyMuPDF→pypdf, OnlyOffice→Collabora) |
| 1 | Unified Contact (Backend+Frontend) | **~81h** | ~33h | **+48h** — Company-Referenzen in 6 Plugins + Permission-Registry + Addresses + conftest unterschätzt |
| 2 | Code-Splitting & Performance | ~25h | ~25h | — |
| 3 | Plugin-UI-System | ~58h | ~48h | +10h (Plugin-Install-System) |
| 3.5 | Automation & Agents Plugin | ~105h | — | **NEU** — Agent Builder, Automation, Cron, Logs, Safety, Agent-zu-Agent, Versionshistorie, MiniApps |
| 4 | KI-UI-Steuerung | ~44h | ~44h | — |
| 5 | API, Testbarkeit, Backup, MCP, Reports, Custom Fields, Tasks, Saved Searches, Dedup, PWA, Dashboard | ~145h | ~57h | +88h |
| 6 | React Hook Form + Zod | ~23h | ~23h | — |
| 7 | Test-Vollendung | ~36h | ~36h | — |
| | **GESAMT** | **~590h** | ~280h | **+310h** |
---
## Empfohlene Reihenfolge
```
Phase 0 (Vorbereitung & Cleanup)
↓
Phase 1 (Unified Contact — Backend+Frontend) ← Core-CRM-Feature, größte Phase
↓
Phase 2 (Code-Splitting & Performance)
↓
Phase 3 (Plugin-UI-System) ← WordPress-Style, nicht zu lange schieben
↓
Phase 3.5 (Automation & Agents Plugin) ← Agent Builder, Cron-Scheduler, Automation
↓
Phase 4 (KI-UI-Steuerung) ← Baut auf Plugin-System auf
↓
Phase 5 (API-Vollständigkeit & Testbarkeit) ← KI kann selbstständig testen
↓
Phase 6 (React Hook Form + Zod) ← Qualität
↓
Phase 7 (Test-Vollendung) ← Wartbarkeit für KI
```
**Begründung der Reihenfolge:**
1. Phase 0 zuerst: Dependencies und Cleanup als Fundament
2. Phase 1 als Nächstes: Core-CRM-Feature (Contacts) muss vollständig sein. Größte Phase (~74h) weil 'company' überall im Code verankert ist.
3. Phase 2: Code-Splitting ist schnell und bringt sofortige Performance-Verbesserung
4. Phase 3: Plugin-UI-System — je früher desto besser, sonst wird Umbau später schwieriger
5. Phase 4: KI-UI-Steuerung baut auf Plugin-System auf (dynamische Routes, Tabs etc.). Bestehende WebSocket-Infrastruktur aus kommunikation Plugin als Referenz.
6. Phase 5: API-Vollständigkeit und E2E-Tests für KI-Wartbarkeit
7. Phase 6+7: Qualität und Test-Vollendung
---
## Was bei der Überprüfung gefunden wurde
### Phase 1 Korrektur: +41h Aufwand
Die ursprüngliche Schätzung von 33h für Phase 1 war **massiv unterschätzt**. Die gründliche Code-Analyse zeigte:
**'company' als entity_type ist in 6 Plugins verankert:**
- `entity_links`: entity_type Pattern, company_router, on_company_deleted Event-Handler
- `unified_search`: CompanySearchProvider, index_company, company.created/updated Events, search_engine Mapping
- `calendar`: entity_type Pattern für EntryLinks
- `tags`: entity_type Pattern für Tag-Assignments
- `mail`: company_id Spalte in mails Tabelle (DB-Migration nötig!)
- `ai/action_mapper`: Company-Intents (create/delete/update/list)
**Event-Namen müssen migriert werden:**
- `company.created` → `contact.created`
- `company.updated` → `contact.updated`
- `company.deleted` → `contact.deleted`
- Betroffen: unified_search, entity_links, workflows, test_sample, manifest.py
**DB-Migration nötig:**
- `entity_links.entity_type = 'company'` → `'contact'`
- `tag_assignments.entity_type = 'company'` → `'contact'`
- `calendar_entry_links.entity_type = 'company'` → `'contact'`
- `mails.company_id` → `mails.contact_id` (Spalte umbenennen)
**Was NICHT geändert wird:**
- `system_settings.company_name`, `company_street` etc. → Das ist die CRM-Besitzer-Firmeninfo für Rechnungen. Bleibt wie es ist.
- `CalendarType = 'company'` → Das ist ein Kalender-Typ (Firmenkalender), keine Entity-Referenz. Kann bleiben.
### Bestehende WebSocket-Infrastruktur
Das `kommunikation` Plugin hat bereits eine vollständige WebSocket-Implementierung (`/api/v1/comm/ws`, `websocket_manager.py`). Diese kann als Referenz für die KI-UI-Steuerung (Phase 4) dienen — das spart Entwicklungszeit.
---
## KI-Wartbarkeit: Schlüssel-Anforderungen
Damit ein KI-Agent die App selbstständig warten kann:
1. **Vollständige API-Abdeckung:** Jede UI-Funktion per API steuerbar (Phase 5)
2. **E2E-Tests:** Playwright-Tests die KI ausführen kann (Phase 5)
3. **API-Health-Check:** Script das alle Endpunkte prüft (Phase 5)
4. **Test-Runner:** Script das alle Tests ausführt und strukturiert reportet (Phase 7)
5. **Deploy-Script:** KI kann Build erstellen, testen, deployen, rollback (Phase 5)
6. **Plugin-Richtlinien:** Klare Vorgaben damit KI neue Plugins erstellen kann (Phase 3)
7. **Dokumentation:** Aktuelle Architektur-Doku, API-Doku, Plugin-Guide (Phase 0+3+5)
---
## Nächste Schritte
1. ✅ Nextcloud Backup erstellt (`/Backups/leocrm/leocrm-backup-20260722.bundle`)
2. ✅ Plan gründlich überprüft und korrigiert (+45h)
3. ⬜ Plan freigeben
4. ⬜ Phase 0 starten
5. ⬜ Planungsdokumente aktualisieren
---
## Test-Strategie (pro Phase)
### Phase 0: Vorbereitung & Cleanup
- **Pro Task:** Unit-Test für geänderte Funktionalität (z.B. Test dass lucide-react Icons rendern, Test dass date-fns formatiert, Test dass Storage Backend local+S3 funktioniert)
- **Regression:** Alle bestehenden Tests müssen weiterhin durchlaufen
- **Lizenz-Test:** `pip-licenses` Script prüft dass keine AGPL-Packages mehr in requirements.txt
### Phase 1: Unified Contact Model
- **Pro Task:** API-Integration-Test (httpx + pytest) für jeden geänderten Endpoint
- **DB-Migration-Test:** Test dass Migration 0023 (entity_type company→contact) korrekt ausführt und rollbackbar ist
- **Plugin-Test:** Pro Plugin (entity_links, unified_search, calendar, tags, mail) Test dass entity_type='contact' funktioniert
- **Frontend-Test:** Vitest für ContactDetail, ContactEditModal, ContactPerson-Verwaltung
- **Cross-Tenant-Test:** Test dass Tenant-Isolation nach Migration noch funktioniert
### Phase 2: Code-Splitting & Performance
- **Bundle-Test:** Test dass Initial-Bundle < 300KB (vorher alle Pages im Bundle)
- **Virtual Scrolling Test:** Test mit 10.000 Datensätzen — Rendering-Zeit < 500ms
- **Lazy-Loading Test:** Test dass Plugin-Pages nicht im Initial-Bundle sind
### Phase 3: Plugin-UI-System
- **PluginRegistry-Test:** Test dass Manifests korrekt geladen und gerendert werden
- **PluginLoader-Test:** Test dass lazy-loaded Komponenten mit Suspense funktionieren
- **Plugin-Install-Test:** Test dass ZIP-Upload validiert und installiert wird
- **Error-Boundary-Test:** Test dass fehlerhaftes Plugin nicht die ganze App crashen lässt
### Phase 3.5: Automation & Agents
- **Cron-Scheduler-Test:** Test dass Cron-Jobs zur richtigen Zeit enqueued werden
- **Workflow-Timeout-Test:** Test dass abgelaufene Workflows cancelled werden
- **Agent-Runner-Test:** Test dass Agent LLM-Call ausführt und Ergebnis zurückgibt (Mock-LLM)
- **Automation-Engine-Test:** Test dass Event-Trigger → Conditions → Actions korrekt ausgeführt werden
- **Agent-zu-Agent-Test:** Test dass Agent A Nachricht an Agent B sendet und B reagiert
- **Rate-Limiting-Test:** Test dass Agent nach Max-Ausführungen gestoppt wird
- **Dry-Run-Test:** Test dass Dry-Run keine destruktiven Actions ausführt
### Phase 4: KI-UI-Steuerung
- **WebSocket-Test:** Test dass Commands korrekt gesendet und empfangen werden
- **Command-Test:** Pro Command-Typ (navigate, filter, open_contact, modal, tab, settings) ein Test
- **Feedback-Test:** Test dass Frontend Bestätigung an KI zurücksendet
### Phase 5: API-Vollständigkeit & Features
- **E2E-Tests (Playwright):** auth, contact-crud, search, plugin-toggle, mail, dms, calendar (7 Specs)
- **API-Health-Check-Test:** Test dass alle Endpoints erreichbar und korrekt responden
- **Backup-Test:** Test dass Backup erstellt wird und Restore funktioniert
- **MCP-Test:** Test dass MCP-Server Tools bereitstellt und MCP-Client Tools nutzt
- **Report-Test:** Test dass PDF/CSV/Excel generiert wird und korrekt formatiert ist
- **Custom-Fields-Test:** Test dass Plugin-Felder in UI gerendert und gespeichert werden
- **Tasks-Plugin-Test:** Vollständige CRUD-Tests für Tasks
- **Saved-Searches-Test:** Test dass Filter gespeichert und geladen werden
- **Dedup-Test:** Test dass Dubletten erkannt und gemerged werden
- **PWA-Test:** Test dass Service Worker registriert wird und Offline-Caching funktioniert
- **Dashboard-Test:** Test dass Plugin-Widgets dynamisch gerendert werden
### Phase 6: React Hook Form + Zod
- **Pro Form:** Test dass Validierung korrekt funktioniert (Pflichtfelder, E-Mail-Format, Datum-Range)
- **Error-Display-Test:** Test dass Fehlermeldungen korrekt angezeigt werden
### Phase 7: Test-Vollendung
- **Coverage-Target:** >80% Backend, >70% Frontend
- **Test-Runner-Script:** `scripts/ai_run_tests.py` führt alle Tests aus und gibt strukturierten Report
- **Multi-Tenant-Test:** Test mit 3 Tenants — Isolation, Cross-Tenant-Access → 404
- **Performance-Test:** 200k Contacts — List < 500ms, FTS < 500ms
### Test-Infrastruktur
- **Backend:** pytest + httpx + pytest-asyncio + pytest-cov (bereits vorhanden)
- **Frontend:** Vitest + @testing-library/react (bereits vorhanden)
- **E2E:** Playwright (neu in Phase 5)
- **Test-DB:** PostgreSQL mit `pytest-asyncio` fixture (bereits in conftest.py)
- **Test-Redis:** Redis-Mock oder echte Redis-Instanz
- **Mock-LLM:** LiteLLM mock mode für AI-Tests (bereits vorhanden)
---
## Agent-Anleitung: Wie ein KI-Agent diesen Plan umsetzt
Dieser Plan ist so strukturiert dass ein KI-Agent (wie Agent Zero) ihn Task-für-Task umsetzen kann.
### Vorgehensweise pro Task
1. **Task lesen:** Jeder Task hat Nummer, Aufwand, Beschreibung und Details
2. **Code prüfen:** Vor der Umsetzung den aktuellen Code inspizieren (Dateien lesen, Abhängigkeiten prüfen)
3. **Minimal-invasiv arbeiten:** Nur das ändern was der Task verlangt. Keine Refactoring-Touren.
4. **Tests schreiben/aktualisieren:** Pro Task mindestens ein Test der die Änderung abdeckt
5. **Commit:** Pro Task ein Git-Commit mit klarer Message (z.B. `Phase 0.2: install lucide-react and migrate icons`)
6. **Verifizieren:** Nach jedem Task: Tests laufen, Build funktioniert, keine Regressionen
### Phasen-Reihenfolge ist verbindlich
- Phase N+1 darf erst starten wenn Phase N abgeschlossen ist
- Innerhalb einer Phase können Tasks parallel sein (z.B. 0.2 und 0.3 unabhängig)
- Abhängigkeiten sind in den Task-Beschreibungen genannt
### Was ein Agent pro Task braucht
- Dateipfade der zu ändernden Dateien (in Task-Beschreibung genannt)
- Akzeptanzkriterien (in Task-Beschreibung genannt)
- Test-Strategie (pro Task mindestens ein Test)
- Git-Commit pro Task
### Plugin-Entwicklung
Wenn ein Agent ein neues Plugin erstellt (z.B. Tasks-Plugin 5.21):
1. Plugin-Verzeichnis in `app/plugins/builtins/<name>/` erstellen
2. `plugin.py` mit Manifest (Name, Version, Dependencies, Routes, Permissions, Events)
3. `models.py` mit SQLAlchemy Models (TenantMixin!)
4. `schemas.py` mit Pydantic Schemas
5. `routes.py` mit FastAPI Router (require_permission!)
6. `services.py` mit Business-Logic
7. Migration in `migrations/` Verzeichnis
8. Frontend-Komponenten in `frontend/src/components/<name>/`
9. Frontend-Seite in `frontend/src/pages/<Name>.tsx`
10. API-Modul in `frontend/src/api/<name>.ts`
11. Route in `frontend/src/routes/index.tsx` registrieren
12. i18n-Keys in `frontend/src/i18n/locales/de.json` und `en.json`
13. Tests in `tests/test_<name>.py` und `frontend/src/__tests__/<name>/`
### Plugin-Manifest-Format (für neue Plugins)
```python
manifest = PluginManifest(
name="my_plugin",
version="1.0.0",
display_name="My Plugin",
description="What it does",
dependencies=["permissions"], # other plugins this depends on
routes=[PluginRouteDef(path="/api/v1/my-plugin", module="...", router_attr="router")],
events=["my.event"], # events this plugin listens to
migrations=["0001_initial.sql"],
permissions=["my_plugin:read", "my_plugin:write"],
is_core=False,
# Neue Felder (nach Phase 3+3.5):
# agent_definitions=[...], # Agent-Templates
# automation_templates=[...], # Automation-Vorlagen
# cron_jobs=[...], # Periodische Tasks
# custom_fields=[...], # Custom Field Definitionen
# dashboard_widgets=[...], # Dashboard-Komponenten
# miniapps=[...], # MiniApp-Definitionen
)
```
### Wichtige Regeln für Agent-Updates
1. **Niemals Tests ändern** um sie grün zu bekommen — Code fixen nicht Tests anpassen
2. **Niemals .env committen** — Secrets gehören nicht ins Repo
3. **Jede DB-Änderung braucht Alembic-Migration** — keine manuellen SQL-Changes
4. **Jede API-Route braucht RBAC** — `require_permission()` auf jedem Endpoint
5. **Jedes Plugin-Model braucht TenantMixin** — tenant_id auf jeder Tabelle
6. **Frontend-Änderungen brauchen i18n** — alle Texte in de.json und en.json
7. **Pro Task ein Commit** — nicht mehrere Tasks in einem Commit
8. **Nach jedem Task: Tests + Build verifizieren** — keine Regressionen
9. **Nach jedem Task: Progress aktualisieren** — `PROGRESS.md` im Repo aktualisieren mit: Task-Nummer, Status (done/in-progress/blocked), Datum, was gemacht wurde, was als Nächstes ansteht. **Zwingend für jeden Agenten der am Plan arbeitet.**
+37
View File
@@ -0,0 +1,37 @@
# LeoCRM — Umbau Progress
**Plan:** `MASTER-PLAN.md`
**Start:** 2026-07-23
---
## Phase 0: Vorbereitung & Cleanup
| Task | Status | Datum | Notiz |
|---|---|---|---|
| 0.1 | ✅ done | 2026-07-23 | codebase-vs-requirements.md neu geschrieben, security-review-phase2.md Resolution Summary, architecture.md Implementation Status, MASTER-PLAN.md + PROGRESS.md erstellt |
| 0.2 | ✅ done | 2026-07-23 | lucide-react installieren + Icons migrieren |
| 0.3 | ✅ done | 2026-07-23 | date-fns installieren + Datum-Formatierung |
| 0.4 | ✅ done | 2026-07-23 | hooks.ts aufteilen — 1298 Zeilen → 12 Module + Re-Export-Hub |
| 0.5 | ✅ done | 2026-07-23 | calendarStore.ts nach store/ verschoben, stores/ entfernt |
| 0.6 | ✅ done | 2026-07-23 | frontend-gap-analysis.md gespeichert (176 Zeilen) |
| 0.7 | ✅ done | 2026-07-23 | UI-Design-Richtlinien erstellt (docs/ui-design-guidelines.md, 535 Zeilen) |
| 0.8 | ✅ done | 2026-07-23 | Theme-Customization Backend: 4 Felder (primary_color, accent_color, font_family, border_radius) zu model/schema/service, Migration 0023 | Theme-Customization Backend |
| 0.9 | ✅ done | 2026-07-23 | Theme-Customization Frontend: SettingsTheme.tsx, themeStore.ts, Route + Nav, i18n keys, Live-Preview, Dark-Mode-Toggle | Theme-Customization Frontend |
| 0.10 | ✅ done | 2026-07-23 | RBAC-Audit: 4 Plugins (calendar, dms, entity_links, tags) mit Permissions versehen, 53 Routes mit require_permission abgesichert | RBAC-Audit & Plugin-Permissions nachrüsten |
| 0.11 | ✅ done | 2026-07-23 | LiteLLM-Cleanup: llm_client.py von httpx auf litellm.acompletion migriert, AI_PROVIDER env var, System-Prompt companies→contacts | LiteLLM-Cleanup & alte llm_client.py migrieren |
| 0.12 | ✅ done | 2026-07-23 | KI-Agent-Framework: docs/plugin-development-guide.md (348 Zeilen), agent_capabilities Feld im PluginManifest | KI-Agent-Framework in Plugin-Richtlinien dokumentieren |
| 0.13 | ✅ done | 2026-07-23 | Heartbeat konfigurierbar: ProactiveSettings um heartbeat_enabled/interval/target_room erweitert, Migration 0024, Schema+Service+Routes, Frontend-UI, Jobs.py nutzt Settings | Heartbeat konfigurierbar machen |
| 0.14 | ✅ done | 2026-07-23 | Unified Search Field-Level RBAC: resolve_permissions + filter_fields_by_permission in search route, entity-to-module mapping | Unified Search: Field-Level RBAC nachrüsten |
| 0.15 | ✅ done | 2026-07-23 | Undo/History-System: EntityHistory model+service+routes, Migration 0025, contact_service Integration, HistoryViewer Komponente, ContactDetail Integration, i18n | Undo/History-System für CRUD-Operationen |
| 0.16 | ✅ done | 2026-07-23 | Storage Backend: app/core/storage.py (LocalStorage + S3Storage), DMS + Attachments + Mail auf Storage Backend umgestellt, minio zu requirements, S3 env vars | Storage Backend implementieren (S3-Support) |
| 0.17 | ✅ done | 2026-07-23 | Import/Export: unified Contact Fields (firstname, surname, email_1, phone_1, mobilephone, function), Company-Import als Contact type=company, Export mit unified Fields, Backward-compat für alte CSV-Spalten | Import/Export an unified Contact Model anpassen |
| 0.18 | ✅ done | 2026-07-23 | .gitignore: webui→frontend, python-jose entfernt, pyproject.toml Python 3.12, .env aus Git entfernt, dump.rdb+test.txt gelöscht, JWT-Vars aus .env.docker.example entfernt | .gitignore & Config-Cleanup |
| 0.19 | ✅ done | 2026-07-23 | Mail-Salt Security-Fix: per-account random salt (generate_salt), encrypt/decrypt mit salt_b64, backward-compat mit Legacy-Salt, Migration 0026 | Mail-Salt Security-Fix |
| 0.20 | ✅ done | 2026-07-23 | AGPL ersetzt: PyMuPDF→pypdf (BSD), OnlyOffice→Collabora (LGPL/MPL), requirements.txt, LICENSE (MIT), THIRD_PARTY_LICENSES.md | AGPL-Lizenzen durch pypdf + Collabora ersetzen |
---
## Phase 1-7: Noch nicht gestartet
Siehe `MASTER-PLAN.md` für alle Tasks.
+74
View File
@@ -0,0 +1,74 @@
# Third-Party Licenses
This file lists all third-party software components used by LeoCRM,
along with their respective licenses.
## Backend Dependencies (Python)
| Package | License | Usage |
|---|---|---|
| FastAPI | MIT | Web framework |
| SQLAlchemy | MIT | ORM / database toolkit |
| Alembic | MIT | Database migrations |
| Pydantic | MIT | Data validation |
| Pydantic Settings | MIT | Settings management |
| asyncpg | Apache 2.0 | PostgreSQL async driver |
| Redis (redis-py) | MIT | Redis client |
| httpx | BSD-3-Clause | HTTP client |
| LiteLLM | MIT | Unified LLM interface |
| PydanticAI | MIT | AI agent framework |
| pypdf | BSD-3-Clause | PDF text extraction |
| python-docx | MIT | DOCX text extraction |
| openpyxl | MIT | XLSX text extraction |
| python-pptx | MIT | PPTX text extraction |
| aiofiles | Apache 2.0 | Async file I/O |
| minio | Apache 2.0 | S3-compatible storage client |
| cryptography | Apache 2.0 | Encryption (Fernet, PBKDF2) |
| bcrypt | Apache 2.0 | Password hashing |
| nh3 | MIT | HTML sanitization |
| python-multipart | Apache 2.0 | Multipart form parsing |
| pgvector | PostgreSQL License | Vector similarity search |
| APScheduler | MIT | Job scheduling |
| websockets | BSD-3-Clause | WebSocket support |
## Frontend Dependencies (Node.js)
| Package | License | Usage |
|---|---|---|
| React | MIT | UI framework |
| React Router | MIT | Client-side routing |
| TanStack Query | MIT | Server state management |
| TanStack Table | MIT | Table/data grid |
| Zustand | MIT | State management |
| Tailwind CSS | MIT | CSS framework |
| lucide-react | ISC | Icon library |
| date-fns | MIT | Date utilities |
| react-i18next | MIT | Internationalization |
| i18next | MIT | Internationalization core |
| react-hook-form | MIT | Form management |
| zod | MIT | Schema validation |
| clsx | MIT | Class name utility |
| Vite | MIT | Build tool |
| Vitest | MIT | Test framework |
## External Services
| Service | License | Usage |
|---|---|---|
| Collabora Online | LGPL/MPL | Document editing (DMS) |
| PostgreSQL | PostgreSQL License | Database |
| Redis | BSD-3-Clause | Cache / sessions |
## Replaced AGPL Components
The following AGPL-licensed components have been replaced with permissively
licensed alternatives to allow commercial use without copyleft obligations:
| Original | License | Replacement | License |
|---|---|---|---|
| PyMuPDF (fitz) | AGPL-3.0 | pypdf | BSD-3-Clause |
| OnlyOffice | AGPL-3.0 | Collabora Online | LGPL/MPL |
---
*This file is maintained manually and should be updated when dependencies change.*
@@ -0,0 +1,26 @@
"""Theme customization — add theme fields to system_settings.
Revision ID: 0023
Revises: 0022_contact_folders
Create Date: 2026-07-23
"""
from alembic import op
import sqlalchemy as sa
revision = "0023_theme_customization"
down_revision = "0022_contact_folders"
def upgrade():
op.add_column("system_settings", sa.Column("theme_primary_color", sa.String(20), nullable=False, server_default="#2563eb"))
op.add_column("system_settings", sa.Column("theme_accent_color", sa.String(20), nullable=False, server_default="#d946ef"))
op.add_column("system_settings", sa.Column("theme_font_family", sa.String(100), nullable=False, server_default="Inter"))
op.add_column("system_settings", sa.Column("theme_border_radius", sa.String(20), nullable=False, server_default="0.5rem"))
def downgrade():
op.drop_column("system_settings", "theme_border_radius")
op.drop_column("system_settings", "theme_font_family")
op.drop_column("system_settings", "theme_accent_color")
op.drop_column("system_settings", "theme_primary_color")
+24
View File
@@ -0,0 +1,24 @@
"""Heartbeat configuration — add heartbeat fields to ai_proactive_settings.
Revision ID: 0024
Revises: 0023_theme_customization
Create Date: 2026-07-23
"""
from alembic import op
import sqlalchemy as sa
revision = "0024_heartbeat_config"
down_revision = "0023_theme_customization"
def upgrade():
op.add_column("ai_proactive_settings", sa.Column("heartbeat_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
op.add_column("ai_proactive_settings", sa.Column("heartbeat_interval_seconds", sa.Integer(), nullable=False, server_default=sa.text("300")))
op.add_column("ai_proactive_settings", sa.Column("heartbeat_target_room", sa.String(200), nullable=False, server_default="Live KI"))
def downgrade():
op.drop_column("ai_proactive_settings", "heartbeat_target_room")
op.drop_column("ai_proactive_settings", "heartbeat_interval_seconds")
op.drop_column("ai_proactive_settings", "heartbeat_enabled")
+57
View File
@@ -0,0 +1,57 @@
"""Entity history table for undo/restore functionality.
Revision ID: 0025_entity_history
Revises: 0024_heartbeat_config
Create Date: 2026-07-23
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "0025_entity_history"
down_revision: Union[str, None] = "0024_heartbeat_config"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"entity_history",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("entity_type", sa.String(50), nullable=False),
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("action", sa.String(20), nullable=False),
sa.Column("snapshot_before", postgresql.JSONB, nullable=True),
sa.Column("snapshot_after", postgresql.JSONB, nullable=True),
sa.Column("changes", postgresql.JSONB, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_entity_history_tenant_id", "entity_history", ["tenant_id"])
op.create_index("ix_entity_history_entity_type", "entity_history", ["entity_type"])
op.create_index("ix_entity_history_entity_id", "entity_history", ["entity_id"])
op.create_index("ix_entity_history_user_id", "entity_history", ["user_id"])
op.create_index("ix_entity_history_created_at", "entity_history", ["created_at"])
op.create_index(
"ix_entity_history_tenant_entity",
"entity_history",
["tenant_id", "entity_type", "entity_id", "created_at"],
)
def downgrade() -> None:
op.drop_index("ix_entity_history_tenant_entity", table_name="entity_history")
op.drop_index("ix_entity_history_created_at", table_name="entity_history")
op.drop_index("ix_entity_history_user_id", table_name="entity_history")
op.drop_index("ix_entity_history_entity_id", table_name="entity_history")
op.drop_index("ix_entity_history_entity_type", table_name="entity_history")
op.drop_index("ix_entity_history_tenant_id", table_name="entity_history")
op.drop_table("entity_history")
@@ -0,0 +1,24 @@
"""Mail salt security fix — add password_salt column to mail_accounts.
Revision ID: 0026
Revises: 0025_entity_history
Create Date: 2026-07-23
Existing accounts get an empty salt and will use the legacy hardcoded salt
for backward compatibility. New accounts and password changes will use
per-account random salts.
"""
from alembic import op
import sqlalchemy as sa
revision = "0026_mail_salt_security"
down_revision = "0025_entity_history"
def upgrade():
op.add_column("mail_accounts", sa.Column("password_salt", sa.String(64), nullable=False, server_default=""))
def downgrade():
op.drop_column("mail_accounts", "password_salt")
+60 -35
View File
@@ -1,8 +1,11 @@
"""Configurable LLM client — supports OpenAI-compatible API or mock/stub mode. """Configurable LLM client — supports LiteLLM (100+ providers) or mock/stub mode.
Reads AI_MODEL and AI_API_KEY from environment. If not set, uses mock mode Reads AI_MODEL, AI_API_KEY, AI_PROVIDER from environment. If not set, uses mock mode
which returns predefined actions based on keyword matching. This allows which returns predefined actions based on keyword matching. This allows
tests to run without external API dependencies. tests to run without external API dependencies.
LiteLLM provides a unified interface to OpenAI, Anthropic, Google, Azure,
AWS Bedrock, Ollama, and many more providers.
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,7 +15,7 @@ import logging
import os import os
from typing import Any from typing import Any
import httpx import litellm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,16 +42,20 @@ class LLMClient:
"""LLM client that translates natural language to proposed API actions. """LLM client that translates natural language to proposed API actions.
Modes: Modes:
- If AI_MODEL and AI_API_KEY are set: calls OpenAI-compatible chat completions API - If AI_MODEL and AI_API_KEY are set: calls LiteLLM chat completions API
- Otherwise: mock/stub mode with keyword-based action mapping - Otherwise: mock/stub mode with keyword-based action mapping
LiteLLM model format: "provider/model_name" (e.g. "openai/gpt-4o", "anthropic/claude-3-sonnet", "ollama/llama3")
""" """
def __init__( def __init__(
self, model: str | None = None, api_key: str | None = None, api_base: str | None = None self, model: str | None = None, api_key: str | None = None, api_base: str | None = None,
): provider: str | None = None,
) -> None:
self.model = model or os.environ.get("AI_MODEL", "") self.model = model or os.environ.get("AI_MODEL", "")
self.api_key = api_key or os.environ.get("AI_API_KEY", "") self.api_key = api_key or os.environ.get("AI_API_KEY", "")
self.api_base = api_base or os.environ.get("AI_API_BASE", "https://api.openai.com/v1") self.api_base = api_base or os.environ.get("AI_API_BASE", "")
self.provider = provider or os.environ.get("AI_PROVIDER", "openai")
self.is_mock = not bool(self.model and self.api_key) self.is_mock = not bool(self.model and self.api_key)
async def generate(self, user_query: str, context: dict[str, Any] | None = None) -> LLMResponse: async def generate(self, user_query: str, context: dict[str, Any] | None = None) -> LLMResponse:
@@ -83,20 +90,28 @@ class LLMClient:
) )
async def _api_generate(self, query: str, context: dict[str, Any]) -> LLMResponse: async def _api_generate(self, query: str, context: dict[str, Any]) -> LLMResponse:
"""Call OpenAI-compatible chat completions API. """Call LLM via LiteLLM unified interface.
Sends a system prompt explaining the available API endpoints and asks Supports 100+ providers through a single API:
the LLM to propose actions in structured JSON format. - OpenAI: "openai/gpt-4o"
- Anthropic: "anthropic/claude-3-sonnet"
- Google: "gemini/gemini-pro"
- Azure: "azure/<deployment-name>"
- Ollama: "ollama/llama3"
- And many more.
""" """
system_prompt = self._build_system_prompt(context) system_prompt = self._build_system_prompt(context)
user_prompt = f"User request: {query}\n\nRespond with proposed actions as JSON." user_prompt = f"User request: {query}\n\nRespond with proposed actions as JSON."
headers = { # Build LiteLLM model string: "provider/model" or just "model" for OpenAI compat
"Authorization": f"Bearer {self.api_key}", if self.provider and self.provider != "openai":
"Content-Type": "application/json", litellm_model = f"{self.provider}/{self.model}"
} else:
body = { litellm_model = self.model
"model": self.model,
# Build kwargs for litellm.acompletion
kwargs: dict[str, Any] = {
"model": litellm_model,
"messages": [ "messages": [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}, {"role": "user", "content": user_prompt},
@@ -105,42 +120,52 @@ class LLMClient:
"max_tokens": 1000, "max_tokens": 1000,
} }
async with httpx.AsyncClient(timeout=30.0) as client: # Add API key if set
resp = await client.post( if self.api_key:
f"{self.api_base}/chat/completions", kwargs["api_key"] = self.api_key
headers=headers,
json=body,
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"] # Add API base if set (for self-hosted or custom endpoints)
if self.api_base:
kwargs["api_base"] = self.api_base
try:
response = await litellm.acompletion(**kwargs)
content = response.choices[0].message.content
return self._parse_llm_response(content) return self._parse_llm_response(content)
except Exception as e:
logger.error("LiteLLM API call failed: %s", e)
# Fall back to mock mode on API error
return LLMResponse(
message=f"LLM API call failed: {e}. Falling back to keyword matching.",
proposed_actions=[],
confidence=0.1,
)
def _build_system_prompt(self, context: dict[str, Any]) -> str: def _build_system_prompt(self, context: dict[str, Any]) -> str:
"""Build system prompt describing available API actions.""" """Build system prompt describing available API actions."""
available_apis = [ available_apis = [
{"method": "GET", "path": "/api/v1/companies", "description": "List companies"}, {"method": "GET", "path": "/api/v1/contacts", "description": "List contacts (persons and companies)"},
{"method": "POST", "path": "/api/v1/companies", "description": "Create a company"}, {"method": "POST", "path": "/api/v1/contacts", "description": "Create a contact (person or company)"},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/companies/{id}", "path": "/api/v1/contacts/{id}",
"description": "Get company details", "description": "Get contact details",
}, },
{ {
"method": "PATCH", "method": "PATCH",
"path": "/api/v1/companies/{id}", "path": "/api/v1/contacts/{id}",
"description": "Update a company", "description": "Update a contact",
}, },
{ {
"method": "DELETE", "method": "DELETE",
"path": "/api/v1/companies/{id}", "path": "/api/v1/contacts/{id}",
"description": "Delete a company", "description": "Delete a contact",
}, },
{"method": "GET", "path": "/api/v1/contacts", "description": "List contacts"},
{"method": "POST", "path": "/api/v1/contacts", "description": "Create a contact"},
{"method": "GET", "path": "/api/v1/workflows", "description": "List workflows"}, {"method": "GET", "path": "/api/v1/workflows", "description": "List workflows"},
{"method": "POST", "path": "/api/v1/workflows", "description": "Create a workflow"}, {"method": "POST", "path": "/api/v1/workflows", "description": "Create a workflow"},
{"method": "GET", "path": "/api/v1/calendar/entries", "description": "List calendar entries"},
{"method": "POST", "path": "/api/v1/calendar/entries", "description": "Create a calendar entry"},
{"method": "GET", "path": "/api/v1/dms/files", "description": "List DMS files"},
] ]
context_str = json.dumps(context) if context else "{}" context_str = json.dumps(context) if context else "{}"
return ( return (
+224
View File
@@ -0,0 +1,224 @@
"""Abstract storage backend — supports local filesystem and S3-compatible storage.
Configuration via environment variables:
- STORAGE_BACKEND: "local" (default) or "s3"
- STORAGE_PATH: Local storage base path (default: /data/uploads)
- S3_ENDPOINT: S3-compatible endpoint URL
- S3_BUCKET: Bucket name
- S3_ACCESS_KEY: Access key
- S3_SECRET_KEY: Secret key
- S3_REGION: Region (default: us-east-1)
- S3_SECURE: Use HTTPS (default: true)
"""
from __future__ import annotations
import io
import logging
import os
from abc import ABC, abstractmethod
from typing import Any
logger = logging.getLogger(__name__)
class StorageBackend(ABC):
"""Abstract storage backend for file operations."""
@abstractmethod
async def save(self, path: str, data: bytes) -> str:
"""Save data to storage at the given path. Returns the full storage path."""
...
@abstractmethod
async def read(self, path: str) -> bytes:
"""Read data from storage at the given path."""
...
@abstractmethod
async def delete(self, path: str) -> bool:
"""Delete a file from storage. Returns True if deleted, False if not found."""
...
@abstractmethod
async def exists(self, path: str) -> bool:
"""Check if a file exists in storage."""
...
@abstractmethod
async def get_url(self, path: str, expires: int = 3600) -> str:
"""Get a URL for accessing the file (presigned URL for S3, file path for local)."""
...
@abstractmethod
async def list_files(self, prefix: str) -> list[str]:
"""List all file paths under the given prefix."""
...
class LocalStorage(StorageBackend):
"""Local filesystem storage backend."""
def __init__(self, base_path: str | None = None) -> None:
self.base_path = base_path or os.environ.get("STORAGE_PATH", "/data/uploads")
os.makedirs(self.base_path, exist_ok=True)
def _full_path(self, path: str) -> str:
"""Get the full filesystem path."""
return os.path.join(self.base_path, path)
async def save(self, path: str, data: bytes) -> str:
full_path = self._full_path(path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "wb") as f:
f.write(data)
logger.debug("LocalStorage: saved %s (%d bytes)", path, len(data))
return path
async def read(self, path: str) -> bytes:
full_path = self._full_path(path)
with open(full_path, "rb") as f:
return f.read()
async def delete(self, path: str) -> bool:
full_path = self._full_path(path)
if os.path.exists(full_path):
os.remove(full_path)
return True
return False
async def exists(self, path: str) -> bool:
return os.path.exists(self._full_path(path))
async def get_url(self, path: str, expires: int = 3600) -> str:
# Local storage returns the file path for direct access
return self._full_path(path)
async def list_files(self, prefix: str) -> list[str]:
full_prefix = self._full_path(prefix)
if not os.path.isdir(full_prefix):
return []
result: list[str] = []
for root, _dirs, files in os.walk(full_prefix):
for fname in files:
rel = os.path.relpath(os.path.join(root, fname), self.base_path)
result.append(rel)
return result
class S3Storage(StorageBackend):
"""S3-compatible storage backend (works with AWS S3, MinIO, etc.)."""
def __init__(
self,
endpoint: str | None = None,
bucket: str | None = None,
access_key: str | None = None,
secret_key: str | None = None,
region: str | None = None,
secure: bool | None = None,
) -> None:
self.endpoint = endpoint or os.environ.get("S3_ENDPOINT", "")
self.bucket = bucket or os.environ.get("S3_BUCKET", "")
self.access_key = access_key or os.environ.get("S3_ACCESS_KEY", "")
self.secret_key = secret_key or os.environ.get("S3_SECRET_KEY", "")
self.region = region or os.environ.get("S3_REGION", "us-east-1")
self.secure = secure if secure is not None else os.environ.get("S3_SECURE", "true").lower() == "true"
self._client: Any = None # lazy init
def _get_client(self) -> Any:
"""Lazy-initialize the S3 client (minio or boto3)."""
if self._client is not None:
return self._client
try:
from minio import Minio # type: ignore
self._client = Minio(
endpoint=self.endpoint.replace("https://", "").replace("http://", ""),
access_key=self.access_key,
secret_key=self.secret_key,
secure=self.secure,
region=self.region,
)
# Ensure bucket exists
if not self._client.bucket_exists(self.bucket):
self._client.make_bucket(self.bucket)
logger.info("S3Storage: connected to %s, bucket=%s", self.endpoint, self.bucket)
return self._client
except ImportError:
logger.error("S3Storage: minio package not installed. Install with: pip install minio")
raise
except Exception as e:
logger.error("S3Storage: failed to connect to %s: %s", self.endpoint, e)
raise
async def save(self, path: str, data: bytes) -> str:
from io import BytesIO
client = self._get_client()
client.put_object(
bucket_name=self.bucket,
object_name=path,
data=BytesIO(data),
length=len(data),
)
logger.debug("S3Storage: saved %s (%d bytes)", path, len(data))
return path
async def read(self, path: str) -> bytes:
client = self._get_client()
response = client.get_object(self.bucket, path)
return response.read()
async def delete(self, path: str) -> bool:
client = self._get_client()
try:
client.remove_object(self.bucket, path)
return True
except Exception:
return False
async def exists(self, path: str) -> bool:
client = self._get_client()
try:
client.stat_object(self.bucket, path)
return True
except Exception:
return False
async def get_url(self, path: str, expires: int = 3600) -> str:
from datetime import timedelta
client = self._get_client()
return client.presigned_get_object(self.bucket, path, expires=timedelta(seconds=expires))
async def list_files(self, prefix: str) -> list[str]:
client = self._get_client()
objects = client.list_objects(self.bucket, prefix=prefix, recursive=True)
return [obj.object_name for obj in objects]
# ─── Factory ───
_storage_backend: StorageBackend | None = None
def get_storage_backend() -> StorageBackend:
"""Get the configured storage backend singleton."""
global _storage_backend
if _storage_backend is None:
backend_type = os.environ.get("STORAGE_BACKEND", "local").lower()
if backend_type == "s3":
_storage_backend = S3Storage()
logger.info("Storage backend: S3 (%s)", os.environ.get("S3_ENDPOINT", ""))
else:
_storage_backend = LocalStorage()
logger.info("Storage backend: Local (%s)", os.environ.get("STORAGE_PATH", "/data/uploads"))
return _storage_backend
def reset_storage_backend() -> None:
"""Reset the storage backend singleton (for testing)."""
global _storage_backend
_storage_backend = None
+2
View File
@@ -31,6 +31,7 @@ from app.routes import (
companies, companies,
contact_folders, contact_folders,
contacts, contacts,
entity_history,
groups, groups,
health, health,
import_export, import_export,
@@ -235,6 +236,7 @@ def create_app() -> FastAPI:
app.include_router(companies.router) app.include_router(companies.router)
app.include_router(contacts.router) app.include_router(contacts.router)
app.include_router(contact_folders.router) app.include_router(contact_folders.router)
app.include_router(entity_history.router)
app.include_router(import_export.router) app.include_router(import_export.router)
app.include_router(plugins.router) app.include_router(plugins.router)
app.include_router(ai_copilot.router) app.include_router(ai_copilot.router)
+2
View File
@@ -8,6 +8,7 @@ from app.models.auth import ApiToken, PasswordResetToken
from app.models.company import Company from app.models.company import Company
from app.models.contact import Contact, ContactPerson from app.models.contact import Contact, ContactPerson
from app.models.contact_folder import ContactFolder from app.models.contact_folder import ContactFolder
from app.models.entity_history import EntityHistory
from app.models.currency import Currency from app.models.currency import Currency
from app.models.group import Group, UserGroup from app.models.group import Group, UserGroup
from app.models.notification import Notification, NotificationPreference, NotificationType from app.models.notification import Notification, NotificationPreference, NotificationType
@@ -40,6 +41,7 @@ __all__ = [
"Contact", "Contact",
"ContactPerson", "ContactPerson",
"ContactFolder", "ContactFolder",
"EntityHistory",
"Currency", "Currency",
"TaxRate", "TaxRate",
"Sequence", "Sequence",
+40
View File
@@ -0,0 +1,40 @@
"""EntityHistory model — snapshot history for undo/restore functionality."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class EntityHistory(Base, TenantMixin):
"""Snapshot history for undo/restore functionality.
Every CRUD action (create/update/delete) stores a full entity snapshot
so users can undo changes or revert to previous versions.
"""
__tablename__ = "entity_history"
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False, index=True)
action: Mapped[str] = mapped_column(String(20), nullable=False) # 'create', 'update', 'delete'
snapshot_before: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
snapshot_after: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
changes: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
)
+5
View File
@@ -45,3 +45,8 @@ class SystemSettings(Base, TenantMixin):
invoice_prefix: Mapped[str] = mapped_column(String(20), nullable=False, default="RE-") invoice_prefix: Mapped[str] = mapped_column(String(20), nullable=False, default="RE-")
quote_prefix: Mapped[str] = mapped_column(String(20), nullable=False, default="AN-") quote_prefix: Mapped[str] = mapped_column(String(20), nullable=False, default="AN-")
payment_terms_days: Mapped[int] = mapped_column(Integer, nullable=False, default=14) payment_terms_days: Mapped[int] = mapped_column(Integer, nullable=False, default=14)
# Theme customization
theme_primary_color: Mapped[str] = mapped_column(String(20), nullable=False, default="#2563eb")
theme_accent_color: Mapped[str] = mapped_column(String(20), nullable=False, default="#d946ef")
theme_font_family: Mapped[str] = mapped_column(String(100), nullable=False, default="Inter")
theme_border_radius: Mapped[str] = mapped_column(String(20), nullable=False, default="0.5rem")
@@ -383,10 +383,10 @@ async def _extract_attachment_content(
text_content = content.decode("utf-8", errors="replace") text_content = content.decode("utf-8", errors="replace")
elif mime == "application/pdf" or att.filename.endswith(".pdf"): elif mime == "application/pdf" or att.filename.endswith(".pdf"):
try: try:
import fitz from pypdf import PdfReader
doc = fitz.open(stream=content, filetype="pdf") from io import BytesIO
text_content = "\n".join(page.get_text() for page in doc) reader = PdfReader(BytesIO(content))
doc.close() text_content = "\n".join(page.extract_text() or "" for page in reader.pages)
except ImportError: except ImportError:
text_content = f"[PDF file: {att.filename} - extraction not available]" text_content = f"[PDF file: {att.filename} - extraction not available]"
elif mime.startswith("image/"): elif mime.startswith("image/"):
+25 -4
View File
@@ -322,8 +322,9 @@ async def deep_analysis(
async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None: async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
"""Heartbeat job for the AI Proactive plugin. """Heartbeat job for the AI Proactive plugin.
Runs every 5 minutes (scheduled by the plugin on activation). Interval is configurable via ProactiveSettings.heartbeat_interval_seconds.
Posts a status message to the 'Live KI' room in the kommunikation system. Target room is configurable via ProactiveSettings.heartbeat_target_room.
Posts a status message to the configured room in the kommunikation system.
""" """
try: try:
uid = uuid.UUID(user_id) uid = uuid.UUID(user_id)
@@ -340,13 +341,33 @@ async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
) )
async with create_db_session(tid) as db: async with create_db_session(tid) as db:
# Create or get the 'Live KI' room for this user # Check if heartbeat is enabled and get configuration
from app.plugins.builtins.ai_proactive.models import ProactiveSettings
from sqlalchemy import select as sa_select
settings_result = await db.execute(
sa_select(ProactiveSettings)
.where(ProactiveSettings.tenant_id == tid)
.where(ProactiveSettings.user_id == uid)
.limit(1)
)
settings = settings_result.scalar_one_or_none()
# If no settings or heartbeat disabled, skip
if settings is not None and not settings.heartbeat_enabled:
logger.debug("heartbeat: disabled for user %s", user_id)
return
# Get target room name from settings or use default
target_room_title = settings.heartbeat_target_room if settings else "Live KI"
# Create or get the target room for this user
room = await create_plugin_room( room = await create_plugin_room(
db, db,
tid, tid,
uid, uid,
plugin_name="ai_proactive", plugin_name="ai_proactive",
title="Live KI", title=target_room_title,
participant_type="ai_proactive", participant_type="ai_proactive",
user_role="member", user_role="member",
) )
@@ -109,3 +109,7 @@ class ProactiveSettings(Base, TenantMixin):
Integer, nullable=False, default=10 Integer, nullable=False, default=10
) )
model: Mapped[str] = mapped_column(String(100), nullable=False, default="ollama/deepseek-v4-flash") model: Mapped[str] = mapped_column(String(100), nullable=False, default="ollama/deepseek-v4-flash")
# Heartbeat configuration
heartbeat_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
heartbeat_interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=300)
heartbeat_target_room: Mapped[str] = mapped_column(String(200), nullable=False, default="Live KI")
@@ -72,6 +72,9 @@ def _settings_to_response(s: ProactiveSettings) -> SettingsResponse:
confidence_threshold=s.confidence_threshold, confidence_threshold=s.confidence_threshold,
rate_limit_seconds=s.rate_limit_seconds, rate_limit_seconds=s.rate_limit_seconds,
model=s.model, model=s.model,
heartbeat_enabled=s.heartbeat_enabled,
heartbeat_interval_seconds=s.heartbeat_interval_seconds,
heartbeat_target_room=s.heartbeat_target_room,
) )
@@ -73,6 +73,9 @@ class SettingsResponse(BaseModel):
confidence_threshold: float confidence_threshold: float
rate_limit_seconds: int rate_limit_seconds: int
model: str model: str
heartbeat_enabled: bool = True
heartbeat_interval_seconds: int = 300
heartbeat_target_room: str = "Live KI"
available_models: list[str] = Field(default_factory=lambda: [ available_models: list[str] = Field(default_factory=lambda: [
'ollama/deepseek-v4-flash', 'ollama/deepseek-v4-flash',
'ollama/deepseek-v4-pro', 'ollama/deepseek-v4-pro',
@@ -90,6 +93,9 @@ class SettingsUpdate(BaseModel):
confidence_threshold: float | None = None confidence_threshold: float | None = None
rate_limit_seconds: int | None = None rate_limit_seconds: int | None = None
model: str | None = None model: str | None = None
heartbeat_enabled: bool | None = None
heartbeat_interval_seconds: int | None = None
heartbeat_target_room: str | None = None
class StatsResponse(BaseModel): class StatsResponse(BaseModel):
@@ -114,6 +114,9 @@ async def get_user_settings(
confidence_threshold=0.5, confidence_threshold=0.5,
rate_limit_seconds=10, rate_limit_seconds=10,
model="ollama/deepseek-v4-flash", model="ollama/deepseek-v4-flash",
heartbeat_enabled=True,
heartbeat_interval_seconds=300,
heartbeat_target_room="Live KI",
) )
db.add(settings) db.add(settings)
await db.flush() await db.flush()
+7 -1
View File
@@ -34,5 +34,11 @@ class CalendarPlugin(BasePlugin):
], ],
events=[], events=[],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql"],
permissions=[], permissions=[
"calendar:read",
"calendar:write",
"calendar:delete",
"calendar:share",
"calendar:admin",
],
) )
+22 -22
View File
@@ -22,7 +22,7 @@ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.deps import get_current_user, require_admin from app.deps import get_current_user, require_admin, require_permission
from app.plugins.builtins.calendar.ics_utils import ( from app.plugins.builtins.calendar.ics_utils import (
export_entries_to_ics, export_entries_to_ics,
ics_events_to_entry_data, ics_events_to_entry_data,
@@ -163,7 +163,7 @@ async def _check_write_permission(
# ─── Calendar CRUD ─── # ─── Calendar CRUD ───
@calendar_router.get("") @calendar_router.get("", dependencies=[Depends(require_permission("calendar:read"))])
async def list_calendars( async def list_calendars(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
@@ -180,7 +180,7 @@ async def list_calendars(
return [_calendar_to_dict(c) for c in cals] return [_calendar_to_dict(c) for c in cals]
@calendar_router.post("", status_code=status.HTTP_201_CREATED) @calendar_router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("calendar:write"))])
async def create_calendar( async def create_calendar(
body: CalendarCreate, body: CalendarCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -201,7 +201,7 @@ async def create_calendar(
return _calendar_to_dict(cal) return _calendar_to_dict(cal)
@calendar_router.patch("/{calendar_id}") @calendar_router.patch("/{calendar_id}", dependencies=[Depends(require_permission("calendar:write"))])
async def update_calendar( async def update_calendar(
calendar_id: str, calendar_id: str,
body: CalendarUpdate, body: CalendarUpdate,
@@ -226,7 +226,7 @@ async def update_calendar(
return _calendar_to_dict(cal) return _calendar_to_dict(cal)
@calendar_router.delete("/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT) @calendar_router.delete("/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("calendar:delete"))])
async def delete_calendar( async def delete_calendar(
calendar_id: str, calendar_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -259,7 +259,7 @@ async def delete_calendar(
return Response(status_code=204) return Response(status_code=204)
@calendar_router.post("/{calendar_id}/share") @calendar_router.post("/{calendar_id}/share", dependencies=[Depends(require_permission("calendar:share"))])
async def share_calendar( async def share_calendar(
calendar_id: str, calendar_id: str,
body: ShareRequest, body: ShareRequest,
@@ -297,7 +297,7 @@ async def share_calendar(
} }
@calendar_router.get("/{calendar_id}/permissions") @calendar_router.get("/{calendar_id}/permissions", dependencies=[Depends(require_permission("calendar:read"))])
async def get_permissions( async def get_permissions(
calendar_id: str, calendar_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -324,7 +324,7 @@ async def get_permissions(
# ─── Entries ─── # ─── Entries ───
@router.get("/calendar/entries") @router.get("/calendar/entries", dependencies=[Depends(require_permission("calendar:read"))])
async def list_entries( async def list_entries(
start: str | None = None, start: str | None = None,
end: str | None = None, end: str | None = None,
@@ -385,7 +385,7 @@ async def list_entries(
return all_occurrences return all_occurrences
@router.post("/calendar/entries", status_code=status.HTTP_201_CREATED) @router.post("/calendar/entries", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("calendar:write"))])
async def create_entry( async def create_entry(
body: EntryCreate, body: EntryCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -458,7 +458,7 @@ async def create_entry(
return _entry_to_dict(entry) return _entry_to_dict(entry)
@router.get("/calendar/entries/export") @router.get("/calendar/entries/export", dependencies=[Depends(require_permission("calendar:read"))])
async def export_entries( async def export_entries(
format: str = "csv", format: str = "csv",
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -519,7 +519,7 @@ async def export_entries(
) )
@router.get("/calendar/entries/{entry_id}") @router.get("/calendar/entries/{entry_id}", dependencies=[Depends(require_permission("calendar:read"))])
async def get_entry( async def get_entry(
entry_id: str, entry_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -555,7 +555,7 @@ async def get_entry(
return _entry_to_dict(entry, links, subtasks) return _entry_to_dict(entry, links, subtasks)
@router.patch("/calendar/entries/{entry_id}") @router.patch("/calendar/entries/{entry_id}", dependencies=[Depends(require_permission("calendar:write"))])
async def update_entry( async def update_entry(
entry_id: str, entry_id: str,
body: EntryUpdate, body: EntryUpdate,
@@ -611,7 +611,7 @@ async def update_entry(
return _entry_to_dict(entry) return _entry_to_dict(entry)
@router.delete("/calendar/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/calendar/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("calendar:delete"))])
async def delete_entry( async def delete_entry(
entry_id: str, entry_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -631,7 +631,7 @@ async def delete_entry(
return Response(status_code=204) return Response(status_code=204)
@router.post("/calendar/entries/{entry_id}/link") @router.post("/calendar/entries/{entry_id}/link", dependencies=[Depends(require_permission("calendar:write"))])
async def link_entry( async def link_entry(
entry_id: str, entry_id: str,
body: LinkRequest, body: LinkRequest,
@@ -658,7 +658,7 @@ async def link_entry(
} }
@router.post("/calendar/entries/{entry_id}/subtasks", status_code=status.HTTP_201_CREATED) @router.post("/calendar/entries/{entry_id}/subtasks", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("calendar:write"))])
async def create_subtask( async def create_subtask(
entry_id: str, entry_id: str,
body: SubtaskCreate, body: SubtaskCreate,
@@ -684,7 +684,7 @@ async def create_subtask(
} }
@router.patch("/calendar/entries/{entry_id}/subtasks/{sub_id}") @router.patch("/calendar/entries/{entry_id}/subtasks/{sub_id}", dependencies=[Depends(require_permission("calendar:write"))])
async def update_subtask( async def update_subtask(
entry_id: str, entry_id: str,
sub_id: str, sub_id: str,
@@ -717,7 +717,7 @@ async def update_subtask(
# ─── Bulk + Kanban + Export ─── # ─── Bulk + Kanban + Export ───
@router.post("/calendar/entries/bulk") @router.post("/calendar/entries/bulk", dependencies=[Depends(require_permission("calendar:write"))])
async def bulk_action( async def bulk_action(
body: BulkAction, body: BulkAction,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -751,7 +751,7 @@ async def bulk_action(
return {"action": body.action, "affected": len(entry_ids)} return {"action": body.action, "affected": len(entry_ids)}
@router.get("/calendar/kanban") @router.get("/calendar/kanban", dependencies=[Depends(require_permission("calendar:read"))])
async def kanban_board( async def kanban_board(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
@@ -783,7 +783,7 @@ async def kanban_board(
# ─── ICS Feed + Import ─── # ─── ICS Feed + Import ───
@router.get("/calendar/{calendar_id}/ics-feed") @router.get("/calendar/{calendar_id}/ics-feed", dependencies=[Depends(require_permission("calendar:read"))])
async def ics_feed( async def ics_feed(
calendar_id: str, calendar_id: str,
token: str | None = None, token: str | None = None,
@@ -820,7 +820,7 @@ async def ics_feed(
return Response(content=ics_content, media_type="text/calendar") return Response(content=ics_content, media_type="text/calendar")
@router.post("/calendar/import") @router.post("/calendar/import", dependencies=[Depends(require_permission("calendar:write"))])
async def import_ics( async def import_ics(
file: UploadFile = File(...), file: UploadFile = File(...),
calendar_id: str | None = None, calendar_id: str | None = None,
@@ -879,7 +879,7 @@ async def import_ics(
# ─── Resources ─── # ─── Resources ───
@resource_router.post("", status_code=status.HTTP_201_CREATED) @resource_router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("calendar:write"))])
async def create_resource( async def create_resource(
body: ResourceCreate, body: ResourceCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -897,7 +897,7 @@ async def create_resource(
return {"id": str(resource.id), "name": resource.name, "type": resource.type} return {"id": str(resource.id), "name": resource.name, "type": resource.type}
@router.post("/calendar/entries/{entry_id}/book-resource") @router.post("/calendar/entries/{entry_id}/book-resource", dependencies=[Depends(require_permission("calendar:write"))])
async def book_resource( async def book_resource(
entry_id: str, entry_id: str,
body: BookResourceRequest, body: BookResourceRequest,
+9 -3
View File
@@ -1,4 +1,4 @@
"""DMS plugin — folders, files, preview, OnlyOffice, internal sharing, search, bulk ops.""" """DMS plugin — folders, files, preview, Collabora, internal sharing, search, bulk ops."""
from __future__ import annotations from __future__ import annotations
@@ -13,7 +13,7 @@ class DmsPlugin(BasePlugin):
name="dms", name="dms",
version="1.0.0", version="1.0.0",
display_name="DMS", display_name="DMS",
description="Document management: folder hierarchy, file upload, PDF preview, OnlyOffice edit sessions, internal sharing, search, bulk ops.", description="Document management: folder hierarchy, file upload, PDF preview, Collabora edit sessions, internal sharing, search, bulk ops.",
dependencies=["permissions"], dependencies=["permissions"],
routes=[ routes=[
PluginRouteDef( PluginRouteDef(
@@ -24,5 +24,11 @@ class DmsPlugin(BasePlugin):
], ],
events=[], events=[],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql"],
permissions=[], permissions=[
"dms:read",
"dms:write",
"dms:delete",
"dms:share",
"dms:admin",
],
) )
+34 -35
View File
@@ -1,4 +1,4 @@
"""DMS plugin routes — folders, files, preview, OnlyOffice, internal sharing, search, bulk.""" """DMS plugin routes — folders, files, preview, Collabora, internal sharing, search, bulk."""
from __future__ import annotations from __future__ import annotations
@@ -21,7 +21,8 @@ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.deps import get_current_user from app.core.storage import get_storage_backend
from app.deps import get_current_user, require_permission
from app.plugins.builtins.dms.models import File as DmsFile from app.plugins.builtins.dms.models import File as DmsFile
from app.plugins.builtins.dms.models import Folder from app.plugins.builtins.dms.models import Folder
from app.plugins.builtins.dms.schemas import ( from app.plugins.builtins.dms.schemas import (
@@ -37,10 +38,7 @@ from app.plugins.builtins.permissions.models import Permission
router = APIRouter(prefix="/api/v1/dms", tags=["dms"]) router = APIRouter(prefix="/api/v1/dms", tags=["dms"])
# Configurable storage base path # Office file extensions mapped to Collabora file types
DMS_STORAGE_BASE = os.environ.get("DMS_STORAGE_BASE", "/tmp/dms")
# Office file extensions mapped to OnlyOffice file types
OFFICE_EXTENSIONS = { OFFICE_EXTENSIONS = {
".docx": "docx", ".docx": "docx",
".xlsx": "xlsx", ".xlsx": "xlsx",
@@ -61,8 +59,8 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
def _file_storage_path(tenant_id: uuid.UUID, file_id: uuid.UUID) -> str: def _file_storage_path(tenant_id: uuid.UUID, file_id: uuid.UUID) -> str:
"""Build on-disk storage path for a file.""" """Build relative storage path for a file (relative to storage base)."""
return os.path.join(DMS_STORAGE_BASE, str(tenant_id), str(file_id)) return f"{tenant_id}/{file_id}"
def _get_file_extension(filename: str) -> str: def _get_file_extension(filename: str) -> str:
@@ -73,7 +71,7 @@ def _get_file_extension(filename: str) -> str:
# ─── Folders ─── # ─── Folders ───
@router.get("/folders") @router.get("/folders", dependencies=[Depends(require_permission("dms:read"))])
async def list_folders( async def list_folders(
parent_id: str | None = None, parent_id: str | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -142,7 +140,7 @@ async def list_folders(
return root_nodes return root_nodes
@router.post("/folders", status_code=status.HTTP_201_CREATED) @router.post("/folders", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))])
async def create_folder( async def create_folder(
body: FolderCreate, body: FolderCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -221,7 +219,7 @@ async def create_folder(
} }
@router.patch("/folders/{folder_id}") @router.patch("/folders/{folder_id}", dependencies=[Depends(require_permission("dms:write"))])
async def update_folder( async def update_folder(
folder_id: str, folder_id: str,
body: FolderUpdate, body: FolderUpdate,
@@ -334,7 +332,7 @@ async def update_folder(
} }
@router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))])
async def delete_folder( async def delete_folder(
folder_id: str, folder_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -396,7 +394,7 @@ async def delete_folder(
# ─── Files ─── # ─── Files ───
@router.post("/files/upload", status_code=status.HTTP_201_CREATED) @router.post("/files/upload", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))])
async def upload_file( async def upload_file(
file: UploadFile = File(...), file: UploadFile = File(...),
folder_id: str | None = Form(None), folder_id: str | None = Form(None),
@@ -433,10 +431,9 @@ async def upload_file(
file_id = uuid.uuid4() file_id = uuid.uuid4()
storage_path = _file_storage_path(tenant_id, file_id) storage_path = _file_storage_path(tenant_id, file_id)
# Ensure directory exists and write file # Save file via storage backend
os.makedirs(os.path.dirname(storage_path), exist_ok=True) storage = get_storage_backend()
with open(storage_path, "wb") as f: # noqa: ASYNC230 await storage.save(storage_path, content)
f.write(content)
mime_type = file.content_type or "application/octet-stream" mime_type = file.content_type or "application/octet-stream"
@@ -467,7 +464,7 @@ async def upload_file(
} }
@router.get("/files/{file_id}") @router.get("/files/{file_id}", dependencies=[Depends(require_permission("dms:read"))])
async def get_file( async def get_file(
file_id: str, file_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -502,7 +499,7 @@ async def get_file(
} }
@router.get("/files") @router.get("/files", dependencies=[Depends(require_permission("dms:read"))])
async def list_all_files( async def list_all_files(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
@@ -534,7 +531,7 @@ async def list_all_files(
] ]
@router.get("/folders/{folder_id}/files") @router.get("/folders/{folder_id}/files", dependencies=[Depends(require_permission("dms:read"))])
async def list_files_in_folder( async def list_files_in_folder(
folder_id: str, folder_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -580,7 +577,7 @@ async def list_files_in_folder(
] ]
@router.patch("/files/{file_id}") @router.patch("/files/{file_id}", dependencies=[Depends(require_permission("dms:write"))])
async def update_file( async def update_file(
file_id: str, file_id: str,
body: FileUpdate, body: FileUpdate,
@@ -638,7 +635,7 @@ async def update_file(
} }
@router.delete("/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))])
async def delete_file( async def delete_file(
file_id: str, file_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -666,7 +663,7 @@ async def delete_file(
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/files/{file_id}/restore") @router.post("/files/{file_id}/restore", dependencies=[Depends(require_permission("dms:write"))])
async def restore_file( async def restore_file(
file_id: str, file_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -708,7 +705,7 @@ async def restore_file(
# ─── Preview & Edit ─── # ─── Preview & Edit ───
@router.get("/files/{file_id}/preview") @router.get("/files/{file_id}/preview", dependencies=[Depends(require_permission("dms:read"))])
async def preview_file( async def preview_file(
file_id: str, file_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -734,14 +731,16 @@ async def preview_file(
400, detail={"detail": "Only PDF files can be previewed", "code": "not_pdf"} 400, detail={"detail": "Only PDF files can be previewed", "code": "not_pdf"}
) )
if not os.path.exists(dms_file.storage_path): storage = get_storage_backend()
if not await storage.exists(dms_file.storage_path):
raise HTTPException( raise HTTPException(
404, detail={"detail": "File not found on disk", "code": "file_missing"} 404, detail={"detail": "File not found on disk", "code": "file_missing"}
) )
content = await storage.read(dms_file.storage_path)
def _stream(): def _stream():
with open(dms_file.storage_path, "rb") as f: yield content
yield from iter(lambda: f.read(65536), b"")
return StreamingResponse( return StreamingResponse(
_stream(), _stream(),
@@ -750,13 +749,13 @@ async def preview_file(
) )
@router.post("/files/{file_id}/edit-session") @router.post("/files/{file_id}/edit-session", dependencies=[Depends(require_permission("dms:write"))])
async def create_edit_session( async def create_edit_session(
file_id: str, file_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
"""AC11: POST /api/v1/dms/files/{id}/edit-session → 200 + OnlyOffice config.""" """AC11: POST /api/v1/dms/files/{id}/edit-session → 200 + Collabora config."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = current_user["user_id"] user_id = current_user["user_id"]
user_name = current_user.get("name", "Unknown") user_name = current_user.get("name", "Unknown")
@@ -810,7 +809,7 @@ async def create_edit_session(
# ─── Internal Sharing ─── # ─── Internal Sharing ───
@router.post("/files/{file_id}/share") @router.post("/files/{file_id}/share", dependencies=[Depends(require_permission("dms:share"))])
async def share_file( async def share_file(
file_id: str, file_id: str,
body: ShareRequest, body: ShareRequest,
@@ -902,7 +901,7 @@ async def share_file(
} }
@router.delete("/files/{file_id}/share", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/files/{file_id}/share", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:share"))])
async def remove_share( async def remove_share(
file_id: str, file_id: str,
body: ShareRemoveRequest = Body(...), body: ShareRemoveRequest = Body(...),
@@ -946,7 +945,7 @@ async def remove_share(
# ─── Search & Bulk ─── # ─── Search & Bulk ───
@router.get("/search") @router.get("/search", dependencies=[Depends(require_permission("dms:read"))])
async def search_files( async def search_files(
q: str, q: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -979,7 +978,7 @@ async def search_files(
] ]
@router.get("/shared-with-me") @router.get("/shared-with-me", dependencies=[Depends(require_permission("dms:read"))])
async def shared_with_me( async def shared_with_me(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
@@ -1031,7 +1030,7 @@ async def shared_with_me(
] ]
@router.post("/files/bulk-move") @router.post("/files/bulk-move", dependencies=[Depends(require_permission("dms:write"))])
async def bulk_move( async def bulk_move(
body: BulkMoveRequest, body: BulkMoveRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -1082,7 +1081,7 @@ async def bulk_move(
} }
@router.post("/files/bulk-delete") @router.post("/files/bulk-delete", dependencies=[Depends(require_permission("dms:delete"))])
async def bulk_delete( async def bulk_delete(
body: BulkDeleteRequest, body: BulkDeleteRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
+1 -1
View File
@@ -65,6 +65,6 @@ class BulkDeleteRequest(BaseModel):
file_ids: list[str] = Field(..., min_length=1) file_ids: list[str] = Field(..., min_length=1)
class OnlyOfficeConfig(BaseModel): class CollaboraConfig(BaseModel):
document: dict document: dict
editorConfig: dict # noqa: N815 editorConfig: dict # noqa: N815
+5 -1
View File
@@ -36,7 +36,11 @@ class EntityLinksPlugin(BasePlugin):
], ],
events=["company.deleted", "contact.deleted"], events=["company.deleted", "contact.deleted"],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql"],
permissions=[], permissions=[
"entity_links:read",
"entity_links:write",
"entity_links:delete",
],
is_core=True, is_core=True,
) )
+6 -6
View File
@@ -9,7 +9,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.deps import get_current_user from app.deps import get_current_user, require_permission
from app.plugins.builtins.entity_links.models import EntityLink from app.plugins.builtins.entity_links.models import EntityLink
from app.plugins.builtins.entity_links.schemas import EntityLinkRequest from app.plugins.builtins.entity_links.schemas import EntityLinkRequest
@@ -29,7 +29,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
) from None ) from None
@router.post("/files/{file_id}/link") @router.post("/files/{file_id}/link", dependencies=[Depends(require_permission("entity_links:write"))])
async def link_file_to_entity( async def link_file_to_entity(
file_id: str, file_id: str,
body: EntityLinkRequest, body: EntityLinkRequest,
@@ -84,7 +84,7 @@ async def link_file_to_entity(
} }
@router.delete("/files/{file_id}/link", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/files/{file_id}/link", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("entity_links:delete"))])
async def unlink_file_from_entity( async def unlink_file_from_entity(
file_id: str, file_id: str,
body: EntityLinkRequest = Body(...), body: EntityLinkRequest = Body(...),
@@ -112,7 +112,7 @@ async def unlink_file_from_entity(
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/files/{file_id}/links") @router.get("/files/{file_id}/links", dependencies=[Depends(require_permission("entity_links:read"))])
async def list_file_links( async def list_file_links(
file_id: str, file_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -140,7 +140,7 @@ async def list_file_links(
] ]
@company_router.get("/{company_id}/files") @company_router.get("/{company_id}/files", dependencies=[Depends(require_permission("entity_links:read"))])
async def list_company_files( async def list_company_files(
company_id: str, company_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -169,7 +169,7 @@ async def list_company_files(
] ]
@contact_router.get("/{contact_id}/files") @contact_router.get("/{contact_id}/files", dependencies=[Depends(require_permission("entity_links:read"))])
async def list_contact_files( async def list_contact_files(
contact_id: str, contact_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
+1
View File
@@ -47,6 +47,7 @@ class MailAccount(Base, TenantMixin):
smtp_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) smtp_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
username: Mapped[str] = mapped_column(String(255), nullable=False) username: Mapped[str] = mapped_column(String(255), nullable=False)
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False) encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
password_salt: Mapped[str] = mapped_column(String(64), nullable=False, default="") # base64-encoded random salt
is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
sent_folder_imap_name: Mapped[str | None] = mapped_column(String(255), nullable=True) sent_folder_imap_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+21 -33
View File
@@ -8,9 +8,9 @@ so that GET /search, /threads, /templates etc. are not shadowed by GET /{mail_id
from __future__ import annotations from __future__ import annotations
import json import json
import os
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -18,6 +18,7 @@ from sqlalchemy import and_, asc, desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.core.storage import get_storage_backend
from app.deps import require_permission from app.deps import require_permission
from app.plugins.builtins.mail.models import ( from app.plugins.builtins.mail.models import (
ContactPgpKey, ContactPgpKey,
@@ -109,27 +110,26 @@ def _parse_uuid(val: str, field: str = "id") -> uuid.UUID:
) from None ) from None
def _resolve_attachment_paths(attachment_ids: list[str]) -> list[dict]: async def _resolve_attachment_paths(attachment_ids: list[str]) -> list[dict]:
"""Resolve temporary attachment upload IDs to file paths on disk. """Resolve temporary attachment upload IDs to file paths on disk.
Each uploaded attachment is stored under Each uploaded attachment is stored under
``<storage>/mail_uploads/<temp_id>/<filename>``. We scan the ``<storage>/mail_uploads/<temp_id>/<filename>``. We scan the
directory for the single file inside and return its metadata. directory for the single file inside and return its metadata.
""" """
import os
resolved: list[dict] = [] resolved: list[dict] = []
base_dir = os.environ.get("STORAGE_PATH", "/tmp") storage = get_storage_backend()
for att_id in attachment_ids: for att_id in attachment_ids:
upload_dir = os.path.join(base_dir, "mail_uploads", att_id) prefix = f"mail_uploads/{att_id}/"
if not os.path.isdir(upload_dir): files = await storage.list_files(prefix)
if not files:
continue continue
for fname in os.listdir(upload_dir): for fpath in files:
fpath = os.path.join(upload_dir, fname) fname = os.path.basename(fpath)
if os.path.isfile(fpath): abs_path = await storage.get_url(fpath)
resolved.append( resolved.append(
{ {
"path": fpath, "path": abs_path,
"filename": fname, "filename": fname,
"mime_type": "application/octet-stream", "mime_type": "application/octet-stream",
} }
@@ -752,19 +752,10 @@ async def upload_attachment(
safe_filename = _sanitize_filename(file.filename or "attachment") safe_filename = _sanitize_filename(file.filename or "attachment")
mime_type = file.content_type or "application/octet-stream" mime_type = file.content_type or "application/octet-stream"
# Store in a temp directory keyed by the temp_id # Store via storage backend in a path keyed by the temp_id
import os storage = get_storage_backend()
file_path = f"mail_uploads/{temp_id}/{safe_filename}"
temp_dir = os.path.join( await storage.save(file_path, content)
os.environ.get("STORAGE_PATH", "/tmp"), "mail_uploads", temp_id
)
os.makedirs(temp_dir, exist_ok=True)
file_path = os.path.join(temp_dir, safe_filename)
import aiofiles
async with aiofiles.open(file_path, "wb") as f:
await f.write(content)
return { return {
"id": temp_id, "id": temp_id,
@@ -828,7 +819,7 @@ async def send_mail(
in_reply_to=data.in_reply_to, in_reply_to=data.in_reply_to,
references_header=data.references_header, references_header=data.references_header,
signature=signature, signature=signature,
attachment_paths=_resolve_attachment_paths(data.attachments), attachment_paths=await _resolve_attachment_paths(data.attachments),
) )
if result.get("status") == "error": if result.get("status") == "error":
raise HTTPException( raise HTTPException(
@@ -1423,19 +1414,16 @@ async def download_attachment(
).scalar_one_or_none() ).scalar_one_or_none()
if not attachment: if not attachment:
raise HTTPException(404, detail={"detail": "Attachment not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "Attachment not found", "code": "not_found"})
if not Path(attachment.storage_path).exists(): storage = get_storage_backend()
if not await storage.exists(attachment.storage_path):
raise HTTPException( raise HTTPException(
404, detail={"detail": "File not found on disk", "code": "file_missing"} 404, detail={"detail": "File not found on disk", "code": "file_missing"}
) )
import aiofiles
content = await storage.read(attachment.storage_path)
async def file_stream(): async def file_stream():
async with aiofiles.open(attachment.storage_path, "rb") as f: yield content
while True:
chunk = await f.read(8192)
if not chunk:
break
yield chunk
return StreamingResponse( return StreamingResponse(
file_stream(), file_stream(),
+45 -13
View File
@@ -134,9 +134,12 @@ def attachment_to_response(att: MailAttachment) -> dict:
MAIL_ENCRYPTION_KEY = os.environ.get("MAIL_ENCRYPTION_KEY", "leocrm-mail-encryption-key-2024") MAIL_ENCRYPTION_KEY = os.environ.get("MAIL_ENCRYPTION_KEY", "leocrm-mail-encryption-key-2024")
# Legacy salt for backward compatibility with existing encrypted passwords
_LEGACY_SALT = b"leocrm-mail-salt"
def _derive_key(password: str, salt: bytes = b"leocrm-mail-salt") -> bytes:
"""Derive a 32-byte Fernet key from a password using PBKDF2.""" def _derive_key(password: str, salt: bytes) -> bytes:
"""Derive a 32-byte Fernet key from a password using PBKDF2 with the given salt."""
kdf = PBKDF2HMAC( kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(), algorithm=hashes.SHA256(),
length=32, length=32,
@@ -146,17 +149,39 @@ def _derive_key(password: str, salt: bytes = b"leocrm-mail-salt") -> bytes:
return base64.urlsafe_b64encode(kdf.derive(password.encode())) return base64.urlsafe_b64encode(kdf.derive(password.encode()))
_fernet = Fernet(_derive_key(MAIL_ENCRYPTION_KEY)) def generate_salt() -> str:
"""Generate a random 32-byte salt and return as base64 string."""
salt = os.urandom(32)
return base64.urlsafe_b64encode(salt).decode()
def encrypt_password(plaintext: str) -> str: def _get_fernet(salt_b64: str | None = None) -> Fernet:
"""Encrypt a password using AES-256 (Fernet). Returns base64 ciphertext.""" """Get a Fernet instance. If salt_b64 is provided, use it; otherwise use legacy salt."""
return _fernet.encrypt(plaintext.encode()).decode() if salt_b64:
salt = base64.urlsafe_b64decode(salt_b64.encode())
else:
salt = _LEGACY_SALT
return Fernet(_derive_key(MAIL_ENCRYPTION_KEY, salt))
def decrypt_password(ciphertext: str) -> str: def encrypt_password(plaintext: str, salt_b64: str | None = None) -> str:
"""Decrypt a password encrypted with encrypt_password.""" """Encrypt a password using AES-256 (Fernet). Returns base64 ciphertext.
return _fernet.decrypt(ciphertext.encode()).decode()
If salt_b64 is provided, uses that salt for key derivation.
If not, uses the legacy hardcoded salt (for backward compatibility).
"""
fernet = _get_fernet(salt_b64)
return fernet.encrypt(plaintext.encode()).decode()
def decrypt_password(ciphertext: str, salt_b64: str | None = None) -> str:
"""Decrypt a password encrypted with encrypt_password.
If salt_b64 is provided, uses that salt for key derivation.
If not, uses the legacy hardcoded salt (for backward compatibility).
"""
fernet = _get_fernet(salt_b64)
return fernet.decrypt(ciphertext.encode()).decode()
# ─── HTML Sanitization (F-MAIL: no script tags) ─── # ─── HTML Sanitization (F-MAIL: no script tags) ───
@@ -255,6 +280,7 @@ async def create_mail_account(
db: AsyncSession, *, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict db: AsyncSession, *, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
) -> MailAccount: ) -> MailAccount:
"""Create a new mail account with encrypted password.""" """Create a new mail account with encrypted password."""
salt = generate_salt()
account = MailAccount( account = MailAccount(
tenant_id=tenant_id, tenant_id=tenant_id,
user_id=user_id, user_id=user_id,
@@ -267,7 +293,8 @@ async def create_mail_account(
smtp_port=data.get("smtp_port", 587), smtp_port=data.get("smtp_port", 587),
smtp_tls=data.get("smtp_tls", True), smtp_tls=data.get("smtp_tls", True),
username=data.get("username") or data["email_address"], username=data.get("username") or data["email_address"],
encrypted_password=encrypt_password(data["password"]), encrypted_password=encrypt_password(data["password"], salt),
password_salt=salt,
is_shared=data.get("is_shared", False), is_shared=data.get("is_shared", False),
is_active=True, is_active=True,
sent_folder_imap_name=data.get("sent_folder_imap_name"), sent_folder_imap_name=data.get("sent_folder_imap_name"),
@@ -333,15 +360,20 @@ async def update_mail_account(db: AsyncSession, account: MailAccount, data: dict
if api_field in data and data[api_field] is not None: if api_field in data and data[api_field] is not None:
setattr(account, model_field, data[api_field]) setattr(account, model_field, data[api_field])
if "password" in data and data["password"] is not None: if "password" in data and data["password"] is not None:
account.encrypted_password = encrypt_password(data["password"]) new_salt = generate_salt()
account.password_salt = new_salt
account.encrypted_password = encrypt_password(data["password"], new_salt)
await db.flush() await db.flush()
await db.refresh(account) await db.refresh(account)
return account return account
async def get_account_password(account: MailAccount) -> str: async def get_account_password(account: MailAccount) -> str:
"""Decrypt and return the account password (internal use only).""" """Decrypt and return the account password (internal use only).
return decrypt_password(account.encrypted_password)
Uses per-account salt if available, falls back to legacy salt for old accounts.
"""
return decrypt_password(account.encrypted_password, account.password_salt or None)
def account_to_response(account: MailAccount) -> dict: def account_to_response(account: MailAccount) -> dict:
+6 -1
View File
@@ -24,6 +24,11 @@ class TagsPlugin(BasePlugin):
], ],
events=[], events=[],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql"],
permissions=[], permissions=[
"tags:read",
"tags:write",
"tags:delete",
"tags:admin",
],
is_core=True, is_core=True,
) )
+9 -9
View File
@@ -9,7 +9,7 @@ from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.deps import get_current_user from app.deps import get_current_user, require_permission
from app.plugins.builtins.tags.models import Tag, TagAssignment from app.plugins.builtins.tags.models import Tag, TagAssignment
from app.plugins.builtins.tags.schemas import ( from app.plugins.builtins.tags.schemas import (
TagAssignRequest, TagAssignRequest,
@@ -33,7 +33,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
) from None ) from None
@router.get("") @router.get("", dependencies=[Depends(require_permission("tags:read"))])
async def list_tags( async def list_tags(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
@@ -71,7 +71,7 @@ async def list_tags(
] ]
@router.post("", status_code=status.HTTP_201_CREATED) @router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("tags:write"))])
async def create_tag( async def create_tag(
body: TagCreate, body: TagCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -98,7 +98,7 @@ async def create_tag(
} }
@router.patch("/{tag_id}") @router.patch("/{tag_id}", dependencies=[Depends(require_permission("tags:write"))])
async def update_tag( async def update_tag(
tag_id: str, tag_id: str,
body: TagUpdate, body: TagUpdate,
@@ -138,7 +138,7 @@ async def update_tag(
} }
@router.post("/assign") @router.post("/assign", dependencies=[Depends(require_permission("tags:write"))])
async def assign_tag( async def assign_tag(
body: TagAssignRequest, body: TagAssignRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -194,7 +194,7 @@ async def assign_tag(
} }
@router.delete("/assign", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/assign", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("tags:delete"))])
async def unassign_tag( async def unassign_tag(
body: TagUnassignRequest = Body(...), body: TagUnassignRequest = Body(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -221,7 +221,7 @@ async def unassign_tag(
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("tags:delete"))])
async def delete_tag( async def delete_tag(
tag_id: str, tag_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -246,7 +246,7 @@ async def delete_tag(
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/bulk-assign") @router.post("/bulk-assign", dependencies=[Depends(require_permission("tags:write"))])
async def bulk_assign_tags( async def bulk_assign_tags(
body: TagBulkAssignRequest, body: TagBulkAssignRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -303,7 +303,7 @@ async def bulk_assign_tags(
} }
@router.get("/{tag_id}/entities") @router.get("/{tag_id}/entities", dependencies=[Depends(require_permission("tags:read"))])
async def list_tag_entities( async def list_tag_entities(
tag_id: str, tag_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
+19 -1
View File
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.core.jobs import enqueue_job from app.core.jobs import enqueue_job
from app.core.permissions import resolve_permissions, filter_fields_by_permission
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
from app.plugins.builtins.unified_search.provider_registry import get_search_registry from app.plugins.builtins.unified_search.provider_registry import get_search_registry
from app.plugins.builtins.unified_search.query_understanding import ( from app.plugins.builtins.unified_search.query_understanding import (
@@ -49,6 +50,7 @@ async def search(
) -> SearchResponse: ) -> SearchResponse:
"""Perform hybrid search with KI query understanding.""" """Perform hybrid search with KI query understanding."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# KI query understanding # KI query understanding
query_analysis = await llm_analyze_query(req.query, db=db, tenant_id=tenant_id) query_analysis = await llm_analyze_query(req.query, db=db, tenant_id=tenant_id)
@@ -62,6 +64,18 @@ async def search(
limit=req.limit, limit=req.limit,
) )
# Resolve user permissions for field-level RBAC
resolved_perms = await resolve_permissions(db, user_id, tenant_id)
# Map entity_type to module name for field-level permissions
_ENTITY_TO_MODULE = {
"contact": "contacts",
"company": "contacts",
"mail": "mail",
"file": "dms",
"event": "calendar",
}
# KI result aggregation # KI result aggregation
aggregation = await llm_aggregate_results(results, req.query, db=db, tenant_id=tenant_id) aggregation = await llm_aggregate_results(results, req.query, db=db, tenant_id=tenant_id)
@@ -72,7 +86,11 @@ async def search(
title=r.get("title", ""), title=r.get("title", ""),
snippet=r.get("snippet", ""), snippet=r.get("snippet", ""),
score=r.get("score", 0.0), score=r.get("score", 0.0),
data=r.get("data", {}), data=filter_fields_by_permission(
r.get("data", {}),
resolved_perms,
_ENTITY_TO_MODULE.get(r.get("entity_type", ""), r.get("entity_type", "")),
),
) )
for r in results for r in results
] ]
@@ -14,7 +14,7 @@ async def extract_text_from_file(file_path: str, mime_type: str) -> str:
"""Extract text content from a file based on its MIME type. """Extract text content from a file based on its MIME type.
Supports: Supports:
- PDF (via PyMuPDF/fitz) - PDF (via pypdf)
- DOCX (via python-docx) - DOCX (via python-docx)
- XLSX (via openpyxl) - XLSX (via openpyxl)
- PPTX (via python-pptx) - PPTX (via python-pptx)
@@ -57,14 +57,15 @@ def _truncate(text: str) -> str:
async def _extract_pdf(file_path: str) -> str: async def _extract_pdf(file_path: str) -> str:
"""Extract text from PDF using PyMuPDF (fitz).""" """Extract text from PDF using pypdf (BSD-licensed)."""
import fitz # PyMuPDF from pypdf import PdfReader
doc = fitz.open(file_path) reader = PdfReader(file_path)
parts: list[str] = [] parts: list[str] = []
for page in doc: for page in reader.pages:
parts.append(page.get_text()) text = page.extract_text()
doc.close() if text:
parts.append(text)
return _truncate("\n".join(parts)) return _truncate("\n".join(parts))
+4
View File
@@ -54,6 +54,10 @@ class PluginManifest(BaseModel):
field_definitions: list[FieldDefinition] = Field( field_definitions: list[FieldDefinition] = Field(
default_factory=list, description="Field definitions for field-level permissions" default_factory=list, description="Field definitions for field-level permissions"
) )
agent_capabilities: list[str] = Field(
default_factory=list,
description="AI agent capabilities this plugin provides (e.g. 'contact_search', 'email_draft')",
)
@field_validator("name") @field_validator("name")
@classmethod @classmethod
+1
View File
@@ -7,6 +7,7 @@ from app.routes import (
auth, # noqa: F401 auth, # noqa: F401
companies, # noqa: F401 companies, # noqa: F401
contacts, # noqa: F401 contacts, # noqa: F401
entity_history, # noqa: F401
currencies, # noqa: F401 currencies, # noqa: F401
taxes, # noqa: F401 taxes, # noqa: F401
sequences, # noqa: F401 sequences, # noqa: F401
+2 -1
View File
@@ -117,11 +117,12 @@ async def delete_contact(
): ):
"""Soft-delete (or hard-delete with ?hard=true) a contact.""" """Soft-delete (or hard-delete with ?hard=true) a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try: try:
if hard: if hard:
await contact_service.hard_delete_contact(db, tenant_id, contact_id) await contact_service.hard_delete_contact(db, tenant_id, contact_id)
else: else:
await contact_service.delete_contact(db, tenant_id, contact_id) await contact_service.delete_contact(db, tenant_id, contact_id, user_id)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
+94
View File
@@ -0,0 +1,94 @@
"""Entity history routes — query, restore, and undo entity snapshots."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.schemas.entity_history import EntityHistoryListResponse, EntityHistoryResponse, RestoreRequest
from app.services import entity_history_service
router = APIRouter(prefix="/api/v1/entity-history", tags=["entity-history"])
def _entry_to_dict(e) -> dict:
"""Serialize an EntityHistory ORM object to dict."""
return {
"id": str(e.id),
"entity_type": e.entity_type,
"entity_id": str(e.entity_id),
"action": e.action,
"snapshot_before": e.snapshot_before,
"snapshot_after": e.snapshot_after,
"changes": e.changes,
"user_id": str(e.user_id) if e.user_id else None,
"created_at": e.created_at.isoformat() if e.created_at else None,
}
@router.get("/{entity_type}/{entity_id}", response_model=EntityHistoryListResponse)
async def get_entity_history(
entity_type: str,
entity_id: str,
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get history entries for an entity, newest first."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
eid = uuid.UUID(entity_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid entity_id") from None
entries = await entity_history_service.get_entity_history(
db, tenant_id, entity_type, eid, limit=limit
)
return EntityHistoryListResponse(
items=[EntityHistoryResponse(**_entry_to_dict(e)) for e in entries],
total=len(entries),
)
@router.post("/restore")
async def restore_from_history(
body: RestoreRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Restore an entity from a history entry."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
hid = uuid.UUID(body.history_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid history_id") from None
try:
return await entity_history_service.restore_from_history(db, tenant_id, hid, user_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from None
@router.post("/undo/{entity_type}/{entity_id}")
async def undo_last_action(
entity_type: str,
entity_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Undo the most recent action for an entity."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
eid = uuid.UUID(entity_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid entity_id") from None
try:
return await entity_history_service.undo_last_action(
db, tenant_id, user_id, entity_type, eid
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from None
+26
View File
@@ -0,0 +1,26 @@
"""Entity history schemas — response, list, and restore request."""
from __future__ import annotations
from pydantic import BaseModel
class EntityHistoryResponse(BaseModel):
id: str
entity_type: str
entity_id: str
action: str
snapshot_before: dict | None = None
snapshot_after: dict | None = None
changes: dict | None = None
user_id: str | None = None
created_at: str | None = None
class EntityHistoryListResponse(BaseModel):
items: list[EntityHistoryResponse]
total: int
class RestoreRequest(BaseModel):
history_id: str
+10
View File
@@ -24,6 +24,11 @@ class SystemSettingsUpsert(BaseModel):
invoice_prefix: str = Field("RE-", max_length=20) invoice_prefix: str = Field("RE-", max_length=20)
quote_prefix: str = Field("AN-", max_length=20) quote_prefix: str = Field("AN-", max_length=20)
payment_terms_days: int = Field(14, ge=0, le=365) payment_terms_days: int = Field(14, ge=0, le=365)
# Theme customization
theme_primary_color: str = Field("#2563eb", max_length=20)
theme_accent_color: str = Field("#d946ef", max_length=20)
theme_font_family: str = Field("Inter", max_length=100)
theme_border_radius: str = Field("0.5rem", max_length=20)
class SystemSettingsResponse(BaseModel): class SystemSettingsResponse(BaseModel):
@@ -46,5 +51,10 @@ class SystemSettingsResponse(BaseModel):
invoice_prefix: str = "RE-" invoice_prefix: str = "RE-"
quote_prefix: str = "AN-" quote_prefix: str = "AN-"
payment_terms_days: int = 14 payment_terms_days: int = 14
# Theme customization
theme_primary_color: str = "#2563eb"
theme_accent_color: str = "#d946ef"
theme_font_family: str = "Inter"
theme_border_radius: str = "0.5rem"
created_at: str | None = None created_at: str | None = None
updated_at: str | None = None updated_at: str | None = None
+6 -12
View File
@@ -11,11 +11,9 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit from app.core.audit import log_audit
from app.core.storage import get_storage_backend
from app.models.attachment import Attachment from app.models.attachment import Attachment
# Storage path for uploaded files
STORAGE_PATH = os.environ.get("STORAGE_PATH", "/data/uploads")
def _attachment_to_dict(a: Attachment) -> dict[str, Any]: def _attachment_to_dict(a: Attachment) -> dict[str, Any]:
"""Serialize an Attachment ORM object to dict.""" """Serialize an Attachment ORM object to dict."""
@@ -50,17 +48,13 @@ async def save_attachment(
mime_type: str, mime_type: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Save a file to storage and create an Attachment record.""" """Save a file to storage and create an Attachment record."""
# Ensure storage directory exists # Generate unique filename and relative storage path
entity_dir = os.path.join(STORAGE_PATH, entity_type, str(entity_id))
os.makedirs(entity_dir, exist_ok=True)
# Generate unique filename
unique_filename = _generate_unique_filename(filename) unique_filename = _generate_unique_filename(filename)
file_path = os.path.join(entity_dir, unique_filename) file_path = f"{entity_type}/{entity_id}/{unique_filename}"
# Write file to disk # Save file via storage backend
with open(file_path, "wb") as f: storage = get_storage_backend()
f.write(file_content) await storage.save(file_path, file_content)
file_size = len(file_content) file_size = len(file_content)
+53 -3
View File
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.models.contact import Contact, ContactPerson from app.models.contact import Contact, ContactPerson
from app.services.entity_history_service import record_history
def _compute_displayname(data: dict) -> str: def _compute_displayname(data: dict) -> str:
@@ -239,7 +240,15 @@ async def create_contact(
q = select(Contact).options(selectinload(Contact.contact_persons)).where(Contact.id == contact.id) q = select(Contact).options(selectinload(Contact.contact_persons)).where(Contact.id == contact.id)
result = await db.execute(q) result = await db.execute(q)
contact = result.scalar_one() contact = result.scalar_one()
return _serialize_contact_detail(contact) serialized = _serialize_contact_detail(contact)
# Record history
await record_history(
db, tenant_id, user_id, "contact", contact.id,
action="create", snapshot_after=serialized,
)
return serialized
async def update_contact( async def update_contact(
@@ -260,6 +269,9 @@ async def update_contact(
if not contact: if not contact:
raise ValueError("Contact not found") raise ValueError("Contact not found")
# Capture snapshot before update
snapshot_before = _serialize_contact_detail(contact)
# Recompute displayname if name fields changed # Recompute displayname if name fields changed
if any(k in data for k in ("type", "name", "firstname", "surname", "surfix")): if any(k in data for k in ("type", "name", "firstname", "surname", "surfix")):
merged = {**_serialize_contact(contact), **data} merged = {**_serialize_contact(contact), **data}
@@ -271,10 +283,30 @@ async def update_contact(
contact.updated_by = user_id contact.updated_by = user_id
await db.flush() await db.flush()
return _serialize_contact_detail(contact) snapshot_after = _serialize_contact_detail(contact)
# Compute changes diff
changes: dict = {}
for key, new_val in snapshot_after.items():
old_val = snapshot_before.get(key)
if old_val != new_val:
changes[key] = {"old": old_val, "new": new_val}
# Record history
await record_history(
db, tenant_id, user_id, "contact", contact.id,
action="update",
snapshot_before=snapshot_before,
snapshot_after=snapshot_after,
changes=changes or None,
)
return snapshot_after
async def delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> None: async def delete_contact(
db: AsyncSession, tenant_id: uuid.UUID, contact_id: str, user_id: uuid.UUID | None = None
) -> None:
"""Soft-delete a contact.""" """Soft-delete a contact."""
q = select(Contact).where( q = select(Contact).where(
Contact.id == uuid.UUID(contact_id), Contact.id == uuid.UUID(contact_id),
@@ -285,10 +317,28 @@ async def delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str
contact = result.scalar_one_or_none() contact = result.scalar_one_or_none()
if not contact: if not contact:
raise ValueError("Contact not found") raise ValueError("Contact not found")
# Capture snapshot before deletion
from sqlalchemy.orm import selectinload
q2 = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where(Contact.id == contact.id)
)
result2 = await db.execute(q2)
contact_full = result2.scalar_one()
snapshot_before = _serialize_contact_detail(contact_full)
from datetime import datetime, timezone from datetime import datetime, timezone
contact.deleted_at = datetime.now(timezone.utc) contact.deleted_at = datetime.now(timezone.utc)
await db.flush() await db.flush()
# Record history
await record_history(
db, tenant_id, user_id, "contact", contact.id,
action="delete", snapshot_before=snapshot_before,
)
async def hard_delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> None: async def hard_delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> None:
"""GDPR hard-delete a contact.""" """GDPR hard-delete a contact."""
+199
View File
@@ -0,0 +1,199 @@
"""Entity history service — record, query, restore, and undo entity snapshots."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.entity_history import EntityHistory
async def record_history(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID | None,
entity_type: str,
entity_id: uuid.UUID,
action: str,
snapshot_before: dict[str, Any] | None = None,
snapshot_after: dict[str, Any] | None = None,
changes: dict[str, Any] | None = None,
) -> EntityHistory:
"""Create a history entry for a CRUD action."""
entry = EntityHistory(
tenant_id=tenant_id,
user_id=user_id,
entity_type=entity_type,
entity_id=entity_id,
action=action,
snapshot_before=snapshot_before,
snapshot_after=snapshot_after,
changes=changes,
)
db.add(entry)
await db.flush()
return entry
async def get_entity_history(
db: AsyncSession,
tenant_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
limit: int = 50,
) -> list[EntityHistory]:
"""Get all history entries for an entity, newest first."""
q = (
select(EntityHistory)
.where(
EntityHistory.tenant_id == tenant_id,
EntityHistory.entity_type == entity_type,
EntityHistory.entity_id == entity_id,
)
.order_by(EntityHistory.created_at.desc())
.limit(limit)
)
result = await db.execute(q)
return list(result.scalars().all())
async def get_history_entry(
db: AsyncSession,
tenant_id: uuid.UUID,
history_id: uuid.UUID,
) -> EntityHistory | None:
"""Get a specific history entry by ID."""
q = select(EntityHistory).where(
EntityHistory.id == history_id,
EntityHistory.tenant_id == tenant_id,
)
result = await db.execute(q)
return result.scalar_one_or_none()
async def restore_from_history(
db: AsyncSession,
tenant_id: uuid.UUID,
history_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict[str, Any]:
"""Restore an entity to a previous snapshot state.
For 'delete' actions: un-delete the entity (clear deleted_at).
For 'update' actions: revert entity fields to snapshot_before.
For 'create' actions: soft-delete the entity (undo creation).
Returns the restored data dict.
"""
entry = await get_history_entry(db, tenant_id, history_id)
if entry is None:
raise ValueError("History entry not found")
entity_type = entry.entity_type
entity_id = entry.entity_id
action = entry.action
# Import here to avoid circular imports
from app.models.contact import Contact
if entity_type == "contact":
q = select(Contact).where(
Contact.id == entity_id,
Contact.tenant_id == tenant_id,
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if action == "delete":
# Un-delete: clear deleted_at
if contact is None:
raise ValueError("Entity not found for restore")
contact.deleted_at = None
contact.updated_by = user_id
await db.flush()
from app.services.contact_service import _serialize_contact_detail
from sqlalchemy.orm import selectinload
q2 = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where(Contact.id == contact.id)
)
result2 = await db.execute(q2)
contact = result2.scalar_one()
return _serialize_contact_detail(contact)
elif action == "update":
# Revert to snapshot_before
if contact is None:
raise ValueError("Entity not found for restore")
if entry.snapshot_before is None:
raise ValueError("No snapshot_before available for restore")
for key, value in entry.snapshot_before.items():
if hasattr(contact, key) and key not in ("id", "tenant_id", "created_at", "updated_at", "deleted_at"):
setattr(contact, key, value)
contact.updated_by = user_id
await db.flush()
from app.services.contact_service import _serialize_contact_detail
from sqlalchemy.orm import selectinload
q2 = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where(Contact.id == contact.id)
)
result2 = await db.execute(q2)
contact = result2.scalar_one()
return _serialize_contact_detail(contact)
elif action == "create":
# Undo creation: soft-delete the entity
if contact is None:
raise ValueError("Entity not found for restore")
contact.deleted_at = datetime.now(timezone.utc)
contact.updated_by = user_id
await db.flush()
from app.services.contact_service import _serialize_contact_detail
from sqlalchemy.orm import selectinload
q2 = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where(Contact.id == contact.id)
)
result2 = await db.execute(q2)
contact = result2.scalar_one()
return _serialize_contact_detail(contact)
raise ValueError(f"Unsupported entity type for restore: {entity_type}")
async def undo_last_action(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
) -> dict[str, Any]:
"""Undo the most recent action for an entity.
Returns the restored entity data.
Raises ValueError if no history exists.
"""
q = (
select(EntityHistory)
.where(
EntityHistory.tenant_id == tenant_id,
EntityHistory.entity_type == entity_type,
EntityHistory.entity_id == entity_id,
)
.order_by(EntityHistory.created_at.desc())
.limit(1)
)
result = await db.execute(q)
entry = result.scalar_one_or_none()
if entry is None:
raise ValueError("No history found for this entity")
return await restore_from_history(db, tenant_id, entry.id, user_id)
+55 -40
View File
@@ -1,4 +1,8 @@
"""Import/export service — CSV import with dry-run preview, CSV/XLSX export.""" """Import/export service — CSV import with dry-run preview, CSV/XLSX export.
Uses unified Contact model fields: firstname, surname, email_1, phone_1, mobilephone, function.
Company import creates Contact with type='company'.
"""
from __future__ import annotations from __future__ import annotations
@@ -11,14 +15,14 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit from app.core.audit import log_audit
from app.models.contact import Contact, ContactPerson as Company from app.models.contact import Contact
from app.models.contact import Contact, ContactPerson
from app.services.company_service import _company_to_dict
from app.services.contact_service import _serialize_contact as _contact_to_dict from app.services.contact_service import _serialize_contact as _contact_to_dict
# Expected CSV columns for each entity type # Expected CSV columns for each entity type
# Company import creates Contact with type='company' using name field
COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"] COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"]
CONTACT_COLUMNS = ["first_name", "last_name", "email", "phone", "mobile", "position", "department"] # Contact import uses unified Contact fields
CONTACT_COLUMNS = ["firstname", "surname", "email", "phone", "mobile", "function", "department"]
def _parse_csv(content: str) -> list[dict[str, str]]: def _parse_csv(content: str) -> list[dict[str, str]]:
@@ -44,9 +48,9 @@ async def import_companies(
csv_content: str, csv_content: str,
dry_run: bool = False, dry_run: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Import companies from CSV. If dry_run=True, no DB changes are made. """Import companies from CSV as Contact with type='company'.
Returns {total, valid, invalid, errors, created (empty in dry_run)}. Uses unified Contact model: name field for company name, email_1/phone_1 for contact info.
""" """
rows = _parse_csv(csv_content) rows = _parse_csv(csv_content)
total = len(rows) total = len(rows)
@@ -73,29 +77,30 @@ async def import_companies(
created = [] created = []
for row in valid_rows: for row in valid_rows:
company = Company( contact = Contact(
tenant_id=tenant_id, tenant_id=tenant_id,
type="company",
name=row["name"].strip(), name=row["name"].strip(),
industry=row.get("industry", "").strip() or None, displayname=row["name"].strip(),
phone=row.get("phone", "").strip() or None, email_1=row.get("email", "").strip() or None,
email=row.get("email", "").strip() or None, phone_1=row.get("phone", "").strip() or None,
website=row.get("website", "").strip() or None, website=row.get("website", "").strip() or None,
description=row.get("description", "").strip() or None, description=row.get("description", "").strip() or None,
created_by=user_id, created_by=user_id,
updated_by=user_id, updated_by=user_id,
) )
db.add(company) db.add(contact)
await db.flush() await db.flush()
await log_audit( await log_audit(
db, db,
tenant_id, tenant_id,
user_id, user_id,
"import", "import",
"company", "contact",
company.id, contact.id,
changes={"name": company.name}, changes={"name": contact.name, "type": "company"},
) )
created.append(_company_to_dict(company)) created.append(_contact_to_dict(contact))
return { return {
"total": total, "total": total,
@@ -114,9 +119,10 @@ async def import_contacts(
csv_content: str, csv_content: str,
dry_run: bool = False, dry_run: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Import contacts from CSV. If dry_run=True, no DB changes are made. """Import contacts from CSV using unified Contact model fields.
Returns {total, valid, invalid, errors, created (empty in dry_run)}. CSV columns: firstname, surname, email, phone, mobile, function, department.
Maps to Contact fields: firstname, surname, email_1, phone_1, mobilephone, function.
""" """
rows = _parse_csv(csv_content) rows = _parse_csv(csv_content)
total = len(rows) total = len(rows)
@@ -124,11 +130,15 @@ async def import_contacts(
errors = [] errors = []
for idx, row in enumerate(rows, start=1): for idx, row in enumerate(rows, start=1):
row_errors = _validate_row(row, ["first_name", "last_name"]) # Accept both old (first_name/last_name) and new (firstname/surname) column names
if row_errors: firstname = (row.get("firstname") or row.get("first_name") or "").strip()
for e in row_errors: surname = (row.get("surname") or row.get("last_name") or "").strip()
errors.append({"row": idx, "error": e}) if not firstname and not surname:
errors.append({"row": idx, "error": "Missing required field: firstname or surname"})
else: else:
# Normalize row to use unified field names
row["firstname"] = firstname
row["surname"] = surname
valid_rows.append(row) valid_rows.append(row)
if dry_run: if dry_run:
@@ -145,13 +155,14 @@ async def import_contacts(
for row in valid_rows: for row in valid_rows:
contact = Contact( contact = Contact(
tenant_id=tenant_id, tenant_id=tenant_id,
first_name=row["first_name"].strip(), type="person",
last_name=row["last_name"].strip(), firstname=row["firstname"].strip() or None,
email=row.get("email", "").strip() or None, surname=row["surname"].strip() or None,
phone=row.get("phone", "").strip() or None, displayname=f"{row['firstname']} {row['surname']}",
mobile=row.get("mobile", "").strip() or None, email_1=row.get("email", "").strip() or None,
position=row.get("position", "").strip() or None, phone_1=row.get("phone", "").strip() or None,
department=row.get("department", "").strip() or None, mobilephone=row.get("mobile", "").strip() or None,
function=row.get("function", "").strip() or None,
created_by=user_id, created_by=user_id,
updated_by=user_id, updated_by=user_id,
) )
@@ -164,7 +175,7 @@ async def import_contacts(
"import", "import",
"contact", "contact",
contact.id, contact.id,
changes={"first_name": contact.first_name, "last_name": contact.last_name}, changes={"firstname": contact.firstname, "surname": contact.surname},
) )
created.append(_contact_to_dict(contact)) created.append(_contact_to_dict(contact))
@@ -206,14 +217,14 @@ async def export_contacts_csv(
db: AsyncSession, db: AsyncSession,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
) -> str: ) -> str:
"""Export contacts as CSV string.""" """Export contacts as CSV string using unified Contact model fields."""
q = ( q = (
select(Contact) select(Contact)
.where( .where(
Contact.tenant_id == tenant_id, Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None), Contact.deleted_at.is_(None),
) )
.order_by(Contact.last_name, Contact.first_name) .order_by(Contact.surname, Contact.firstname)
) )
result = await db.execute(q) result = await db.execute(q)
contacts = result.scalars().all() contacts = result.scalars().all()
@@ -221,19 +232,23 @@ async def export_contacts_csv(
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output) writer = csv.writer(output)
writer.writerow( writer.writerow(
["id", "first_name", "last_name", "email", "phone", "mobile", "position", "department"] ["id", "type", "firstname", "surname", "name", "email", "phone", "mobile", "function", "city", "postalcode", "country"]
) )
for c in contacts: for c in contacts:
writer.writerow( writer.writerow(
[ [
str(c.id), str(c.id),
c.first_name, c.type or "person",
c.last_name, c.firstname or "",
c.email or "", c.surname or "",
c.phone or "", c.name or "",
c.mobile or "", c.email_1 or "",
c.position or "", c.phone_1 or "",
c.department or "", c.mobilephone or "",
c.function or "",
c.mailing_city or "",
c.mailing_postalcode or "",
c.mailing_country or "",
] ]
) )
return output.getvalue() return output.getvalue()
+9
View File
@@ -34,6 +34,10 @@ def _settings_to_dict(s: SystemSettings) -> dict[str, Any]:
"invoice_prefix": s.invoice_prefix, "invoice_prefix": s.invoice_prefix,
"quote_prefix": s.quote_prefix, "quote_prefix": s.quote_prefix,
"payment_terms_days": s.payment_terms_days, "payment_terms_days": s.payment_terms_days,
"theme_primary_color": s.theme_primary_color,
"theme_accent_color": s.theme_accent_color,
"theme_font_family": s.theme_font_family,
"theme_border_radius": s.theme_border_radius,
"created_at": s.created_at.isoformat() if s.created_at else None, "created_at": s.created_at.isoformat() if s.created_at else None,
"updated_at": s.updated_at.isoformat() if s.updated_at else None, "updated_at": s.updated_at.isoformat() if s.updated_at else None,
} }
@@ -106,6 +110,10 @@ async def upsert_system_settings(
invoice_prefix=data.get("invoice_prefix", "RE-"), invoice_prefix=data.get("invoice_prefix", "RE-"),
quote_prefix=data.get("quote_prefix", "AN-"), quote_prefix=data.get("quote_prefix", "AN-"),
payment_terms_days=data.get("payment_terms_days", 14), payment_terms_days=data.get("payment_terms_days", 14),
theme_primary_color=data.get("theme_primary_color", "#2563eb"),
theme_accent_color=data.get("theme_accent_color", "#d946ef"),
theme_font_family=data.get("theme_font_family", "Inter"),
theme_border_radius=data.get("theme_border_radius", "0.5rem"),
) )
db.add(settings) db.add(settings)
await db.flush() await db.flush()
@@ -122,6 +130,7 @@ async def upsert_system_settings(
"company_zip", "company_country", "tax_number", "vat_id", "iban", "company_zip", "company_country", "tax_number", "vat_id", "iban",
"bic", "bank_name", "ceo", "trade_register", "bic", "bank_name", "ceo", "trade_register",
"invoice_prefix", "quote_prefix", "payment_terms_days", "invoice_prefix", "quote_prefix", "payment_terms_days",
"theme_primary_color", "theme_accent_color", "theme_font_family", "theme_border_radius",
) )
for field in all_fields: for field in all_fields:
if field in data and data[field] is not None: if field in data and data[field] is not None:
+48
View File
@@ -6,6 +6,8 @@
> **v1 Tasks:** T01, T02, T03, T07, T09, T10 > **v1 Tasks:** T01, T02, T03, T07, T09, T10
> **v2 Tasks:** T04, T05, T06, T11, T08a, T08b, T08c > **v2 Tasks:** T04, T05, T06, T11, T08a, T08b, T08c
> **Update 2026-07-23:** Implementation Status siehe Abschnitt am Ende dieses Dokuments.
**Projekt:** leocrm — Greenfield **Projekt:** leocrm — Greenfield
**Architekt:** Solution Architect (Agent Zero) **Architekt:** Solution Architect (Agent Zero)
**Datum:** 2026-06-28 **Datum:** 2026-06-28
@@ -2017,3 +2019,49 @@ e2e/
- AGENTS.md status: PENDING - AGENTS.md status: PENDING
- Open questions: 4 (listed above, non-blocking for implementation) - Open questions: 4 (listed above, non-blocking for implementation)
- Ready for implementation: NO (pending quality_reviewer review + task graph + AGENTS.md) - Ready for implementation: NO (pending quality_reviewer review + task graph + AGENTS.md)
---
## 11. Implementation Status (Update 2026-07-23)
### Was implementiert wurde
| Komponente | Status | Anmerkung |
|-----------|--------|-----------|
| FastAPI Backend | ✅ Fertig | ~35.800 Zeilen, 22 Routes, 18 Services, 20 Models |
| PostgreSQL 16 + asyncpg | ✅ Fertig | 22 Alembic-Migrationen |
| Multi-Tenant + RLS | ✅ Fertig | ORM-Filter + PostgreSQL RLS (Migration 0015) |
| RBAC + Field-Level Permissions | ✅ Fertig | Role + Groups + Permission Registry |
| Session-Auth + Rate Limiting | ✅ Fertig | Redis + bcrypt + CSRF |
| Plugin-System | ✅ Fertig | 12 Plugins, Registry, Manifest, Lifecycle, Migration Runner |
| Unified Contact Model | ✅ Fertig | Contact type='company'\|'person', ContactPerson 1:N |
| Workflow Engine | ✅ Fertig | 306 Zeilen, 4 Step-Types, Event-Trigger |
| KI-Copilot + AI Assistant | ✅ Fertig | LiteLLM + PydanticAI + tool_registry |
| AI Proactive | ✅ Fertig | Context-aware, SSE, Heartbeat, Deep Analysis |
| Unified Search | ✅ Fertig | Hybrid FTS+Vector (pgvector), RRF, KI Query Understanding |
| Kommunikation | ✅ Fertig | WebSocket, MiniApps, Rich Content Blocks |
| Mail Plugin | ✅ Fertig | IMAP/SMTP, PGP, Vacation, Rules, Templates |
| Calendar Plugin | ✅ Fertig | ICS, Kanban, Resources, Subtasks, Recurrence |
| DMS Plugin | ✅ Fertig | Folders, Files, Preview, OnlyOffice (→Collabora), Share |
| Report Generator | ⚠️ Teilweise | Backend (CSV/Excel/JSON), Frontend fehlt, PDF fehlt |
| React Frontend | ✅ Fertig | ~30.000 Zeilen, 27 Pages, 70 Components, i18n DE/EN |
| Docker Multi-Stage Build | ✅ Fertig | Frontend+Backend in einem Container |
| Monitoring | ✅ Fertig | Prometheus + structlog + Health Checks |
### Was noch fehlt (im MASTER-PLAN.md eingeplant)
Siehe `MASTER-PLAN.md` für den vollständigen Umbau-Plan (~590h, 8 Phasen + Phase 3.5).
Wichtigste Lücken:
- Storage Backend (S3-Support) — Phase 0.16
- Company-Routes entfernen (unified Contact) — Phase 1
- Plugin-UI-System (PluginRegistry/PluginLoader) — Phase 3
- Automation & Agents Plugin (Agent Builder, Cron-Scheduler) — Phase 3.5
- KI-UI-Steuerung (WebSocket Commands) — Phase 4
- E2E Tests (Playwright) — Phase 5
- Backup-System — Phase 5.15
- MCP Integration — Phase 5.16-5.17
- Report Frontend + PDF — Phase 5.18-5.19
- Custom Fields, Tasks-Plugin, Saved Searches, PWA, Dashboard — Phase 5.20-5.25
- Code-Splitting + Virtual Scrolling — Phase 2
- AGPL-Lizenzen ersetzen (PyMuPDF→pypdf, OnlyOffice→Collabora) — Phase 0.20
+161 -525
View File
@@ -1,540 +1,176 @@
# LeoCRM — Codebase vs Requirements Analysis # LeoCRM — Codebase vs Requirements (IST-Stand Juli 2026)
**Datum:** 2026-06-28 **Datum:** 2026-07-23
**Prüfer:** Codebase Explorer (Agent Zero) **Prüfer:** Agent Zero
**Methode:** Read-only-Inspektion der bestehenden Codebase gegen bereinigte `requirements.md` **Methode:** Vollständige Code-Inspektion gegen requirements.md und architecture.md
--- ---
## 1. Bestehende Architektur-Übersicht ## 1. Aktueller Stack
### Stack
| Komponente | Code-Realität | Requirements | Status | | Komponente | Code-Realität | Requirements | Status |
|------------|-------------|-------------|--------| |------------|-------------|-------------|--------|
| Backend | FastAPI 0.115.6 | FastAPI | ✅ kompatibel | | Backend | FastAPI 0.115+ | FastAPI | ✅ kompatibel |
| Python | 3.11+ (pyproject.toml) | 3.12 (Annahme 10) | ⚠️ Minor-Abweichung | | Python | 3.12 (Dockerfile) | 3.12 | ✅ kompatibel |
| Datenbank | **SQLite** (WAL mode) | **PostgreSQL 16** | ❌ KONFLIKT | | Datenbank | PostgreSQL 16 + asyncpg | PostgreSQL 16 | ✅ erfüllt |
| ORM | SQLAlchemy 2.0.36 | (offen — architecture.md) | ✅ kompatibel | | ORM | SQLAlchemy 2.0 async | SQLAlchemy 2.0 | ✅ erfüllt |
| Frontend | **Jinja2 Templates** (server-side) | **React SPA** (client-side) | ❌ KONFLIKT | | Frontend | React 18 SPA (Vite) | React SPA | ✅ erfüllt |
| Auth | Starlette SessionMiddleware (Cookie) | Session-basiert (Cookie) | ✅ kompatibel | | Auth | Session-based (Redis + HttpOnly Cookie) | Session-based | ✅ erfüllt |
| Deployment | Docker (single container) | Coolify (Docker) | ⚠️ Single-Container vs Multi-Container | | Deployment | Docker Multi-Stage + Coolify | Docker + Coolify | ✅ erfüllt |
| Testing | pytest (backend only) | pytest + Vitest + Playwright | ⚠️ Backend-only | | Testing | pytest + httpx (Backend), Vitest (Frontend) | pytest + Vitest + Playwright | ⚠️ Playwright fehlt |
| KI | LiteLLM + PydanticAI | KI-Copilot | ✅ erfüllt + erweitert |
| Search | pgvector + FTS Hybrid (RRF Fusion) | FTS | ✅ übertroffen |
### Projekt-Struktur ## 2. Projekt-Struktur (IST)
``` ```
app/ app/
├── main.py — FastAPI app, lifespan, middleware, router wiring ├── main.py — FastAPI app, lifespan, middleware, router wiring
├── config.py — Pydantic Settings (env: LEOCRM_*) ├── config.py — Pydantic Settings (env: LEOCRM_*)
├── deps.py — Auth dependencies (get_current_user, require_admin) ├── deps.py — Auth dependencies, require_permission
├── db/ ├── core/
│ ├── models.py — 862 Zeilen, 15 SQLAlchemy-Modelle (alle Core, keine Plugins) │ ├── auth.py — Session auth, bcrypt, Redis session store
│ ├── session.py — SQLite-Engine, SessionLocal, get_db dependency │ ├── tenant.py — Tenant-scoping ORM filter
│ └── init_db.py — Table creation + demo seed (admin/admin) │ ├── permissions.py — RBAC resolver with Redis cache
├── routes/ │ ├── permission_registry.py — Central permission catalog
│ ├── api_routes.py — JSON auth endpoints (/api/auth/login, /api/auth/logout) │ ├── event_bus.py — In-process async event bus
│ ├── html_routes.py — HTML auth endpoints (/login, /logout — Jinja2) │ ├── service_container.py — DI container
│ ├── company_routes.py— JSON API /api/companies (CRUD, search, export) │ ├── cache.py — Redis cache wrapper
│ ├── contact_routes.py— JSON API /api/contacts (CRUD, search) │ ├── jobs.py — ARQ job queue integration
│ ├── dms_routes.py — JSON API /api/dms/* (folders, files, search, links, bulk) │ ├── worker.py — ARQ worker configuration
│ ├── tag_routes.py — JSON API /api/tags (CRUD, assign, bulk-assign) │ ├── audit.py — Audit log + deletion log
│ ├── calendar_routes.py— JSON API /api/calendars, /api/entries (CRUD, shares, subtasks, attendees, links) │ ├── notifications.py — Notification service
│ ├── notification_routes.py — JSON API /api/notifications │ ├── monitoring.py — Prometheus metrics + structlog
│ ├── import_routes.py — JSON API /api/companies/import, /api/contacts/import (CSV) │ ├── rate_limit.py — Redis-based rate limiting
│ ├── public_routes.py — Public share links /api/public/share/{token} │ ├── middleware.py — CSRF, CORS, request logging
│ └── health_routes.py — /api/health │ └── seeds.py — Seed data
├── services/ ├── models/ — 20 SQLAlchemy models (all with TenantMixin)
│ ├── auth_service.py — bcrypt password hashing, authenticate_user ├── schemas/ — 19 Pydantic schema modules
│ ├── company_service.py — Company CRUD logic ├── services/ — 18 service modules
│ ├── contact_service.py — Contact CRUD logic ├── routes/ — 22 FastAPI routers
│ ├── dms_service.py — DMS file/folder operations (26KB, größte Service-Datei) ├── plugins/
│ ├── tag_service.py — Tag CRUD + assignment │ ├── base.py — BasePlugin class
│ ├── calendar_service.py — Calendar/entry/subtask/attendee/notification logic (21KB) │ ├── manifest.py — PluginManifest, PluginRouteDef
│ ├── permission_service.py— DMS permissions + share links │ ├── registry.py — PluginRegistry (698 lines)
│ ├── import_service.py — CSV import for companies/contacts │ ├── migration_runner.py — Plugin DB migration runner
│ └── export_service.py — CSV/XLSX export for companies │ └── builtins/ — 12 built-in plugins
├── schemas/ — Pydantic schemas (auth, company, contact, dms, tag, calendar, common) ├── workflows/
└── templates/ — Jinja2 HTML templates (login, register, dashboard, company_form, contact_form, contact_list, base) │ ├── engine.py — Workflow execution engine (306 lines)
│ └── code/onboarding.py — Onboarding workflow
├── ai/
│ ├── llm_client.py — LLM client (LiteLLM migration pending)
│ └── action_mapper.py — NL→API action mapping
└── utils/
frontend/
├── src/
│ ├── pages/ — 27 pages (~7.826 lines)
│ ├── components/ — 70 components (~13.893 lines)
│ ├── api/ — 12 API modules (~3.456 lines)
│ ├── store/ + stores/ — 5 Zustand stores (~461 lines)
│ ├── hooks/ — 5 custom hooks (~247 lines)
│ ├── i18n/ — DE + EN (750 keys each)
│ └── routes/ — Router + ProtectedRoute
├── package.json — 26 deps, 15 devDeps
├── vite.config.ts — React + Vitest + proxy
└── tailwind.config.js — Design tokens, dark mode
``` ```
### Patterns ## 3. Requirements-Erfüllung
- **Monolith:** Single FastAPI app, alle Module fest eingebaut ### Core Features (v1)
- **Dual-Interface:** HTML routes (Jinja2) + JSON API routes parallel
- **Service-Layer:** Business-Logik in `services/`, Routes sind dünn | Feature | Status | Anmerkung |
- **SQLAlchemy 2.0:** DeclarativeBase, Mapped types, mapped_column |---------|--------|-----------|
- **Soft-Delete:** `deleted_at` auf Company, Contact, Folder, File | F-AUTH-01: Login/Logout | ✅ | Session + Redis + bcrypt |
- **N:M Junctions:** CompanyContact, TagAssignment, FileEntityLink, EntryLink, CalendarShare | F-AUTH-03: User Management | ✅ | CRUD + RBAC |
- **RBAC:** 3 Rollen (admin, editor, viewer) — hardcoded in `require_admin` dependency | F-AUTH-04: RBAC | ✅ | Role + Groups + Field-Level |
- **Demo-Seed:** init_db() erstellt admin/admin + 2 Firmen + 3 Kontakte | F-AUTH-05: Password Reset | ✅ | Token-based, 1h expiry |
| F-AUTH-06: Custom Roles | ✅ | Role editor in frontend |
--- | F-AUTH-07: Multi-Tenant | ✅ | ORM filter + RLS (Migration 0015) |
| F-COMP-01-08: Company CRUD | ⚠️ | Unified Contact Model — Company = Contact type='company'. Company-Routes werden entfernt (Phase 1) |
## 2. Konflikte: Requirements vs Code-Realität | F-CONT-01-08: Contact CRUD | ✅ | Unified Contact mit type='company'\|'person' + ContactPerson 1:N |
| F-CORE-01: Event Bus | ✅ | In-process async, 53 Zeilen |
### K1: Multi-Tenant (F-AUTH-07, F-CORE-02) — KRITISCH | F-CORE-02: Multi-Tenant | ✅ | TenantMixin + RLS |
| F-CORE-03: Plugin System | ✅ | 12 Plugins, Registry, Manifest, Lifecycle |
| Aspekt | Requirements | Code-Realität | | F-CORE-07: ARQ Job Queue | ✅ | Redis-based, worker.py |
|---------|-------------|---------------| | F-CORE-08: Caching | ✅ | Redis cache wrapper |
| tenant_id | Auf allen Core-Tabellen | **Nirgendwo vorhanden** | | F-CORE-10: Storage | ❌ | Architecture beschreibt StorageBackend, aber NICHT implementiert. Hardcoded Pfade. (Phase 0.16) |
| Tenant-Isolation | ORM filtert automatisch | **Keine Filterung** | | F-DATA-03: Validation | ✅ | Pydantic auf allen Inputs |
| User-Tenant-Zuordnung | User kann zu mehreren Tenants gehören | **Nicht implementiert** | | F-DATA-04: PostgreSQL | ✅ | PostgreSQL 16 + asyncpg |
| Tenant-Switch UI | Wechsel aktiver Tenant | **Nicht vorhanden** | | F-PLUGIN-01-02: Plugin System | ✅ | Registry, Manifest, Lifecycle, Migration Runner |
| Plugin-Tabellen | Müssen tenant_id haben | **N/A (keine Plugins)** | | F-SEC-01: CSRF | ✅ | SameSite=Strict + Origin validation |
| F-SEC-02: CSP | ✅ | In architecture definiert (Nginx fehlt — Phase 0) |
**Evidence:** | F-SEARCH-01: Global Search | ✅ | Hybrid FTS + Vector + RRF + KI Query Understanding |
- `models.py` Zeile 42: `class User(Base):` docstring sagt explizit `"Login account for LeoCRM (single-tenant)."` | F-AI-01: KI-Copilot | ✅ | LiteLLM + PydanticAI + tool_registry |
- Keine `tenant_id`-Spalte auf Company, Contact, Folder, File, Tag, Calendar, CalendarEntry, Notification, Permission, ShareLink | F-WF-01: Workflow Engine | ✅ | 306 Zeilen, 4 Step-Types (action/approval/notification/condition) |
- `deps.py`: Session speichert nur `user_id`, kein `tenant_id`-Kontext | F-INFRA-01: Health Check | ✅ | /api/v1/health |
- Keine Tenant-Modell-Klasse existiert | F-INFRA-04: Monitoring | ✅ | Prometheus + structlog |
| F-PERF-01: Performance | ⚠️ | Indizes vorhanden, aber kein Virtual Scrolling (Phase 2) |
**Impact:** Fundamentale Architektur-Veränderung erforderlich. Jede Tabelle braucht tenant_id, ORM-Queries müssen tenant-gefiltert sein, User-Tenant-Mapping-Tabelle nötig. | F-TEST-01: Testing | ⚠️ | Backend + Frontend Tests da, E2E fehlt (Phase 5) |
--- ### Plugin Features (v2)
### K2: Plugin-System (F-PLUGIN-01, F-PLUGIN-02) — KRITISCH | Plugin | Status | Anmerkung |
|--------|--------|-----------|
| Aspekt | Requirements | Code-Realität | | Calendar | ✅ | 12 Komponenten, ICS, Kanban, Resources, Subtasks |
|---------|-------------|---------------| | DMS | ✅ | 9 Komponenten, OnlyOffice (→Collabora Phase 0.20), Share, Bulk |
| Plugin-Architektur | Core-Feature v1 | **Nicht existent** | | Mail | ✅ | 13 Komponenten, PGP, Vacation, Rules, Templates, IMAP/SMTP |
| DMS/Kalender/Tags/Mail | Als Plugins implementiert | **Fest im Core eingebaut** | | Tags | ✅ | TagPicker, TagCloud, BulkTagDialog |
| Plugin-Manifest | Definiertes Format | **Nicht vorhanden** | | Permissions | ✅ | File/folder permissions, share links |
| Lifecycle-Hooks | install/activate/deactivate/uninstall | **Nicht vorhanden** | | Entity Links | ✅ | File↔Entity links |
| Plugin-API-Endpunkte | Plugins registrieren eigene Routes | **Nicht vorhanden** | | Unified Search | ✅ | Hybrid FTS+Vector, 5 Provider, KI Query Understanding |
| Plugin-DB-Migration | Eigene Migrationen | **Nicht vorhanden** | | AI Assistant | ✅ | Multi-provider LLM, Agents, Tools, Streaming |
| Plugin-Abhängigkeiten | Deklarierbar | **Nicht vorhanden** | | AI Proactive | ✅ | Context-aware suggestions, SSE, Heartbeat, Deep Analysis |
| Kommunikation | ✅ | WebSocket messaging, MiniApps, Rich Content Blocks |
**Evidence:** | Report Generator | ⚠️ | Backend da (CSV/Excel/JSON), Frontend fehlt, PDF fehlt (Phase 5.18-5.19) |
- `grep -rn 'plugin\|Plugin\|manifest\|lifecycle\|activate\|deactivate' app/` → **0 Treffer** | System Notifications | ✅ | Participant handler, notification types |
- DMS: `models.py` Folder/File/FileEntityLink + `dms_service.py` (26KB) + `dms_routes.py` — alles fest im Core
- Kalender: `models.py` Calendar/CalendarShare/CalendarEntry/Attendee/EntryLink/SubTask + `calendar_service.py` (21KB) + `calendar_routes.py` — fest im Core ## 4. Bekannte Lücken (im MASTER-PLAN.md eingeplant)
- Tags: `models.py` Tag/TagAssignment + `tag_service.py` + `tag_routes.py` — fest im Core
- Keine Plugin-Registry, kein Plugin-Loader, kein Manifest-Format | Lücke | Phase | Task |
|-------|-------|------|
**Impact:** Komplette Plugin-Architektur muss neu gebaut werden. Bestehende DMS/Kalender/Tag-Module müssen in Plugins umgewandelt werden. | Storage Backend (S3) | 0 | 0.16 |
| Company-Routes entfernen | 1 | 1.1-1.20 |
--- | Plugin-UI-System (PluginRegistry) | 3 | 3.1-3.10 |
| Automation & Agents Plugin | 3.5 | 3.11-3.32 |
### K3: Datenbank — SQLite vs PostgreSQL — KRITISCH | KI-UI-Steuerung | 4 | 4.1-4.12 |
| E2E Tests (Playwright) | 5 | 5.4-5.11 |
| Aspekt | Requirements | Code-Realität | | Backup-System | 5 | 5.15 |
|---------|-------------|---------------| | MCP Integration | 5 | 5.16-5.17 |
| DB-Engine | PostgreSQL 16 | **SQLite** | | Report Frontend + PDF | 5 | 5.18-5.19 |
| Connection-Pooling | PostgreSQL MVCC | **SQLite WAL, check_same_thread=False** | | Custom Fields UI | 5 | 5.20 |
| Concurrent Writes | Multi-User fähig | **SQLite limitiert** | | Tasks-Plugin | 5 | 5.21 |
| Saved Searches | 5 | 5.22 |
**Evidence:** | Deduplication | 5 | 5.23 |
- `config.py`: `db_path: str = Field(default=str(Path("/data/leocrm.db")))` → SQLite-Datei | PWA | 5 | 5.24 |
- `config.py`: `database_url` property → `f"sqlite:///{self.db_path}"` | Dashboard-System | 5 | 5.25 |
- `session.py`: SQLite-spezifische PRAGMAs (`PRAGMA foreign_keys = ON`, `PRAGMA journal_mode = WAL`) | Code-Splitting | 2 | 2.1-2.7 |
- `session.py`: `connect_args={"check_same_thread": False}` — SQLite-only | Virtual Scrolling | 2 | 2.2-2.6 |
- `pyproject.toml`: Keine `psycopg2`/`asyncpg`/`psycopg`-Dependency | RHF + Zod überall | 6 | 6.1-6.6 |
| AGPL-Lizenzen (PyMuPDF, OnlyOffice) | 0 | 0.20 |
**Impact:** DB-Layer muss auf PostgreSQL umgestellt werden. Session-Engine, PRAGMAs, connect_args müssen angepasst werden. | RBAC in 4 Plugins | 0 | 0.10 |
| Undo/History | 0 | 0.15 |
--- | .env in Git | 0 | 0.18 |
| Mail-Salt hardcoded | 0 | 0.19 |
### K4: Frontend — Jinja2 vs React SPA — KRITISCH
## 5. Statistik
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------| | Metrik | Wert |
| Frontend | React SPA (client-side) | **Jinja2 Templates (server-side)** | |--------|------|
| i18n | DE + EN, Sprachwahl persistiert | **Nicht implementiert** | | Backend Python-Zeilen | ~35.800 |
| UI-Plugin-Framework | Plugins registrieren UI-Komponenten | **Nicht vorhanden** | | Frontend TS/TSX-Zeilen | ~30.000 |
| Test-Zeilen (Backend) | ~17.300 |
**Evidence:** | Test-Zeilen (Frontend) | ~3.045 |
- `app/templates/`: 7 Jinja2-HTML-Templates (login, register, dashboard, company_form, contact_form, contact_list, base) | Plugins | 12 |
- `html_routes.py`: Jinja2Templates, TemplateResponse | API-Endpoints | ~120+ |
- Keine `package.json`, keine `.tsx`/`.jsx`-Dateien, kein React/Vite-Setup | DB-Migrationen | 22 |
- `pyproject.toml`: `jinja2==3.1.5` als Dependency | UI-Komponenten | 12 (+2 zusätzliche) |
- Requirements Annahme 3: "SPA-Frontend: Client-side rendering mit React SPA (bestätigt durch genehmigten Prototyp leocrm-prototype-x7k2p9)" | Frontend Pages | 27 |
| i18n Keys (pro Sprache) | 750 |
**Impact:** Komplettes Frontend muss als React SPA neu gebaut werden. Jinja2-Templates und HTML-Routes werden obsolet. UI-Plugin-Framework (F-CORE-04) muss in React integriert werden.
## 6. Fazit
---
Die Codebase hat den ursprünglichen Requirements-Review (der SQLite, Jinja2, keine Plugins beschrieb) **weit übertroffen**. Das unified Contact Model, das Plugin-System, die KI-Integration und die Vector Search sind implementiert und funktionieren.
### K5: F-CORE-01 — Event Bus — FEHLT
Die verbleibenden Lücken sind im `MASTER-PLAN.md` detailliert eingeplant (~590h Gesamt-Aufwand über 8 Phasen + Phase 3.5).
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| Event Bus | Core-Feature v1 | **Nicht implementiert** |
| Events emit/subscribe | Typisiert, Payload, asynchron | **Nicht vorhanden** |
| Plugin-Listener | Registrieren beim Aktivieren | **N/A** |
**Evidence:** `grep -rn 'event.bus\|EventBus\|event_bus\|emit\|subscribe\|listener' app/` → **0 Treffer**
---
### K6: F-CORE-05 — Service Container / DI — FEHLT
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| Service Container | Core-Services über Container | **Nicht implementiert** |
| DI für Plugins | Services injiziert | **N/A** |
| Mocking für Tests | Mock-Services injizierbar | **Nur DB-Session override** |
**Evidence:** Services werden direkt importiert (`from app.services import company_service`), nicht über Container. FastAPI `Depends()` ist das einzige DI-Muster, aber nur für Request-Scoped dependencies (DB-Session, Current-User).
---
### K7: F-CORE-06 — API-First Architecture — TEILWEISE
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| Alle Features über API | API-First | **Teilweise** — API routes existieren für alle Module |
| UI ist API-Client | UI nutzt API | **❌ Jinja2 rendert server-side** |
| API versioniert | z.B. /api/v1/ | **❌ Keine Versionierung** |
| OpenAPI/Swagger | Auto-gen, dokumentiert | **⚠️ FastAPI auto-gen existiert, aber nicht explizit konfiguriert** |
| Plugin-API-Endpunkte | Registrierbar | **N/A** |
| KI-Copilot nutzt API | Gleiche Endpunkte | **Nicht implementiert** |
**Evidence:**
- API routes: `/api/companies`, `/api/contacts`, `/api/dms/*`, `/api/tags/*`, `/api/calendars`, `/api/entries`, `/api/notifications`, `/api/auth/*`
- Kein `/api/v1/` Prefix — alle routes sind unversioniert
- FastAPI generiert automatisch OpenAPI unter `/openapi.json`, aber nicht explizit konfiguriert oder dokumentiert
- HTML routes existieren parallel (`/login`, `/` dashboard) — UI ist NICHT API-Client
---
### K8: F-CORE-07 — Async Job Queue — FEHLT
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| Queue-System | Background-Jobs asynchron | **Nicht implementiert** |
| Retry-Logic | Automatische Retries | **Nicht vorhanden** |
| Dead-Letter-Queue | Bei wiederholtem Fehlschlag | **Nicht vorhanden** |
| Job-Status UI | Sichtbar im UI | **Nicht vorhanden** |
**Evidence:** `grep -rn 'celery\|Celery\|queue\|Queue\|async_job\|background_job\|job_queue' app/` → **0 Treffer**
---
### K9: F-CORE-08 — Caching-Strategie — FEHLT
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| Cache-Backend | Sessions, Query-Cache, Plugin-Data | **Nicht implementiert** |
| Cache-Invalidierung | Event-basiert | **N/A** |
| TTL-Caching | Fallback | **Nur `@lru_cache` für Settings** |
**Evidence:** `grep -rn 'cache\|Cache\|redis\|Redis' app/` → nur `functools.lru_cache` in `config.py` für Settings-Caching. Kein Redis, kein Query-Cache.
---
### K10: F-CORE-09 — User-Profile und Preferences — FEHLT
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| User-Profile | Profil mit Preferences | **Nicht implementiert** |
| Sprache/Zeitzone/Theme | Umschaltbar | **Nicht vorhanden** |
| Dashboard-Konfiguration | Konfigurierbar | **Nicht vorhanden** |
| Plugin-Preferences | Eigene Felder registrierbar | **N/A** |
**Evidence:** `User`-Modell hat nur: id, username, password_hash, role, personal_folder_id, default_calendar_id, created_at. Keine Preferences, keine Sprache, keine Zeitzone.
---
### K11: F-CORE-10 — Storage-Backend — FEHLT
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| S3-kompatibel | Konfigurierbar | **Nicht implementiert** |
| Lokales Volume | Alternative | **Lokales Dateisystem** |
| Presigned-URLs | Download ohne Plugin-Code | **Nicht vorhanden** |
| Storage-Service | Core-Service für Plugins | **Direkter Dateizugriff** |
**Evidence:**
- `config.py`: `dms_storage_path: str = Field(default="/data/dms")` — lokales Verzeichnis
- `dms_service.py`: Direkter Dateizugriff via `open()`, `Path`-Operationen
- Keine S3/MinIO/boto3-Integration
- `grep -rn 's3\|S3\|boto3\|storage_backend\|presigned' app/` → **0 Treffer**
---
### K12: F-CORE-11 — Generic Import/Export Service — TEILWEISE
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| CSV-Import | Mit Preview, Dry-Run, Fehler-Reporting | **⚠️ Nur direkter Import ohne Preview/Dry-Run** |
| Excel-Export | Feld-Auswahl, Filterung | **⚠️ CSV + XLSX Export, aber begrenzte Feld-Auswahl** |
| Plugin-Definitionen | Registrierbar | **N/A** |
**Evidence:**
- `import_service.py`: `import_companies_csv()`, `import_contacts_csv()` — direkter Import, kein Preview, kein Dry-Run
- `export_service.py`: `export_companies_csv()`, `export_companies_xlsx()` — Export funktioniert, aber nicht generisch/plugin-fähig
- Import/Export ist hardcoded für Companies/Contacts, nicht generisch
---
### K13: F-CORE-12 — PDF/Document Generation Service — FEHLT
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| PDF-Generierung | Aus Templates | **Nicht implementiert** |
| Template-Engine | Variablen, Conditionals, Tabellen | **Nicht vorhanden** |
| Storage-Integration | PDFs im Storage gespeichert | **N/A** |
**Evidence:** `grep -rn 'pdf\|PDF\|weasyprint\|reportlab\|pdfkit' app/` → nur DMS-Preview (stream existing PDFs), keine Generierung
---
### K14: F-CORE-13 — Notification Service — TEILWEISE
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| In-App-Notifications | Bell-Icon, Badge-Zähler | **⚠️ DB-Modell existiert, keine UI** |
| E-Mail-Channel | Notifications per Mail | **Nicht implementiert** |
| Preferences | Pro User konfigurierbar | **Nicht vorhanden** |
| Tenant-Isolation | Pro Tenant isoliert | **N/A (single-tenant)** |
| Plugin-Notification-Typen | Registrierbar | **N/A** |
**Evidence:**
- `models.py`: `Notification`-Modell existiert (id, user_id, type, title, body, related_entry_id, is_read, created_at)
- `notification_routes.py`: API für List/Mark-Read existiert
- Keine E-Mail-Integration, keine Preferences, kein Badge-Zähler in UI (Jinja2-Templates haben kein Notification-UI)
---
### K15: F-AUTH-01 — Login mit E-Mail — KONFLIKT
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| Login-Feld | **E-Mail** + Passwort | **Username** + Passwort |
| Session-Cookie | HttpOnly, Secure, SameSite=Strict | SameSite=**lax**, https_only conditional |
**Evidence:**
- `auth_service.py`: `authenticate_user(db, username, password)` — verwendet `username`, nicht `email`
- `models.py`: `User.username: Mapped[str]` — kein `email`-Feld auf User
- `deps.py`: Session speichert `user_id`, kein Tenant-Kontext
- `main.py`: `same_site="lax"` (requirements sagen Strict), `https_only=settings.is_production` (requirements sagen Secure)
---
### K16: F-AUTH-03 — User-Verwaltung durch Admin — FEHLT
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| Admin legt User an | E-Mail, Name, Rolle, Passwort | **Nicht implementiert** |
| User-Tenant-Zuordnung | User wird Tenant zugeordnet | **N/A** |
| Keine Self-Registration | Admin-only | **⚠️ Register-Template existiert** |
**Evidence:**
- Keine User-Management-Routes (kein `/api/users`, kein Admin-User-CRUD)
- `app/templates/register.html` existiert — Self-Registration-Template (widerspricht Non-Goal #1)
- `init_db.py`: Demo-Seed erstellt nur admin/admin
---
### K17: F-AUTH-05 — Passwort-Reset — FEHLT
| Aspekt | Requirements | Code-Realität |
|---------|-------------|---------------|
| Reset-Flow | E-Mail mit Reset-Link | **Nicht implementiert** |
| Reset-Link | Gültig 24h | **Nicht vorhanden** |
**Evidence:** Keine Reset-Routes, keine Reset-Templates, keine Token-Generierung.
---
### K18: F-AUTH-07 — Multi-Tenant — FEHLT (siehe K1)
Bereits in K1 abgedeckt. Keine Tenant-Modelle, keine User-Tenant-Mapping-Tabelle.
---
### K19: DMS/Calendar/Tags als Core vs Plugin — ARCHITEKTUR-KONFLIKT
| Modul | Requirements | Code-Realität |
|-------|-------------|---------------|
| DMS | v2-Plugin (F-FILE/F-DMS/F-LINK/F-PERM) | **Core: 3 Modelle + 26KB Service + eigene Routes** |
| Kalender | v2-Plugin (F-CAL-01..18) | **Core: 6 Modelle + 21KB Service + eigene Routes** |
| Tags | v2-Plugin (F-TAG-01..04) | **Core: 2 Modelle + 8KB Service + eigene Routes** |
| Mail | v2-Plugin (F-MAIL-01..19) | **Nicht implementiert** |
**Evidence:** Alle Module sind direkt in `models.py`, `services/`, `routes/` integriert. Keine Plugin-Grenzen, keine Plugin-Schnittstellen.
**Hinweis:** Requirements sagen Plugin-System ist v1-Core-Feature, aber die Module selbst sind v2-Plugins. Das bedeutet: In v1 muss das Plugin-System gebaut werden, aber DMS/Kalender/Tags können als v2-Plugins nachgezogen werden. Die bestehenden Implementierungen können als Referenz dienen, müssen aber auf Plugin-Architektur umgebaut werden.
---
## 3. Kompatibel — Was bereits passt
### ✅ Session-basierte Auth (F-AUTH-01/02, Annahme 12)
- Starlette `SessionMiddleware` mit signed Cookie
- `session_cookie="leocrm_session"`, `max_age` konfigurierbar
- Login setzt `request.session[SESSION_USER_ID_KEY] = user.id`
- Logout cleared session
- **Kompatibel** mit Requirements (Session-basiert, Cookie-basiert)
### ✅ RBAC Grundgerüst (F-AUTH-04/06)
- 3 Rollen: admin, editor, viewer
- `require_admin` dependency prüft `user.role == "admin"`
- `get_current_user` dependency für auth-geschützte Routes
- **Kompatibel** mit Requirements (3 Rollen v1)
### ✅ Company/Contact CRUD (F-COMP-01..06, F-CONT-01..07)
- Company: 27 Felder (Name, Adresse, Industrie, Revenue, etc.)
- Contact: 29 Felder (Name, Email, Phone, Title, etc.)
- N:M Junction: `CompanyContact`
- Soft-Delete: `deleted_at` auf beiden
- Pagination, Search, Filter, Sort in Routes
- **Kompatibel** mit Requirements
### ✅ Data-Features (F-DATA-01..04)
- Pagination: `PageResponse` schema
- Search: Query-Parameter in company/contact routes
- Sort: Sortier-Parameter
- Soft-Delete: `deleted_at` + restore functionality
- **Kompatibel** mit Requirements
### ✅ Health-Check (F-INFRA-01)
- `/api/health` endpoint, prüft DB, gibt Status + Version
- Nicht auth-geschützt (für Coolify/LB)
- **Kompatibel** mit Requirements
### ✅ Import/Export Grundgerüst (F-MIG-01, F-DATA-01/02)
- CSV-Import für Companies/Contacts
- CSV + XLSX Export für Companies
- **Teilweise kompatibel** — fehlt Preview, Dry-Run, generische Service-Architektur
### ✅ DMS-Features (als Referenz für späteres Plugin)
- Folder-Tree mit materialized path
- File-Upload, Preview (PDF), Soft-Delete, Restore
- Entity-Links (N:M zu Companies/Contacts)
- Permissions (Individual/Group/Default)
- Share-Links mit Password + Expiry
- OnlyOffice-Edit-Session
- **Vollständig implementiert** — kann als Plugin-Referenz dienen
### ✅ Calendar-Features (als Referenz für späteres Plugin)
- Calendar CRUD, Sharing, Visibility-Toggle
- Entries: Events/Tasks/Reminders, Kanban-Status
- Subtasks, Attendees, Entry-Links
- Notifications für Reminders/Invites/Shares
- **Vollständig implementiert** — kann als Plugin-Referenz dienen
### ✅ Tag-System (als Referenz für späteres Plugin)
- Tag CRUD (admin-only), Color, Assignment
- Bulk-Assign, Entity-Type polymorphic
- **Vollständig implementiert** — kann als Plugin-Referenz dienen
### ✅ Testing-Setup (F-TEST-01)
- pytest mit 20+ Test-Dateien
- conftest.py mit Fixtures
- Coverage-Messung konfiguriert
- **Teilweise kompatibel** — fehlt Vitest (Frontend) und Playwright (E2E)
---
## 4. F-CORE-Feature-Matrix
| F-CORE-ID | Feature | Status im Code | Anmerkung |
|-----------|--------|---------------|----------|
| F-CORE-01 | Event Bus | ❌ Nicht implementiert | Keine Event-Infrastruktur |
| F-CORE-02 | Tenant-Isolation | ❌ Nicht implementiert | Kein tenant_id, single-tenant |
| F-CORE-03 | Plugin-DB-Migration | ❌ Nicht implementiert | Kein Plugin-System |
| F-CORE-04 | UI-Plugin-Framework | ❌ Nicht implementiert | Jinja2, keine Plugin-UI |
| F-CORE-05 | Service Container / DI | ❌ Nicht implementiert | Direkte Imports, nur FastAPI Depends |
| F-CORE-06 | API-First Architecture | ⚠️ Teilweise | API routes existieren, aber HTML parallel, keine Versionierung |
| F-CORE-07 | Async Job Queue | ❌ Nicht implementiert | Keine Queue-Infrastruktur |
| F-CORE-08 | Caching-Strategie | ❌ Nicht implementiert | Nur lru_cache für Settings |
| F-CORE-09 | User-Profile/Preferences | ❌ Nicht implementiert | User hat nur username/role |
| F-CORE-10 | Storage-Backend | ❌ Nicht implementiert | Lokales Dateisystem, kein S3 |
| F-CORE-11 | Generic Import/Export | ⚠️ Teilweise | CSV/XLSX funktioniert, nicht generisch, kein Preview/Dry-Run |
| F-CORE-12 | PDF Generation | ❌ Nicht implementiert | Keine PDF-Generierung |
| F-CORE-13 | Notification Service | ⚠️ Teilweise | DB-Modell + API existiert, keine UI, kein E-Mail-Channel |
**Bilanz:** 0/13 vollständig implementiert, 3/13 teilweise, 10/13 fehlen komplett.
---
## 5. Empfehlung: Was vor Phase 2 angepasst werden muss
### Priorität 1 — Fundamentale Architektur (vor allem anderen)
1. **Datenbank-Migration: SQLite → PostgreSQL**
- `config.py`: `database_url` auf PostgreSQL umstellen
- `session.py`: SQLite-PRAGMAs entfernen, PostgreSQL-Engine konfigurieren
- `pyproject.toml`: `psycopg[binary]` oder `asyncpg` hinzufügen
- `docker-compose.yml`: PostgreSQL-Service hinzufügen
2. **Multi-Tenant-Architektur**
- Neues `Tenant`-Modell + `UserTenant`-Mapping-Tabelle
- `tenant_id`-Spalte auf ALLE Core-Tabellen (Company, Contact, Folder, File, Tag, Calendar, etc.)
- ORM-Query-Filter: automatische tenant_id-Filterung (SQLAlchemy Event oder Query-Wrapper)
- Session-Kontext: aktiver tenant_id in Session speichern
- Tenant-Switch-Endpoint + UI
3. **Frontend-Wechsel: Jinja2 → React SPA**
- React-Projekt-Setup (Vite + React + TypeScript)
- API-Client-Layer (fetch/axios gegen /api/* Endpunkte)
- Jinja2-Templates und html_routes.py werden obsolet
- i18n-Integration (DE + EN)
- UI-Plugin-Framework vorbereiten (F-CORE-04)
### Priorität 2 — Core-Infrastructure (F-CORE)
4. **Service Container / DI (F-CORE-05)**
- Zentralen Service-Container implementieren
- Core-Services registrieren: DB, Cache, Event Bus, Auth, Config, Logger
- Plugin-Schnittstelle für Service-Requests definieren
5. **Event Bus (F-CORE-01)**
- Event-Publish/Subscribe-System implementieren
- Typisierte Events mit Payload
- Asynchrone Verarbeitung (ggf. via Job Queue)
6. **Plugin-System (F-PLUGIN-01/02)**
- Plugin-Manifest-Format definieren
- Lifecycle-Hooks: install, activate, deactivate, uninstall
- Plugin-Registry + Loader
- Plugin-API-Endpunkt-Registrierung
- Plugin-DB-Migration (F-CORE-03)
- Plugin-Abhängigkeiten
7. **API-Versionierung (F-CORE-06)**
- `/api/v1/` Prefix für alle API-Routes
- OpenAPI/Swagger explizit konfigurieren und dokumentieren
- HTML-Routes entfernen (UI wird React SPA = API-Client)
### Priorität 3 — Weitere Core-Infrastructure
8. **Async Job Queue (F-CORE-07)** — Queue-System für Background-Jobs
9. **Caching (F-CORE-08)** — Redis-Anbindung, Query-Cache, Cache-Invalidierung
10. **Storage-Backend (F-CORE-10)** — S3-kompatibler Storage-Service
11. **User-Profile/Preferences (F-CORE-09)** — Profil-Erweiterung, Preferences
12. **Notification Service (F-CORE-13)** — E-Mail-Channel, Preferences, Badge-UI
13. **PDF Generation (F-CORE-12)** — Template-Engine, PDF-Generierung
14. **Generic Import/Export (F-CORE-11)** — Generischer Service, Preview, Dry-Run
### Priorität 4 — Auth-Ergänzungen
15. **Login auf E-Mail umstellen (F-AUTH-01)** — username → email
16. **User-Verwaltung durch Admin (F-AUTH-03)** — Admin-CRUD für User, Tenant-Zuordnung
17. **Passwort-Reset (F-AUTH-05)** — Reset-Flow mit E-Mail
18. **Register-Template entfernen** — Self-Registration ist Non-Goal
19. **Cookie-Security anpassen** — SameSite=Strict, Secure immer
### Was beibehalten werden kann
- **Backend-Services** (company_service, contact_service, etc.) — Business-Logik ist solide
- **Pydantic-Schemas** — Können für API-Validierung weiterverwendet werden
- **DB-Modelle** — Felder/Beziehungen sind korrekt, müssen nur tenant_id ergänzt werden
- **Test-Suite** — pytest-Tests können erweitert werden
- **DMS/Calendar/Tag-Implementierungen** — Als Referenz für spätere Plugin-Entwicklung behalten
---
## 6. Zusammenfassung
| Kategorie | Anzahl | Status |
|-----------|--------|--------|
| Kritische Konflikte | 4 | Multi-Tenant, Plugin-System, DB, Frontend |
| F-CORE fehlend | 10/13 | Event Bus, Tenant-Isolation, Plugin-Migration, UI-Plugin, Service Container, Job Queue, Caching, User-Profile, Storage, PDF |
| F-CORE teilweise | 3/13 | API-First, Import/Export, Notification |
| F-CORE vollständig | 0/13 | — |
| Auth-Konflikte | 4 | Login (username vs email), User-Verwaltung, Passwort-Reset, Cookie-Security |
| Kompatibel | 7+ | Session-Auth, RBAC, Company/Contact CRUD, Data-Features, Health, DMS/Calendar/Tags (als Referenz) |
**Fazit:** Die bestehende Codebase ist eine funktionsfähige v0.1-Implementierung (Single-Tenant, SQLite, Jinja2), die den bereinigten v1-Requirements in 4 kritischen Bereichen nicht entspricht: Multi-Tenant, Plugin-System, PostgreSQL, React SPA. 10 von 13 F-CORE-Features fehlen komplett. Die bestehende Business-Logik (Services, Schemas, Modelle) ist jedoch solide und kann als Basis für den Umbau dienen. Der Aufwand für Phase 2 ist erheblich — es handelt sich um eine Architektur-Migration, nicht um inkrementelle Erweiterungen.
+348
View File
@@ -0,0 +1,348 @@
# LeoCRM Plugin Development Guide
> **Version:** 1.0
> **Datum:** 2026-07-23
> **Gültig für:** Alle Plugin-Entwickler
---
## 1. Plugin-Struktur
Jedes Plugin liegt unter `app/plugins/builtins/<plugin_name>/`:
```
app/plugins/builtins/my_plugin/
├── __init__.py
├── plugin.py # Plugin-Klasse mit Manifest
├── routes.py # API-Routes
├── models.py # SQLAlchemy-Modelle (optional)
├── schemas.py # Pydantic-Schemas (optional)
├── services.py # Business-Logik (optional)
├── migrations/ # SQL-Migrationen
│ └── 0001_initial.sql
└── tests/ # Plugin-Tests (optional)
```
## 2. Plugin-Manifest
Das Manifest definiert Metadaten, Abhängigkeiten, Routes, Events und Permissions:
```python
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
class MyPlugin(BasePlugin):
manifest = PluginManifest(
name="my_plugin",
version="1.0.0",
display_name="My Plugin",
description="Description of what the plugin does.",
dependencies=["permissions"], # Other plugins this depends on
routes=[
PluginRouteDef(
path="/api/v1/my-plugin",
module="app.plugins.builtins.my_plugin.routes",
router_attr="router",
),
],
events=["contact.created", "contact.updated"],
migrations=["0001_initial.sql"],
permissions=[
"my_plugin:read",
"my_plugin:write",
"my_plugin:delete",
],
agent_capabilities=[
"my_plugin:search",
"my_plugin:analyze",
],
)
```
### Manifest-Felder
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `name` | `str` | Ja | Eindeutiger Plugin-Name (snake_case) |
| `version` | `str` | Ja | Semantic Version |
| `display_name` | `str` | Ja | Anzeigename |
| `description` | `str` | Nein | Kurzbeschreibung |
| `dependencies` | `list[str]` | Nein | Andere Plugins, die geladen sein müssen |
| `routes` | `list[PluginRouteDef]` | Nein | API-Routen-Definitionen |
| `events` | `list[str]` | Nein | Events, die das Plugin abonniert |
| `migrations` | `list[str]` | Nein | SQL-Migrationsdateien |
| `permissions` | `list[str]` | Nein | RBAC-Permissions, die das Plugin definiert |
| `field_definitions` | `list[FieldDefinition]` | Nein | Feld-Level-Permissions |
| `agent_capabilities` | `list[str]` | Nein | KI-Agent-Fähigkeiten, die das Plugin bietet |
| `is_core` | `bool` | Nein | Core-Plugin (kann nicht deaktiviert werden) |
## 3. RBAC-Permissions
### Permissions definieren
Im Manifest werden alle Permissions des Plugins aufgelistet:
```python
permissions=[
"my_plugin:read",
"my_plugin:write",
"my_plugin:delete",
"my_plugin:admin",
],
```
### Routes absichern
Jede Route muss mit `require_permission` abgesichert werden:
```python
from app.deps import get_current_user, require_permission
from fastapi import Depends
@router.get("", dependencies=[Depends(require_permission("my_plugin:read"))])
async def list_items(current_user: dict = Depends(get_current_user)):
...
@router.post("", status_code=201, dependencies=[Depends(require_permission("my_plugin:write"))])
async def create_item(data: ItemCreate, current_user: dict = Depends(get_current_user)):
...
@router.delete("/{item_id}", dependencies=[Depends(require_permission("my_plugin:delete"))])
async def delete_item(item_id: str, current_user: dict = Depends(get_current_user)):
...
```
### Permission-Namenskonvention
- Format: `<plugin_name>:<action>`
- Standard-Actions: `read`, `write`, `delete`, `share`, `admin`
- Beispiele: `calendar:read`, `dms:write`, `tags:delete`
## 4. KI-Agent-Framework
LeoCRM bietet ein integriertes KI-Agent-Framework basierend auf **LiteLLM** und **PydanticAI**.
### Architektur
```
Plugin (ai_assistant, ai_proactive, zukünftige)
↓
LiteLLM (unified LLM interface — 100+ Provider)
↓
Provider (OpenAI, Anthropic, Google, Ollama, ...)
↑
Tool Registry (Plugin-Tools für KI-Agenten)
```
### LiteLLM — Unified LLM Interface
LiteLLM bietet eine einheitliche API für über 100 LLM-Provider. Alle KI-Funktionen
in LeoCRM nutzen `litellm.acompletion()`:
```python
import litellm
response = await litellm.acompletion(
model="openai/gpt-4o", # oder anthropic/claude-3-sonnet, ollama/llama3
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_query},
],
temperature=0.3,
max_tokens=1000,
api_key=os.environ.get("AI_API_KEY"),
api_base=os.environ.get("AI_API_BASE"), # optional für self-hosted
)
content = response.choices[0].message.content
```
### Konfiguration
| Env-Var | Beschreibung | Standard |
|---|---|---|
| `AI_MODEL` | Modell-Name (z.B. `gpt-4o`, `claude-3-sonnet`, `llama3`) | — |
| `AI_API_KEY` | API-Key für den Provider | — |
| `AI_API_BASE` | Custom API-Base-URL (optional) | Provider-Standard |
| `AI_PROVIDER` | Provider-Präfix (`openai`, `anthropic`, `google`, `ollama`) | `openai` |
Wenn `AI_MODEL` und `AI_API_KEY` nicht gesetzt sind, läuft der LLM-Client im Mock-Modus
(keyword-basierte Action-Mapping für Tests).
### Tool Registry — KI-Tools registrieren
Plugins können Tools registrieren, die KI-Agenten während Chat-Sessions aufrufen können.
Jedes Tool deklariert Name, Beschreibung, JSON-Schema für Parameter und einen async Handler.
```python
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
registry = get_tool_registry()
registry.register(
name="search_contacts",
description="Search contacts by name, email, or phone number",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"limit": {"type": "integer", "description": "Max results", "default": 10},
},
"required": ["query"],
},
handler=my_search_handler,
plugin_name="my_plugin",
required_permission="contacts:read",
category="search",
)
```
### Tool Handler
Der Handler ist eine async Funktion, die Argumente und Kontext empfängt:
```python
async def my_search_handler(arguments: dict, context: dict) -> str:
query = arguments.get("query", "")
limit = arguments.get("limit", 10)
# ... perform search ...
return json.dumps({"results": results})
```
### Tools bei Plugin-Deaktivierung abmelden
```python
def on_deactivate(self):
registry = get_tool_registry()
registry.unregister_plugin("my_plugin")
```
### Agent Capabilities im Manifest
Das `agent_capabilities` Feld im Manifest deklariert, welche KI-Fähigkeiten ein Plugin bietet:
```python
agent_capabilities=[
"contact_search", # Kontakt-Suche
"email_draft", # E-Mail-Entwürfe generieren
"calendar_scheduling", # Terminvorschläge
],
```
Diese Informationen werden vom AI Assistant verwendet, um Nutzern zu zeigen,
welche KI-Funktionen verfügbar sind.
## 5. Events
Plugins können Events abonnieren und auslösen:
```python
# Im Manifest:
events=["contact.created", "contact.updated", "contact.deleted"]
# Event-Handler im Plugin:
async def on_contact_created(self, event_data: dict):
# Reagiere auf neues Kontakt-Event
pass
```
Events werden vom Event-Publisher im Contact-Service ausgelöst:
```python
from app.core.events import publish_event
await publish_event(db, "contact.created", {"contact_id": str(contact.id)})
```
## 6. Datenbank-Migrationen
SQL-Migrationen liegen unter `migrations/` im Plugin-Verzeichnis:
```sql
-- migrations/0001_initial.sql
CREATE TABLE my_plugin_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name VARCHAR(200) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
Im Manifest referenzieren:
```python
migrations=["0001_initial.sql"]
```
## 7. UI-Integration
Siehe `docs/ui-design-guidelines.md` für Frontend-Konventionen.
- Plugin-Seiten verwenden das 3-Spalten-Explorer-Layout
- PluginToolbar für Aktionen
- Plugin-Settings als eigene Settings-Sub-Seite
- i18n-Keys mit Plugin-Präfix
## 8. Testing
Tests liegen unter `tests/` im Plugin-Verzeichnis oder im zentralen `tests/` Ordner:
```python
# tests/test_my_plugin.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_list_items_requires_permission(client: AsyncClient, auth_headers):
response = await client.get("/api/v1/my-plugin", headers=auth_headers)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_list_items_without_permission_returns_403(client: AsyncClient, no_perm_headers):
response = await client.get("/api/v1/my-plugin", headers=no_perm_headers)
assert response.status_code == 403
```
## 9. Plugin-Beispiel
Minimal-Beispiel für ein neues Plugin:
```python
# app/plugins/builtins/my_plugin/plugin.py
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
class MyPlugin(BasePlugin):
manifest = PluginManifest(
name="my_plugin",
version="1.0.0",
display_name="My Plugin",
description="A minimal example plugin.",
dependencies=[],
routes=[
PluginRouteDef(
path="/api/v1/my-plugin",
module="app.plugins.builtins.my_plugin.routes",
router_attr="router",
),
],
events=[],
migrations=[],
permissions=["my_plugin:read", "my_plugin:write"],
agent_capabilities=[],
)
```
```python
# app/plugins/builtins/my_plugin/routes.py
from fastapi import APIRouter, Depends
from app.deps import get_current_user, require_permission
router = APIRouter()
@router.get("", dependencies=[Depends(require_permission("my_plugin:read"))])
async def list_items(current_user: dict = Depends(get_current_user)):
return {"items": []}
```
---
*Dieses Dokument ist verbindlich für alle Plugin-Entwicklung an LeoCRM.*
+535
View File
@@ -0,0 +1,535 @@
# LeoCRM UI-Design-Richtlinien
> **Version:** 1.0
> **Datum:** 2026-07-23
> **Gültig für:** Alle Frontend-Komponenten, Plugin-Seiten und zukünftige Entwicklungen
---
## 1. Farbsystem
Alle Farben sind als Tailwind Design Tokens in `tailwind.config.js` definiert. Jede Farbe hat Schattierungen von 50 (hell) bis 900 (dunkel) plus einen `DEFAULT`-Wert.
| Token | Hex (DEFAULT) | Verwendung |
|---|---|---|
| `primary` | `#2563eb` (Blau) | Hauptaktionen, aktive Zustände, Links, Fokus-Ringe |
| `secondary` | `#64748b` (Slate) | Text, Borders, Hintergründe, inaktive Zustände |
| `accent` | `#d946ef` (Fuchsia) | Hervorhebungen, Info-Badges, KI-Features |
| `danger` | `#dc2626` (Rot) | Löschen, Fehler, destruktive Aktionen |
| `warning` | `#f59e0b` (Amber) | Warnungen, ausstehende Aktionen |
| `success` | `#16a34a` (Grün) | Erfolg, Bestätigungen, aktive Status |
### Verwendungsregeln
- **Primary** nur für die wichtigste Aktion pro View. Nicht mehr als eine Primary-Button pro Formular.
- **Secondary** für Text, Borders und inaktive UI-Elemente. `secondary-50` für Card-Footer, `secondary-100` für Hover-Zustände.
- **Accent** sparsam für KI-Features und Hervorhebungen. Nicht für Standard-Aktionen.
- **Danger** ausschließlich für destruktive Aktionen (Löschen, Entfernen). Immer mit `ConfirmDialog` kombinieren.
- **Warning** für Status-Badges und Warnhinweise. Nicht als Button-Farbe.
- **Success** für Erfolgsmeldungen und Status-Indikatoren. Nicht als Standard-Button.
### Dark Mode
- Aktiviert via `darkMode: 'class'` in Tailwind Config.
- CSS-Variablen in `:root` (Light) und `.dark` (Dark) definiert.
- Dark Mode-Toggle in Settings.
- Beim Dark Mode werden `secondary-900` als Hintergrund und `secondary-50` als Text verwendet.
---
## 2. Typografie
| Eigenschaft | Wert |
|---|---|
| Font Family | `Inter` (system-ui fallback) |
| Mono Font | `JetBrains Mono` für Code/Daten |
| Rendering | `antialiased` |
### Schriftgrößen-Hierarchie
| Token | Größe | Zeilenhöhe | Verwendung |
|---|---|---|---|
| `text-xs` | 0.75rem | 1rem | Badges, Tooltips, Metadaten |
| `text-sm` | 0.875rem | 1.25rem | Labels, Helper-Text, Tabellen-Spalten |
| `text-base` | 1rem | 1.5rem | Body-Text, Input-Felder |
| `text-lg` | 1.125rem | 1.75rem | Card-Titel, Section-Header |
| `text-xl` | 1.25rem | 1.75rem | Seiten-Titel |
| `text-2xl` | 1.5rem | 2rem | Dashboard-Überschriften |
| `text-3xl` | 1.875rem | 2.25rem | Große Überschriften |
| `text-4xl` | 2.25rem | 2.5rem | Hero-Text, Login-Titel |
### Font-Weight
- `font-medium` (500) — Buttons, Labels, Tab-Header
- `font-semibold` (600) — Card-Titel, Seiten-Titel
- `font-bold` (700) — Nur für Hervorhebungen, sparsam
---
## 3. Layout-Patterns
### 3-Spalten-Explorer-Layout
Standard-Layout für Explorer-Plugins (Calendar, Mail, DMS, Contacts):
```
┌─────────────┬──────────────────┬──────────────────────┐
│ Tree │ Liste/Explorer │ Detail │
│ (224px) │ (flex-1) │ (flex-1 / 60%) │
│ ResizablePanel│ ResizablePanel │ ResizablePanel │
└─────────────┴──────────────────┴──────────────────────┘
```
- Linke Spalte: `ResizablePanel` mit `initialWidth=224`, `minWidth=150`, `maxWidth=600`
- Mittlere Spalte: `ResizablePanel` mit `resizable=false` (flex-1)
- Rechte Spalte: `ResizablePanel` mit `resizable=false` oder `handleSide="left"`
- Drag-Handle auf der rechten Kante der linken Spalte
```tsx
import { ResizablePanel } from '@/components/ui/ResizablePanel';
<div className="flex h-full">
<ResizablePanel initialWidth={224} minWidth={150} maxWidth={600}>
<TreeView />
</ResizablePanel>
<div className="flex-1 overflow-auto">
<ListView />
</div>
<div className="flex-1 overflow-auto">
<DetailView />
</div>
</div>
```
### PluginToolbar
Jede Plugin-Seite registriert Aktionen über den `usePluginToolbarStore`:
```tsx
import { usePluginToolbarStore, type ToolbarItem } from '@/store/pluginToolbarStore';
const { setItems, setActivePlugin } = usePluginToolbarStore();
useEffect(() => {
setActivePlugin('calendar');
setItems([
{ id: 'create', plugin: 'calendar', type: 'button', label: 'Neu', icon: <Plus />, onClick: handleCreate, group: 'actions' },
{ id: 'search', plugin: 'calendar', type: 'search', searchPlaceholder: 'Suchen...', onSearch: handleSearch, group: 'search' },
]);
}, []);
```
- Toolbar-Items werden nach `group` gruppiert mit Trennern zwischen Gruppen.
- Button-Labels sind auf Mobile (`hidden sm:inline`) ausgeblendet, Icons bleiben sichtbar.
- Toolbar-Höhe: `min-h-[43px]`, Hintergrund `bg-white`, Border-Bottom `border-secondary-200`.
### Modal-Dialoge
Für Formulare, Bestätigungen und Dialoge:
```tsx
import { Modal } from '@/components/ui/Modal';
<Modal open={open} onClose={onClose} title="Kontakt bearbeiten" size="lg">
<ContactForm />
</Modal>
```
| Size | max-width | Verwendung |
|---|---|---|
| `sm` | max-w-md | Bestätigungsdialoge |
| `md` | max-w-lg | Einfache Formulare |
| `lg` | max-w-2xl | Komplexe Formulare, Edit-Dialoge |
| `xl` | max-w-4xl | Große Formulare, Multi-Step |
- `ConfirmDialog` für destruktive Aktionen (Löschen, Entfernen).
- Focus-Trap: Fokus wird beim Öffnen auf erstes fokussierbares Element gesetzt, beim Schließen auf ursprüngliches Element zurückgegeben.
- Escape-Taste schließt Modal (sofern `closeOnEscape=true`).
- Backdrop-Klick schließt Modal (sofern `closeOnBackdrop=true`).
### Settings-Layout
Settings-Seiten verwenden einen Tree-Navigator links und das Formular rechts:
```
┌─────────────┬──────────────────────────────────┐
│ Settings │ Settings-Formular │
│ Tree │ (Cards mit Sections) │
│ (224px) │ (flex-1, scrollable) │
└─────────────┴──────────────────────────────────┘
```
---
## 4. Komponenten-Referenz
### Button
```tsx
import { Button } from '@/components/ui/Button';
<Button variant="primary" size="md" onClick={handleSave} isLoading={saving}>
Speichern
</Button>
```
| Prop | Typ | Default | Beschreibung |
|---|---|---|---|
| `variant` | `'primary' \| 'secondary' \| 'danger' \| 'ghost'` | `'primary'` | Visuelle Variante |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Größe |
| `isLoading` | `boolean` | `false` | Zeigt Spinner, deaktiviert Button |
| `icon` | `ReactNode` | — | Icon links vom Text |
| `fullWidth` | `boolean` | `false` | `width: 100%` |
- Alle Buttons haben `min-h-touch` (44px) für Touch-Accessibility.
- `focus-visible:ring-2` für Tastatur-Navigation.
- `motion-safe:duration-200` für Übergänge (respektiert `prefers-reduced-motion`).
### Card
```tsx
import { Card } from '@/components/ui/Card';
<Card title="Kontaktdaten" description="Stammdaten" actions={<Button>Edit</Button>}>
<CardContent />
</Card>
```
| Prop | Typ | Beschreibung |
|---|---|---|
| `title` | `string` | Card-Header-Titel |
| `description` | `string` | Subtitel im Header |
| `actions` | `ReactNode` | Aktionen rechts im Header |
| `footer` | `ReactNode` | Footer-Bereich (bg-secondary-50) |
- Hintergrund: `bg-white`, Border: `border-secondary-200`, Radius: `rounded-lg`, Shadow: `shadow-sm`.
- Body-Padding: `px-6 py-4`, Footer-Padding: `px-6 py-3`.
### Badge
```tsx
import { Badge } from '@/components/ui/Badge';
<Badge variant="success" dot>Aktiv</Badge>
```
| Variant | Verwendung |
|---|---|
| `default` | Neutrale Tags |
| `primary` | Primäre Zustände |
| `success` | Aktiv, bestätigt, online |
| `warning` | Ausstehend, Warnung |
| `danger` | Fehler, inaktiv, abgelaufen |
| `info` | Info, KI-Vorschläge |
| `secondary` | Sekundäre Tags |
- `dot` prop zeigt einen farbigen Punkt links an.
- Größe: `text-xs`, `px-2.5 py-0.5`, `rounded-full`.
### Input / Select
```tsx
import { Input } from '@/components/ui/Input';
<Input label="Name" error={errors.name} helperText="Vollständiger Name" required />
```
- `focus:ring-2 focus:ring-primary-500` bei Fokus.
- Error-State: `border-danger-500`, `text-danger-900`.
- `aria-invalid`, `aria-describedby` für Accessibility.
- `min-h-touch` (44px) für Touch-Targets.
- Label: `text-sm font-medium text-secondary-700`.
### Table / DataGrid
- Verwendet TanStack Table für Sortierung, Filterung, Pagination.
- `aria-label` auf sortierbare Headers.
- Zebra-Stiping optional: `even:bg-secondary-50`.
### Toast
```tsx
import { useToast } from '@/components/ui/Toast';
const { toast } = useToast();
toast({ title: 'Gespeichert', description: 'Kontakt wurde gespeichert', variant: 'success' });
```
- Wird nach jeder CRUD-Aktion verwendet (Erfolg/Fehler).
- Auto-Dismiss nach 5 Sekunden.
- Position: Top-Right (Desktop), Top (Mobile).
### EmptyState
```tsx
import { EmptyState } from '@/components/ui/EmptyState';
<EmptyState icon={<Users />} title="Keine Kontakte" description="Erstellen Sie einen neuen Kontakt" action={<Button>Neu</Button>} />
```
- Verwendet wenn Liste leer ist.
- Icon groß zentriert, Titel + Beschreibung, optional Aktion.
### Skeleton
```tsx
import { Skeleton } from '@/components/ui/Skeleton';
<Skeleton className="h-8 w-full" />
```
- Verwendet während Daten laden.
- `animate-pulse` Animation.
- Respektiert `prefers-reduced-motion`.
---
## 5. Spacing & Sizing
### Padding
| Element | Padding |
|---|---|
| Card Body | `px-6 py-4` |
| Card Footer | `px-6 py-3` |
| Panel | `p-4` |
| Modal Body | `p-6` |
| Input | `px-3 py-2` |
### Gap
| Verwendung | Gap |
|---|---|
| Button-Gruppen | `gap-2` |
| Form-Sections | `gap-4` |
| Spalten / Panels | `gap-6` |
| Toolbar-Items | `gap-1` |
### Border-Radius
| Token | Wert | Verwendung |
|---|---|---|
| `rounded-sm` | 0.375rem | Badges, kleine Elemente |
| `rounded-md` | 0.5rem | Inputs, Buttons, Panels (Standard) |
| `rounded-lg` | 0.75rem | Cards, Modals |
| `rounded-xl` | 1rem | Große Container |
| `rounded-full` | 9999px | Badges, Avatars |
### Shadow
| Token | Verwendung |
|---|---|
| `shadow-sm` | Cards, Panels |
| `shadow-md` | Dropdowns, Popovers |
| `shadow-lg` | Modals, Dialoge |
---
## 6. Accessibility
### Pflicht-Regeln
1. **Focus-Ring**: Alle interaktiven Elemente haben `focus-visible:ring-2 focus-visible:ring-primary-500`.
2. **Touch-Targets**: Mindestens 44×44px (`min-h-touch min-w-touch`).
3. **ARIA-Labels**: Dekorative SVGs erhalten `aria-hidden="true"`. Interaktive Elemente ohne sichtbaren Text erhalten `aria-label`.
4. **Screen Reader**: `sr-only` Klasse für Text nur für Screen Reader. `sr-only-focusable` für Skip-Links.
5. **Reduced Motion**: `motion-safe:` und `motion-reduce:` Präfixe verwenden. `prefers-reduced-motion` Media Query wird respektiert.
6. **Tastatur-Navigation**: Tab-Reihenfolge folgt visueller Reihenfolge. Escape schließt Modals/Dropdowns.
7. **Farbkontrast**: Mindestens 4.5:1 für Body-Text, 3:1 für große Texte und UI-Komponenten.
### Implementierte Patterns
- `focus-ring` Klasse: `focus-visible:ring-2 focus-visible:ring-primary-500`
- `btn-touch` Klasse: `min-h-touch min-w-touch` (44px)
- `sr-only` und `sr-only-focusable` Klassen
- `prefers-reduced-motion` Media Query
- `aria-hidden="true"` auf dekorativen Icons
- `aria-label` auf Icon-Only-Buttons
- `aria-busy="true"` auf ladenden Buttons
- `aria-invalid` und `aria-describedby` auf Inputs mit Fehlern
---
## 7. Plugin-UI-Patterns
### Neue Plugin-Seite — Checkliste
1. **3-Spalten-Layout** verwenden (wenn anwendbar): Tree | Liste | Detail
2. **PluginToolbar** registrieren: Create, Import, Export, Search als Toolbar-Items
3. **Plugin-Settings** als eigene Settings-Sub-Seite (Settings-Tree-Navigation)
4. **Detail-Tabs** für Entity-Detail (z.B. "Dateien", "Verlauf", "Notizen")
5. **EmptyState** wenn keine Daten vorhanden
6. **Skeleton/LoadingState** während Daten laden
7. **Toast** nach jeder CRUD-Aktion (Erfolg/Fehler)
8. **ConfirmDialog** vor destruktiven Aktionen
9. **i18n** — alle Texte über `useTranslation()` (DE/EN)
10. **Dark Mode** — alle Komponenten müssen in Light und Dark funktionieren
### Plugin-Toolbar Registrierung
```tsx
useEffect(() => {
setActivePlugin('myplugin');
setItems([
{ id: 'create', plugin: 'myplugin', type: 'button', label: t('actions.create'), icon: <Plus size={16} />, onClick: handleCreate, group: 'actions' },
{ id: 'import', plugin: 'myplugin', type: 'button', label: t('actions.import'), icon: <Upload size={16} />, onClick: handleImport, group: 'actions' },
{ id: 'search', plugin: 'myplugin', type: 'search', searchPlaceholder: t('search'), onSearch: handleSearch, group: 'search' },
]);
return () => setItems([]);
}, []);
```
### i18n
- Alle Texte über `useTranslation()` Hook.
- Übersetzungen in `src/i18n/locales/de.json` und `src/i18n/locales/en.json`.
- Keys nach Plugin-Präfix: `myplugin.actions.create`, `myplugin.search`, etc.
- Ca. 750 Keys pro Sprache aktuell.
---
## 8. Do's & Don'ts
### Do's
- ✅ Bestehende UI-Komponenten aus `components/ui/` verwenden
- ✅ `clsx` für bedingte Klassen verwenden
- ✅ `lucide-react` Icons verwenden (keine inline SVGs)
- ✅ `date-fns` für Datumsformatierung verwenden
- ✅ Zustand-Stores für State Management verwenden
- ✅ TanStack Query für API-Calls verwenden
- ✅ `min-h-touch` (44px) für alle interaktiven Elemente
- ✅ `focus-visible:ring-2` für Tastatur-Accessibility
- ✅ `motion-safe:` / `motion-reduce:` für Animationen
- ✅ Toast nach jeder CRUD-Aktion anzeigen
- ✅ ConfirmDialog vor jeder destruktiven Aktion
- ✅ EmptyState für leere Listen
- ✅ Skeleton für Lade-Zustände
### Don'ts
- ❌ Keine inline SVGs — immer `lucide-react` verwenden
- ❌ Keine `Date.parse()` oder `new Date()` Formatierung — `date-fns` verwenden
- ❌ Keine hardcoded Farben — Tailwind Design Tokens verwenden
- ❌ Keine `alert()` oder `confirm()` — Toast und ConfirmDialog verwenden
- ❌ Keine CSS-Module oder styled-components — Tailwind-Klassen verwenden
- ❌ Keine `useEffect` für State-Management — Zustand-Stores verwenden
- ❌ Keine direkten `fetch()` Calls — TanStack Query Hooks verwenden
- ❌ Keine `any` Types — TypeScript-Interfaces definieren
- ❌ Keine deutschen Strings im Code — i18n-Keys verwenden
- ❌ Keine `px-` Werte für Touch-Targets unter 44px
- ❌ Keine `display: none` für Accessibility-relevante Elemente — `sr-only` verwenden
---
## 9. Code-Beispiele
### Beispiel: Plugin-Seite mit 3-Spalten-Layout
```tsx
import { useEffect } from 'react';
import { Plus, Search } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { EmptyState } from '@/components/ui/EmptyState';
import { Button } from '@/components/ui/Button';
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
export function MyPluginPage() {
const { t } = useTranslation();
const { setItems, setActivePlugin } = usePluginToolbarStore();
useEffect(() => {
setActivePlugin('myplugin');
setItems([
{ id: 'create', plugin: 'myplugin', type: 'button', label: t('actions.create'), icon: <Plus size={16} />, onClick: handleCreate, group: 'actions' },
{ id: 'search', plugin: 'myplugin', type: 'search', searchPlaceholder: t('search'), onSearch: handleSearch, group: 'search' },
]);
return () => setItems([]);
}, []);
const handleCreate = () => { /* ... */ };
const handleSearch = (q: string) => { /* ... */ };
return (
<div className="flex h-full">
<ResizablePanel initialWidth={224} minWidth={150} maxWidth={600}>
<TreeView />
</ResizablePanel>
<div className="flex-1 overflow-auto">
{items.length === 0 ? (
<EmptyState icon={<FileIcon />} title={t('empty.title')} description={t('empty.description')} action={<Button onClick={handleCreate}>{t('actions.create')}</Button>} />
) : (
<ListView items={items} />
)}
</div>
<div className="flex-1 overflow-auto">
<DetailView />
</div>
</div>
);
}
```
### Beispiel: Formular mit Validation
```tsx
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { useToast } from '@/components/ui/Toast';
function ContactForm({ open, onClose }) {
const { toast } = useToast();
const [errors, setErrors] = useState({});
const handleSubmit = async (e) => {
e.preventDefault();
try {
await saveContact(formData);
toast({ title: 'Gespeichert', variant: 'success' });
onClose();
} catch (err) {
toast({ title: 'Fehler', description: err.message, variant: 'danger' });
}
};
return (
<Modal open={open} onClose={onClose} title="Kontakt bearbeiten" size="lg">
<form onSubmit={handleSubmit} className="space-y-4">
<Input label="Vorname" error={errors.firstname} required />
<Input label="Nachname" error={errors.surname} required />
<div className="flex justify-end gap-2 pt-4">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button type="submit" variant="primary">Speichern</Button>
</div>
</form>
</Modal>
);
}
```
---
## 10. Datei-Struktur
```
frontend/src/
├── components/
│ ├── ui/ # Basis-Komponenten (Button, Card, Modal, etc.)
│ ├── layout/ # Layout-Komponenten (PluginToolbar, etc.)
│ └── [plugin]/ # Plugin-spezifische Komponenten
├── pages/ # Seiten-Komponenten (Routes)
├── store/ # Zustand-Stores
├── hooks/ # Custom Hooks (aufgeteilt nach Domain)
├── utils/ # Utilities (date.ts, api.ts, etc.)
├── i18n/ # Übersetzungen
│ └── locales/
│ ├── de.json
│ └── en.json
└── routes/ # React Router Konfiguration
```
---
*Diese Richtlinien sind verbindlich für alle Frontend-Entwicklung an LeoCRM.*
BIN
View File
Binary file not shown.
+176
View File
@@ -0,0 +1,176 @@
# LeoCRM Frontend — Vollständige Bestandsanalyse
**Verzeichnis:** `/a0/usr/workdir/leocrm-fix/frontend`
**Architektur-Referenz:** `architecture.md` Abschnitt 7
**Datum:** 2026-07-23
---
## 1. Pages/Routes (27 Pages, 7.826 Zeilen)
### Vorhandene Seiten
| Seite | File | Lines | Status |
|-------|------|-------|--------|
| Login | src/pages/Login.tsx | 92 | ✅ |
| PasswordReset Request | src/pages/PasswordResetRequest.tsx | 91 | ✅ |
| PasswordReset Confirm | src/pages/PasswordResetConfirm.tsx | 106 | ✅ |
| Dashboard | src/pages/Dashboard.tsx | 79 | ✅ |
| ContactsList | src/pages/ContactsList.tsx | 445 | ✅ |
| Calendar | src/pages/Calendar.tsx | 717 | ✅ |
| CalendarKanban | src/pages/CalendarKanban.tsx | 123 | ✅ |
| DMS | src/pages/Dms.tsx | 731 | ✅ |
| DmsTrash | src/pages/DmsTrash.tsx | 155 | ✅ |
| Mail | src/pages/Mail.tsx | 978 | ✅ |
| MailSettings | src/pages/MailSettings.tsx | 449 | ✅ |
| AuditLog | src/pages/AuditLog.tsx | 167 | ✅ |
| GlobalSearchResults | src/pages/GlobalSearchResults.tsx | 228 | ✅ |
| Settings (Hub) | src/pages/Settings.tsx | 52 | ✅ |
| SettingsProfile | src/pages/SettingsProfile.tsx | 181 | ✅ |
| SettingsUsers | src/pages/SettingsUsers.tsx | 298 | ✅ |
| SettingsRoles | src/pages/SettingsRoles.tsx | 522 | ✅ |
| SettingsGroups | src/pages/SettingsGroups.tsx | 688 | ✅ |
| SettingsPlugins | src/pages/SettingsPlugins.tsx | 255 | ✅ |
| SettingsSystem | src/pages/SettingsSystem.tsx | 277 | ✅ |
| SettingsCurrencies | src/pages/SettingsCurrencies.tsx | 142 | ✅ |
| SettingsTaxes | src/pages/SettingsTaxes.tsx | 143 | ✅ |
| SettingsSequences | src/pages/SettingsSequences.tsx | 138 | ✅ |
| SettingsNotifications | src/pages/SettingsNotifications.tsx | 158 | ✅ |
| AIAssistant | src/pages/AIAssistant.tsx | 122 | ✅ |
| AISettings | src/pages/AISettings.tsx | 332 | ✅ |
| ProactiveAISettings | src/pages/ProactiveAISettings.tsx | 157 | ✅ |
### Fehlende Seiten
| Seite | Status | Anmerkung |
|-------|--------|-----------|
| Companies List | ❌ MISSING | Keine Companies.tsx, API-Hooks existieren |
| Company Detail | ❌ MISSING | Keine CompanyDetail.tsx |
| Contact Detail Route | ❌ MISSING | ContactDetail.tsx existiert als Komponente (372 Zeilen), aber keine Route /contacts/:id |
## 2. Component Library (12+2 Komponenten)
Alle 12 geforderten UI-Komponenten vorhanden: Button, Input, Select, Modal, Toast, Table, Card, Badge, Avatar, Pagination, EmptyState, Skeleton.
Zusätzlich: ConfirmDialog, ResizablePanel.
## 3. Layout
- AppShell ✅, Sidebar ✅ (237 Zeilen), TopBar ✅ (146 Zeilen)
- ContentArea ⚠️ Inline in AppShell
- PluginToolbar ✅, AISidebar ✅ (322 Zeilen), MessageSidebar ✅ (689 Zeilen)
## 4. Plugin UI System
- PluginRegistry.tsx ❌ MISSING
- PluginLoader.tsx ❌ MISSING
- src/plugins/ Verzeichnis ❌ MISSING
- Plugin-Routes hartkodiert in routes/index.tsx
## 5. State Management
- TanStack Query: 138 Aufrufe, 60+ Hooks ✅
- Zustand: 5 Stores (authStore, uiStore, commStore, pluginToolbarStore, calendarStore) ✅
## 6. Code-Splitting
- React.lazy ❌ MISSING (0 Verwendungen)
- Suspense ❌ MISSING
## 7. i18n
- de.json: 750 Keys ✅
- en.json: 750 Keys ✅
- Perfekt synchron
## 8. Forms
- React Hook Form + Zod installiert ✅
- Nur in 3 Pages aktiv genutzt ⚠️
## 9. Feature Modules (70 Components, 13.893 Zeilen)
- ai/ (5 Komponenten) ✅
- calendar/ (12 Komponenten) ✅
- comm/ (11 Block-Typen) ✅
- contacts/ (4 Komponenten) ✅
- dms/ (9 Komponenten) ✅
- mail/ (13 Komponenten) ✅
- tags/ (3 Komponenten) ✅
- companies/ ❌ MISSING
- dashboard/ ❌ MISSING
## 10. API Client (12 Module, 3.456 Zeilen)
- client.ts, hooks.ts (Re-Export-Hub), auth.ts, users.ts, contacts.ts, roles.ts, groups.ts, audit.ts, notifications.ts, plugins.ts, settings.ts, attachments.ts, unifiedContacts.ts ✅
- workflows.ts ❌ MISSING
## 11. Custom Hooks (5 Hooks)
useAuth, useTenant, usePermission, useAIContext, useCommWebSocket ✅
## 12. Accessibility
- ARIA roles ⚠️ Partial
- sr-only ⚠️ Partial
- prefers-reduced-motion ✅
- 44px touch targets ✅
- focus-ring ✅
## 13. Tests (38 Dateien, 3.045 Zeilen)
- UI Components: 13 Tests ✅
- Shell/Layout: 4 Tests ✅
- Auth: 2 Tests ✅
- Dashboard: 1 Test ✅
- Contacts: 1 Test ✅
- Settings: 3 Tests ✅
- Calendar: 3 Tests ✅
- Mail: 3 Tests ✅
- DMS: 2 Tests ✅
- Search: 2 Tests ✅
- Tags: 2 Tests ✅
- Permissions: 1 Test ✅
- AuditLog: 1 Test ✅
- i18n: 1 Test ✅
- AI Components ❌ MISSING
- SettingsGroups/System/Currencies/Taxes/Sequences/Notifications/Plugins ❌ MISSING
- Calendar Page ❌ MISSING
- DMS Sub-Components ❌ MISSING
- Contact Sub-Components ❌ MISSING
- Comm Blocks ❌ MISSING
- API Hooks ❌ MISSING
- Stores ❌ MISSING
## 14. E2E Tests (Playwright)
- Playwright ❌ MISSING (komplett)
## 15. Frontend Build
- vite.config.ts ✅
- tsconfig.json ✅
- package.json ✅ (26 deps, 15 devDeps)
- tailwind.config.js ✅
- postcss.config.js ✅
## 16. Frontend Dockerfile
- ❌ MISSING (Multi-Stage Build im Haupt-Dockerfile)
## 17. Nginx Config
- ❌ MISSING
## 18. TipTap (Rich Text Editor)
- RichTextEditor.tsx (231 Zeilen) ✅
- 8 TipTap-Extensions ✅
## 19. PDF.js
- Nicht nötig — FilePreviewModal nutzt iframe mit Browser-PDF-Viewer ✅
## 20. TanStack Table
- DataGrid.tsx (160 Zeilen) ✅
- AuditLog ColumnDef ✅
- Virtual Scrolling ❌ MISSING
## Zusammenfassung
| Kategorie | Dateien | Zeilen |
|-----------|---------|--------|
| Pages | 27 | 7.826 |
| Components | 70 | 13.893 |
| API Modules | 12+ | 3.456 |
| Stores | 5 | 461 |
| Hooks | 5 | 247 |
| Routes | 2 | ~100 |
| i18n | 3 | 1.749 |
| Tests | 38 | 3.045 |
| Config | 5 | ~200 |
| Utils | 1 | ~100 |
| **Total** | **~167** | **~30.000** |
**Frontend ist zu ~70% vollständig.** Kritische Lücken: Plugin-UI-System, Code-Splitting, E2E Tests, Contact-Detail-Route. Alle im MASTER-PLAN.md eingeplant.
+19
View File
@@ -23,8 +23,10 @@
"@tiptap/starter-kit": "^3.28.0", "@tiptap/starter-kit": "^3.28.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0",
"i18next": "^23.14.0", "i18next": "^23.14.0",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
"lucide-react": "^1.25.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
@@ -2746,6 +2748,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3854,6 +3865,14 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz",
"integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+2
View File
@@ -27,8 +27,10 @@
"@tiptap/starter-kit": "^3.28.0", "@tiptap/starter-kit": "^3.28.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0",
"i18next": "^23.14.0", "i18next": "^23.14.0",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
"lucide-react": "^1.25.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
+7
View File
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AppRouter } from '@/routes'; import { AppRouter } from '@/routes';
import { setUnauthorizedHandler } from '@/api/client'; import { setUnauthorizedHandler } from '@/api/client';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { useThemeStore } from '@/store/themeStore';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -16,6 +17,7 @@ const queryClient = new QueryClient({
export default function App() { export default function App() {
const { logout } = useAuthStore(); const { logout } = useAuthStore();
const loadThemeFromStorage = useThemeStore((s) => s.loadFromStorage);
React.useEffect(() => { React.useEffect(() => {
setUnauthorizedHandler(() => { setUnauthorizedHandler(() => {
@@ -24,6 +26,11 @@ export default function App() {
}); });
}, [logout]); }, [logout]);
// Load theme from localStorage on app start
React.useEffect(() => {
loadThemeFromStorage();
}, [loadThemeFromStorage]);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AppRouter /> <AppRouter />
+123
View File
@@ -0,0 +1,123 @@
/**
* Attachment and address hooks.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete, apiClient } from './client';
export interface Attachment {
id: string;
entity_type: string;
entity_id: string;
filename: string;
file_path: string;
mime_type: string;
file_size: number;
uploaded_by?: string | null;
created_at?: string;
updated_at?: string;
}
export interface Address {
id: string;
entity_type: string;
entity_id: string;
label: string;
address_type: string;
street: string | null;
street_number: string | null;
city: string | null;
zip: string | null;
state: string | null;
country: string | null;
is_default: boolean;
created_at?: string;
updated_at?: string;
}
// Attachments
export function useAttachments(entityType?: string, entityId?: string) {
return useQuery({
queryKey: ['attachments', entityType, entityId],
queryFn: () => {
const params = new URLSearchParams();
if (entityType) params.set('entity_type', entityType);
if (entityId) params.set('entity_id', entityId);
return apiGet<{ items: Attachment[]; total: number }>(`/attachments?${params.toString()}`);
},
enabled: !!entityType && !!entityId,
});
}
export function useUploadAttachment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ file, entityType, entityId }: { file: File; entityType: string; entityId: string }) => {
const formData = new FormData();
formData.append('file', file);
formData.append('entity_type', entityType);
formData.append('entity_id', entityId);
const response = await apiClient.post('/attachments', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['attachments'] });
},
});
}
export function useDeleteAttachment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/attachments/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['attachments'] });
},
});
}
// Addresses
export function useAddresses(entityType?: string, entityId?: string) {
return useQuery({
queryKey: ['addresses', entityType, entityId],
queryFn: () =>
apiGet<{ items: Address[]; total: number }>(
`/addresses?entity_type=${entityType}&entity_id=${entityId}`
),
enabled: !!entityType && !!entityId,
});
}
export function useCreateAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Address> & { entity_type: string; entity_id: string }) =>
apiPost('/addresses', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['addresses'] });
},
});
}
export function useUpdateAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Address> }) =>
apiPatch(`/addresses/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['addresses'] });
},
});
}
export function useDeleteAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/addresses/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['addresses'] });
},
});
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Audit log hooks.
*/
import { useQuery } from '@tanstack/react-query';
import { apiGet } from './client';
import { PaginatedResponse } from './types';
export interface AuditLogEntry {
id: string;
timestamp: string;
user: string;
action: string;
entity: string;
entity_id?: string;
details?: string;
}
export function useAuditLog(page = 1, pageSize = 25, filters?: { user?: string; action?: string; entity?: string; dateFrom?: string; dateTo?: string }) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (filters?.user) params.set('user', filters.user);
if (filters?.action) params.set('action', filters.action);
if (filters?.entity) params.set('entity', filters.entity);
if (filters?.dateFrom) params.set('date_from', filters.dateFrom);
if (filters?.dateTo) params.set('date_to', filters.dateTo);
return useQuery({
queryKey: ['auditLog', page, pageSize, filters],
queryFn: () =>
apiGet<PaginatedResponse<AuditLogEntry>>(`/audit-log?${params.toString()}`),
retry: false,
});
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Authentication hooks: login, logout, current user, password reset, tenant switching.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiPost, apiGet, setCsrfToken } from './client';
import { useAuthStore } from '@/store/authStore';
export interface LoginPayload {
email: string;
password: string;
}
export interface PasswordResetRequestPayload {
email: string;
}
export interface PasswordResetConfirmPayload {
token: string;
password: string;
}
export function useLogin() {
const { setUser, setError } = useAuthStore();
return useMutation({
mutationFn: (payload: LoginPayload) =>
apiPost('/auth/login', payload),
onSuccess: (data: any) => {
setUser(data.user || data);
setError(null);
// Store CSRF token for subsequent unsafe requests
if (data.csrf_token) {
setCsrfToken(data.csrf_token);
}
},
onError: (error: any) => {
setError(error.message || 'Login failed');
},
});
}
export function useLogout() {
const { logout } = useAuthStore();
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => apiPost('/auth/logout'),
onSettled: () => {
logout();
setCsrfToken(null);
queryClient.clear();
},
});
}
export function useCurrentUser() {
const { setUser, setAuthenticated } = useAuthStore();
return useQuery({
queryKey: ['currentUser'],
queryFn: async () => {
const data = await apiGet<any>('/auth/me');
setUser(data.user || data);
setAuthenticated(true);
return data;
},
retry: false,
staleTime: 5 * 60 * 1000,
});
}
export function usePasswordResetRequest() {
return useMutation({
mutationFn: (payload: PasswordResetRequestPayload) =>
apiPost('/auth/password-reset/request', payload),
});
}
export function usePasswordResetConfirm() {
return useMutation({
mutationFn: (payload: PasswordResetConfirmPayload) =>
apiPost('/auth/password-reset/confirm', payload),
});
}
export function useSwitchTenant() {
const { setTenant } = useAuthStore();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tenantId: string) =>
apiPost('/auth/switch-tenant', { tenant_id: tenantId }),
onSuccess: (data: any) => {
setTenant(data.tenant || data);
queryClient.invalidateQueries();
},
});
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Contact CRUD hooks (legacy contact model — not unified contacts).
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete, apiPut } from './client';
import { PaginatedResponse, Contact, ContactDetail } from './types';
import { ContactFolder } from './contactFolders';
export function useContacts(page = 1, pageSize = 25, search?: string) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (search) params.set('search', search);
return useQuery({
queryKey: ['contacts', page, pageSize, search],
queryFn: () =>
apiGet<PaginatedResponse<Contact>>(`/contacts?${params.toString()}`),
});
}
export function useContact(id?: string) {
return useQuery({
queryKey: ['contacts', id],
queryFn: () => apiGet<ContactDetail>(`/contacts/${id}`),
enabled: !!id,
});
}
export function useCreateContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Contact> & { company_ids?: string[] }) =>
apiPost('/contacts', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contacts'] });
},
});
}
export function useUpdateContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Contact> & { company_ids?: string[] } }) =>
apiPatch(`/contacts/${id}`, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['contacts'] });
queryClient.invalidateQueries({ queryKey: ['contacts', variables.id] });
},
});
}
export function useDeleteContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/contacts/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contacts'] });
},
});
}
// ── Contact Folders ──
export function useContactFolders() {
return useQuery({
queryKey: ['contactFolders'],
queryFn: () => apiGet<ContactFolder[]>('/contact-folders'),
});
}
export function useCreateContactFolder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; parent_id?: string }) => apiPost('/contact-folders', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contactFolders'] });
},
});
}
export function useUpdateContactFolder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: { name?: string; parent_id?: string | null; sort_order?: number } }) =>
apiPut(`/contact-folders/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contactFolders'] });
},
});
}
export function useDeleteContactFolder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/contact-folders/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contactFolders'] });
},
});
}
export function useMoveContactToFolder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contactId, folderId }: { contactId: string; folderId: string | null }) =>
apiPut(`/contact-folders/contacts/${contactId}/move`, { folder_id: folderId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
queryClient.invalidateQueries({ queryKey: ['contactFolders'] });
},
});
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Entity History hooks — undo/restore functionality.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiClient } from './client';
export interface EntityHistoryEntry {
id: string;
entity_type: string;
entity_id: string;
action: 'create' | 'update' | 'delete';
snapshot_before: Record<string, any> | null;
snapshot_after: Record<string, any> | null;
changes: Record<string, { old: any; new: any }> | null;
user_id: string | null;
created_at: string;
}
export interface EntityHistoryList {
items: EntityHistoryEntry[];
total: number;
}
export function useEntityHistory(entityType?: string, entityId?: string) {
return useQuery({
queryKey: ['entityHistory', entityType, entityId],
queryFn: () =>
apiGet<EntityHistoryList>(`/entity-history/${entityType}/${entityId}`),
enabled: !!entityType && !!entityId,
});
}
export function useRestoreFromHistory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (historyId: string) =>
apiClient.post('/entity-history/restore', { history_id: historyId }).then(r => r.data),
onSuccess: (_data, _variables, context) => {
queryClient.invalidateQueries({ queryKey: ['entityHistory'] });
queryClient.invalidateQueries({ queryKey: ['contacts'] });
queryClient.invalidateQueries({ queryKey: ['contact'] });
},
});
}
export function useUndoLastAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ entityType, entityId }: { entityType: string; entityId: string }) =>
apiPost(`/entity-history/undo/${entityType}/${entityId}`, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['entityHistory'] });
queryClient.invalidateQueries({ queryKey: ['contacts'] });
queryClient.invalidateQueries({ queryKey: ['contact'] });
},
});
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Group and group member hooks.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
export interface Group {
id: string;
name: string;
description: string | null;
permissions: Record<string, any>;
denied_permissions: string[];
field_permissions: Record<string, any>;
permission_version: number;
}
export interface GroupMember {
user_id: string;
email: string;
name: string;
is_active: boolean;
}
export function useGroups() {
return useQuery({
queryKey: ['groups'],
queryFn: () => apiGet<{ items: Group[] }>('/groups'),
});
}
export function useCreateGroup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Group>) => apiPost('/groups', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groups'] });
},
});
}
export function useUpdateGroup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Group> }) =>
apiPatch(`/groups/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groups'] });
},
});
}
export function useDeleteGroup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/groups/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groups'] });
},
});
}
export function useGroupMembers(groupId: string | null) {
return useQuery({
queryKey: ['groupMembers', groupId],
queryFn: () => apiGet<{ items: GroupMember[] }>(`/groups/${groupId}/members`),
enabled: !!groupId,
});
}
export function useAddGroupMember() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
apiPost(`/groups/${groupId}/members`, { user_id: userId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groupMembers'] });
},
});
}
export function useRemoveGroupMember() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
apiDelete(`/groups/${groupId}/members/${userId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groupMembers'] });
},
});
}
export function useUserGroups(userId: string | null) {
return useQuery({
queryKey: ['userGroups', userId],
queryFn: () => apiGet<{ items: Group[] }>(`/groups/user/${userId}`),
enabled: !!userId,
});
}
+35 -1197
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -438,13 +438,14 @@ export function decodeMimeHeader(value: string | undefined | null): string {
* Variables: {{user.name}}, {{user.first_name}}, {{user.last_name}}, * Variables: {{user.name}}, {{user.first_name}}, {{user.last_name}},
* {{user.email}}, {{user.role}}, {{tenant.name}}, {{date}} * {{user.email}}, {{user.role}}, {{tenant.name}}, {{date}}
*/ */
import { formatDateShort } from '@/utils/date';
export function replaceSignatureVariables( export function replaceSignatureVariables(
html: string, html: string,
user: { name?: string; email?: string; role?: string; first_name?: string; last_name?: string }, user: { name?: string; email?: string; role?: string; first_name?: string; last_name?: string },
tenant?: { name?: string }, tenant?: { name?: string },
): string { ): string {
const now = new Date(); const dateStr = formatDateShort(new Date());
const dateStr = now.toLocaleDateString([], { year: 'numeric', month: '2-digit', day: '2-digit' });
const firstName = user.first_name || (user.name ? user.name.split(' ')[0] : ''); const firstName = user.first_name || (user.name ? user.name.split(' ')[0] : '');
const lastName = user.last_name || (user.name ? user.name.split(' ').slice(1).join(' ') : ''); const lastName = user.last_name || (user.name ? user.name.split(' ').slice(1).join(' ') : '');
+77
View File
@@ -0,0 +1,77 @@
/**
* Notification hooks: list, unread count, mark read, delete, types, preferences.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPatch, apiDelete } from './client';
import { PaginatedResponse } from './types';
export interface NotificationTypeItem {
type_key: string;
plugin_name: string;
category: string;
label: string;
description: string | null;
is_enabled_by_default: boolean;
is_enabled: boolean;
}
export function useNotifications() {
return useQuery({
queryKey: ['notifications'],
queryFn: () => apiGet<PaginatedResponse<any>>('/notifications'),
});
}
export function useUnreadNotificationCount() {
return useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: () => apiGet<number>('/notifications/unread-count'),
});
}
export function useMarkNotificationRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiPatch(`/notifications/${id}/read`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
}
export function useDeleteNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/notifications/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
}
export function useNotificationTypes() {
return useQuery({
queryKey: ['notification-types'],
queryFn: () => apiGet<{ items: NotificationTypeItem[] }>('/notifications/types'),
});
}
export function useNotificationPreferences() {
return useQuery({
queryKey: ['notification-preferences'],
queryFn: () => apiGet<{ items: { type_key: string; is_enabled: boolean }[] }>('/notifications/preferences'),
});
}
export function useUpdateNotificationPreference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ typeKey, isEnabled }: { typeKey: string; isEnabled: boolean }) =>
apiPatch(`/notifications/preferences/${typeKey}`, { is_enabled: isEnabled }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-preferences'] });
queryClient.invalidateQueries({ queryKey: ['notification-types'] });
},
});
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Plugin management hooks: list, install, activate, deactivate, uninstall.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiDelete } from './client';
export interface Plugin {
name: string;
display_name?: string;
description?: string;
version?: string;
status: 'discovered' | 'installed' | 'active' | 'inactive';
installed?: boolean;
active?: boolean;
}
export function usePlugins() {
return useQuery({
queryKey: ['plugins'],
queryFn: async () => {
const data = await apiGet<any>('/plugins');
return data;
},
});
}
export function useInstallPlugin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => apiPost(`/plugins/${name}/install`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugins'] });
},
});
}
export function useActivatePlugin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => apiPost(`/plugins/${name}/activate`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugins'] });
},
});
}
export function useDeactivatePlugin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => apiPost(`/plugins/${name}/deactivate`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugins'] });
},
});
}
export function useUninstallPlugin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, removeData }: { name: string; removeData: boolean }) =>
apiDelete(`/plugins/${name}?remove_data=${removeData}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugins'] });
},
});
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Role and permission hooks.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
export interface Role {
id: string;
name: string;
permissions: Record<string, any>;
field_permissions?: Record<string, any>;
}
export interface PermissionItem {
key: string;
label: string;
category: string;
plugin_name?: string;
}
export interface FieldDefinition {
module: string;
field: string;
label: string;
sensitivity: string;
}
export interface PermissionsResponse {
system: PermissionItem[];
plugins: PermissionItem[];
all: PermissionItem[];
field_definitions?: FieldDefinition[];
}
export function useRoles() {
return useQuery({
queryKey: ['roles'],
queryFn: async () => {
const data = await apiGet<any>('/roles');
return data;
},
});
}
export function usePermissions() {
return useQuery({
queryKey: ['permissions'],
queryFn: () => apiGet<PermissionsResponse>('/roles/permissions'),
});
}
export function useCreateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; permissions: Record<string, any>; field_permissions: Record<string, any> }) =>
apiPost('/roles', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roles'] });
},
});
}
export function useUpdateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: { name?: string; permissions?: Record<string, any>; field_permissions?: Record<string, any> } }) =>
apiPatch(`/roles/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roles'] });
},
});
}
export function useDeleteRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/roles/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roles'] });
},
});
}
+200
View File
@@ -0,0 +1,200 @@
/**
* System settings, currency, tax, and sequence hooks.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete, apiClient } from './client';
export interface Currency {
id: string;
code: string;
name: string;
symbol: string;
is_default: boolean;
created_at?: string;
updated_at?: string;
}
export interface TaxRate {
id: string;
name: string;
rate: number;
is_default: boolean;
country?: string | null;
created_at?: string;
updated_at?: string;
}
export interface Sequence {
id: string;
name: string;
prefix: string;
next_number: number;
padding: number;
created_at?: string;
updated_at?: string;
}
export interface SystemSettings {
id?: string;
company_name: string;
company_legal_form?: string | null;
company_street: string;
company_city: string;
company_zip: string;
company_country: string;
tax_number?: string | null;
vat_id?: string | null;
iban?: string | null;
bic?: string | null;
bank_name?: string | null;
ceo?: string | null;
trade_register?: string | null;
default_currency_id?: string | null;
default_tax_id?: string | null;
invoice_prefix: string;
quote_prefix: string;
payment_terms_days: number;
// Theme customization
theme_primary_color?: string;
theme_accent_color?: string;
theme_font_family?: string;
theme_border_radius?: string;
created_at?: string;
updated_at?: string;
}
// System Settings
export function useSystemSettings() {
return useQuery({
queryKey: ['systemSettings'],
queryFn: () => apiGet<SystemSettings>('/system-settings'),
});
}
export function useUpdateSystemSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<SystemSettings>) => apiClient.put('/system-settings', data).then(r => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['systemSettings'] });
},
});
}
// Currencies
export function useCurrencies() {
return useQuery({
queryKey: ['currencies'],
queryFn: () => apiGet<{ items: Currency[]; total: number }>('/currencies'),
});
}
export function useCreateCurrency() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Currency>) => apiPost('/currencies', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
},
});
}
export function useUpdateCurrency() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Currency> }) =>
apiPatch(`/currencies/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
},
});
}
export function useDeleteCurrency() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/currencies/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
},
});
}
// Taxes
export function useTaxes() {
return useQuery({
queryKey: ['taxes'],
queryFn: () => apiGet<{ items: TaxRate[]; total: number }>('/taxes'),
});
}
export function useCreateTax() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<TaxRate>) => apiPost('/taxes', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['taxes'] });
},
});
}
export function useUpdateTax() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<TaxRate> }) =>
apiPatch(`/taxes/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['taxes'] });
},
});
}
export function useDeleteTax() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/taxes/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['taxes'] });
},
});
}
// Sequences
export function useSequences() {
return useQuery({
queryKey: ['sequences'],
queryFn: () => apiGet<{ items: Sequence[]; total: number }>('/sequences'),
});
}
export function useCreateSequence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Sequence>) => apiPost('/sequences', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sequences'] });
},
});
}
export function useUpdateSequence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Sequence> }) =>
apiPatch(`/sequences/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sequences'] });
},
});
}
export function useDeleteSequence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/sequences/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sequences'] });
},
});
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Shared type definitions used across multiple API modules.
*/
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
page_size: number;
}
export interface Company {
id: string;
name: string;
account_number?: string | null;
industry?: string | null;
phone?: string | null;
email?: string | null;
website?: string | null;
description?: string | null;
created_at?: string;
updated_at?: string;
}
export interface CompanyDetail extends Company {
contacts?: Contact[];
}
export interface Contact {
id: string;
first_name: string;
last_name: string;
email: string;
phone?: string | null;
position?: string | null;
company_ids?: string[];
created_at?: string;
updated_at?: string;
}
export interface ContactDetail extends Contact {
companies?: Company[];
}
+268
View File
@@ -0,0 +1,268 @@
/**
* Unified Contact (Rentman-style) hooks and standalone API functions.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPut, apiDelete } from './client';
import { PaginatedResponse } from './types';
export interface ContactPerson {
id: string;
contact_id: string;
displayname: string;
firstname?: string | null;
middle_name?: string | null;
lastname?: string | null;
function?: string | null;
phone?: string | null;
mobilephone?: string | null;
email?: string | null;
street?: string | null;
number?: string | null;
postalcode?: string | null;
city?: string | null;
state?: string | null;
country?: string | null;
tags?: string | null;
custom?: Record<string, any> | null;
created_at?: string;
updated_at?: string;
}
export interface UnifiedContact {
id: string;
type: 'company' | 'person';
displayname: string;
name?: string | null;
firstname?: string | null;
surname?: string | null;
surfix?: string | null;
ext_name_line?: string | null;
gender?: string | null;
code?: string | null;
accounting_code?: string | null;
vendor_accounting_code?: string | null;
// Mailing address
mailing_street?: string | null;
mailing_number?: string | null;
mailing_unit_number?: string | null;
mailing_district?: string | null;
mailing_extra_address_line?: string | null;
mailing_postalcode?: string | null;
mailing_city?: string | null;
mailing_state?: string | null;
mailing_country?: string | null;
// Visit address
visit_street?: string | null;
visit_number?: string | null;
visit_unit_number?: string | null;
visit_district?: string | null;
visit_extra_address_line?: string | null;
visit_postalcode?: string | null;
visit_city?: string | null;
visit_state?: string | null;
// Invoice address
invoice_street?: string | null;
invoice_number?: string | null;
invoice_unit_number?: string | null;
invoice_district?: string | null;
invoice_extra_address_line?: string | null;
invoice_postalcode?: string | null;
invoice_city?: string | null;
invoice_state?: string | null;
invoice_country?: string | null;
country?: string | null;
// Communication
phone_1?: string | null;
phone_2?: string | null;
email_1?: string | null;
email_2?: string | null;
website?: string | null;
// Financial
vat_code?: string | null;
fiscal_code?: string | null;
commerce_code?: string | null;
purchase_number?: string | null;
bic?: string | null;
bank_account?: string | null;
// Discounts
discount_crew: number;
discount_transport: number;
discount_rental: number;
discount_sale: number;
discount_subrent: number;
discount_total: number;
// Geo
latitude?: number | null;
longitude?: number | null;
// Notes
projectnote?: string | null;
projectnote_title?: string | null;
contact_warning?: string | null;
tags?: string | null;
image?: string | null;
custom?: Record<string, any> | null;
folder_id?: string | null;
default_person_id?: string | null;
admin_contactperson_id?: string | null;
contact_persons?: ContactPerson[];
created_at?: string;
updated_at?: string;
}
export function useUnifiedContacts(
page = 1,
pageSize = 25,
search?: string,
contactType?: string,
sortBy?: string,
sortOrder?: string,
folderId?: string,
) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (search) params.set('search', search);
if (contactType) params.set('type', contactType);
if (sortBy) params.set('sort_by', sortBy);
if (sortOrder) params.set('sort_order', sortOrder);
if (folderId) params.set('folder_id', folderId);
return useQuery({
queryKey: ['unifiedContacts', page, pageSize, search, contactType, sortBy, sortOrder, folderId],
queryFn: () =>
apiGet<PaginatedResponse<UnifiedContact>>(`/contacts?${params.toString()}`),
});
}
export function useUnifiedContact(id?: string) {
return useQuery({
queryKey: ['unifiedContacts', id],
queryFn: () => apiGet<UnifiedContact & { contact_persons: ContactPerson[] }>(`/contacts/${id}`),
enabled: !!id,
});
}
export function useCreateUnifiedContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<UnifiedContact>) => apiPost('/contacts', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
},
});
}
export function useUpdateUnifiedContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<UnifiedContact> }) =>
apiPut(`/contacts/${id}`, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
queryClient.invalidateQueries({ queryKey: ['unifiedContacts', variables.id] });
},
});
}
export function useDeleteUnifiedContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, hard }: { id: string; hard?: boolean }) =>
apiDelete(`/contacts/${id}${hard ? '?hard=true' : ''}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
},
});
}
export function useContactPersons(contactId?: string) {
return useQuery({
queryKey: ['unifiedContacts', contactId, 'persons'],
queryFn: () => apiGet<{ items: ContactPerson[] }>(`/contacts/${contactId}/persons`),
enabled: !!contactId,
});
}
export function useCreateContactPerson() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contactId, data }: { contactId: string; data: Partial<ContactPerson> }) =>
apiPost(`/contacts/${contactId}/persons`, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
queryClient.invalidateQueries({ queryKey: ['unifiedContacts', variables.contactId] });
queryClient.invalidateQueries({ queryKey: ['unifiedContacts', variables.contactId, 'persons'] });
},
});
}
export function useUpdateContactPerson() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contactId, personId, data }: { contactId: string; personId: string; data: Partial<ContactPerson> }) =>
apiPut(`/contacts/${contactId}/persons/${personId}`, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
queryClient.invalidateQueries({ queryKey: ['unifiedContacts', variables.contactId] });
queryClient.invalidateQueries({ queryKey: ['unifiedContacts', variables.contactId, 'persons'] });
},
});
}
export function useDeleteContactPerson() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contactId, personId }: { contactId: string; personId: string }) =>
apiDelete(`/contacts/${contactId}/persons/${personId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unifiedContacts'] });
},
});
}
// Standalone API functions (for non-hook usage)
export async function fetchContacts(
page = 1,
pageSize = 25,
search?: string,
contactType?: string,
sortBy?: string,
sortOrder?: string,
): Promise<PaginatedResponse<UnifiedContact>> {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (search) params.set('search', search);
if (contactType) params.set('type', contactType);
if (sortBy) params.set('sort_by', sortBy);
if (sortOrder) params.set('sort_order', sortOrder);
return apiGet<PaginatedResponse<UnifiedContact>>(`/contacts?${params.toString()}`);
}
export async function fetchContact(id: string): Promise<UnifiedContact & { contact_persons: ContactPerson[] }> {
return apiGet<UnifiedContact & { contact_persons: ContactPerson[] }>(`/contacts/${id}`);
}
export async function createContact(data: Partial<UnifiedContact>): Promise<UnifiedContact> {
return apiPost<UnifiedContact>('/contacts', data);
}
export async function updateContact(id: string, data: Partial<UnifiedContact>): Promise<UnifiedContact> {
return apiPut<UnifiedContact>(`/contacts/${id}`, data);
}
export async function deleteContact(id: string, hard?: boolean): Promise<void> {
await apiDelete(`/contacts/${id}${hard ? '?hard=true' : ''}`);
}
export async function fetchContactPersons(contactId: string): Promise<{ items: ContactPerson[] }> {
return apiGet<{ items: ContactPerson[] }>(`/contacts/${contactId}/persons`);
}
export async function createContactPerson(contactId: string, data: Partial<ContactPerson>): Promise<ContactPerson> {
return apiPost<ContactPerson>(`/contacts/${contactId}/persons`, data);
}
export async function updateContactPerson(contactId: string, personId: string, data: Partial<ContactPerson>): Promise<ContactPerson> {
return apiPut<ContactPerson>(`/contacts/${contactId}/persons/${personId}`, data);
}
export async function deleteContactPerson(contactId: string, personId: string): Promise<void> {
await apiDelete(`/contacts/${contactId}/persons/${personId}`);
}
+54
View File
@@ -0,0 +1,54 @@
/**
* User CRUD hooks.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
import { PaginatedResponse } from './types';
export function useUsers(page = 1, pageSize = 25) {
return useQuery({
queryKey: ['users', page, pageSize],
queryFn: () =>
apiGet<PaginatedResponse<any>>(`/users?page=${page}&page_size=${pageSize}`),
});
}
export function useUser(id?: string) {
return useQuery({
queryKey: ['users', id],
queryFn: () => apiGet<any>(`/users/${id}`),
enabled: !!id,
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => apiPost('/users', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
apiPatch(`/users/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/users/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
+168
View File
@@ -0,0 +1,168 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { History, RotateCcw, Undo, ChevronDown, ChevronRight } from 'lucide-react';
import { useEntityHistory, useRestoreFromHistory, useUndoLastAction } from '@/api/entityHistory';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Skeleton } from '@/components/ui/Skeleton';
import { useToast } from '@/components/ui/Toast';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
interface HistoryViewerProps {
entityType: string;
entityId: string;
}
const actionConfig = {
create: { variant: 'success' as const, label: 'Erstellt' },
update: { variant: 'primary' as const, label: 'Aktualisiert' },
delete: { variant: 'danger' as const, label: 'Gelöscht' },
};
export function HistoryViewer({ entityType, entityId }: HistoryViewerProps) {
const { t, i18n } = useTranslation();
const toast = useToast();
const { data: history, isLoading } = useEntityHistory(entityType, entityId);
const restoreMutation = useRestoreFromHistory();
const undoMutation = useUndoLastAction();
const [expandedId, setExpandedId] = useState<string | null>(null);
const dateLocale = i18n.language === 'de' ? de : enUS;
const handleUndo = async () => {
try {
await undoMutation.mutateAsync({ entityType, entityId });
toast.success(t('history.undone', 'Aktion rückgängig gemacht'));
} catch {
toast.error(t('history.undoError', 'Rückgängig machen fehlgeschlagen'));
}
};
const handleRestore = async (historyId: string) => {
try {
await restoreMutation.mutateAsync(historyId);
toast.success(t('history.restored', 'Version wiederhergestellt'));
} catch {
toast.error(t('history.restoreError', 'Wiederherstellung fehlgeschlagen'));
}
};
if (isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
);
}
const entries = history?.items ?? [];
if (entries.length === 0) {
return (
<div className="text-center py-8 text-secondary-400">
<History className="w-8 h-8 mx-auto mb-2 opacity-50" aria-hidden="true" />
<p className="text-sm">{t('history.empty', 'Keine Änderungshistorie vorhanden')}</p>
</div>
);
}
return (
<div className="space-y-4" data-testid="history-viewer">
{/* Undo button */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-secondary-900 flex items-center gap-2">
<History className="w-5 h-5" aria-hidden="true" />
{t('history.title', 'Änderungshistorie')}
</h3>
<Button
variant="secondary"
size="sm"
onClick={handleUndo}
isLoading={undoMutation.isPending}
icon={<Undo className="w-4 h-4" />}
>
{t('history.undo', 'Rückgängig')}
</Button>
</div>
{/* History entries */}
<div className="space-y-2">
{entries.map((entry) => {
const config = actionConfig[entry.action] || actionConfig.update;
const isExpanded = expandedId === entry.id;
const changes = entry.changes || {};
const changeKeys = Object.keys(changes);
return (
<div
key={entry.id}
className="border border-secondary-200 rounded-lg overflow-hidden"
>
<button
onClick={() => setExpandedId(isExpanded ? null : entry.id)}
className="w-full flex items-center justify-between p-3 hover:bg-secondary-50 transition-colors text-left"
aria-expanded={isExpanded}
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-secondary-400" aria-hidden="true" />
) : (
<ChevronRight className="w-4 h-4 text-secondary-400" aria-hidden="true" />
)}
<Badge variant={config.variant}>{config.label}</Badge>
<span className="text-sm text-secondary-500">
{formatDistanceToNow(new Date(entry.created_at), { addSuffix: true, locale: dateLocale })}
</span>
</div>
{changeKeys.length > 0 && (
<span className="text-xs text-secondary-400">
{changeKeys.length} {t('history.fieldsChanged', 'Felder geändert')}
</span>
)}
</button>
{isExpanded && (
<div className="px-4 pb-3 border-t border-secondary-100">
{/* Changes diff */}
{changeKeys.length > 0 && (
<div className="mt-3 space-y-1">
<p className="text-xs font-medium text-secondary-500 mb-2">{t('history.changes', 'Änderungen')}:</p>
{changeKeys.map((key) => (
<div key={key} className="flex items-start gap-2 text-sm">
<span className="font-mono text-secondary-600 min-w-[120px]">{key}:</span>
<span className="text-danger-600 line-through">
{String(changes[key].old ?? '—')}
</span>
<span className="text-secondary-400">→</span>
<span className="text-success-600">
{String(changes[key].new ?? '—')}
</span>
</div>
))}
</div>
)}
{/* Restore button */}
<div className="mt-3 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => handleRestore(entry.id)}
isLoading={restoreMutation.isPending}
icon={<RotateCcw className="w-3.5 h-3.5" />}
>
{t('history.restore', 'Diese Version wiederherstellen')}
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -21,6 +21,7 @@ import {
type EntryPriority, type EntryPriority,
type EntrySubtype, type EntrySubtype,
} from '@/api/calendar'; } from '@/api/calendar';
import { formatDateTimeInput } from '@/utils/date';
export interface AppointmentModalProps { export interface AppointmentModalProps {
open: boolean; open: boolean;
@@ -41,9 +42,7 @@ export interface AppointmentModalProps {
function toDateTimeLocalValue(d: Date | null | undefined): string { function toDateTimeLocalValue(d: Date | null | undefined): string {
if (!d) return ''; if (!d) return '';
// datetime-local needs YYYY-MM-DDTHH:mm in local time return formatDateTimeInput(d);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
} }
function fromDateTimeLocalValue(v: string): Date | null { function fromDateTimeLocalValue(v: string): Date | null {
@@ -20,21 +20,12 @@ import {
} from '@/api/calendar'; } from '@/api/calendar';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Calendar as CalendarIcon, Pencil, Trash2, X } from 'lucide-react';
import { formatDateTime } from '@/utils/date';
function formatDate(dateStr: string | null | undefined): string { function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '-'; if (!dateStr) return '-';
try { return formatDateTime(dateStr) || '-';
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return dateStr;
}
} }
function priorityClass(p: string): string { function priorityClass(p: string): string {
@@ -139,9 +130,7 @@ export function CalendarDetail({
className="flex flex-col items-center justify-center h-full p-6 text-center" className="flex flex-col items-center justify-center h-full p-6 text-center"
data-testid="calendar-detail-empty" data-testid="calendar-detail-empty"
> >
<svg className="w-12 h-12 text-secondary-300 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <CalendarIcon className="w-12 h-12 text-secondary-300 mb-3" aria-hidden="true" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-sm font-medium text-secondary-600">{t('calendar.detail.noSelection')}</p> <p className="text-sm font-medium text-secondary-600">{t('calendar.detail.noSelection')}</p>
<p className="mt-1 text-xs text-secondary-400">{t('calendar.detail.noSelectionHint')}</p> <p className="mt-1 text-xs text-secondary-400">{t('calendar.detail.noSelectionHint')}</p>
</div> </div>
@@ -163,9 +152,7 @@ export function CalendarDetail({
aria-label={t('common.close')} aria-label={t('common.close')}
data-testid="calendar-detail-close" data-testid="calendar-detail-close"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <X className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>
@@ -196,9 +183,7 @@ export function CalendarDetail({
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-primary-50 text-primary-700 hover:bg-primary-100 min-h-touch" className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-primary-50 text-primary-700 hover:bg-primary-100 min-h-touch"
data-testid="calendar-detail-edit" data-testid="calendar-detail-edit"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Pencil className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{t('calendar.detail.edit')} {t('calendar.detail.edit')}
</button> </button>
<button <button
@@ -206,9 +191,7 @@ export function CalendarDetail({
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-danger-50 text-danger-700 hover:bg-danger-100 min-h-touch" className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-danger-50 text-danger-700 hover:bg-danger-100 min-h-touch"
data-testid="calendar-detail-delete" data-testid="calendar-detail-delete"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{t('calendar.detail.delete')} {t('calendar.detail.delete')}
</button> </button>
</div> </div>
@@ -10,6 +10,7 @@ import React, { useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { Calendar, CalendarType } from '@/api/calendar'; import type { Calendar, CalendarType } from '@/api/calendar';
import { ChevronDown, Plus } from 'lucide-react';
const TYPE_ORDER: CalendarType[] = ['personal', 'team', 'project', 'company']; const TYPE_ORDER: CalendarType[] = ['personal', 'team', 'project', 'company'];
@@ -115,9 +116,7 @@ function TypeSection({
className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600" className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600"
aria-label={expanded ? 'Collapse' : 'Expand'} aria-label={expanded ? 'Collapse' : 'Expand'}
> >
<svg className="w-3 h-3 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <ChevronDown className="w-3 h-3 transition-transform" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
</svg>
</button> </button>
<span className="truncate text-secondary-700">{t(`calendar.type.${type}`)}</span> <span className="truncate text-secondary-700">{t(`calendar.type.${type}`)}</span>
<span className="ml-auto text-xs text-secondary-400">{calendars.length}</span> <span className="ml-auto text-xs text-secondary-400">{calendars.length}</span>
@@ -218,9 +217,7 @@ export function CalendarTree({
)} )}
data-testid="calendar-tree-new" data-testid="calendar-tree-new"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Plus className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{t('calendar.tree.newCalendar')} {t('calendar.tree.newCalendar')}
</button> </button>
</div> </div>
@@ -7,6 +7,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import clsx from 'clsx'; import clsx from 'clsx';
import type { CalendarEntry } from '@/api/calendar'; import type { CalendarEntry } from '@/api/calendar';
import { formatDateShort } from '@/utils/date';
export type KanbanStatus = 'open' | 'in_progress' | 'done' | 'cancelled'; export type KanbanStatus = 'open' | 'in_progress' | 'done' | 'cancelled';
@@ -38,8 +39,7 @@ function priorityBadgeClass(p: string): string {
function fmtDue(entry: CalendarEntry): string | null { function fmtDue(entry: CalendarEntry): string | null {
if (!entry.due_date) return null; if (!entry.due_date) return null;
const d = new Date(entry.due_date); return formatDateShort(entry.due_date) || null;
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
} }
export function KanbanBoard({ board, loading, onMove, onSelect }: KanbanBoardProps) { export function KanbanBoard({ board, loading, onMove, onSelect }: KanbanBoardProps) {
@@ -15,6 +15,7 @@ import {
type CalendarEntry, type CalendarEntry,
type Subtask, type Subtask,
} from '@/api/calendar'; } from '@/api/calendar';
import { formatDateShort } from '@/utils/date';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
@@ -129,7 +130,7 @@ export function TaskDetailPanel({ entry, onClose, onChanged }: TaskDetailPanelPr
</span> </span>
{entry.due_date && ( {entry.due_date && (
<span className="px-2 py-0.5 rounded bg-primary-50 text-primary-700"> <span className="px-2 py-0.5 rounded bg-primary-50 text-primary-700">
{new Date(entry.due_date).toLocaleDateString('de-DE')} {formatDateShort(entry.due_date)}
</span> </span>
)} )}
</div> </div>
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import type { MessageBlock } from '@/store/commStore'; import type { MessageBlock } from '@/store/commStore';
import { ChevronRight } from 'lucide-react';
interface ContactCardBlockProps { interface ContactCardBlockProps {
block: MessageBlock; block: MessageBlock;
@@ -31,20 +32,7 @@ const ContactCardBlock: React.FC<ContactCardBlockProps> = ({ block }) => {
<p className="text-sm font-medium text-secondary-700 truncate">{contactName}</p> <p className="text-sm font-medium text-secondary-700 truncate">{contactName}</p>
<p className="text-xs text-secondary-400">Kontakt anzeigen</p> <p className="text-xs text-secondary-400">Kontakt anzeigen</p>
</div> </div>
<svg <ChevronRight className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
className="w-4 h-4 text-secondary-400 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</a> </a>
); );
}; };
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import type { MessageBlock } from '@/store/commStore'; import type { MessageBlock } from '@/store/commStore';
import { Download, FileText } from 'lucide-react';
interface FileBlockProps { interface FileBlockProps {
block: MessageBlock; block: MessageBlock;
@@ -14,37 +15,11 @@ function formatFileSize(bytes: number | undefined): string {
} }
const fileIcon = ( const fileIcon = (
<svg <FileText className="w-8 h-8 text-secondary-400" aria-hidden="true" strokeWidth={1.5} />
className="w-8 h-8 text-secondary-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z M14 2v6h6 M8 13h8 M8 17h5"
/>
</svg>
); );
const downloadIcon = ( const downloadIcon = (
<svg <Download className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
); );
const FileBlock: React.FC<FileBlockProps> = ({ block }) => { const FileBlock: React.FC<FileBlockProps> = ({ block }) => {
@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import type { MessageBlock } from '@/store/commStore'; import type { MessageBlock } from '@/store/commStore';
import { AppWindow } from 'lucide-react';
interface MiniAppBlockProps { interface MiniAppBlockProps {
block: MessageBlock; block: MessageBlock;
} }
@@ -14,20 +16,7 @@ const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
return ( return (
<div className="border-2 border-dashed border-secondary-300 rounded-lg p-4 text-center bg-secondary-50"> <div className="border-2 border-dashed border-secondary-300 rounded-lg p-4 text-center bg-secondary-50">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<svg <AppWindow className="w-8 h-8 text-secondary-400" aria-hidden="true" strokeWidth={1.5} />
className="w-8 h-8 text-secondary-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 6a2 2 0 012-2h12a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6z M9 9h6v6H9z"
/>
</svg>
<p className="text-sm font-medium text-secondary-600"> <p className="text-sm font-medium text-secondary-600">
Mini-App: {appId} Mini-App: {appId}
</p> </p>
@@ -6,6 +6,9 @@ import { EmptyState } from '@/components/ui/EmptyState';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { useToast } from '@/components/ui/Toast'; import { useToast } from '@/components/ui/Toast';
import { Loader2 } from 'lucide-react';
import { HistoryViewer } from '@/components/HistoryViewer';
import { import {
type UnifiedContact, type UnifiedContact,
type ContactPerson, type ContactPerson,
@@ -145,10 +148,7 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12" data-testid="contact-detail-loading"> <div className="flex items-center justify-center py-12" data-testid="contact-detail-loading">
<svg className="animate-spin h-5 w-5 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true"> <Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span> <span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
</div> </div>
); );
@@ -359,6 +359,13 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
</pre> </pre>
</Section> </Section>
)} )}
{/* History */}
{contact.id && (
<Section title={t('history.title', 'Änderungshistorie')}>
<HistoryViewer entityType="contact" entityId={contact.id} />
</Section>
)}
</div> </div>
<ContactPersonModal <ContactPersonModal
@@ -9,6 +9,7 @@ import {
useMoveContactToFolder, useMoveContactToFolder,
} from '@/api/hooks'; } from '@/api/hooks';
import { buildFolderTree, type ContactFolderTreeNode } from '@/api/contactFolders'; import { buildFolderTree, type ContactFolderTreeNode } from '@/api/contactFolders';
import { ChevronRight, Folder, Pencil, Plus, Tag, Trash2, Users } from 'lucide-react';
export type ContactFilter = 'all' | 'company' | 'person' | `tag:${string}` | `folder:${string}`; export type ContactFilter = 'all' | 'company' | 'person' | `tag:${string}` | `folder:${string}`;
@@ -22,28 +23,21 @@ export interface ContactFolderTreeProps {
// Icons // Icons
const icon = (path: string, cls = 'w-4 h-4') => ( const icon = (IconComp: React.ElementType, cls = 'w-4 h-4') => (
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <IconComp className={cls} aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
</svg>
); );
const chevron = (open: boolean) => ( const chevron = (open: boolean) => (
<svg <ChevronRight className={clsx('w-3.5 h-3.5 transition-transform flex-shrink-0', open && 'rotate-90')} aria-hidden="true" strokeWidth={2} />
className={clsx('w-3.5 h-3.5 transition-transform flex-shrink-0', open && 'rotate-90')}
fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
); );
const ICONS = { const ICONS = {
all: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z', all: Users,
folder: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z', folder: Folder,
tag: 'M7 7h.01M7 3h5a1.99 1.99 0 01.832.184l4 2A2 2 0 0118 7v10a2 2 0 01-2 2H7a2 2 0 01-2-2V5a2 2 0 012-2z', tag: Tag,
edit: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z', edit: Pencil,
trash: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16', trash: Trash2,
plus: 'M12 4v16m8-8H4', plus: Plus,
}; };
// Context Menu // Context Menu
@@ -6,6 +6,8 @@ import { Pagination } from '@/components/ui/Pagination';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import type { UnifiedContact } from '@/api/hooks'; import type { UnifiedContact } from '@/api/hooks';
import { Loader2 } from 'lucide-react';
export type ContactViewMode = 'list' | 'table' | 'cards'; export type ContactViewMode = 'list' | 'table' | 'cards';
export interface ContactListProps { export interface ContactListProps {
@@ -77,10 +79,7 @@ export function ContactList({
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12" data-testid="contact-list-loading"> <div className="flex items-center justify-center py-12" data-testid="contact-list-loading">
<svg className="animate-spin h-5 w-5 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true"> <Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span> <span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
</div> </div>
); );
+10 -28
View File
@@ -8,15 +8,12 @@ import clsx from 'clsx';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { DmsFile } from '@/api/dms'; import type { DmsFile } from '@/api/dms';
import { getFileIcon, formatFileSize } from './FileExplorer'; import { getFileIcon, formatFileSize } from './FileExplorer';
import { Download, Eye, FileText, Share2, Trash2, X } from 'lucide-react';
import { formatDateTime } from '@/utils/date';
function formatDate(dateStr: string | null | undefined): string { function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '-'; if (!dateStr) return '-';
try { return formatDateTime(dateStr) || '-';
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch {
return dateStr;
}
} }
function getFileSize(file: DmsFile): number { function getFileSize(file: DmsFile): number {
@@ -37,9 +34,7 @@ export function FileDetails({ file, onPreview, onShare, onDelete, onClose }: Fil
if (!file) { if (!file) {
return ( return (
<div className="flex flex-col items-center justify-center h-full p-6 text-center" data-testid="file-details-empty"> <div className="flex flex-col items-center justify-center h-full p-6 text-center" data-testid="file-details-empty">
<svg className="w-12 h-12 text-secondary-300 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <FileText className="w-12 h-12 text-secondary-300 mb-3" aria-hidden="true" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<p className="text-sm font-medium text-secondary-600">{t('dms.noFileSelected')}</p> <p className="text-sm font-medium text-secondary-600">{t('dms.noFileSelected')}</p>
<p className="mt-1 text-xs text-secondary-400">{t('dms.noFileSelectedHint')}</p> <p className="mt-1 text-xs text-secondary-400">{t('dms.noFileSelectedHint')}</p>
</div> </div>
@@ -60,9 +55,7 @@ export function FileDetails({ file, onPreview, onShare, onDelete, onClose }: Fil
className="p-1 rounded text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch" className="p-1 rounded text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch"
aria-label={t('common.close')} aria-label={t('common.close')}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <X className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>
@@ -82,9 +75,7 @@ export function FileDetails({ file, onPreview, onShare, onDelete, onClose }: Fil
/> />
</div> </div>
) : ( ) : (
<svg className={clsx('w-24 h-24', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <icon.icon className={clsx('w-24 h-24', icon.color)} aria-hidden="true" strokeWidth={1} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d={icon.path} />
</svg>
)} )}
</div> </div>
@@ -97,19 +88,14 @@ export function FileDetails({ file, onPreview, onShare, onDelete, onClose }: Fil
onClick={() => onPreview(file)} onClick={() => onPreview(file)}
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-primary-50 text-primary-700 hover:bg-primary-100 min-h-touch" className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-primary-50 text-primary-700 hover:bg-primary-100 min-h-touch"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Eye className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{t('dms.preview')} {t('dms.preview')}
</button> </button>
<button <button
onClick={() => onShare(file)} onClick={() => onShare(file)}
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-secondary-50 text-secondary-700 hover:bg-secondary-100 min-h-touch" className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-secondary-50 text-secondary-700 hover:bg-secondary-100 min-h-touch"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Share2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.7 10.7l6.6-3.4M8.7 13.3l6.6 3.4M18 12a3 3 0 11-6 0 3 3 0 016 0zM9 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{t('dms.share')} {t('dms.share')}
</button> </button>
<a <a
@@ -117,18 +103,14 @@ export function FileDetails({ file, onPreview, onShare, onDelete, onClose }: Fil
download={file.name} download={file.name}
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-secondary-50 text-secondary-700 hover:bg-secondary-100 min-h-touch" className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-secondary-50 text-secondary-700 hover:bg-secondary-100 min-h-touch"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Download className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{t('dms.download')} {t('dms.download')}
</a> </a>
<button <button
onClick={() => onDelete(file)} onClick={() => onDelete(file)}
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-danger-50 text-danger-700 hover:bg-danger-100 min-h-touch" className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-danger-50 text-danger-700 hover:bg-danger-100 min-h-touch"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{t('dms.delete')} {t('dms.delete')}
</button> </button>
</div> </div>
+21 -43
View File
@@ -7,27 +7,28 @@ import React, { useMemo } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { DmsFile } from '@/api/dms'; import type { DmsFile } from '@/api/dms';
import { Archive, ChevronDown, ChevronUp, Eye, FileText, Image, Loader2, Share2, Trash2 } from 'lucide-react';
export function getFileIcon(mimeType: string): { path: string; color: string } { export function getFileIcon(mimeType: string): { icon: React.ElementType; color: string } {
if (mimeType === 'application/pdf') { if (mimeType === 'application/pdf') {
return { path: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-danger-500' }; return { icon: FileText, color: 'text-danger-500' };
} }
if (mimeType.startsWith('image/')) { if (mimeType.startsWith('image/')) {
return { path: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', color: 'text-accent-500' }; return { icon: Image, color: 'text-accent-500' };
} }
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) { if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) {
return { path: 'M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-success-500' }; return { icon: FileText, color: 'text-success-500' };
} }
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) { if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) {
return { path: 'M7 8h10M7 16h10M7 12h6m-7 8h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', color: 'text-warning-500' }; return { icon: FileText, color: 'text-warning-500' };
} }
if (mimeType.startsWith('text/') || mimeType.includes('document') || mimeType.includes('word')) { if (mimeType.startsWith('text/') || mimeType.includes('document') || mimeType.includes('word')) {
return { path: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-primary-500' }; return { icon: FileText, color: 'text-primary-500' };
} }
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive')) { if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive')) {
return { path: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4', color: 'text-secondary-500' }; return { icon: Archive, color: 'text-secondary-500' };
} }
return { path: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z', color: 'text-secondary-400' }; return { icon: FileText, color: 'text-secondary-400' };
} }
export function formatFileSize(bytes: number): string { export function formatFileSize(bytes: number): string {
@@ -37,6 +38,8 @@ export function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
} }
import { formatDateShort } from '@/utils/date';
function getFileSize(file: DmsFile): number { function getFileSize(file: DmsFile): number {
return file.size_bytes ?? file.size ?? 0; return file.size_bytes ?? file.size ?? 0;
@@ -44,12 +47,7 @@ function getFileSize(file: DmsFile): number {
function formatDate(dateStr: string | null | undefined): string { function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return ''; if (!dateStr) return '';
try { return formatDateShort(dateStr) || '';
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
} catch {
return dateStr;
}
} }
export type ViewMode = 'list' | 'table' | 'icons-sm' | 'icons-md' | 'icons-lg'; export type ViewMode = 'list' | 'table' | 'icons-sm' | 'icons-md' | 'icons-lg';
@@ -101,9 +99,7 @@ function SortHeader({
> >
{label} {label}
{isActive && ( {isActive && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> sortOrder === 'asc' ? <ChevronUp className="w-3 h-3" aria-hidden="true" strokeWidth={2} /> : <ChevronDown className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'asc' ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'} />
</svg>
)} )}
</button> </button>
); );
@@ -130,10 +126,7 @@ function ActionButtons({
aria-label={t('dms.preview')} aria-label={t('dms.preview')}
title={t('dms.preview')} title={t('dms.preview')}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Eye className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); onFileShare(file); }} onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
@@ -141,9 +134,7 @@ function ActionButtons({
aria-label={t('dms.share')} aria-label={t('dms.share')}
title={t('dms.share')} title={t('dms.share')}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Share2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.7 10.7l6.6-3.4M8.7 13.3l6.6 3.4M18 12a3 3 0 11-6 0 3 3 0 016 0zM9 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }} onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
@@ -151,9 +142,7 @@ function ActionButtons({
aria-label={t('dms.delete')} aria-label={t('dms.delete')}
title={t('dms.delete')} title={t('dms.delete')}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button> </button>
</div> </div>
); );
@@ -198,10 +187,7 @@ export function FileExplorer({
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12" data-testid="file-explorer-loading"> <div className="flex items-center justify-center py-12" data-testid="file-explorer-loading">
<svg className="animate-spin h-6 w-6 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true"> <Loader2 className="animate-spin h-6 w-6 text-secondary-400" aria-hidden="true" />
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="ml-2 text-secondary-500 text-sm">{t('dms.loading')}</span> <span className="ml-2 text-secondary-500 text-sm">{t('dms.loading')}</span>
</div> </div>
); );
@@ -210,9 +196,7 @@ export function FileExplorer({
if (files.length === 0) { if (files.length === 0) {
return ( return (
<div className="text-center py-12" data-testid="file-explorer-empty"> <div className="text-center py-12" data-testid="file-explorer-empty">
<svg className="mx-auto h-12 w-12 text-secondary-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <FileText className="mx-auto h-12 w-12 text-secondary-300" aria-hidden="true" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<p className="mt-2 text-sm text-secondary-500">{t('dms.noFiles')}</p> <p className="mt-2 text-sm text-secondary-500">{t('dms.noFiles')}</p>
</div> </div>
); );
@@ -254,9 +238,7 @@ export function FileExplorer({
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500 flex-shrink-0" className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500 flex-shrink-0"
aria-label={t('dms.bulkSelect')} aria-label={t('dms.bulkSelect')}
/> />
<svg className={clsx('w-5 h-5 flex-shrink-0', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <icon.icon className={clsx('w-5 h-5 flex-shrink-0', icon.color)} aria-hidden="true" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
</svg>
<span className="text-sm font-medium text-secondary-900 truncate flex-1" title={file.name}>{file.name}</span> <span className="text-sm font-medium text-secondary-900 truncate flex-1" title={file.name}>{file.name}</span>
<span className="text-xs text-secondary-500 flex-shrink-0">{formatFileSize(getFileSize(file))}</span> <span className="text-xs text-secondary-500 flex-shrink-0">{formatFileSize(getFileSize(file))}</span>
<span className="text-xs text-secondary-400 flex-shrink-0 hidden sm:inline">{formatDate(file.updated_at || file.created_at)}</span> <span className="text-xs text-secondary-400 flex-shrink-0 hidden sm:inline">{formatDate(file.updated_at || file.created_at)}</span>
@@ -327,9 +309,7 @@ export function FileExplorer({
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className={clsx('w-5 h-5 flex-shrink-0', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <icon.icon className={clsx('w-5 h-5 flex-shrink-0', icon.color)} aria-hidden="true" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
</svg>
<span className="font-medium text-secondary-900 truncate" title={file.name}>{file.name}</span> <span className="font-medium text-secondary-900 truncate" title={file.name}>{file.name}</span>
</div> </div>
</td> </td>
@@ -415,9 +395,7 @@ export function FileExplorer({
/> />
</div> </div>
) : ( ) : (
<svg className={clsx(iconSize, icon.color, isSmall ? 'mb-0.5' : 'mb-1')} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <icon.icon className={clsx(iconSize, icon.color, isSmall ? 'mb-0.5' : 'mb-1')} aria-hidden="true" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
</svg>
)} )}
<p className={clsx('font-medium text-secondary-900 truncate w-full', isSmall ? 'text-[10px]' : 'text-xs')} title={file.name}>{file.name}</p> <p className={clsx('font-medium text-secondary-900 truncate w-full', isSmall ? 'text-[10px]' : 'text-xs')} title={file.name}>{file.name}</p>
{!isSmall && ( {!isSmall && (
+7 -44
View File
@@ -7,35 +7,9 @@ import React, { useMemo } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { DmsFile } from '@/api/dms'; import type { DmsFile } from '@/api/dms';
import { Eye, FileText, Share2, Trash2 } from 'lucide-react';
function getFileIcon(mimeType: string): { path: string; color: string } { import { getFileIcon, formatFileSize } from "./FileExplorer";
if (mimeType === 'application/pdf') {
return { path: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-danger-500' };
}
if (mimeType.startsWith('image/')) {
return { path: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', color: 'text-accent-500' };
}
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) {
return { path: 'M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-success-500' };
}
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) {
return { path: 'M7 8h10M7 16h10M7 12h6m-7 8h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', color: 'text-warning-500' };
}
if (mimeType.startsWith('text/') || mimeType.includes('document') || mimeType.includes('word')) {
return { path: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-primary-500' };
}
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive')) {
return { path: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4', color: 'text-secondary-500' };
}
return { path: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z', color: 'text-secondary-400' };
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export interface FileGridProps { export interface FileGridProps {
files: DmsFile[]; files: DmsFile[];
@@ -81,9 +55,7 @@ export function FileGrid({
if (files.length === 0) { if (files.length === 0) {
return ( return (
<div className="text-center py-12" data-testid="file-grid-empty"> <div className="text-center py-12" data-testid="file-grid-empty">
<svg className="mx-auto h-12 w-12 text-secondary-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <FileText className="mx-auto h-12 w-12 text-secondary-300" aria-hidden="true" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<p className="mt-2 text-sm text-secondary-500">{t('dms.noFiles')}</p> <p className="mt-2 text-sm text-secondary-500">{t('dms.noFiles')}</p>
</div> </div>
); );
@@ -114,9 +86,7 @@ export function FileGrid({
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500" className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={t('dms.bulkSelect')} aria-label={t('dms.bulkSelect')}
/> />
<svg className={clsx('w-10 h-10', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <icon.icon className={clsx('w-10 h-10', icon.color)} aria-hidden="true" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
</svg>
</div> </div>
</div> </div>
<p className="text-sm font-medium text-secondary-900 truncate" title={file.name}>{file.name}</p> <p className="text-sm font-medium text-secondary-900 truncate" title={file.name}>{file.name}</p>
@@ -131,10 +101,7 @@ export function FileGrid({
aria-label={t('dms.preview')} aria-label={t('dms.preview')}
title={t('dms.preview')} title={t('dms.preview')}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Eye className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); onFileShare(file); }} onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
@@ -142,9 +109,7 @@ export function FileGrid({
aria-label={t('dms.share')} aria-label={t('dms.share')}
title={t('dms.share')} title={t('dms.share')}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Share2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.7 10.7l6.6-3.4M8.7 13.3l6.6 3.4M18 12a3 3 0 11-6 0 3 3 0 016 0zM9 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }} onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
@@ -152,9 +117,7 @@ export function FileGrid({
aria-label={t('dms.delete')} aria-label={t('dms.delete')}
title={t('dms.delete')} title={t('dms.delete')}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button> </button>
</div> </div>
</div> </div>
@@ -8,8 +8,10 @@ import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { formatDateShort } from '@/utils/date';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { getFilePreviewUrl, type DmsFile } from '@/api/dms'; import { getFilePreviewUrl, type DmsFile } from '@/api/dms';
import { FileText } from 'lucide-react';
export interface FilePreviewModalProps { export interface FilePreviewModalProps {
open: boolean; open: boolean;
@@ -55,7 +57,7 @@ export function FilePreviewModal({ open, file, onClose }: FilePreviewModalProps)
<span className="text-sm text-secondary-500">{formatFileSize(file.size_bytes ?? file.size ?? 0)}</span> <span className="text-sm text-secondary-500">{formatFileSize(file.size_bytes ?? file.size ?? 0)}</span>
{file.created_at && ( {file.created_at && (
<span className="text-sm text-secondary-500"> <span className="text-sm text-secondary-500">
{t('dms.fileModified')}: {new Date(file.created_at).toLocaleDateString()} {t('dms.fileModified')}: {formatDateShort(file.created_at)}
</span> </span>
)} )}
</div> </div>
@@ -81,9 +83,7 @@ export function FilePreviewModal({ open, file, onClose }: FilePreviewModalProps)
{!isPdf && !isImage && ( {!isPdf && !isImage && (
<div className="text-center py-12" data-testid="no-preview-available"> <div className="text-center py-12" data-testid="no-preview-available">
<svg className="mx-auto h-12 w-12 text-secondary-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <FileText className="mx-auto h-12 w-12 text-secondary-300" aria-hidden="true" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<p className="mt-2 text-sm text-secondary-500">{t('dms.preview')}</p> <p className="mt-2 text-sm text-secondary-500">{t('dms.preview')}</p>
</div> </div>
)} )}
+4 -9
View File
@@ -7,6 +7,7 @@ import React, { useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { DmsFolder } from '@/api/dms'; import type { DmsFolder } from '@/api/dms';
import { ChevronDown, Folder, Menu } from 'lucide-react';
interface FolderTreeItemProps { interface FolderTreeItemProps {
folder: DmsFolder; folder: DmsFolder;
@@ -52,15 +53,11 @@ function FolderTreeItem({ folder, level, selectedFolderId, onSelect }: FolderTre
className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600" className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600"
aria-label={expanded ? t('dms.folders') : t('dms.folders')} aria-label={expanded ? t('dms.folders') : t('dms.folders')}
> >
<svg className="w-3 h-3 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <ChevronDown className="w-3 h-3 transition-transform" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
</svg>
</button> </button>
)} )}
{!hasChildren && <span className="w-4 flex-shrink-0" aria-hidden="true" />} {!hasChildren && <span className="w-4 flex-shrink-0" aria-hidden="true" />}
<svg className="w-4 h-4 text-warning-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Folder className="w-4 h-4 text-warning-500 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="truncate">{folder.name}</span> <span className="truncate">{folder.name}</span>
{typeof folder.file_count === 'number' && folder.file_count > 0 && ( {typeof folder.file_count === 'number' && folder.file_count > 0 && (
<span className="ml-auto text-xs text-secondary-400">{folder.file_count}</span> <span className="ml-auto text-xs text-secondary-400">{folder.file_count}</span>
@@ -125,9 +122,7 @@ export function FolderTree({ folders, selectedFolderId, onSelect, loading = fals
role="button" role="button"
aria-label={t('dms.allFiles')} aria-label={t('dms.allFiles')}
> >
<svg className="w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Menu className="w-4 h-4 text-secondary-400" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span>{t('dms.allFiles')}</span> <span>{t('dms.allFiles')}</span>
</div> </div>
</li> </li>
+2 -1
View File
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { formatDateShort } from '@/utils/date';
import { Select } from '@/components/ui/Select'; import { Select } from '@/components/ui/Select';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
@@ -259,7 +260,7 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps)
)} )}
{link.expires_at && ( {link.expires_at && (
<Badge variant="info"> <Badge variant="info">
{t('permissions.expiresAt')}: {new Date(link.expires_at).toLocaleDateString()} {t('permissions.expiresAt')}: {formatDateShort(link.expires_at)}
</Badge> </Badge>
)} )}
</div> </div>
+9 -24
View File
@@ -12,6 +12,7 @@ import React, { useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { DmsFolder, DmsFile } from '@/api/dms'; import type { DmsFolder, DmsFile } from '@/api/dms';
import { ChevronDown, FileText, Folder, Home, Menu, Users } from 'lucide-react';
interface SourceTreeFolderItemProps { interface SourceTreeFolderItemProps {
folder: DmsFolder; folder: DmsFolder;
@@ -109,15 +110,11 @@ function SourceTreeFolderItem({
className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600" className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600"
aria-label={expanded ? t('dms.folders') : t('dms.folders')} aria-label={expanded ? t('dms.folders') : t('dms.folders')}
> >
<svg className="w-3 h-3 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <ChevronDown className="w-3 h-3 transition-transform" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
</svg>
</button> </button>
)} )}
{!hasChildren && <span className="w-4 flex-shrink-0" aria-hidden="true" />} {!hasChildren && <span className="w-4 flex-shrink-0" aria-hidden="true" />}
<svg className={clsx('w-4 h-4 flex-shrink-0', isDropTarget ? 'text-primary-500' : 'text-warning-500')} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Folder className={clsx('w-4 h-4 flex-shrink-0', isDropTarget ? 'text-primary-500' : 'text-warning-500')} aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="truncate">{folder.name}</span> <span className="truncate">{folder.name}</span>
{typeof folder.file_count === 'number' && folder.file_count > 0 && ( {typeof folder.file_count === 'number' && folder.file_count > 0 && (
<span className="ml-auto text-xs text-secondary-400">{folder.file_count}</span> <span className="ml-auto text-xs text-secondary-400">{folder.file_count}</span>
@@ -187,9 +184,7 @@ function SourceSection({ title, icon, selected, onClick, children, defaultExpand
className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600" className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600"
aria-label={expanded ? 'Collapse' : 'Expand'} aria-label={expanded ? 'Collapse' : 'Expand'}
> >
<svg className="w-3 h-3 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <ChevronDown className="w-3 h-3 transition-transform" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
</svg>
</button> </button>
{icon} {icon}
<span className="truncate">{title}</span> <span className="truncate">{title}</span>
@@ -298,9 +293,7 @@ export function SourceTree({
role="button" role="button"
aria-label={t('dms.allFiles')} aria-label={t('dms.allFiles')}
> >
<svg className="w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Menu className="w-4 h-4 text-secondary-400" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span>{t('dms.allFiles')}</span> <span>{t('dms.allFiles')}</span>
</div> </div>
@@ -315,9 +308,7 @@ export function SourceTree({
} }
}} }}
icon={ icon={
<svg className="w-4 h-4 text-primary-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Folder className="w-4 h-4 text-primary-500 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
} }
> >
{folders.length === 0 ? ( {folders.length === 0 ? (
@@ -346,9 +337,7 @@ export function SourceTree({
onSelectFolder(null); onSelectFolder(null);
}} }}
icon={ icon={
<svg className="w-4 h-4 text-accent-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Users className="w-4 h-4 text-accent-500 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
} }
> >
{sharedFiles.length === 0 ? ( {sharedFiles.length === 0 ? (
@@ -367,9 +356,7 @@ export function SourceTree({
role="button" role="button"
aria-label={file.name} aria-label={file.name}
> >
<svg className="w-4 h-4 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <FileText className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="truncate">{file.name}</span> <span className="truncate">{file.name}</span>
</div> </div>
</li> </li>
@@ -386,9 +373,7 @@ export function SourceTree({
onSelectFolder(null); onSelectFolder(null);
}} }}
icon={ icon={
<svg className="w-4 h-4 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Home className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12H3l9-9 9 9h-2M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7M9 21v-6h6v6" />
</svg>
} }
defaultExpanded={false} defaultExpanded={false}
> >
@@ -8,6 +8,7 @@ import clsx from 'clsx';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { uploadFile } from '@/api/dms'; import { uploadFile } from '@/api/dms';
import { useToast } from '@/components/ui/Toast'; import { useToast } from '@/components/ui/Toast';
import { Upload } from 'lucide-react';
export interface UploadDropzoneProps { export interface UploadDropzoneProps {
folderId: string | null; folderId: string | null;
@@ -124,9 +125,7 @@ export function UploadDropzone({ folderId, onUploaded }: UploadDropzoneProps) {
className="hidden" className="hidden"
aria-hidden="true" aria-hidden="true"
/> />
<svg className="mx-auto h-10 w-10 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Upload className="mx-auto h-10 w-10 text-secondary-400" aria-hidden="true" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="mt-2 text-sm text-secondary-600">{t('dms.uploadDropHere')}</p> <p className="mt-2 text-sm text-secondary-600">{t('dms.uploadDropHere')}</p>
</div> </div>
{uploads.length > 0 && ( {uploads.length > 0 && (
+10 -27
View File
@@ -7,41 +7,30 @@ import { useUIStore } from '@/store/uiStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useUsers, useGroups } from '@/api/hooks'; import { useUsers, useGroups } from '@/api/hooks';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import { Bell, Bot, ChevronLeft, ChevronRight, Lightbulb, MessageSquare, Users, X } from 'lucide-react';
const robotIcon = (className: string) => ( const robotIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Bot className={className} aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2a4 4 0 014 4v1h1a3 3 0 013 3v6a3 3 0 01-3 3h-1v1a4 4 0 01-4 4H8a4 4 0 01-4-4v-1H3a3 3 0 01-3-3V10a3 3 0 013-3h1V6a4 4 0 014-4z M9 10h.01M15 10h.01M9 15h6" />
</svg>
); );
const bellIcon = (className: string) => ( const bellIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Bell className={className} aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
); );
const bulbIcon = (className: string) => ( const bulbIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Lightbulb className={className} aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
); );
const teamIcon = (className: string) => ( const teamIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Users className={className} aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
); );
const chatBubbleIcon = (className: string) => ( const chatBubbleIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <MessageSquare className={className} aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
); );
const chevronRightIcon = ( const chevronRightIcon = (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <ChevronRight className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
); );
interface TabDef { interface TabDef {
@@ -94,9 +83,7 @@ function TeamPanel() {
{groups.map((g) => ( {groups.map((g) => (
<div key={g.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary-50 transition-colors"> <div key={g.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary-50 transition-colors">
<div className="w-8 h-8 rounded-full bg-secondary-200 flex items-center justify-center flex-shrink-0"> <div className="w-8 h-8 rounded-full bg-secondary-200 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-secondary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Users className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-secondary-700 truncate">{g.name}</p> <p className="text-sm font-medium text-secondary-700 truncate">{g.name}</p>
@@ -201,9 +188,7 @@ export function AISidebar() {
aria-label="Benachrichtigung löschen" aria-label="Benachrichtigung löschen"
onClick={() => removeNotification(i)} onClick={() => removeNotification(i)}
> >
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <X className="w-3.5 h-3.5" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>
))} ))}
@@ -283,9 +268,7 @@ export function AISidebar() {
aria-label="Zurück" aria-label="Zurück"
data-testid="ai-sidebar-back" data-testid="ai-sidebar-back"
> >
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm font-medium">Zurück</span> <span className="text-sm font-medium">Zurück</span>
</button> </button>
</div> </div>
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { formatTime } from '@/utils/date';
import { ResizablePanel } from '@/components/ui/ResizablePanel'; import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { useUIStore } from '@/store/uiStore'; import { useUIStore } from '@/store/uiStore';
import { useCommStore } from '@/store/commStore'; import { useCommStore } from '@/store/commStore';
@@ -15,55 +16,40 @@ import {
import { useCommWebSocket } from '@/hooks/useCommWebSocket'; import { useCommWebSocket } from '@/hooks/useCommWebSocket';
import type { Conversation, Message } from '@/store/commStore'; import type { Conversation, Message } from '@/store/commStore';
import { BlockRenderer } from '@/components/comm/blocks'; import { BlockRenderer } from '@/components/comm/blocks';
import { Bell, Bookmark, Bot, ChevronLeft, ChevronRight, Lightbulb, MessageSquare, Send, Users } from 'lucide-react';
// ─── Icons (identical to AISidebar) ─── // ─── Icons (identical to AISidebar) ───
const robotIcon = (className: string) => ( const robotIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Bot className={className} aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2a4 4 0 014 4v1h1a3 3 0 013 3v6a3 3 0 01-3 3h-1v1a4 4 0 01-4 4H8a4 4 0 01-4-4v-1H3a3 3 0 01-3-3V10a3 3 0 013-3h1V6a4 4 0 014-4z M9 10h.01M15 10h.01M9 15h6" />
</svg>
); );
const bellIcon = (className: string) => ( const bellIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Bell className={className} aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
); );
const bulbIcon = (className: string) => ( const bulbIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Lightbulb className={className} aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
); );
const teamIcon = (className: string) => ( const teamIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Users className={className} aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
); );
const chatBubbleIcon = (className: string) => ( const chatBubbleIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <MessageSquare className={className} aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
); );
const chevronRightIcon = ( const chevronRightIcon = (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <ChevronRight className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
); );
const sendIcon = ( const sendIcon = (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Send className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
); );
const pinIcon = ( const pinIcon = (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Bookmark className="w-3.5 h-3.5" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
); );
// ─── Types ─── // ─── Types ───
@@ -128,9 +114,7 @@ function TeamPanel({ onStartDirectChat }: { onStartDirectChat: (userId: string,
{groups.map((g) => ( {groups.map((g) => (
<div key={g.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary-50 transition-colors"> <div key={g.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary-50 transition-colors">
<div className="w-8 h-8 rounded-full bg-secondary-200 flex items-center justify-center flex-shrink-0"> <div className="w-8 h-8 rounded-full bg-secondary-200 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-secondary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Users className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-secondary-700 truncate">{g.name}</p> <p className="text-sm font-medium text-secondary-700 truncate">{g.name}</p>
@@ -258,7 +242,7 @@ function MessageFeed({
)} )}
{msg.created_at && ( {msg.created_at && (
<span className="text-[10px] opacity-50 mt-0.5 block"> <span className="text-[10px] opacity-50 mt-0.5 block">
{new Date(msg.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} {formatTime(msg.created_at)}
</span> </span>
)} )}
</div> </div>
@@ -660,9 +644,7 @@ export function MessageSidebar() {
aria-label="Zurück" aria-label="Zurück"
data-testid="message-sidebar-back" data-testid="message-sidebar-back"
> >
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm font-medium">Zurück</span> <span className="text-sm font-medium">Zurück</span>
</button> </button>
</div> </div>
+8 -21
View File
@@ -3,6 +3,7 @@ import clsx from 'clsx';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useUIStore } from '@/store/uiStore'; import { useUIStore } from '@/store/uiStore';
import { Calendar, ChevronRight, FileText, Home, Mail, Monitor, Users } from 'lucide-react';
interface NavLeaf { interface NavLeaf {
to: string; to: string;
@@ -21,33 +22,19 @@ interface NavSingleItem {
icon: React.ReactNode; icon: React.ReactNode;
} }
const navIcon = (path: string) => (
<svg className="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
</svg>
);
const chevronIcon = (expanded: boolean) => ( const chevronIcon = (expanded: boolean) => (
<svg <ChevronRight className={clsx('w-4 h-4 flex-shrink-0 transition-transform duration-200', expanded ? 'rotate-90' : '')} aria-hidden="true" strokeWidth={2} />
className={clsx('w-4 h-4 flex-shrink-0 transition-transform duration-200', expanded ? 'rotate-90' : '')}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
); );
const singleItems: NavSingleItem[] = [ const singleItems: NavSingleItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: navIcon('M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6') }, { to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
{ to: '/contacts', labelKey: 'nav.contacts', icon: navIcon('M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z') }, { to: '/contacts', labelKey: 'nav.contacts', icon: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
]; ];
const treeItems: NavTreeItem[] = [ const treeItems: NavTreeItem[] = [
{ {
labelKey: 'nav.calendar', labelKey: 'nav.calendar',
icon: navIcon('M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'), icon: <Calendar className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />,
children: [ children: [
{ to: '/calendar', labelKey: 'nav.calendar' }, { to: '/calendar', labelKey: 'nav.calendar' },
{ to: '/calendar/kanban', labelKey: 'nav.calendarKanban' }, { to: '/calendar/kanban', labelKey: 'nav.calendarKanban' },
@@ -55,7 +42,7 @@ const treeItems: NavTreeItem[] = [
}, },
{ {
labelKey: 'nav.files', labelKey: 'nav.files',
icon: navIcon('M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z'), icon: <FileText className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />,
children: [ children: [
{ to: '/dms', labelKey: 'nav.files' }, { to: '/dms', labelKey: 'nav.files' },
{ to: '/dms/trash', labelKey: 'nav.filesTrash' }, { to: '/dms/trash', labelKey: 'nav.filesTrash' },
@@ -63,7 +50,7 @@ const treeItems: NavTreeItem[] = [
}, },
{ {
labelKey: 'nav.email', labelKey: 'nav.email',
icon: navIcon('M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z'), icon: <Mail className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />,
children: [ children: [
{ to: '/mail', labelKey: 'nav.email' }, { to: '/mail', labelKey: 'nav.email' },
{ to: '/mail/settings', labelKey: 'nav.emailSettings' }, { to: '/mail/settings', labelKey: 'nav.emailSettings' },
@@ -72,7 +59,7 @@ const treeItems: NavTreeItem[] = [
]; ];
const bottomItems: NavSingleItem[] = [ const bottomItems: NavSingleItem[] = [
{ to: '/ai-assistant', labelKey: 'nav.aiAssistant', icon: navIcon('M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z') }, { to: '/ai-assistant', labelKey: 'nav.aiAssistant', icon: <Monitor className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
]; ];
export function Sidebar() { export function Sidebar() {
+5 -12
View File
@@ -8,6 +8,7 @@ import { useLogout } from '@/api/hooks';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import { SearchDropdown } from '@/components/shared/SearchDropdown'; import { SearchDropdown } from '@/components/shared/SearchDropdown';
import { SuggestionBadge } from '@/components/ai/SuggestionBadge'; import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
import { Building, ChevronDown, Menu } from 'lucide-react';
export function TopBar() { export function TopBar() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -52,9 +53,7 @@ export function TopBar() {
aria-label="Seitenleiste ein-/ausklappen" aria-label="Seitenleiste ein-/ausklappen"
aria-expanded={true} aria-expanded={true}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Menu className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button> </button>
{/* Tenant switcher */} {/* Tenant switcher */}
@@ -64,13 +63,9 @@ export function TopBar() {
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-secondary-100 min-h-touch text-sm font-medium text-secondary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500" className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-secondary-100 min-h-touch text-sm font-medium text-secondary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('topbar.switchTenant')} aria-label={t('topbar.switchTenant')}
> >
<svg className="w-4 h-4 text-secondary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Building className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
{currentTenant?.name || tenants[0]?.name || ''} {currentTenant?.name || tenants[0]?.name || ''}
<svg className="w-3 h-3 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <ChevronDown className="w-3 h-3 text-secondary-400" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button> </button>
</div> </div>
)} )}
@@ -95,9 +90,7 @@ export function TopBar() {
<span className="hidden md:block text-sm font-medium text-secondary-700 max-w-24 truncate"> <span className="hidden md:block text-sm font-medium text-secondary-700 max-w-24 truncate">
{user?.first_name || ''} {user?.first_name || ''}
</span> </span>
<svg className="w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <ChevronDown className="w-4 h-4 text-secondary-400" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button> </button>
{userMenuOpen && ( {userMenuOpen && (
<div <div
@@ -14,6 +14,7 @@ import { RichTextEditor } from './RichTextEditor';
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload, MailDraftPayload } from '@/api/mail'; import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload, MailDraftPayload } from '@/api/mail';
import { uploadAttachment, type UploadedAttachment, replaceSignatureVariables } from '@/api/mail'; import { uploadAttachment, type UploadedAttachment, replaceSignatureVariables } from '@/api/mail';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { FileText, Paperclip, X } from 'lucide-react';
export type ComposeMode = 'new' | 'reply' | 'forward' | 'draft'; export type ComposeMode = 'new' | 'reply' | 'forward' | 'draft';
@@ -309,9 +310,7 @@ export function ComposeModal({
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
isLoading={uploadingFile} isLoading={uploadingFile}
icon={ icon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Paperclip className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
} }
> >
{t('mail.addAttachment')} {t('mail.addAttachment')}
@@ -322,9 +321,7 @@ export function ComposeModal({
<ul className="mt-2 space-y-1"> <ul className="mt-2 space-y-1">
{attachments.map((att) => ( {attachments.map((att) => (
<li key={att.id} className="flex items-center gap-3 p-2 rounded-md bg-secondary-50"> <li key={att.id} className="flex items-center gap-3 p-2 rounded-md bg-secondary-50">
<svg className="w-5 h-5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <FileText className="w-5 h-5 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm text-secondary-800 truncate">{att.filename}</p> <p className="text-sm text-secondary-800 truncate">{att.filename}</p>
<p className="text-xs text-secondary-400">{formatBytes(att.size_bytes)}</p> <p className="text-xs text-secondary-400">{formatBytes(att.size_bytes)}</p>
@@ -335,9 +332,7 @@ export function ComposeModal({
className="p-1 rounded hover:bg-secondary-200 text-secondary-500" className="p-1 rounded hover:bg-secondary-200 text-secondary-500"
aria-label={t('common.remove')} aria-label={t('common.remove')}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <X className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</li> </li>
))} ))}
@@ -10,6 +10,7 @@ import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { useToast } from '@/components/ui/Toast'; import { useToast } from '@/components/ui/Toast';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { Loader2, X } from 'lucide-react';
import { import {
fetchLabels, fetchLabels,
createLabel, createLabel,
@@ -81,10 +82,7 @@ export function LabelManager() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-8" data-testid="label-manager-loading"> <div className="flex items-center justify-center py-8" data-testid="label-manager-loading">
<svg className="animate-spin h-5 w-5 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true"> <Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div> </div>
); );
} }
@@ -155,9 +153,7 @@ export function LabelManager() {
data-testid={`delete-label-${label.id}`} data-testid={`delete-label-${label.id}`}
type="button" type="button"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <X className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>
))} ))}
+5 -15
View File
@@ -10,6 +10,8 @@ import type { Mail, MailAttachment } from '@/api/mail';
import { decodeMimeHeader } from '@/api/mail'; import { decodeMimeHeader } from '@/api/mail';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { FileText, Loader2 } from 'lucide-react';
import { formatDateTime } from '@/utils/date';
export interface MailDetailProps { export interface MailDetailProps {
mail: Mail | null; loading: boolean; mail: Mail | null; loading: boolean;
@@ -25,14 +27,7 @@ export interface MailDetailProps {
} }
function formatFullDate(dateStr: string): string { function formatFullDate(dateStr: string): string {
const date = new Date(dateStr); return formatDateTime(dateStr) || dateStr;
return date.toLocaleString([], {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} }
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
@@ -75,10 +70,7 @@ export function MailDetail({
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12" data-testid="mail-detail-loading"> <div className="flex items-center justify-center py-12" data-testid="mail-detail-loading">
<svg className="animate-spin h-5 w-5 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true"> <Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span> <span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
</div> </div>
); );
@@ -158,9 +150,7 @@ export function MailDetail({
<ul className="space-y-1 md:space-y-2"> <ul className="space-y-1 md:space-y-2">
{mail.attachments.map((att) => ( {mail.attachments.map((att) => (
<li key={att.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50 min-h-touch"> <li key={att.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50 min-h-touch">
<svg className="w-5 h-5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <FileText className="w-5 h-5 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm text-secondary-800 truncate">{decodeMimeHeader(att.filename)}</p> <p className="text-sm text-secondary-800 truncate">{decodeMimeHeader(att.filename)}</p>
<p className="text-xs text-secondary-400">{formatBytes(att.size_bytes)}</p> <p className="text-xs text-secondary-400">{formatBytes(att.size_bytes)}</p>

Some files were not shown because too many files have changed in this diff Show More