Compare commits
8 Commits
c2143eea19
...
ec81940178
| Author | SHA1 | Date | |
|---|---|---|---|
| ec81940178 | |||
| 3d06cb2353 | |||
| 75d2f884da | |||
| 57d18c1381 | |||
| 241850fddd | |||
| 4f8cda1566 | |||
| e9a5eee524 | |||
| 3f2307ab54 |
@@ -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
@@ -21,7 +21,7 @@ POSTGRES_DB=crm_db
|
||||
DATABASE_URL=postgresql+asyncpg://crm_user:STRONG_PASSWORD_HERE@postgres:5432/crm_db
|
||||
|
||||
# --- 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:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
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
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# --- JWT / bcrypt tuning (keep aligned with .env.example) ---------------------
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRY_HOURS=24
|
||||
# --- bcrypt tuning (keep aligned with .env.example) --------------------------
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
@@ -36,6 +36,15 @@ SECRET_KEY=change-me-in-production-use-a-secure-random-string
|
||||
|
||||
# Storage (file uploads, DMS)
|
||||
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_HOST=localhost
|
||||
|
||||
+3
-3
@@ -52,9 +52,9 @@ logs/
|
||||
# Alembic (autogenerated migrations excluded, but keep 0001)
|
||||
alembic/versions/__pycache__/
|
||||
|
||||
# Frontend build artifacts (Phase 4c)
|
||||
webui/node_modules/
|
||||
webui/dist/
|
||||
# Frontend build artifacts
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Docker
|
||||
.docker-data/
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
+61
-36
@@ -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
|
||||
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
|
||||
@@ -12,7 +15,7 @@ import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import litellm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,16 +42,20 @@ class LLMClient:
|
||||
"""LLM client that translates natural language to proposed API actions.
|
||||
|
||||
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
|
||||
|
||||
LiteLLM model format: "provider/model_name" (e.g. "openai/gpt-4o", "anthropic/claude-3-sonnet", "ollama/llama3")
|
||||
"""
|
||||
|
||||
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.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)
|
||||
|
||||
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:
|
||||
"""Call OpenAI-compatible chat completions API.
|
||||
"""Call LLM via LiteLLM unified interface.
|
||||
|
||||
Sends a system prompt explaining the available API endpoints and asks
|
||||
the LLM to propose actions in structured JSON format.
|
||||
Supports 100+ providers through a single API:
|
||||
- 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)
|
||||
user_prompt = f"User request: {query}\n\nRespond with proposed actions as JSON."
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = {
|
||||
"model": self.model,
|
||||
# Build LiteLLM model string: "provider/model" or just "model" for OpenAI compat
|
||||
if self.provider and self.provider != "openai":
|
||||
litellm_model = f"{self.provider}/{self.model}"
|
||||
else:
|
||||
litellm_model = self.model
|
||||
|
||||
# Build kwargs for litellm.acompletion
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": litellm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
@@ -105,42 +120,52 @@ class LLMClient:
|
||||
"max_tokens": 1000,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{self.api_base}/chat/completions",
|
||||
headers=headers,
|
||||
json=body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# Add API key if set
|
||||
if self.api_key:
|
||||
kwargs["api_key"] = self.api_key
|
||||
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
return self._parse_llm_response(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)
|
||||
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:
|
||||
"""Build system prompt describing available API actions."""
|
||||
available_apis = [
|
||||
{"method": "GET", "path": "/api/v1/companies", "description": "List companies"},
|
||||
{"method": "POST", "path": "/api/v1/companies", "description": "Create a company"},
|
||||
{"method": "GET", "path": "/api/v1/contacts", "description": "List contacts (persons and companies)"},
|
||||
{"method": "POST", "path": "/api/v1/contacts", "description": "Create a contact (person or company)"},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/companies/{id}",
|
||||
"description": "Get company details",
|
||||
"path": "/api/v1/contacts/{id}",
|
||||
"description": "Get contact details",
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/companies/{id}",
|
||||
"description": "Update a company",
|
||||
"path": "/api/v1/contacts/{id}",
|
||||
"description": "Update a contact",
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/companies/{id}",
|
||||
"description": "Delete a company",
|
||||
"path": "/api/v1/contacts/{id}",
|
||||
"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": "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 "{}"
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
@@ -31,6 +31,7 @@ from app.routes import (
|
||||
companies,
|
||||
contact_folders,
|
||||
contacts,
|
||||
entity_history,
|
||||
groups,
|
||||
health,
|
||||
import_export,
|
||||
@@ -235,6 +236,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(companies.router)
|
||||
app.include_router(contacts.router)
|
||||
app.include_router(contact_folders.router)
|
||||
app.include_router(entity_history.router)
|
||||
app.include_router(import_export.router)
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(ai_copilot.router)
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.models.auth import ApiToken, PasswordResetToken
|
||||
from app.models.company import Company
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.models.contact_folder import ContactFolder
|
||||
from app.models.entity_history import EntityHistory
|
||||
from app.models.currency import Currency
|
||||
from app.models.group import Group, UserGroup
|
||||
from app.models.notification import Notification, NotificationPreference, NotificationType
|
||||
@@ -40,6 +41,7 @@ __all__ = [
|
||||
"Contact",
|
||||
"ContactPerson",
|
||||
"ContactFolder",
|
||||
"EntityHistory",
|
||||
"Currency",
|
||||
"TaxRate",
|
||||
"Sequence",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -45,3 +45,8 @@ class SystemSettings(Base, TenantMixin):
|
||||
invoice_prefix: Mapped[str] = mapped_column(String(20), nullable=False, default="RE-")
|
||||
quote_prefix: Mapped[str] = mapped_column(String(20), nullable=False, default="AN-")
|
||||
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")
|
||||
elif mime == "application/pdf" or att.filename.endswith(".pdf"):
|
||||
try:
|
||||
import fitz
|
||||
doc = fitz.open(stream=content, filetype="pdf")
|
||||
text_content = "\n".join(page.get_text() for page in doc)
|
||||
doc.close()
|
||||
from pypdf import PdfReader
|
||||
from io import BytesIO
|
||||
reader = PdfReader(BytesIO(content))
|
||||
text_content = "\n".join(page.extract_text() or "" for page in reader.pages)
|
||||
except ImportError:
|
||||
text_content = f"[PDF file: {att.filename} - extraction not available]"
|
||||
elif mime.startswith("image/"):
|
||||
|
||||
@@ -322,8 +322,9 @@ async def deep_analysis(
|
||||
async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
|
||||
"""Heartbeat job for the AI Proactive plugin.
|
||||
|
||||
Runs every 5 minutes (scheduled by the plugin on activation).
|
||||
Posts a status message to the 'Live KI' room in the kommunikation system.
|
||||
Interval is configurable via ProactiveSettings.heartbeat_interval_seconds.
|
||||
Target room is configurable via ProactiveSettings.heartbeat_target_room.
|
||||
Posts a status message to the configured room in the kommunikation system.
|
||||
"""
|
||||
try:
|
||||
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:
|
||||
# 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(
|
||||
db,
|
||||
tid,
|
||||
uid,
|
||||
plugin_name="ai_proactive",
|
||||
title="Live KI",
|
||||
title=target_room_title,
|
||||
participant_type="ai_proactive",
|
||||
user_role="member",
|
||||
)
|
||||
|
||||
@@ -109,3 +109,7 @@ class ProactiveSettings(Base, TenantMixin):
|
||||
Integer, nullable=False, default=10
|
||||
)
|
||||
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,
|
||||
rate_limit_seconds=s.rate_limit_seconds,
|
||||
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
|
||||
rate_limit_seconds: int
|
||||
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: [
|
||||
'ollama/deepseek-v4-flash',
|
||||
'ollama/deepseek-v4-pro',
|
||||
@@ -90,6 +93,9 @@ class SettingsUpdate(BaseModel):
|
||||
confidence_threshold: float | None = None
|
||||
rate_limit_seconds: int | 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):
|
||||
|
||||
@@ -114,6 +114,9 @@ async def get_user_settings(
|
||||
confidence_threshold=0.5,
|
||||
rate_limit_seconds=10,
|
||||
model="ollama/deepseek-v4-flash",
|
||||
heartbeat_enabled=True,
|
||||
heartbeat_interval_seconds=300,
|
||||
heartbeat_target_room="Live KI",
|
||||
)
|
||||
db.add(settings)
|
||||
await db.flush()
|
||||
|
||||
@@ -34,5 +34,11 @@ class CalendarPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
permissions=[
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"calendar:delete",
|
||||
"calendar:share",
|
||||
"calendar:admin",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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 (
|
||||
export_entries_to_ics,
|
||||
ics_events_to_entry_data,
|
||||
@@ -163,7 +163,7 @@ async def _check_write_permission(
|
||||
# ─── Calendar CRUD ───
|
||||
|
||||
|
||||
@calendar_router.get("")
|
||||
@calendar_router.get("", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def list_calendars(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -180,7 +180,7 @@ async def list_calendars(
|
||||
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(
|
||||
body: CalendarCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -201,7 +201,7 @@ async def create_calendar(
|
||||
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(
|
||||
calendar_id: str,
|
||||
body: CalendarUpdate,
|
||||
@@ -226,7 +226,7 @@ async def update_calendar(
|
||||
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(
|
||||
calendar_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -259,7 +259,7 @@ async def delete_calendar(
|
||||
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(
|
||||
calendar_id: str,
|
||||
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(
|
||||
calendar_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -324,7 +324,7 @@ async def get_permissions(
|
||||
# ─── Entries ───
|
||||
|
||||
|
||||
@router.get("/calendar/entries")
|
||||
@router.get("/calendar/entries", dependencies=[Depends(require_permission("calendar:read"))])
|
||||
async def list_entries(
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
@@ -385,7 +385,7 @@ async def list_entries(
|
||||
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(
|
||||
body: EntryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -458,7 +458,7 @@ async def create_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(
|
||||
format: str = "csv",
|
||||
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(
|
||||
entry_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -555,7 +555,7 @@ async def get_entry(
|
||||
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(
|
||||
entry_id: str,
|
||||
body: EntryUpdate,
|
||||
@@ -611,7 +611,7 @@ async def update_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(
|
||||
entry_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -631,7 +631,7 @@ async def delete_entry(
|
||||
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(
|
||||
entry_id: str,
|
||||
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(
|
||||
entry_id: str,
|
||||
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(
|
||||
entry_id: str,
|
||||
sub_id: str,
|
||||
@@ -717,7 +717,7 @@ async def update_subtask(
|
||||
# ─── Bulk + Kanban + Export ───
|
||||
|
||||
|
||||
@router.post("/calendar/entries/bulk")
|
||||
@router.post("/calendar/entries/bulk", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def bulk_action(
|
||||
body: BulkAction,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -751,7 +751,7 @@ async def bulk_action(
|
||||
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(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -783,7 +783,7 @@ async def kanban_board(
|
||||
# ─── 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(
|
||||
calendar_id: str,
|
||||
token: str | None = None,
|
||||
@@ -820,7 +820,7 @@ async def ics_feed(
|
||||
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(
|
||||
file: UploadFile = File(...),
|
||||
calendar_id: str | None = None,
|
||||
@@ -879,7 +879,7 @@ async def import_ics(
|
||||
# ─── 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(
|
||||
body: ResourceCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -897,7 +897,7 @@ async def create_resource(
|
||||
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(
|
||||
entry_id: str,
|
||||
body: BookResourceRequest,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@ class DmsPlugin(BasePlugin):
|
||||
name="dms",
|
||||
version="1.0.0",
|
||||
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"],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
@@ -24,5 +24,11 @@ class DmsPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
permissions=[
|
||||
"dms:read",
|
||||
"dms:write",
|
||||
"dms:delete",
|
||||
"dms:share",
|
||||
"dms:admin",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,8 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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 Folder
|
||||
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"])
|
||||
|
||||
# Configurable storage base path
|
||||
DMS_STORAGE_BASE = os.environ.get("DMS_STORAGE_BASE", "/tmp/dms")
|
||||
|
||||
# Office file extensions mapped to OnlyOffice file types
|
||||
# Office file extensions mapped to Collabora file types
|
||||
OFFICE_EXTENSIONS = {
|
||||
".docx": "docx",
|
||||
".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:
|
||||
"""Build on-disk storage path for a file."""
|
||||
return os.path.join(DMS_STORAGE_BASE, str(tenant_id), str(file_id))
|
||||
"""Build relative storage path for a file (relative to storage base)."""
|
||||
return f"{tenant_id}/{file_id}"
|
||||
|
||||
|
||||
def _get_file_extension(filename: str) -> str:
|
||||
@@ -73,7 +71,7 @@ def _get_file_extension(filename: str) -> str:
|
||||
# ─── Folders ───
|
||||
|
||||
|
||||
@router.get("/folders")
|
||||
@router.get("/folders", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def list_folders(
|
||||
parent_id: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -142,7 +140,7 @@ async def list_folders(
|
||||
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(
|
||||
body: FolderCreate,
|
||||
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(
|
||||
folder_id: str,
|
||||
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(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -396,7 +394,7 @@ async def delete_folder(
|
||||
# ─── 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(
|
||||
file: UploadFile = File(...),
|
||||
folder_id: str | None = Form(None),
|
||||
@@ -433,10 +431,9 @@ async def upload_file(
|
||||
file_id = uuid.uuid4()
|
||||
storage_path = _file_storage_path(tenant_id, file_id)
|
||||
|
||||
# Ensure directory exists and write file
|
||||
os.makedirs(os.path.dirname(storage_path), exist_ok=True)
|
||||
with open(storage_path, "wb") as f: # noqa: ASYNC230
|
||||
f.write(content)
|
||||
# Save file via storage backend
|
||||
storage = get_storage_backend()
|
||||
await storage.save(storage_path, content)
|
||||
|
||||
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(
|
||||
file_id: str,
|
||||
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(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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(
|
||||
folder_id: str,
|
||||
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(
|
||||
file_id: str,
|
||||
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(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -666,7 +663,7 @@ async def delete_file(
|
||||
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(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -708,7 +705,7 @@ async def restore_file(
|
||||
# ─── Preview & Edit ───
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/preview")
|
||||
@router.get("/files/{file_id}/preview", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def preview_file(
|
||||
file_id: str,
|
||||
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"}
|
||||
)
|
||||
|
||||
if not os.path.exists(dms_file.storage_path):
|
||||
storage = get_storage_backend()
|
||||
if not await storage.exists(dms_file.storage_path):
|
||||
raise HTTPException(
|
||||
404, detail={"detail": "File not found on disk", "code": "file_missing"}
|
||||
)
|
||||
|
||||
content = await storage.read(dms_file.storage_path)
|
||||
|
||||
def _stream():
|
||||
with open(dms_file.storage_path, "rb") as f:
|
||||
yield from iter(lambda: f.read(65536), b"")
|
||||
yield content
|
||||
|
||||
return StreamingResponse(
|
||||
_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(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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"])
|
||||
user_id = current_user["user_id"]
|
||||
user_name = current_user.get("name", "Unknown")
|
||||
@@ -810,7 +809,7 @@ async def create_edit_session(
|
||||
# ─── Internal Sharing ───
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/share")
|
||||
@router.post("/files/{file_id}/share", dependencies=[Depends(require_permission("dms:share"))])
|
||||
async def share_file(
|
||||
file_id: str,
|
||||
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(
|
||||
file_id: str,
|
||||
body: ShareRemoveRequest = Body(...),
|
||||
@@ -946,7 +945,7 @@ async def remove_share(
|
||||
# ─── Search & Bulk ───
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
@router.get("/search", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def search_files(
|
||||
q: str,
|
||||
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(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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(
|
||||
body: BulkMoveRequest,
|
||||
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(
|
||||
body: BulkDeleteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -65,6 +65,6 @@ class BulkDeleteRequest(BaseModel):
|
||||
file_ids: list[str] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class OnlyOfficeConfig(BaseModel):
|
||||
class CollaboraConfig(BaseModel):
|
||||
document: dict
|
||||
editorConfig: dict # noqa: N815
|
||||
|
||||
@@ -36,7 +36,11 @@ class EntityLinksPlugin(BasePlugin):
|
||||
],
|
||||
events=["company.deleted", "contact.deleted"],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
permissions=[
|
||||
"entity_links:read",
|
||||
"entity_links:write",
|
||||
"entity_links:delete",
|
||||
],
|
||||
is_core=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.schemas import EntityLinkRequest
|
||||
|
||||
@@ -29,7 +29,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||
) 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(
|
||||
file_id: str,
|
||||
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(
|
||||
file_id: str,
|
||||
body: EntityLinkRequest = Body(...),
|
||||
@@ -112,7 +112,7 @@ async def unlink_file_from_entity(
|
||||
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(
|
||||
file_id: str,
|
||||
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(
|
||||
company_id: str,
|
||||
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(
|
||||
contact_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -47,6 +47,7 @@ class MailAccount(Base, TenantMixin):
|
||||
smtp_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
username: Mapped[str] = mapped_column(String(255), 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_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
sent_folder_imap_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
@@ -8,9 +8,9 @@ so that GET /search, /threads, /templates etc. are not shadowed by GET /{mail_id
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile
|
||||
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 app.core.db import get_db
|
||||
from app.core.storage import get_storage_backend
|
||||
from app.deps import require_permission
|
||||
from app.plugins.builtins.mail.models import (
|
||||
ContactPgpKey,
|
||||
@@ -109,32 +110,31 @@ def _parse_uuid(val: str, field: str = "id") -> uuid.UUID:
|
||||
) 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.
|
||||
|
||||
Each uploaded attachment is stored under
|
||||
``<storage>/mail_uploads/<temp_id>/<filename>``. We scan the
|
||||
directory for the single file inside and return its metadata.
|
||||
"""
|
||||
import os
|
||||
|
||||
resolved: list[dict] = []
|
||||
base_dir = os.environ.get("STORAGE_PATH", "/tmp")
|
||||
storage = get_storage_backend()
|
||||
for att_id in attachment_ids:
|
||||
upload_dir = os.path.join(base_dir, "mail_uploads", att_id)
|
||||
if not os.path.isdir(upload_dir):
|
||||
prefix = f"mail_uploads/{att_id}/"
|
||||
files = await storage.list_files(prefix)
|
||||
if not files:
|
||||
continue
|
||||
for fname in os.listdir(upload_dir):
|
||||
fpath = os.path.join(upload_dir, fname)
|
||||
if os.path.isfile(fpath):
|
||||
resolved.append(
|
||||
{
|
||||
"path": fpath,
|
||||
"filename": fname,
|
||||
"mime_type": "application/octet-stream",
|
||||
}
|
||||
)
|
||||
break
|
||||
for fpath in files:
|
||||
fname = os.path.basename(fpath)
|
||||
abs_path = await storage.get_url(fpath)
|
||||
resolved.append(
|
||||
{
|
||||
"path": abs_path,
|
||||
"filename": fname,
|
||||
"mime_type": "application/octet-stream",
|
||||
}
|
||||
)
|
||||
break
|
||||
return resolved
|
||||
|
||||
|
||||
@@ -752,19 +752,10 @@ async def upload_attachment(
|
||||
safe_filename = _sanitize_filename(file.filename or "attachment")
|
||||
mime_type = file.content_type or "application/octet-stream"
|
||||
|
||||
# Store in a temp directory keyed by the temp_id
|
||||
import os
|
||||
|
||||
temp_dir = os.path.join(
|
||||
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)
|
||||
# Store via storage backend in a path keyed by the temp_id
|
||||
storage = get_storage_backend()
|
||||
file_path = f"mail_uploads/{temp_id}/{safe_filename}"
|
||||
await storage.save(file_path, content)
|
||||
|
||||
return {
|
||||
"id": temp_id,
|
||||
@@ -828,7 +819,7 @@ async def send_mail(
|
||||
in_reply_to=data.in_reply_to,
|
||||
references_header=data.references_header,
|
||||
signature=signature,
|
||||
attachment_paths=_resolve_attachment_paths(data.attachments),
|
||||
attachment_paths=await _resolve_attachment_paths(data.attachments),
|
||||
)
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(
|
||||
@@ -1423,19 +1414,16 @@ async def download_attachment(
|
||||
).scalar_one_or_none()
|
||||
if not attachment:
|
||||
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(
|
||||
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 with aiofiles.open(attachment.storage_path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
yield content
|
||||
|
||||
return StreamingResponse(
|
||||
file_stream(),
|
||||
|
||||
@@ -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")
|
||||
|
||||
# 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(
|
||||
algorithm=hashes.SHA256(),
|
||||
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()))
|
||||
|
||||
|
||||
_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:
|
||||
"""Encrypt a password using AES-256 (Fernet). Returns base64 ciphertext."""
|
||||
return _fernet.encrypt(plaintext.encode()).decode()
|
||||
def _get_fernet(salt_b64: str | None = None) -> Fernet:
|
||||
"""Get a Fernet instance. If salt_b64 is provided, use it; otherwise use legacy salt."""
|
||||
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:
|
||||
"""Decrypt a password encrypted with encrypt_password."""
|
||||
return _fernet.decrypt(ciphertext.encode()).decode()
|
||||
def encrypt_password(plaintext: str, salt_b64: str | None = None) -> str:
|
||||
"""Encrypt a password using AES-256 (Fernet). Returns base64 ciphertext.
|
||||
|
||||
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) ───
|
||||
@@ -255,6 +280,7 @@ async def create_mail_account(
|
||||
db: AsyncSession, *, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
|
||||
) -> MailAccount:
|
||||
"""Create a new mail account with encrypted password."""
|
||||
salt = generate_salt()
|
||||
account = MailAccount(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
@@ -267,7 +293,8 @@ async def create_mail_account(
|
||||
smtp_port=data.get("smtp_port", 587),
|
||||
smtp_tls=data.get("smtp_tls", True),
|
||||
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_active=True,
|
||||
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:
|
||||
setattr(account, model_field, data[api_field])
|
||||
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.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
async def get_account_password(account: MailAccount) -> str:
|
||||
"""Decrypt and return the account password (internal use only)."""
|
||||
return decrypt_password(account.encrypted_password)
|
||||
"""Decrypt and return the account password (internal use only).
|
||||
|
||||
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:
|
||||
|
||||
@@ -24,6 +24,11 @@ class TagsPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
permissions=[
|
||||
"tags:read",
|
||||
"tags:write",
|
||||
"tags:delete",
|
||||
"tags:admin",
|
||||
],
|
||||
is_core=True,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.schemas import (
|
||||
TagAssignRequest,
|
||||
@@ -33,7 +33,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||
) from None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@router.get("", dependencies=[Depends(require_permission("tags:read"))])
|
||||
async def list_tags(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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(
|
||||
body: TagCreate,
|
||||
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(
|
||||
tag_id: str,
|
||||
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(
|
||||
body: TagAssignRequest,
|
||||
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(
|
||||
body: TagUnassignRequest = Body(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -221,7 +221,7 @@ async def unassign_tag(
|
||||
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(
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -246,7 +246,7 @@ async def delete_tag(
|
||||
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(
|
||||
body: TagBulkAssignRequest,
|
||||
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(
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
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.plugins.builtins.unified_search.provider_registry import get_search_registry
|
||||
from app.plugins.builtins.unified_search.query_understanding import (
|
||||
@@ -49,6 +50,7 @@ async def search(
|
||||
) -> SearchResponse:
|
||||
"""Perform hybrid search with KI query understanding."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
# KI query understanding
|
||||
query_analysis = await llm_analyze_query(req.query, db=db, tenant_id=tenant_id)
|
||||
@@ -62,6 +64,18 @@ async def search(
|
||||
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
|
||||
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", ""),
|
||||
snippet=r.get("snippet", ""),
|
||||
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
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Supports:
|
||||
- PDF (via PyMuPDF/fitz)
|
||||
- PDF (via pypdf)
|
||||
- DOCX (via python-docx)
|
||||
- XLSX (via openpyxl)
|
||||
- PPTX (via python-pptx)
|
||||
@@ -57,14 +57,15 @@ def _truncate(text: str) -> str:
|
||||
|
||||
|
||||
async def _extract_pdf(file_path: str) -> str:
|
||||
"""Extract text from PDF using PyMuPDF (fitz)."""
|
||||
import fitz # PyMuPDF
|
||||
"""Extract text from PDF using pypdf (BSD-licensed)."""
|
||||
from pypdf import PdfReader
|
||||
|
||||
doc = fitz.open(file_path)
|
||||
reader = PdfReader(file_path)
|
||||
parts: list[str] = []
|
||||
for page in doc:
|
||||
parts.append(page.get_text())
|
||||
doc.close()
|
||||
for page in reader.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
parts.append(text)
|
||||
return _truncate("\n".join(parts))
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ class PluginManifest(BaseModel):
|
||||
field_definitions: list[FieldDefinition] = Field(
|
||||
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")
|
||||
@classmethod
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.routes import (
|
||||
auth, # noqa: F401
|
||||
companies, # noqa: F401
|
||||
contacts, # noqa: F401
|
||||
entity_history, # noqa: F401
|
||||
currencies, # noqa: F401
|
||||
taxes, # noqa: F401
|
||||
sequences, # noqa: F401
|
||||
|
||||
@@ -117,11 +117,12 @@ async def delete_contact(
|
||||
):
|
||||
"""Soft-delete (or hard-delete with ?hard=true) a contact."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
if hard:
|
||||
await contact_service.hard_delete_contact(db, tenant_id, contact_id)
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -24,6 +24,11 @@ class SystemSettingsUpsert(BaseModel):
|
||||
invoice_prefix: str = Field("RE-", max_length=20)
|
||||
quote_prefix: str = Field("AN-", max_length=20)
|
||||
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):
|
||||
@@ -46,5 +51,10 @@ class SystemSettingsResponse(BaseModel):
|
||||
invoice_prefix: str = "RE-"
|
||||
quote_prefix: str = "AN-"
|
||||
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
|
||||
updated_at: str | None = None
|
||||
|
||||
@@ -11,11 +11,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.storage import get_storage_backend
|
||||
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]:
|
||||
"""Serialize an Attachment ORM object to dict."""
|
||||
@@ -50,17 +48,13 @@ async def save_attachment(
|
||||
mime_type: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Save a file to storage and create an Attachment record."""
|
||||
# Ensure storage directory exists
|
||||
entity_dir = os.path.join(STORAGE_PATH, entity_type, str(entity_id))
|
||||
os.makedirs(entity_dir, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
# Generate unique filename and relative storage path
|
||||
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
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_content)
|
||||
# Save file via storage backend
|
||||
storage = get_storage_backend()
|
||||
await storage.save(file_path, file_content)
|
||||
|
||||
file_size = len(file_content)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.services.entity_history_service import record_history
|
||||
|
||||
|
||||
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)
|
||||
result = await db.execute(q)
|
||||
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(
|
||||
@@ -260,6 +269,9 @@ async def update_contact(
|
||||
if not contact:
|
||||
raise ValueError("Contact not found")
|
||||
|
||||
# Capture snapshot before update
|
||||
snapshot_before = _serialize_contact_detail(contact)
|
||||
|
||||
# Recompute displayname if name fields changed
|
||||
if any(k in data for k in ("type", "name", "firstname", "surname", "surfix")):
|
||||
merged = {**_serialize_contact(contact), **data}
|
||||
@@ -271,10 +283,30 @@ async def update_contact(
|
||||
contact.updated_by = user_id
|
||||
|
||||
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."""
|
||||
q = select(Contact).where(
|
||||
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()
|
||||
if not contact:
|
||||
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
|
||||
contact.deleted_at = datetime.now(timezone.utc)
|
||||
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:
|
||||
"""GDPR hard-delete a contact."""
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -11,14 +15,14 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.models.contact import Contact, ContactPerson as Company
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.services.company_service import _company_to_dict
|
||||
from app.models.contact import Contact
|
||||
from app.services.contact_service import _serialize_contact as _contact_to_dict
|
||||
|
||||
# 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"]
|
||||
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]]:
|
||||
@@ -44,9 +48,9 @@ async def import_companies(
|
||||
csv_content: str,
|
||||
dry_run: bool = False,
|
||||
) -> 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)
|
||||
total = len(rows)
|
||||
@@ -73,29 +77,30 @@ async def import_companies(
|
||||
|
||||
created = []
|
||||
for row in valid_rows:
|
||||
company = Company(
|
||||
contact = Contact(
|
||||
tenant_id=tenant_id,
|
||||
type="company",
|
||||
name=row["name"].strip(),
|
||||
industry=row.get("industry", "").strip() or None,
|
||||
phone=row.get("phone", "").strip() or None,
|
||||
email=row.get("email", "").strip() or None,
|
||||
displayname=row["name"].strip(),
|
||||
email_1=row.get("email", "").strip() or None,
|
||||
phone_1=row.get("phone", "").strip() or None,
|
||||
website=row.get("website", "").strip() or None,
|
||||
description=row.get("description", "").strip() or None,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(company)
|
||||
db.add(contact)
|
||||
await db.flush()
|
||||
await log_audit(
|
||||
db,
|
||||
tenant_id,
|
||||
user_id,
|
||||
"import",
|
||||
"company",
|
||||
company.id,
|
||||
changes={"name": company.name},
|
||||
"contact",
|
||||
contact.id,
|
||||
changes={"name": contact.name, "type": "company"},
|
||||
)
|
||||
created.append(_company_to_dict(company))
|
||||
created.append(_contact_to_dict(contact))
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
@@ -114,9 +119,10 @@ async def import_contacts(
|
||||
csv_content: str,
|
||||
dry_run: bool = False,
|
||||
) -> 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)
|
||||
total = len(rows)
|
||||
@@ -124,11 +130,15 @@ async def import_contacts(
|
||||
errors = []
|
||||
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
row_errors = _validate_row(row, ["first_name", "last_name"])
|
||||
if row_errors:
|
||||
for e in row_errors:
|
||||
errors.append({"row": idx, "error": e})
|
||||
# Accept both old (first_name/last_name) and new (firstname/surname) column names
|
||||
firstname = (row.get("firstname") or row.get("first_name") or "").strip()
|
||||
surname = (row.get("surname") or row.get("last_name") or "").strip()
|
||||
if not firstname and not surname:
|
||||
errors.append({"row": idx, "error": "Missing required field: firstname or surname"})
|
||||
else:
|
||||
# Normalize row to use unified field names
|
||||
row["firstname"] = firstname
|
||||
row["surname"] = surname
|
||||
valid_rows.append(row)
|
||||
|
||||
if dry_run:
|
||||
@@ -145,13 +155,14 @@ async def import_contacts(
|
||||
for row in valid_rows:
|
||||
contact = Contact(
|
||||
tenant_id=tenant_id,
|
||||
first_name=row["first_name"].strip(),
|
||||
last_name=row["last_name"].strip(),
|
||||
email=row.get("email", "").strip() or None,
|
||||
phone=row.get("phone", "").strip() or None,
|
||||
mobile=row.get("mobile", "").strip() or None,
|
||||
position=row.get("position", "").strip() or None,
|
||||
department=row.get("department", "").strip() or None,
|
||||
type="person",
|
||||
firstname=row["firstname"].strip() or None,
|
||||
surname=row["surname"].strip() or None,
|
||||
displayname=f"{row['firstname']} {row['surname']}",
|
||||
email_1=row.get("email", "").strip() or None,
|
||||
phone_1=row.get("phone", "").strip() or None,
|
||||
mobilephone=row.get("mobile", "").strip() or None,
|
||||
function=row.get("function", "").strip() or None,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
@@ -164,7 +175,7 @@ async def import_contacts(
|
||||
"import",
|
||||
"contact",
|
||||
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))
|
||||
|
||||
@@ -206,14 +217,14 @@ async def export_contacts_csv(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> str:
|
||||
"""Export contacts as CSV string."""
|
||||
"""Export contacts as CSV string using unified Contact model fields."""
|
||||
q = (
|
||||
select(Contact)
|
||||
.where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Contact.last_name, Contact.first_name)
|
||||
.order_by(Contact.surname, Contact.firstname)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
contacts = result.scalars().all()
|
||||
@@ -221,19 +232,23 @@ async def export_contacts_csv(
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
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:
|
||||
writer.writerow(
|
||||
[
|
||||
str(c.id),
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
c.email or "",
|
||||
c.phone or "",
|
||||
c.mobile or "",
|
||||
c.position or "",
|
||||
c.department or "",
|
||||
c.type or "person",
|
||||
c.firstname or "",
|
||||
c.surname or "",
|
||||
c.name or "",
|
||||
c.email_1 or "",
|
||||
c.phone_1 or "",
|
||||
c.mobilephone or "",
|
||||
c.function or "",
|
||||
c.mailing_city or "",
|
||||
c.mailing_postalcode or "",
|
||||
c.mailing_country or "",
|
||||
]
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
@@ -34,6 +34,10 @@ def _settings_to_dict(s: SystemSettings) -> dict[str, Any]:
|
||||
"invoice_prefix": s.invoice_prefix,
|
||||
"quote_prefix": s.quote_prefix,
|
||||
"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,
|
||||
"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-"),
|
||||
quote_prefix=data.get("quote_prefix", "AN-"),
|
||||
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)
|
||||
await db.flush()
|
||||
@@ -122,6 +130,7 @@ async def upsert_system_settings(
|
||||
"company_zip", "company_country", "tax_number", "vat_id", "iban",
|
||||
"bic", "bank_name", "ceo", "trade_register",
|
||||
"invoice_prefix", "quote_prefix", "payment_terms_days",
|
||||
"theme_primary_color", "theme_accent_color", "theme_font_family", "theme_border_radius",
|
||||
)
|
||||
for field in all_fields:
|
||||
if field in data and data[field] is not None:
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
> **v1 Tasks:** T01, T02, T03, T07, T09, T10
|
||||
> **v2 Tasks:** T04, T05, T06, T11, T08a, T08b, T08c
|
||||
|
||||
> **Update 2026-07-23:** Implementation Status siehe Abschnitt am Ende dieses Dokuments.
|
||||
|
||||
**Projekt:** leocrm — Greenfield
|
||||
**Architekt:** Solution Architect (Agent Zero)
|
||||
**Datum:** 2026-06-28
|
||||
@@ -2017,3 +2019,49 @@ e2e/
|
||||
- AGENTS.md status: PENDING
|
||||
- Open questions: 4 (listed above, non-blocking for implementation)
|
||||
- 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
|
||||
|
||||
+163
-527
@@ -1,540 +1,176 @@
|
||||
# LeoCRM — Codebase vs Requirements Analysis
|
||||
# LeoCRM — Codebase vs Requirements (IST-Stand Juli 2026)
|
||||
|
||||
**Datum:** 2026-06-28
|
||||
**Prüfer:** Codebase Explorer (Agent Zero)
|
||||
**Methode:** Read-only-Inspektion der bestehenden Codebase gegen bereinigte `requirements.md`
|
||||
**Datum:** 2026-07-23
|
||||
**Prüfer:** Agent Zero
|
||||
**Methode:** Vollständige Code-Inspektion gegen requirements.md und architecture.md
|
||||
|
||||
---
|
||||
|
||||
## 1. Bestehende Architektur-Übersicht
|
||||
|
||||
### Stack
|
||||
## 1. Aktueller Stack
|
||||
|
||||
| Komponente | Code-Realität | Requirements | Status |
|
||||
|------------|-------------|-------------|--------|
|
||||
| Backend | FastAPI 0.115.6 | FastAPI | ✅ kompatibel |
|
||||
| Python | 3.11+ (pyproject.toml) | 3.12 (Annahme 10) | ⚠️ Minor-Abweichung |
|
||||
| Datenbank | **SQLite** (WAL mode) | **PostgreSQL 16** | ❌ KONFLIKT |
|
||||
| ORM | SQLAlchemy 2.0.36 | (offen — architecture.md) | ✅ kompatibel |
|
||||
| Frontend | **Jinja2 Templates** (server-side) | **React SPA** (client-side) | ❌ KONFLIKT |
|
||||
| Auth | Starlette SessionMiddleware (Cookie) | Session-basiert (Cookie) | ✅ kompatibel |
|
||||
| Deployment | Docker (single container) | Coolify (Docker) | ⚠️ Single-Container vs Multi-Container |
|
||||
| Testing | pytest (backend only) | pytest + Vitest + Playwright | ⚠️ Backend-only |
|
||||
| Backend | FastAPI 0.115+ | FastAPI | ✅ kompatibel |
|
||||
| Python | 3.12 (Dockerfile) | 3.12 | ✅ kompatibel |
|
||||
| Datenbank | PostgreSQL 16 + asyncpg | PostgreSQL 16 | ✅ erfüllt |
|
||||
| ORM | SQLAlchemy 2.0 async | SQLAlchemy 2.0 | ✅ erfüllt |
|
||||
| Frontend | React 18 SPA (Vite) | React SPA | ✅ erfüllt |
|
||||
| Auth | Session-based (Redis + HttpOnly Cookie) | Session-based | ✅ erfüllt |
|
||||
| Deployment | Docker Multi-Stage + Coolify | Docker + Coolify | ✅ erfüllt |
|
||||
| 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/
|
||||
├── main.py — FastAPI app, lifespan, middleware, router wiring
|
||||
├── config.py — Pydantic Settings (env: LEOCRM_*)
|
||||
├── deps.py — Auth dependencies (get_current_user, require_admin)
|
||||
├── db/
|
||||
│ ├── models.py — 862 Zeilen, 15 SQLAlchemy-Modelle (alle Core, keine Plugins)
|
||||
│ ├── session.py — SQLite-Engine, SessionLocal, get_db dependency
|
||||
│ └── init_db.py — Table creation + demo seed (admin/admin)
|
||||
├── routes/
|
||||
│ ├── api_routes.py — JSON auth endpoints (/api/auth/login, /api/auth/logout)
|
||||
│ ├── html_routes.py — HTML auth endpoints (/login, /logout — Jinja2)
|
||||
│ ├── company_routes.py— JSON API /api/companies (CRUD, search, export)
|
||||
│ ├── contact_routes.py— JSON API /api/contacts (CRUD, search)
|
||||
│ ├── dms_routes.py — JSON API /api/dms/* (folders, files, search, links, bulk)
|
||||
│ ├── tag_routes.py — JSON API /api/tags (CRUD, assign, bulk-assign)
|
||||
│ ├── calendar_routes.py— JSON API /api/calendars, /api/entries (CRUD, shares, subtasks, attendees, links)
|
||||
│ ├── notification_routes.py — JSON API /api/notifications
|
||||
│ ├── import_routes.py — JSON API /api/companies/import, /api/contacts/import (CSV)
|
||||
│ ├── public_routes.py — Public share links /api/public/share/{token}
|
||||
│ └── health_routes.py — /api/health
|
||||
├── services/
|
||||
│ ├── auth_service.py — bcrypt password hashing, authenticate_user
|
||||
│ ├── company_service.py — Company CRUD logic
|
||||
│ ├── contact_service.py — Contact CRUD logic
|
||||
│ ├── dms_service.py — DMS file/folder operations (26KB, größte Service-Datei)
|
||||
│ ├── tag_service.py — Tag CRUD + assignment
|
||||
│ ├── calendar_service.py — Calendar/entry/subtask/attendee/notification logic (21KB)
|
||||
│ ├── permission_service.py— DMS permissions + share links
|
||||
│ ├── import_service.py — CSV import for companies/contacts
|
||||
│ └── export_service.py — CSV/XLSX export for companies
|
||||
├── schemas/ — Pydantic schemas (auth, company, contact, dms, tag, calendar, common)
|
||||
└── templates/ — Jinja2 HTML templates (login, register, dashboard, company_form, contact_form, contact_list, base)
|
||||
├── main.py — FastAPI app, lifespan, middleware, router wiring
|
||||
├── config.py — Pydantic Settings (env: LEOCRM_*)
|
||||
├── deps.py — Auth dependencies, require_permission
|
||||
├── core/
|
||||
│ ├── auth.py — Session auth, bcrypt, Redis session store
|
||||
│ ├── tenant.py — Tenant-scoping ORM filter
|
||||
│ ├── permissions.py — RBAC resolver with Redis cache
|
||||
│ ├── permission_registry.py — Central permission catalog
|
||||
│ ├── event_bus.py — In-process async event bus
|
||||
│ ├── service_container.py — DI container
|
||||
│ ├── cache.py — Redis cache wrapper
|
||||
│ ├── jobs.py — ARQ job queue integration
|
||||
│ ├── worker.py — ARQ worker configuration
|
||||
│ ├── audit.py — Audit log + deletion log
|
||||
│ ├── notifications.py — Notification service
|
||||
│ ├── monitoring.py — Prometheus metrics + structlog
|
||||
│ ├── rate_limit.py — Redis-based rate limiting
|
||||
│ ├── middleware.py — CSRF, CORS, request logging
|
||||
│ └── seeds.py — Seed data
|
||||
├── models/ — 20 SQLAlchemy models (all with TenantMixin)
|
||||
├── schemas/ — 19 Pydantic schema modules
|
||||
├── services/ — 18 service modules
|
||||
├── routes/ — 22 FastAPI routers
|
||||
├── plugins/
|
||||
│ ├── base.py — BasePlugin class
|
||||
│ ├── manifest.py — PluginManifest, PluginRouteDef
|
||||
│ ├── registry.py — PluginRegistry (698 lines)
|
||||
│ ├── migration_runner.py — Plugin DB migration runner
|
||||
│ └── builtins/ — 12 built-in plugins
|
||||
├── workflows/
|
||||
│ ├── 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
|
||||
|
||||
- **Monolith:** Single FastAPI app, alle Module fest eingebaut
|
||||
- **Dual-Interface:** HTML routes (Jinja2) + JSON API routes parallel
|
||||
- **Service-Layer:** Business-Logik in `services/`, Routes sind dünn
|
||||
- **SQLAlchemy 2.0:** DeclarativeBase, Mapped types, mapped_column
|
||||
- **Soft-Delete:** `deleted_at` auf Company, Contact, Folder, File
|
||||
- **N:M Junctions:** CompanyContact, TagAssignment, FileEntityLink, EntryLink, CalendarShare
|
||||
- **RBAC:** 3 Rollen (admin, editor, viewer) — hardcoded in `require_admin` dependency
|
||||
- **Demo-Seed:** init_db() erstellt admin/admin + 2 Firmen + 3 Kontakte
|
||||
|
||||
---
|
||||
|
||||
## 2. Konflikte: Requirements vs Code-Realität
|
||||
|
||||
### K1: Multi-Tenant (F-AUTH-07, F-CORE-02) — KRITISCH
|
||||
|
||||
| Aspekt | Requirements | Code-Realität |
|
||||
|---------|-------------|---------------|
|
||||
| tenant_id | Auf allen Core-Tabellen | **Nirgendwo vorhanden** |
|
||||
| Tenant-Isolation | ORM filtert automatisch | **Keine Filterung** |
|
||||
| User-Tenant-Zuordnung | User kann zu mehreren Tenants gehören | **Nicht implementiert** |
|
||||
| Tenant-Switch UI | Wechsel aktiver Tenant | **Nicht vorhanden** |
|
||||
| Plugin-Tabellen | Müssen tenant_id haben | **N/A (keine Plugins)** |
|
||||
|
||||
**Evidence:**
|
||||
- `models.py` Zeile 42: `class User(Base):` docstring sagt explizit `"Login account for LeoCRM (single-tenant)."`
|
||||
- Keine `tenant_id`-Spalte auf Company, Contact, Folder, File, Tag, Calendar, CalendarEntry, Notification, Permission, ShareLink
|
||||
- `deps.py`: Session speichert nur `user_id`, kein `tenant_id`-Kontext
|
||||
- Keine Tenant-Modell-Klasse existiert
|
||||
|
||||
**Impact:** Fundamentale Architektur-Veränderung erforderlich. Jede Tabelle braucht tenant_id, ORM-Queries müssen tenant-gefiltert sein, User-Tenant-Mapping-Tabelle nötig.
|
||||
|
||||
---
|
||||
|
||||
### K2: Plugin-System (F-PLUGIN-01, F-PLUGIN-02) — KRITISCH
|
||||
|
||||
| Aspekt | Requirements | Code-Realität |
|
||||
|---------|-------------|---------------|
|
||||
| Plugin-Architektur | Core-Feature v1 | **Nicht existent** |
|
||||
| DMS/Kalender/Tags/Mail | Als Plugins implementiert | **Fest im Core eingebaut** |
|
||||
| Plugin-Manifest | Definiertes Format | **Nicht vorhanden** |
|
||||
| Lifecycle-Hooks | install/activate/deactivate/uninstall | **Nicht vorhanden** |
|
||||
| Plugin-API-Endpunkte | Plugins registrieren eigene Routes | **Nicht vorhanden** |
|
||||
| Plugin-DB-Migration | Eigene Migrationen | **Nicht vorhanden** |
|
||||
| Plugin-Abhängigkeiten | Deklarierbar | **Nicht vorhanden** |
|
||||
|
||||
**Evidence:**
|
||||
- `grep -rn 'plugin\|Plugin\|manifest\|lifecycle\|activate\|deactivate' app/` → **0 Treffer**
|
||||
- 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
|
||||
- Tags: `models.py` Tag/TagAssignment + `tag_service.py` + `tag_routes.py` — fest im Core
|
||||
- Keine Plugin-Registry, kein Plugin-Loader, kein Manifest-Format
|
||||
|
||||
**Impact:** Komplette Plugin-Architektur muss neu gebaut werden. Bestehende DMS/Kalender/Tag-Module müssen in Plugins umgewandelt werden.
|
||||
|
||||
---
|
||||
|
||||
### K3: Datenbank — SQLite vs PostgreSQL — KRITISCH
|
||||
|
||||
| Aspekt | Requirements | Code-Realität |
|
||||
|---------|-------------|---------------|
|
||||
| DB-Engine | PostgreSQL 16 | **SQLite** |
|
||||
| Connection-Pooling | PostgreSQL MVCC | **SQLite WAL, check_same_thread=False** |
|
||||
| Concurrent Writes | Multi-User fähig | **SQLite limitiert** |
|
||||
|
||||
**Evidence:**
|
||||
- `config.py`: `db_path: str = Field(default=str(Path("/data/leocrm.db")))` → SQLite-Datei
|
||||
- `config.py`: `database_url` property → `f"sqlite:///{self.db_path}"`
|
||||
- `session.py`: SQLite-spezifische PRAGMAs (`PRAGMA foreign_keys = ON`, `PRAGMA journal_mode = WAL`)
|
||||
- `session.py`: `connect_args={"check_same_thread": False}` — SQLite-only
|
||||
- `pyproject.toml`: Keine `psycopg2`/`asyncpg`/`psycopg`-Dependency
|
||||
|
||||
**Impact:** DB-Layer muss auf PostgreSQL umgestellt werden. Session-Engine, PRAGMAs, connect_args müssen angepasst werden.
|
||||
|
||||
---
|
||||
|
||||
### K4: Frontend — Jinja2 vs React SPA — KRITISCH
|
||||
|
||||
| Aspekt | Requirements | Code-Realität |
|
||||
|---------|-------------|---------------|
|
||||
| Frontend | React SPA (client-side) | **Jinja2 Templates (server-side)** |
|
||||
| i18n | DE + EN, Sprachwahl persistiert | **Nicht implementiert** |
|
||||
| UI-Plugin-Framework | Plugins registrieren UI-Komponenten | **Nicht vorhanden** |
|
||||
|
||||
**Evidence:**
|
||||
- `app/templates/`: 7 Jinja2-HTML-Templates (login, register, dashboard, company_form, contact_form, contact_list, base)
|
||||
- `html_routes.py`: Jinja2Templates, TemplateResponse
|
||||
- Keine `package.json`, keine `.tsx`/`.jsx`-Dateien, kein React/Vite-Setup
|
||||
- `pyproject.toml`: `jinja2==3.1.5` als Dependency
|
||||
- Requirements Annahme 3: "SPA-Frontend: Client-side rendering mit React SPA (bestätigt durch genehmigten Prototyp leocrm-prototype-x7k2p9)"
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
### K5: F-CORE-01 — Event Bus — FEHLT
|
||||
|
||||
| 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.
|
||||
## 3. Requirements-Erfüllung
|
||||
|
||||
### Core Features (v1)
|
||||
|
||||
| Feature | Status | Anmerkung |
|
||||
|---------|--------|-----------|
|
||||
| F-AUTH-01: Login/Logout | ✅ | Session + Redis + bcrypt |
|
||||
| F-AUTH-03: User Management | ✅ | CRUD + RBAC |
|
||||
| F-AUTH-04: RBAC | ✅ | Role + Groups + Field-Level |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| F-CORE-02: Multi-Tenant | ✅ | TenantMixin + RLS |
|
||||
| F-CORE-03: Plugin System | ✅ | 12 Plugins, Registry, Manifest, Lifecycle |
|
||||
| F-CORE-07: ARQ Job Queue | ✅ | Redis-based, worker.py |
|
||||
| F-CORE-08: Caching | ✅ | Redis cache wrapper |
|
||||
| F-CORE-10: Storage | ❌ | Architecture beschreibt StorageBackend, aber NICHT implementiert. Hardcoded Pfade. (Phase 0.16) |
|
||||
| F-DATA-03: Validation | ✅ | Pydantic auf allen Inputs |
|
||||
| F-DATA-04: PostgreSQL | ✅ | PostgreSQL 16 + asyncpg |
|
||||
| F-PLUGIN-01-02: Plugin System | ✅ | Registry, Manifest, Lifecycle, Migration Runner |
|
||||
| F-SEC-01: CSRF | ✅ | SameSite=Strict + Origin validation |
|
||||
| F-SEC-02: CSP | ✅ | In architecture definiert (Nginx fehlt — Phase 0) |
|
||||
| F-SEARCH-01: Global Search | ✅ | Hybrid FTS + Vector + RRF + KI Query Understanding |
|
||||
| F-AI-01: KI-Copilot | ✅ | LiteLLM + PydanticAI + tool_registry |
|
||||
| F-WF-01: Workflow Engine | ✅ | 306 Zeilen, 4 Step-Types (action/approval/notification/condition) |
|
||||
| F-INFRA-01: Health Check | ✅ | /api/v1/health |
|
||||
| F-INFRA-04: Monitoring | ✅ | Prometheus + structlog |
|
||||
| F-PERF-01: Performance | ⚠️ | Indizes vorhanden, aber kein Virtual Scrolling (Phase 2) |
|
||||
| F-TEST-01: Testing | ⚠️ | Backend + Frontend Tests da, E2E fehlt (Phase 5) |
|
||||
|
||||
### Plugin Features (v2)
|
||||
|
||||
| Plugin | Status | Anmerkung |
|
||||
|--------|--------|-----------|
|
||||
| Calendar | ✅ | 12 Komponenten, ICS, Kanban, Resources, Subtasks |
|
||||
| DMS | ✅ | 9 Komponenten, OnlyOffice (→Collabora Phase 0.20), Share, Bulk |
|
||||
| Mail | ✅ | 13 Komponenten, PGP, Vacation, Rules, Templates, IMAP/SMTP |
|
||||
| Tags | ✅ | TagPicker, TagCloud, BulkTagDialog |
|
||||
| Permissions | ✅ | File/folder permissions, share links |
|
||||
| Entity Links | ✅ | File↔Entity links |
|
||||
| Unified Search | ✅ | Hybrid FTS+Vector, 5 Provider, KI Query Understanding |
|
||||
| AI Assistant | ✅ | Multi-provider LLM, Agents, Tools, Streaming |
|
||||
| AI Proactive | ✅ | Context-aware suggestions, SSE, Heartbeat, Deep Analysis |
|
||||
| Kommunikation | ✅ | WebSocket messaging, MiniApps, Rich Content Blocks |
|
||||
| Report Generator | ⚠️ | Backend da (CSV/Excel/JSON), Frontend fehlt, PDF fehlt (Phase 5.18-5.19) |
|
||||
| System Notifications | ✅ | Participant handler, notification types |
|
||||
|
||||
## 4. Bekannte Lücken (im MASTER-PLAN.md eingeplant)
|
||||
|
||||
| Lücke | Phase | Task |
|
||||
|-------|-------|------|
|
||||
| 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 |
|
||||
| KI-UI-Steuerung | 4 | 4.1-4.12 |
|
||||
| E2E Tests (Playwright) | 5 | 5.4-5.11 |
|
||||
| Backup-System | 5 | 5.15 |
|
||||
| MCP Integration | 5 | 5.16-5.17 |
|
||||
| Report Frontend + PDF | 5 | 5.18-5.19 |
|
||||
| Custom Fields UI | 5 | 5.20 |
|
||||
| Tasks-Plugin | 5 | 5.21 |
|
||||
| Saved Searches | 5 | 5.22 |
|
||||
| Deduplication | 5 | 5.23 |
|
||||
| PWA | 5 | 5.24 |
|
||||
| Dashboard-System | 5 | 5.25 |
|
||||
| Code-Splitting | 2 | 2.1-2.7 |
|
||||
| Virtual Scrolling | 2 | 2.2-2.6 |
|
||||
| RHF + Zod überall | 6 | 6.1-6.6 |
|
||||
| AGPL-Lizenzen (PyMuPDF, OnlyOffice) | 0 | 0.20 |
|
||||
| RBAC in 4 Plugins | 0 | 0.10 |
|
||||
| Undo/History | 0 | 0.15 |
|
||||
| .env in Git | 0 | 0.18 |
|
||||
| Mail-Salt hardcoded | 0 | 0.19 |
|
||||
|
||||
## 5. Statistik
|
||||
|
||||
| Metrik | Wert |
|
||||
|--------|------|
|
||||
| Backend Python-Zeilen | ~35.800 |
|
||||
| Frontend TS/TSX-Zeilen | ~30.000 |
|
||||
| Test-Zeilen (Backend) | ~17.300 |
|
||||
| Test-Zeilen (Frontend) | ~3.045 |
|
||||
| Plugins | 12 |
|
||||
| API-Endpoints | ~120+ |
|
||||
| DB-Migrationen | 22 |
|
||||
| UI-Komponenten | 12 (+2 zusätzliche) |
|
||||
| Frontend Pages | 27 |
|
||||
| i18n Keys (pro Sprache) | 750 |
|
||||
|
||||
## 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.
|
||||
|
||||
Die verbleibenden Lücken sind im `MASTER-PLAN.md` detailliert eingeplant (~590h Gesamt-Aufwand über 8 Phasen + Phase 3.5).
|
||||
|
||||
@@ -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.*
|
||||
@@ -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.*
|
||||
@@ -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.
|
||||
Generated
+19
@@ -23,8 +23,10 @@
|
||||
"@tiptap/starter-kit": "^3.28.0",
|
||||
"axios": "^1.7.7",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"i18next": "^23.14.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"lucide-react": "^1.25.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
@@ -2746,6 +2748,15 @@
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -3854,6 +3865,14 @@
|
||||
"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": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
|
||||
@@ -27,8 +27,10 @@
|
||||
"@tiptap/starter-kit": "^3.28.0",
|
||||
"axios": "^1.7.7",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"i18next": "^23.14.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"lucide-react": "^1.25.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AppRouter } from '@/routes';
|
||||
import { setUnauthorizedHandler } from '@/api/client';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -16,6 +17,7 @@ const queryClient = new QueryClient({
|
||||
|
||||
export default function App() {
|
||||
const { logout } = useAuthStore();
|
||||
const loadThemeFromStorage = useThemeStore((s) => s.loadFromStorage);
|
||||
|
||||
React.useEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
@@ -24,6 +26,11 @@ export default function App() {
|
||||
});
|
||||
}, [logout]);
|
||||
|
||||
// Load theme from localStorage on app start
|
||||
React.useEffect(() => {
|
||||
loadThemeFromStorage();
|
||||
}, [loadThemeFromStorage]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppRouter />
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -438,13 +438,14 @@ export function decodeMimeHeader(value: string | undefined | null): string {
|
||||
* Variables: {{user.name}}, {{user.first_name}}, {{user.last_name}},
|
||||
* {{user.email}}, {{user.role}}, {{tenant.name}}, {{date}}
|
||||
*/
|
||||
import { formatDateShort } from '@/utils/date';
|
||||
|
||||
export function replaceSignatureVariables(
|
||||
html: string,
|
||||
user: { name?: string; email?: string; role?: string; first_name?: string; last_name?: string },
|
||||
tenant?: { name?: string },
|
||||
): string {
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleDateString([], { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
const dateStr = formatDateShort(new Date());
|
||||
const firstName = user.first_name || (user.name ? user.name.split(' ')[0] : '');
|
||||
const lastName = user.last_name || (user.name ? user.name.split(' ').slice(1).join(' ') : '');
|
||||
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 EntrySubtype,
|
||||
} from '@/api/calendar';
|
||||
import { formatDateTimeInput } from '@/utils/date';
|
||||
|
||||
export interface AppointmentModalProps {
|
||||
open: boolean;
|
||||
@@ -41,9 +42,7 @@ export interface AppointmentModalProps {
|
||||
|
||||
function toDateTimeLocalValue(d: Date | null | undefined): string {
|
||||
if (!d) return '';
|
||||
// datetime-local needs YYYY-MM-DDTHH:mm in local time
|
||||
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())}`;
|
||||
return formatDateTimeInput(d);
|
||||
}
|
||||
|
||||
function fromDateTimeLocalValue(v: string): Date | null {
|
||||
|
||||
@@ -20,21 +20,12 @@ import {
|
||||
} from '@/api/calendar';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
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 {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
return formatDateTime(dateStr) || '-';
|
||||
}
|
||||
|
||||
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"
|
||||
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">
|
||||
<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>
|
||||
<CalendarIcon className="w-12 h-12 text-secondary-300 mb-3" aria-hidden="true" strokeWidth={1.5} />
|
||||
<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>
|
||||
</div>
|
||||
@@ -163,9 +152,7 @@ export function CalendarDetail({
|
||||
aria-label={t('common.close')}
|
||||
data-testid="calendar-detail-close"
|
||||
>
|
||||
<svg 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
</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"
|
||||
data-testid="calendar-detail-edit"
|
||||
>
|
||||
<svg 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="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>
|
||||
<Pencil className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
{t('calendar.detail.edit')}
|
||||
</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"
|
||||
data-testid="calendar-detail-delete"
|
||||
>
|
||||
<svg 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="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>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
{t('calendar.detail.delete')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Calendar, CalendarType } from '@/api/calendar';
|
||||
import { ChevronDown, Plus } from 'lucide-react';
|
||||
|
||||
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"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
|
||||
</svg>
|
||||
<ChevronDown className="w-3 h-3 transition-transform" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
<span className="truncate text-secondary-700">{t(`calendar.type.${type}`)}</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"
|
||||
>
|
||||
<svg 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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<Plus className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
{t('calendar.tree.newCalendar')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
import type { CalendarEntry } from '@/api/calendar';
|
||||
import { formatDateShort } from '@/utils/date';
|
||||
|
||||
export type KanbanStatus = 'open' | 'in_progress' | 'done' | 'cancelled';
|
||||
|
||||
@@ -38,8 +39,7 @@ function priorityBadgeClass(p: string): string {
|
||||
|
||||
function fmtDue(entry: CalendarEntry): string | null {
|
||||
if (!entry.due_date) return null;
|
||||
const d = new Date(entry.due_date);
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
return formatDateShort(entry.due_date) || null;
|
||||
}
|
||||
|
||||
export function KanbanBoard({ board, loading, onMove, onSelect }: KanbanBoardProps) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type CalendarEntry,
|
||||
type Subtask,
|
||||
} from '@/api/calendar';
|
||||
import { formatDateShort } from '@/utils/date';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
@@ -129,7 +130,7 @@ export function TaskDetailPanel({ entry, onClose, onChanged }: TaskDetailPanelPr
|
||||
</span>
|
||||
{entry.due_date && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
interface ContactCardBlockProps {
|
||||
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-xs text-secondary-400">Kontakt anzeigen</p>
|
||||
</div>
|
||||
<svg
|
||||
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>
|
||||
<ChevronRight className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
import { Download, FileText } from 'lucide-react';
|
||||
|
||||
interface FileBlockProps {
|
||||
block: MessageBlock;
|
||||
@@ -14,37 +15,11 @@ function formatFileSize(bytes: number | undefined): string {
|
||||
}
|
||||
|
||||
const fileIcon = (
|
||||
<svg
|
||||
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>
|
||||
<FileText className="w-8 h-8 text-secondary-400" aria-hidden="true" strokeWidth={1.5} />
|
||||
);
|
||||
|
||||
const downloadIcon = (
|
||||
<svg
|
||||
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>
|
||||
<Download className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const FileBlock: React.FC<FileBlockProps> = ({ block }) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
|
||||
import { AppWindow } from 'lucide-react';
|
||||
|
||||
interface MiniAppBlockProps {
|
||||
block: MessageBlock;
|
||||
}
|
||||
@@ -14,20 +16,7 @@ const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
|
||||
return (
|
||||
<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">
|
||||
<svg
|
||||
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>
|
||||
<AppWindow className="w-8 h-8 text-secondary-400" aria-hidden="true" strokeWidth={1.5} />
|
||||
<p className="text-sm font-medium text-secondary-600">
|
||||
Mini-App: {appId}
|
||||
</p>
|
||||
|
||||
@@ -6,6 +6,9 @@ import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { HistoryViewer } from '@/components/HistoryViewer';
|
||||
import {
|
||||
type UnifiedContact,
|
||||
type ContactPerson,
|
||||
@@ -145,10 +148,7 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
if (loading) {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
|
||||
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -359,6 +359,13 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
</pre>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
{contact.id && (
|
||||
<Section title={t('history.title', 'Änderungshistorie')}>
|
||||
<HistoryViewer entityType="contact" entityId={contact.id} />
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ContactPersonModal
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useMoveContactToFolder,
|
||||
} from '@/api/hooks';
|
||||
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}`;
|
||||
|
||||
@@ -22,28 +23,21 @@ export interface ContactFolderTreeProps {
|
||||
|
||||
// Icons
|
||||
|
||||
const icon = (path: string, cls = 'w-4 h-4') => (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
|
||||
</svg>
|
||||
const icon = (IconComp: React.ElementType, cls = 'w-4 h-4') => (
|
||||
<IconComp className={cls} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const chevron = (open: boolean) => (
|
||||
<svg
|
||||
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>
|
||||
<ChevronRight className={clsx('w-3.5 h-3.5 transition-transform flex-shrink-0', open && 'rotate-90')} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
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',
|
||||
folder: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
plus: 'M12 4v16m8-8H4',
|
||||
all: Users,
|
||||
folder: Folder,
|
||||
tag: Tag,
|
||||
edit: Pencil,
|
||||
trash: Trash2,
|
||||
plus: Plus,
|
||||
};
|
||||
|
||||
// Context Menu
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Pagination } from '@/components/ui/Pagination';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import type { UnifiedContact } from '@/api/hooks';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export type ContactViewMode = 'list' | 'table' | 'cards';
|
||||
|
||||
export interface ContactListProps {
|
||||
@@ -77,10 +79,7 @@ export function ContactList({
|
||||
if (loading) {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
|
||||
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,15 +8,12 @@ import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DmsFile } from '@/api/dms';
|
||||
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 {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
return formatDateTime(dateStr) || '-';
|
||||
}
|
||||
|
||||
function getFileSize(file: DmsFile): number {
|
||||
@@ -37,9 +34,7 @@ export function FileDetails({ file, onPreview, onShare, onDelete, onClose }: Fil
|
||||
if (!file) {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<FileText className="w-12 h-12 text-secondary-300 mb-3" aria-hidden="true" strokeWidth={1.5} />
|
||||
<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>
|
||||
</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"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<svg 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -82,9 +75,7 @@ export function FileDetails({ file, onPreview, onShare, onDelete, onClose }: Fil
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<svg className={clsx('w-24 h-24', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d={icon.path} />
|
||||
</svg>
|
||||
<icon.icon className={clsx('w-24 h-24', icon.color)} aria-hidden="true" strokeWidth={1} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -97,19 +88,14 @@ export function FileDetails({ file, onPreview, onShare, onDelete, onClose }: Fil
|
||||
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"
|
||||
>
|
||||
<svg 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="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>
|
||||
<Eye className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
{t('dms.preview')}
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<svg 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="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>
|
||||
<Share2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
{t('dms.share')}
|
||||
</button>
|
||||
<a
|
||||
@@ -117,18 +103,14 @@ export function FileDetails({ file, onPreview, onShare, onDelete, onClose }: Fil
|
||||
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"
|
||||
>
|
||||
<svg 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>
|
||||
<Download className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
{t('dms.download')}
|
||||
</a>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<svg 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="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>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
{t('dms.delete')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,27 +7,28 @@ import React, { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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') {
|
||||
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/')) {
|
||||
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')) {
|
||||
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')) {
|
||||
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')) {
|
||||
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')) {
|
||||
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 {
|
||||
@@ -37,6 +38,8 @@ export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
import { formatDateShort } from '@/utils/date';
|
||||
|
||||
|
||||
function getFileSize(file: DmsFile): number {
|
||||
return file.size_bytes ?? file.size ?? 0;
|
||||
@@ -44,12 +47,7 @@ function getFileSize(file: DmsFile): number {
|
||||
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
return formatDateShort(dateStr) || '';
|
||||
}
|
||||
|
||||
export type ViewMode = 'list' | 'table' | 'icons-sm' | 'icons-md' | 'icons-lg';
|
||||
@@ -101,9 +99,7 @@ function SortHeader({
|
||||
>
|
||||
{label}
|
||||
{isActive && (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'asc' ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'} />
|
||||
</svg>
|
||||
sortOrder === 'asc' ? <ChevronUp className="w-3 h-3" aria-hidden="true" strokeWidth={2} /> : <ChevronDown className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -130,10 +126,7 @@ function ActionButtons({
|
||||
aria-label={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">
|
||||
<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>
|
||||
<Eye className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
|
||||
@@ -141,9 +134,7 @@ function ActionButtons({
|
||||
aria-label={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">
|
||||
<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>
|
||||
<Share2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
|
||||
@@ -151,9 +142,7 @@ function ActionButtons({
|
||||
aria-label={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">
|
||||
<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>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -198,10 +187,7 @@ export function FileExplorer({
|
||||
if (loading) {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<Loader2 className="animate-spin h-6 w-6 text-secondary-400" aria-hidden="true" />
|
||||
<span className="ml-2 text-secondary-500 text-sm">{t('dms.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -210,9 +196,7 @@ export function FileExplorer({
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<FileText className="mx-auto h-12 w-12 text-secondary-300" aria-hidden="true" strokeWidth={1.5} />
|
||||
<p className="mt-2 text-sm text-secondary-500">{t('dms.noFiles')}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -254,9 +238,7 @@ export function FileExplorer({
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500 flex-shrink-0"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
||||
</svg>
|
||||
<icon.icon className={clsx('w-5 h-5 flex-shrink-0', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
<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-400 flex-shrink-0 hidden sm:inline">{formatDate(file.updated_at || file.created_at)}</span>
|
||||
@@ -327,9 +309,7 @@ export function FileExplorer({
|
||||
</td>
|
||||
<td className="px-3 py-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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
||||
</svg>
|
||||
<icon.icon className={clsx('w-5 h-5 flex-shrink-0', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
<span className="font-medium text-secondary-900 truncate" title={file.name}>{file.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -415,9 +395,7 @@ export function FileExplorer({
|
||||
/>
|
||||
</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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
||||
</svg>
|
||||
<icon.icon className={clsx(iconSize, icon.color, isSmall ? 'mb-0.5' : 'mb-1')} aria-hidden="true" strokeWidth={1.5} />
|
||||
)}
|
||||
<p className={clsx('font-medium text-secondary-900 truncate w-full', isSmall ? 'text-[10px]' : 'text-xs')} title={file.name}>{file.name}</p>
|
||||
{!isSmall && (
|
||||
|
||||
@@ -7,35 +7,9 @@ import React, { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DmsFile } from '@/api/dms';
|
||||
import { Eye, FileText, Share2, Trash2 } from 'lucide-react';
|
||||
|
||||
function getFileIcon(mimeType: string): { path: string; color: string } {
|
||||
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`;
|
||||
}
|
||||
import { getFileIcon, formatFileSize } from "./FileExplorer";
|
||||
|
||||
export interface FileGridProps {
|
||||
files: DmsFile[];
|
||||
@@ -81,9 +55,7 @@ export function FileGrid({
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<FileText className="mx-auto h-12 w-12 text-secondary-300" aria-hidden="true" strokeWidth={1.5} />
|
||||
<p className="mt-2 text-sm text-secondary-500">{t('dms.noFiles')}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -114,9 +86,7 @@ export function FileGrid({
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
||||
</svg>
|
||||
<icon.icon className={clsx('w-10 h-10', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
<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')}
|
||||
title={t('dms.preview')}
|
||||
>
|
||||
<svg 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="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>
|
||||
<Eye className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
|
||||
@@ -142,9 +109,7 @@ export function FileGrid({
|
||||
aria-label={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">
|
||||
<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>
|
||||
<Share2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
|
||||
@@ -152,9 +117,7 @@ export function FileGrid({
|
||||
aria-label={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">
|
||||
<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>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,10 @@ import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { formatDateShort } from '@/utils/date';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { getFilePreviewUrl, type DmsFile } from '@/api/dms';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
export interface FilePreviewModalProps {
|
||||
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>
|
||||
{file.created_at && (
|
||||
<span className="text-sm text-secondary-500">
|
||||
{t('dms.fileModified')}: {new Date(file.created_at).toLocaleDateString()}
|
||||
{t('dms.fileModified')}: {formatDateShort(file.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -81,9 +83,7 @@ export function FilePreviewModal({ open, file, onClose }: FilePreviewModalProps)
|
||||
|
||||
{!isPdf && !isImage && (
|
||||
<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">
|
||||
<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>
|
||||
<FileText className="mx-auto h-12 w-12 text-secondary-300" aria-hidden="true" strokeWidth={1.5} />
|
||||
<p className="mt-2 text-sm text-secondary-500">{t('dms.preview')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import React, { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DmsFolder } from '@/api/dms';
|
||||
import { ChevronDown, Folder, Menu } from 'lucide-react';
|
||||
|
||||
interface FolderTreeItemProps {
|
||||
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"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
|
||||
</svg>
|
||||
<ChevronDown className="w-3 h-3 transition-transform" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
{!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">
|
||||
<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>
|
||||
<Folder className="w-4 h-4 text-warning-500 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
{typeof folder.file_count === 'number' && folder.file_count > 0 && (
|
||||
<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"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<Menu className="w-4 h-4 text-secondary-400" aria-hidden="true" strokeWidth={2} />
|
||||
<span>{t('dms.allFiles')}</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { formatDateShort } from '@/utils/date';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
@@ -259,7 +260,7 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps)
|
||||
)}
|
||||
{link.expires_at && (
|
||||
<Badge variant="info">
|
||||
{t('permissions.expiresAt')}: {new Date(link.expires_at).toLocaleDateString()}
|
||||
{t('permissions.expiresAt')}: {formatDateShort(link.expires_at)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import React, { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DmsFolder, DmsFile } from '@/api/dms';
|
||||
import { ChevronDown, FileText, Folder, Home, Menu, Users } from 'lucide-react';
|
||||
|
||||
interface SourceTreeFolderItemProps {
|
||||
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"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
|
||||
</svg>
|
||||
<ChevronDown className="w-3 h-3 transition-transform" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
{!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">
|
||||
<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>
|
||||
<Folder className={clsx('w-4 h-4 flex-shrink-0', isDropTarget ? 'text-primary-500' : 'text-warning-500')} aria-hidden="true" strokeWidth={2} />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
{typeof folder.file_count === 'number' && folder.file_count > 0 && (
|
||||
<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"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
|
||||
</svg>
|
||||
<ChevronDown className="w-3 h-3 transition-transform" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
{icon}
|
||||
<span className="truncate">{title}</span>
|
||||
@@ -298,9 +293,7 @@ export function SourceTree({
|
||||
role="button"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<Menu className="w-4 h-4 text-secondary-400" aria-hidden="true" strokeWidth={2} />
|
||||
<span>{t('dms.allFiles')}</span>
|
||||
</div>
|
||||
|
||||
@@ -315,9 +308,7 @@ export function SourceTree({
|
||||
}
|
||||
}}
|
||||
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">
|
||||
<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>
|
||||
<Folder className="w-4 h-4 text-primary-500 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
}
|
||||
>
|
||||
{folders.length === 0 ? (
|
||||
@@ -346,9 +337,7 @@ export function SourceTree({
|
||||
onSelectFolder(null);
|
||||
}}
|
||||
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">
|
||||
<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>
|
||||
<Users className="w-4 h-4 text-accent-500 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
}
|
||||
>
|
||||
{sharedFiles.length === 0 ? (
|
||||
@@ -367,9 +356,7 @@ export function SourceTree({
|
||||
role="button"
|
||||
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">
|
||||
<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>
|
||||
<FileText className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
<span className="truncate">{file.name}</span>
|
||||
</div>
|
||||
</li>
|
||||
@@ -386,9 +373,7 @@ export function SourceTree({
|
||||
onSelectFolder(null);
|
||||
}}
|
||||
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">
|
||||
<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>
|
||||
<Home className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
}
|
||||
defaultExpanded={false}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { uploadFile } from '@/api/dms';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Upload } from 'lucide-react';
|
||||
|
||||
export interface UploadDropzoneProps {
|
||||
folderId: string | null;
|
||||
@@ -124,9 +125,7 @@ export function UploadDropzone({ folderId, onUploaded }: UploadDropzoneProps) {
|
||||
className="hidden"
|
||||
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">
|
||||
<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>
|
||||
<Upload className="mx-auto h-10 w-10 text-secondary-400" aria-hidden="true" strokeWidth={1.5} />
|
||||
<p className="mt-2 text-sm text-secondary-600">{t('dms.uploadDropHere')}</p>
|
||||
</div>
|
||||
{uploads.length > 0 && (
|
||||
|
||||
@@ -7,41 +7,30 @@ import { useUIStore } from '@/store/uiStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUsers, useGroups } from '@/api/hooks';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Bell, Bot, ChevronLeft, ChevronRight, Lightbulb, MessageSquare, Users, X } from 'lucide-react';
|
||||
|
||||
const robotIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<Bot className={className} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const bellIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<Bell className={className} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const bulbIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<Lightbulb className={className} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const teamIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<Users className={className} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const chatBubbleIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<MessageSquare className={className} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const chevronRightIcon = (
|
||||
<svg 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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<ChevronRight className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
interface TabDef {
|
||||
@@ -94,9 +83,7 @@ function TeamPanel() {
|
||||
{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 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">
|
||||
<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>
|
||||
<Users className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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"
|
||||
onClick={() => removeNotification(i)}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X className="w-3.5 h-3.5" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -283,9 +268,7 @@ export function AISidebar() {
|
||||
aria-label="Zurück"
|
||||
data-testid="ai-sidebar-back"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
|
||||
<span className="text-sm font-medium">Zurück</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { formatTime } from '@/utils/date';
|
||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { useCommStore } from '@/store/commStore';
|
||||
@@ -15,55 +16,40 @@ import {
|
||||
import { useCommWebSocket } from '@/hooks/useCommWebSocket';
|
||||
import type { Conversation, Message } from '@/store/commStore';
|
||||
import { BlockRenderer } from '@/components/comm/blocks';
|
||||
import { Bell, Bookmark, Bot, ChevronLeft, ChevronRight, Lightbulb, MessageSquare, Send, Users } from 'lucide-react';
|
||||
|
||||
// ─── Icons (identical to AISidebar) ───
|
||||
|
||||
const robotIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<Bot className={className} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const bellIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<Bell className={className} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const bulbIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<Lightbulb className={className} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const teamIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<Users className={className} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const chatBubbleIcon = (className: string) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<MessageSquare className={className} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const chevronRightIcon = (
|
||||
<svg 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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<ChevronRight className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const sendIcon = (
|
||||
<svg 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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
<Send className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
const pinIcon = (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
<Bookmark className="w-3.5 h-3.5" aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
// ─── Types ───
|
||||
@@ -128,9 +114,7 @@ function TeamPanel({ onStartDirectChat }: { onStartDirectChat: (userId: string,
|
||||
{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 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">
|
||||
<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>
|
||||
<Users className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-secondary-700 truncate">{g.name}</p>
|
||||
@@ -258,7 +242,7 @@ function MessageFeed({
|
||||
)}
|
||||
{msg.created_at && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -660,9 +644,7 @@ export function MessageSidebar() {
|
||||
aria-label="Zurück"
|
||||
data-testid="message-sidebar-back"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
|
||||
<span className="text-sm font-medium">Zurück</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import clsx from 'clsx';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { Calendar, ChevronRight, FileText, Home, Mail, Monitor, Users } from 'lucide-react';
|
||||
|
||||
interface NavLeaf {
|
||||
to: string;
|
||||
@@ -21,33 +22,19 @@ interface NavSingleItem {
|
||||
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) => (
|
||||
<svg
|
||||
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>
|
||||
<ChevronRight className={clsx('w-4 h-4 flex-shrink-0 transition-transform duration-200', expanded ? 'rotate-90' : '')} aria-hidden="true" strokeWidth={2} />
|
||||
);
|
||||
|
||||
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: '/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: '/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: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
|
||||
];
|
||||
|
||||
const treeItems: NavTreeItem[] = [
|
||||
{
|
||||
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: [
|
||||
{ to: '/calendar', labelKey: 'nav.calendar' },
|
||||
{ to: '/calendar/kanban', labelKey: 'nav.calendarKanban' },
|
||||
@@ -55,7 +42,7 @@ const treeItems: NavTreeItem[] = [
|
||||
},
|
||||
{
|
||||
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: [
|
||||
{ to: '/dms', labelKey: 'nav.files' },
|
||||
{ to: '/dms/trash', labelKey: 'nav.filesTrash' },
|
||||
@@ -63,7 +50,7 @@ const treeItems: NavTreeItem[] = [
|
||||
},
|
||||
{
|
||||
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: [
|
||||
{ to: '/mail', labelKey: 'nav.email' },
|
||||
{ to: '/mail/settings', labelKey: 'nav.emailSettings' },
|
||||
@@ -72,7 +59,7 @@ const treeItems: NavTreeItem[] = [
|
||||
];
|
||||
|
||||
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() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useLogout } from '@/api/hooks';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { SearchDropdown } from '@/components/shared/SearchDropdown';
|
||||
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
|
||||
import { Building, ChevronDown, Menu } from 'lucide-react';
|
||||
|
||||
export function TopBar() {
|
||||
const { t } = useTranslation();
|
||||
@@ -52,9 +53,7 @@ export function TopBar() {
|
||||
aria-label="Seitenleiste ein-/ausklappen"
|
||||
aria-expanded={true}
|
||||
>
|
||||
<svg 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 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<Menu className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
{/* 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"
|
||||
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">
|
||||
<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>
|
||||
<Building className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
|
||||
{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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<ChevronDown className="w-3 h-3 text-secondary-400" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -95,9 +90,7 @@ export function TopBar() {
|
||||
<span className="hidden md:block text-sm font-medium text-secondary-700 max-w-24 truncate">
|
||||
{user?.first_name || ''}
|
||||
</span>
|
||||
<svg className="w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<ChevronDown className="w-4 h-4 text-secondary-400" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
{userMenuOpen && (
|
||||
<div
|
||||
|
||||
@@ -14,6 +14,7 @@ import { RichTextEditor } from './RichTextEditor';
|
||||
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload, MailDraftPayload } from '@/api/mail';
|
||||
import { uploadAttachment, type UploadedAttachment, replaceSignatureVariables } from '@/api/mail';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { FileText, Paperclip, X } from 'lucide-react';
|
||||
|
||||
export type ComposeMode = 'new' | 'reply' | 'forward' | 'draft';
|
||||
|
||||
@@ -309,9 +310,7 @@ export function ComposeModal({
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
isLoading={uploadingFile}
|
||||
icon={
|
||||
<svg 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="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>
|
||||
<Paperclip className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
}
|
||||
>
|
||||
{t('mail.addAttachment')}
|
||||
@@ -322,9 +321,7 @@ export function ComposeModal({
|
||||
<ul className="mt-2 space-y-1">
|
||||
{attachments.map((att) => (
|
||||
<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">
|
||||
<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>
|
||||
<FileText className="w-5 h-5 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-secondary-800 truncate">{att.filename}</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"
|
||||
aria-label={t('common.remove')}
|
||||
>
|
||||
<svg 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Card } from '@/components/ui/Card';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
import {
|
||||
fetchLabels,
|
||||
createLabel,
|
||||
@@ -81,10 +82,7 @@ export function LabelManager() {
|
||||
if (loading) {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -155,9 +153,7 @@ export function LabelManager() {
|
||||
data-testid={`delete-label-${label.id}`}
|
||||
type="button"
|
||||
>
|
||||
<svg 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { Mail, MailAttachment } from '@/api/mail';
|
||||
import { decodeMimeHeader } from '@/api/mail';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { FileText, Loader2 } from 'lucide-react';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
|
||||
export interface MailDetailProps {
|
||||
mail: Mail | null; loading: boolean;
|
||||
@@ -25,14 +27,7 @@ export interface MailDetailProps {
|
||||
}
|
||||
|
||||
function formatFullDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString([], {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return formatDateTime(dateStr) || dateStr;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -75,10 +70,7 @@ export function MailDetail({
|
||||
if (loading) {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
|
||||
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -158,9 +150,7 @@ export function MailDetail({
|
||||
<ul className="space-y-1 md:space-y-2">
|
||||
{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">
|
||||
<svg className="w-5 h-5 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 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>
|
||||
<FileText className="w-5 h-5 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-secondary-800 truncate">{decodeMimeHeader(att.filename)}</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
Reference in New Issue
Block a user