feat: Add password reset functionality with request and reset forms
feat: Implement price list import feature with preview and apply options feat: Create price rules management page with CRUD operations feat: Develop quotes management page with itemized quotes and status tracking feat: Introduce organization registration page for new users feat: Build suppliers management page with detailed supplier information feat: Create users management page for inviting and managing roles chore: Add TypeScript configuration for improved type checking chore: Set up Vite configuration for development server and API proxy chore: Add Vite environment type definitions for better TypeScript support
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
DATABASE_URL=postgres://companytool:companytool@localhost:5432/companytool
|
||||||
|
BACKEND_BIND=127.0.0.1:8080
|
||||||
|
VITE_WS_URL=ws://localhost:8080/ws
|
||||||
|
COMPANYTOOL_DATA_KEY_ID=dev-data-key-v1
|
||||||
|
COMPANYTOOL_DATA_KEY_BASE64=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=
|
||||||
|
COMPANYTOOL_EMAIL_TRANSPORT=outbox
|
||||||
|
COMPANYTOOL_DOCUMENT_STORAGE_DIR=storage/documents
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/target/
|
||||||
|
/web-frontend/node_modules/
|
||||||
|
/web-frontend/dist/
|
||||||
|
.env
|
||||||
|
|
||||||
97
BETRIEB.md
Normal file
97
BETRIEB.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Betrieb
|
||||||
|
|
||||||
|
## Authentifizierung
|
||||||
|
|
||||||
|
- Passwörter werden mit Argon2id gehasht.
|
||||||
|
- Initialpasswörter erzwingen `must_change_password`.
|
||||||
|
- Passwort-Reset erfolgt über kurzlebige Tokens aus `password_reset_tokens`.
|
||||||
|
- Firmen-Einladungen verwenden Tokens aus `user_invitations.token_hash`.
|
||||||
|
- Nach Passwort-Reset werden bestehende Sessions widerrufen.
|
||||||
|
|
||||||
|
## E-Mail
|
||||||
|
|
||||||
|
E-Mail-Inhalte werden in `email_outbox` verschlüsselt gespeichert. Im
|
||||||
|
Entwicklungsmodus werden Tokens zusätzlich in API-Antworten ausgegeben. Im
|
||||||
|
Produktivbetrieb werden keine Passwörter oder Tokens in API-Antworten geliefert.
|
||||||
|
|
||||||
|
Transportmodi:
|
||||||
|
|
||||||
|
- `COMPANYTOOL_EMAIL_TRANSPORT=outbox`: nur verschlüsselte Ablage in PostgreSQL.
|
||||||
|
- `COMPANYTOOL_EMAIL_TRANSPORT=file`: zusätzliche Zustellung als JSON-Datei in
|
||||||
|
`COMPANYTOOL_EMAIL_FILE_DIR`.
|
||||||
|
|
||||||
|
SMTP kann später als weiterer Transport hinter demselben Outbox-Modell ergänzt
|
||||||
|
werden.
|
||||||
|
|
||||||
|
## Verschlüsselungsschlüssel
|
||||||
|
|
||||||
|
Produktiv müssen gesetzt sein:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COMPANYTOOL_DATA_KEY_ID=prod-data-key-v1
|
||||||
|
COMPANYTOOL_DATA_KEY_BASE64=<32-byte-key-base64>
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Key muss außerhalb der Datenbank gesichert werden. Ohne diesen Key können
|
||||||
|
verschlüsselte Firmen-, Dokument- und Kommunikationsdaten nicht wiederhergestellt
|
||||||
|
werden.
|
||||||
|
|
||||||
|
Key-Rotation ist vorbereitet über `*_key_id`-Spalten. Ablauf:
|
||||||
|
|
||||||
|
1. neuen Key als `COMPANYTOOL_DATA_KEY_ID` bereitstellen
|
||||||
|
2. neue Schreibvorgänge mit neuem Key speichern
|
||||||
|
3. Re-Encryption-Job für alte Datensätze implementieren und ausführen
|
||||||
|
4. alten Key erst nach verifiziertem Backup entfernen
|
||||||
|
|
||||||
|
## Backup und Restore
|
||||||
|
|
||||||
|
PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_dump --format=custom --file=companytool.dump "$DATABASE_URL"
|
||||||
|
pg_restore --clean --if-exists --dbname "$DATABASE_URL" companytool.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
Dokumente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -C storage -czf companytool-documents.tar.gz documents
|
||||||
|
tar -C storage -xzf companytool-documents.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Für einzelne Firmen müssen das jeweilige `company_*`-Schema und der passende
|
||||||
|
Ordner unter `storage/documents/<schema>` gemeinsam gesichert werden.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Nur PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL und Backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile backend up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## TLS und Reverse Proxy
|
||||||
|
|
||||||
|
Das Backend bleibt intern auf HTTP/WSS hinter einem Reverse Proxy. Öffentlich
|
||||||
|
muss ausschließlich HTTPS/WSS erreichbar sein. Ein nginx-Beispiel liegt unter:
|
||||||
|
|
||||||
|
```text
|
||||||
|
deploy/nginx-companytool.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lokale Einzelkunden-Installation
|
||||||
|
|
||||||
|
Für lokale Installationen bleibt `dev_bootstrap-local` als Entwicklungs- und
|
||||||
|
Installationshelfer vorgesehen. Ein späteres Installationsprogramm soll:
|
||||||
|
|
||||||
|
- PostgreSQL-Verbindung prüfen
|
||||||
|
- lokale Firma anlegen
|
||||||
|
- ersten Besitzer anlegen
|
||||||
|
- Backend- und Client-Konfiguration schreiben
|
||||||
|
- Dokumentenordner und Schlüsseldatei vorbereiten
|
||||||
6042
Cargo.lock
generated
Normal file
6042
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
Normal file
12
Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"backend",
|
||||||
|
"desktop-client",
|
||||||
|
"shared-protocol",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2021"
|
||||||
|
license = "UNLICENSED"
|
||||||
|
|
||||||
58
FENSTERKONZEPT.md
Normal file
58
FENSTERKONZEPT.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Fensterkonzept
|
||||||
|
|
||||||
|
Die Anwendung ist nach dem Login fensterbasiert. Das Dashboard bleibt die
|
||||||
|
Hauptfläche und zeigt Status, Live-Verbindung und zusammenfassende Daten. Alle
|
||||||
|
fachlichen Arbeitsbereiche öffnen als eigene Fenster.
|
||||||
|
|
||||||
|
## Grundregeln
|
||||||
|
|
||||||
|
- Vor dem Login sind nur Login und Registrierung sichtbar.
|
||||||
|
- Nach dem Login bleibt das Dashboard die Basisansicht.
|
||||||
|
- Navigationseinträge außer Dashboard öffnen ein Fenster.
|
||||||
|
- Ein bereits geöffnetes Fenster wird fokussiert, nicht doppelt geöffnet.
|
||||||
|
- Fenster können geschlossen und später erneut geöffnet werden.
|
||||||
|
- Fenster behalten pro Benutzer Position, Größe und Reihenfolge, soweit der
|
||||||
|
jeweilige Client das technisch unterstützt.
|
||||||
|
- Offene Listenfenster müssen auf Live-Events reagieren und ihre Daten
|
||||||
|
aktualisieren oder als veraltet markieren.
|
||||||
|
|
||||||
|
## Einheitliche Fensteraktionen
|
||||||
|
|
||||||
|
- `Öffnen`: Fenster wird erzeugt oder, falls vorhanden, fokussiert.
|
||||||
|
- `Fokussieren`: Fenster erhält die höchste Z-Reihenfolge.
|
||||||
|
- `Schließen`: Fenster wird aus der Arbeitsfläche entfernt.
|
||||||
|
- `Verschieben`: Fensterposition wird geändert.
|
||||||
|
- `Größe ändern`: Fensterabmessungen werden geändert.
|
||||||
|
- `Aktualisieren`: Fenster lädt seine Daten neu oder reagiert auf ein
|
||||||
|
Backend-Event.
|
||||||
|
|
||||||
|
## Webclient
|
||||||
|
|
||||||
|
- Fenster werden über der Dashboard-Fläche dargestellt.
|
||||||
|
- Position und Größe sind per Maus oder Pointer veränderbar.
|
||||||
|
- Fensterstatus wird im `localStorage` pro Benutzer gespeichert.
|
||||||
|
- Wiederherstellung erfolgt nach Login oder Reload.
|
||||||
|
|
||||||
|
## Desktopclient
|
||||||
|
|
||||||
|
- Fenster verwenden native `egui::Window`-Instanzen.
|
||||||
|
- Benutzerrechte, Firmendaten und Freischaltungen sind als Fenster umgesetzt.
|
||||||
|
- Weitere fachliche Module folgen demselben Muster.
|
||||||
|
|
||||||
|
## Live-Updates
|
||||||
|
|
||||||
|
Backend-Änderungen erzeugen Events über die bestehende Socket-Verbindung. Clients
|
||||||
|
verwenden diese Events, um offene Fenster zu aktualisieren oder deren Status auf
|
||||||
|
`Aktualisiert` zu setzen.
|
||||||
|
|
||||||
|
Für Phase 2 genügt ein gemeinsamer Live-Event-Store pro Client. Fachliche
|
||||||
|
Module können später gezielt nach Entity-Typ filtern.
|
||||||
|
|
||||||
|
## Parallelbearbeitung und Konflikte
|
||||||
|
|
||||||
|
- Schreibvorgänge laufen immer über das Backend.
|
||||||
|
- Das Backend entscheidet final über Berechtigungen und Datenstand.
|
||||||
|
- Bei konkurrierenden Änderungen wird zunächst eine frische Liste geladen.
|
||||||
|
- Für spätere fachliche Datensätze wird ein Versionsfeld oder `updated_at`
|
||||||
|
benötigt, damit Konflikte vor dem Speichern erkennbar sind.
|
||||||
|
|
||||||
181
IMPLEMENTIERUNGSPLAN.md
Normal file
181
IMPLEMENTIERUNGSPLAN.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# Implementierungsplan
|
||||||
|
|
||||||
|
Dieser Plan beschreibt die konkrete technische Umsetzung. Die fachlichen
|
||||||
|
Grundentscheidungen stehen in `PLANUNG.md`; Installations- und Betriebsdetails
|
||||||
|
stehen in `INSTALL.md`.
|
||||||
|
|
||||||
|
## Leitlinien
|
||||||
|
|
||||||
|
- Backend zuerst dort stabilisieren, wo mehrere Clients dieselben Funktionen
|
||||||
|
nutzen.
|
||||||
|
- Gemeinsame Datenverträge und Rechte werden vor UI-Komfortfunktionen umgesetzt.
|
||||||
|
- Webclient und Desktopclient sollen dieselben Backend-Endpunkte verwenden.
|
||||||
|
- Alles außer dem Dashboard wird in Fenstern bearbeitet.
|
||||||
|
- Neue fachliche Funktionen bekommen eigene atomare Rechte.
|
||||||
|
- Bestehende Firmenschemas müssen bei Backend-Start idempotent nachgezogen
|
||||||
|
werden.
|
||||||
|
- Benutzer sichtbare Texte verwenden echte Umlaute.
|
||||||
|
|
||||||
|
## Phase 1: Fundament Stabilisieren
|
||||||
|
|
||||||
|
Ziel: Login, Firma, Benutzerrechte, Fensterkonzept und Live-Aktualisierung sind
|
||||||
|
verlässlich testbar.
|
||||||
|
|
||||||
|
- [x] PostgreSQL-Grundschema und Firmenschema-Migrationen anlegen
|
||||||
|
- [x] Dev-Bootstrap für lokale Benutzer/Firma ohne E-Mail-Versand
|
||||||
|
- [x] Webclient auf Vue umstellen
|
||||||
|
- [x] Desktopclient-Konfiguration für Backend-URL
|
||||||
|
- [x] Logo in Webclient und Desktopclient verwenden
|
||||||
|
- [x] Benutzerrechte-Fenster im Webclient
|
||||||
|
- [x] Benutzerrechte-Fenster im Desktopclient
|
||||||
|
- [x] Rollenänderungen über Backend-Endpunkt speichern
|
||||||
|
- [x] Atomare Rechte initial anlegen
|
||||||
|
- [x] Bestehende aktive Firmenschemas beim Backend-Start nachziehen
|
||||||
|
- [x] Echte Session-/Auth-Tokens einführen statt temporärer User-ID im Header
|
||||||
|
- [x] Aktuelle Firma explizit auswählen und in Requests mitsenden
|
||||||
|
- [x] Backend-Rechteprüfung für jeden geschützten Endpunkt zentralisieren
|
||||||
|
- [x] Rollen auf Rechte abbilden und nicht nur Rollen anzeigen
|
||||||
|
- [x] Live-Events für Benutzer-/Rollenänderungen an alle Clients senden
|
||||||
|
- [x] Automatisierten API-Test für Rollenänderung ergänzen
|
||||||
|
- [x] Webclient-Build/Typprüfung für Benutzerrechte-Fenster ergänzen
|
||||||
|
- [x] Desktopclient-Build/Typprüfung für Benutzerrechte-Fenster ergänzen
|
||||||
|
|
||||||
|
## Phase 2: Fenster- und Clientmodell
|
||||||
|
|
||||||
|
Ziel: Die Anwendung verhält sich konsequent fensterbasiert und aktualisiert
|
||||||
|
offene Fenster bei Änderungen.
|
||||||
|
|
||||||
|
- [x] Webclient öffnet angemeldete Arbeitsbereiche als Fenster
|
||||||
|
- [x] Desktopclient öffnet Benutzerrechte als Fenster
|
||||||
|
- [x] Gemeinsames Fensterkonzept dokumentieren
|
||||||
|
- [x] Webclient-Fenster verschiebbar machen
|
||||||
|
- [x] Webclient-Fenstergröße änderbar machen
|
||||||
|
- [x] Webclient-Fensterstatus pro Benutzer lokal speichern
|
||||||
|
- [x] Desktopclient-Fenster für Firmendaten ergänzen
|
||||||
|
- [x] Desktopclient-Fenster für Freischaltung ergänzen
|
||||||
|
- [x] Einheitliche Fensteraktionen definieren: Öffnen, Schließen, Fokussieren,
|
||||||
|
Aktualisieren
|
||||||
|
- [x] Live-Update-Store im Webclient für Stammdaten einführen
|
||||||
|
- [x] Live-Update-Store im Desktopclient für Stammdaten einführen
|
||||||
|
- [x] Konfliktverhalten bei paralleler Bearbeitung definieren
|
||||||
|
|
||||||
|
## Phase 3: Stammdaten
|
||||||
|
|
||||||
|
Ziel: Kunden, Lieferanten, Artikel und Aktivitäten sind als erste fachliche
|
||||||
|
Objekte vollständig nutzbar.
|
||||||
|
|
||||||
|
- [x] Datenmodell Kunden finalisieren
|
||||||
|
- [x] Migration Kunden erstellen
|
||||||
|
- [x] Backend-CRUD Kunden implementieren
|
||||||
|
- [x] Web-Fenster Kundenliste und Kundendetail implementieren
|
||||||
|
- [x] Desktop-Fenster Kundenliste und Kundendetail implementieren
|
||||||
|
- [x] Kundenrabatt und Skonto beim Kunden ablegen
|
||||||
|
- [x] Datenmodell Lieferanten finalisieren
|
||||||
|
- [x] Migration Lieferanten erstellen
|
||||||
|
- [x] Backend-CRUD Lieferanten implementieren
|
||||||
|
- [x] Lieferanten-Skonto ablegen
|
||||||
|
- [x] Datenmodell Artikel finalisieren
|
||||||
|
- [x] Migration Artikel erstellen
|
||||||
|
- [x] Backend-CRUD Artikel implementieren
|
||||||
|
- [x] Artikelpreise historisieren
|
||||||
|
- [x] Datenmodell Aktivitäten finalisieren
|
||||||
|
- [x] Migration Aktivitäten erstellen
|
||||||
|
- [x] Backend-CRUD Aktivitäten implementieren
|
||||||
|
- [x] Web-Fenster für Lieferanten, Artikel und Aktivitäten implementieren
|
||||||
|
- [x] Desktop-Fenster für Lieferanten, Artikel und Aktivitäten implementieren
|
||||||
|
- [x] Live-Events für Stammdatenänderungen senden
|
||||||
|
|
||||||
|
## Phase 4: Angebote und Rechnungen
|
||||||
|
|
||||||
|
Ziel: Angebote und Rechnungen bilden den ersten produktiven Arbeitsablauf.
|
||||||
|
|
||||||
|
- [x] Nummernkreise für Angebote und Rechnungen produktionsreif machen
|
||||||
|
- [x] Nummernkreise für Kunden, Lieferanten, Artikel und Aktivitäten anbinden
|
||||||
|
- [x] Nummernkreis-Verwaltung im Webclient und Desktopclient bereitstellen
|
||||||
|
- [x] Datenmodell Angebote finalisieren
|
||||||
|
- [x] Backend-CRUD Angebote implementieren
|
||||||
|
- [x] Angebotspositionen nur aus vorhandenen Artikeln erlauben
|
||||||
|
- [x] Positionspreis pro Angebot individuell überschreibbar machen
|
||||||
|
- [x] Web-Fenster Angebot erstellen
|
||||||
|
- [x] Desktop-Fenster Angebot erstellen
|
||||||
|
- [x] Angebot zu Ausgangsrechnung umwandeln
|
||||||
|
- [x] Datenmodell Ausgangsrechnungen finalisieren
|
||||||
|
- [x] Rechnungspositionen nur aus vorhandenen Artikeln erlauben
|
||||||
|
- [x] Positionspreis pro Rechnung individuell überschreibbar machen
|
||||||
|
- [x] Kundenrabatt und Skonto automatisch vorschlagen
|
||||||
|
- [x] Rechnung revisionssicher abschließen
|
||||||
|
- [x] Storno-/Korrekturrechnung vorbereiten
|
||||||
|
- [x] Datenmodell Eingangsrechnungen finalisieren
|
||||||
|
- [x] Eingangsrechnungen Lieferanten zuordnen
|
||||||
|
- [x] Lieferanten-Skonto berücksichtigen
|
||||||
|
- [x] Web-Fenster Ausgangs- und Eingangsrechnungen erstellen
|
||||||
|
- [x] Desktop-Fenster Ausgangs- und Eingangsrechnungen erstellen
|
||||||
|
|
||||||
|
## Phase 5: Import und Preisaktualisierung
|
||||||
|
|
||||||
|
Ziel: Artikellisten und externe APIs aktualisieren Preise nachvollziehbar.
|
||||||
|
|
||||||
|
- [x] Importformat CSV definieren
|
||||||
|
- [x] Importformat Excel prüfen und als spätere Erweiterung zurückstellen
|
||||||
|
- [x] Importvorschau im Backend vorbereiten
|
||||||
|
- [x] Preislistenimport mit Mapping speichern
|
||||||
|
- [x] Preisänderungen historisieren
|
||||||
|
- [x] Preisregeln je Lieferant/Quelle definieren
|
||||||
|
- [x] API-Connector-Grundstruktur anlegen
|
||||||
|
- [x] Externe Preis-API-Konfiguration verschlüsselt speichern
|
||||||
|
- [x] Manueller Preisabgleich
|
||||||
|
- [x] Geplanter Preisabgleich vorbereiten: Intervall und letzter Abgleich werden gespeichert
|
||||||
|
- [x] Live-Update an offene Angebots-/Rechnungsfenster senden
|
||||||
|
- [x] Native-Client-Fenster für Preislisten, Preis-APIs und Preisregeln anbinden
|
||||||
|
|
||||||
|
## Phase 6: Kommunikation und Dokumente
|
||||||
|
|
||||||
|
Ziel: Kommunikation, Dokumente und Historie werden je Firma verwaltet.
|
||||||
|
|
||||||
|
- [x] Datenmodell Kommunikation finalisieren
|
||||||
|
- [x] Kommunikation Kunden/Lieferanten/Vorgängen/Rechnungen zuordnen
|
||||||
|
- [x] Dokumentenspeicher-Layout festlegen
|
||||||
|
- [x] Dokumenten-Metadaten verschlüsselt speichern
|
||||||
|
- [x] Upload-Endpunkt implementieren
|
||||||
|
- [x] Download-Endpunkt implementieren
|
||||||
|
- [x] Rechteprüfung für Dokumentzugriff
|
||||||
|
- [x] Audit-Log für Dokumentzugriffe
|
||||||
|
- [x] Web-Fenster für Kommunikation und Dokumente anbinden
|
||||||
|
|
||||||
|
## Phase 7: Sicherheit und Betrieb
|
||||||
|
|
||||||
|
Ziel: Öffentlicher Server und lokale Installation sind trennbar und sicher
|
||||||
|
betreibbar.
|
||||||
|
|
||||||
|
- [x] Produktives Authentifizierungskonzept implementieren
|
||||||
|
- [x] Passwort-Reset implementieren
|
||||||
|
- [x] Einladung mit sicherem Token statt Passwortanzeige implementieren
|
||||||
|
- [x] E-Mail-Outbox und produktiven Datei-Transport anbinden
|
||||||
|
- [x] Dev-Ausgabe für E-Mail-Inhalte klar vom Produktivbetrieb trennen
|
||||||
|
- [x] Mandantenschema-Erzeugung transaktional absichern
|
||||||
|
- [x] Verschlüsselungsschlüssel-Konzept für Betrieb dokumentieren
|
||||||
|
- [x] Schlüsselrotation planen
|
||||||
|
- [x] Backup/Restore je Firma dokumentieren
|
||||||
|
- [x] Docker-Setup für Backend erweitern
|
||||||
|
- [x] Reverse-Proxy/TLS-Beispiel bereitstellen
|
||||||
|
- [x] Installationsprogramm für lokale Einzelkunden-Version planen
|
||||||
|
|
||||||
|
## Phase 8: Qualitätssicherung
|
||||||
|
|
||||||
|
Ziel: Kernabläufe sind reproduzierbar testbar.
|
||||||
|
|
||||||
|
- [x] API-Onboarding-Test erweitern: Registrierung, Freischaltung, Login,
|
||||||
|
Rechteänderung
|
||||||
|
- [x] Kommunikationstest um Live-Events für fachliche Daten erweitern
|
||||||
|
- [x] Migrationstest für bestehende Firmenschemas
|
||||||
|
- [x] Rechteprüfung negativ testen
|
||||||
|
- [x] Webclient-Build und Typprüfung in Standardcheck aufnehmen
|
||||||
|
- [x] Desktopclient-Headless-Tests erweitern
|
||||||
|
- [x] Datenbank-Testsetup dokumentieren
|
||||||
|
- [x] Testdaten-Seed für lokale Entwicklung anlegen
|
||||||
|
|
||||||
|
## Aktueller Nächster Schritt
|
||||||
|
|
||||||
|
1. Optimierungen und Fehlerbehebungen priorisieren.
|
||||||
|
2. Benutzereinstellungen für die Navigation sind umgesetzt: scrollbar,
|
||||||
|
oder einklappbare Gruppen je Benutzer.
|
||||||
395
INSTALL.md
Normal file
395
INSTALL.md
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
Diese Anleitung beschreibt die Installation einer lokalen Company-Tool-Instanz
|
||||||
|
mit PostgreSQL und Backend. Sie ist so aufgebaut, dass sie später als Grundlage
|
||||||
|
für Installationen bei Organisationen verwendet werden kann.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
Für eine lokale Installation werden benötigt:
|
||||||
|
|
||||||
|
- PostgreSQL 16 oder neuer
|
||||||
|
- Rust/Cargo
|
||||||
|
- Node.js 24 oder neuer, für Webfrontend und Kommunikationstests
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- Docker oder Podman, wenn PostgreSQL als Container laufen soll
|
||||||
|
|
||||||
|
## PostgreSQL per Docker Compose
|
||||||
|
|
||||||
|
Im Projekt liegt eine vorbereitete PostgreSQL-Konfiguration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Standardwerte sind:
|
||||||
|
|
||||||
|
- Host: `localhost`
|
||||||
|
- Port: `5432`
|
||||||
|
- Datenbank: `companytool`
|
||||||
|
- Benutzer: `companytool`
|
||||||
|
- Passwort: `companytool`
|
||||||
|
|
||||||
|
Danach `.env` anlegen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Verbindung prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "postgres://companytool:companytool@localhost:5432/companytool" -c "select 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## PostgreSQL manuell anlegen
|
||||||
|
|
||||||
|
Falls PostgreSQL bereits auf einem Server installiert ist, kann die Datenbank
|
||||||
|
manuell angelegt werden.
|
||||||
|
|
||||||
|
Als PostgreSQL-Admin ausführen:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create user companytool with password 'companytool';
|
||||||
|
create database companytool owner companytool;
|
||||||
|
```
|
||||||
|
|
||||||
|
Empfohlene produktive Anpassungen:
|
||||||
|
|
||||||
|
- eigenes starkes Passwort verwenden
|
||||||
|
- Datenbank nur aus dem internen Netz erreichbar machen
|
||||||
|
- PostgreSQL-Backups einrichten
|
||||||
|
- Zugriff über Firewall einschränken
|
||||||
|
|
||||||
|
Beispiel mit sicherem Passwort:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create user companytool with password '<sicheres-passwort>';
|
||||||
|
create database companytool owner companytool;
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann `.env` anpassen:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgres://companytool:<sicheres-passwort>@localhost:5432/companytool
|
||||||
|
BACKEND_BIND=127.0.0.1:8080
|
||||||
|
VITE_WS_URL=ws://localhost:8080/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim Start führt das Backend aktuell die Basistabelle für den Testdatensatz
|
||||||
|
automatisch aus und legt bei Bedarf einen ersten Datensatz an. Die SQLx-
|
||||||
|
Migrationen unter `backend/migrations/` werden beim Backend-Start automatisch
|
||||||
|
ausgeführt.
|
||||||
|
|
||||||
|
Health-Check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartete Antwort:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verschlüsselte Kommunikation testen
|
||||||
|
|
||||||
|
Backend in einem Terminal starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
In einem zweiten Terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/communication-test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Test prüft:
|
||||||
|
|
||||||
|
- zwei echte WebSocket-Clients
|
||||||
|
- `hello`/`hello_ack`-Handshake
|
||||||
|
- eigener AES-256-GCM-Session-Key pro Client
|
||||||
|
- verschlüsselter Snapshot vom Backend
|
||||||
|
- verschlüsselte `subscribe`- und `ping`-Nachrichten
|
||||||
|
- verschlüsseltes `pong`-Event an beide Clients
|
||||||
|
- keine fachlichen Klartextmarker in den Rohframes
|
||||||
|
|
||||||
|
Erwartetes Ergebnis:
|
||||||
|
|
||||||
|
```text
|
||||||
|
client-a handshake, encrypted snapshot and subscribe ok
|
||||||
|
client-b handshake, encrypted snapshot and subscribe ok
|
||||||
|
encrypted multi-client communication test ok
|
||||||
|
```
|
||||||
|
|
||||||
|
## Onboarding-API testen
|
||||||
|
|
||||||
|
Mit laufendem Backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/api-onboarding-test.mjs http://127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Test legt eine Organization-Registrierung an, lädt Liste und Detail,
|
||||||
|
schaltet die Organization frei, testet den Login mit Initialpasswort, speichert
|
||||||
|
Firmendaten, prüft Rechte-Negativfälle, lädt einen weiteren User ein und prüft
|
||||||
|
die Rechteänderung.
|
||||||
|
|
||||||
|
## Schema-Migration testen
|
||||||
|
|
||||||
|
Mit laufendem Backend und PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/schema-migration-test.mjs http://127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Test legt eine Firma an, führt die Mandantenschema-Provisionierung erneut
|
||||||
|
aus und prüft, dass Nummernkreise, Rollen und spätere Tabellen wie
|
||||||
|
Kommunikation weiterhin funktionieren. Dadurch werden idempotente
|
||||||
|
Mandantenmigrationen früh erkannt.
|
||||||
|
|
||||||
|
## Live-Events testen
|
||||||
|
|
||||||
|
Mit laufendem Backend und PostgreSQL kann der Kommunikationstest zusätzlich
|
||||||
|
eine fachliche Änderung über die REST-API auslösen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/communication-test.mjs ws://127.0.0.1:8080/ws http://127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Test prüft dann neben Handshake, Verschlüsselung und Ping/Pong auch, dass
|
||||||
|
eine neu angelegte Aktivität als verschlüsseltes `record_changed`-Event bei
|
||||||
|
zwei verbundenen Clients ankommt.
|
||||||
|
|
||||||
|
## Standardcheck
|
||||||
|
|
||||||
|
Der Standardcheck bündelt die schnellen Prüfungen ohne laufendes Backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/standard-check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Enthalten sind:
|
||||||
|
|
||||||
|
- Rust-Formatprüfung
|
||||||
|
- `cargo check --workspace`
|
||||||
|
- Headless-Unit-Tests des Desktopclients
|
||||||
|
- Syntaxprüfung der Node-Testskripte
|
||||||
|
- Webfrontend-Build inklusive Vue/TypeScript-Prüfung
|
||||||
|
|
||||||
|
## Lokale Testdaten anlegen
|
||||||
|
|
||||||
|
Mit laufendem Backend kann ein reproduzierbarer Entwicklungsdatensatz erzeugt
|
||||||
|
werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/dev-seed.mjs http://127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript nutzt den Dev-Bootstrap, legt eine Firma, einen Admin-Zugang,
|
||||||
|
Skonto, Kunde, Lieferant, Artikel und Aktivität an und gibt die Zugangsdaten
|
||||||
|
als JSON aus. Der Endpunkt ist nur im Entwicklungsbetrieb vorgesehen.
|
||||||
|
|
||||||
|
## Kommunikation ohne PostgreSQL testen
|
||||||
|
|
||||||
|
Falls PostgreSQL noch nicht eingerichtet ist, kann nur die
|
||||||
|
Kommunikationsschicht getestet werden.
|
||||||
|
|
||||||
|
Terminal 1:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMMUNICATION_TEST_MODE=1 cargo run -p companytool-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal 2:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/communication-test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieser Modus ist nur für Entwicklung und Tests gedacht.
|
||||||
|
|
||||||
|
## Webfrontend starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web-frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Standard-URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5175/
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Entwicklungsbetrieb leitet Vite `/api` und `/ws` standardmäßig an
|
||||||
|
`http://127.0.0.1:8080` weiter. Wenn das Backend auf einem anderen Port läuft,
|
||||||
|
wird die Zieladresse so gesetzt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_BACKEND_ORIGIN=http://127.0.0.1:18084 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lokalen Testzugang ohne E-Mail anlegen
|
||||||
|
|
||||||
|
Im Entwicklungsbuild ist auf der Login-Seite ein Dev-Bootstrap verfügbar. Damit
|
||||||
|
wird eine lokale Testfirma mit erstem User angelegt. Das Initialpasswort wird
|
||||||
|
direkt in der Oberfläche angezeigt, weil auf Entwicklungsrechnern kein
|
||||||
|
E-Mail-Versand vorausgesetzt wird.
|
||||||
|
|
||||||
|
Alternativ per API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8080/api/v1/dev/bootstrap-local \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"organization_name":"Lokale Testfirma","email":"admin@example.test"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Produktiv ist dieser Endpunkt nicht vorgesehen. Er ist nur im Debug-Build oder
|
||||||
|
mit `COMPANYTOOL_DEV_MODE=1` aktiv.
|
||||||
|
|
||||||
|
## Desktopclient starten
|
||||||
|
|
||||||
|
Der native Client liest seine Server-Konfiguration aus:
|
||||||
|
|
||||||
|
```text
|
||||||
|
desktop-client/companytool-client.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimaler Inhalt:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
api_base_url = "http://localhost:8080"
|
||||||
|
ws_url = "ws://localhost:8080/ws"
|
||||||
|
```
|
||||||
|
|
||||||
|
API- und WebSocket-URL können überschrieben werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-desktop-client -- --api-url http://127.0.0.1:8080 --ws-url ws://127.0.0.1:8080/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder mit expliziter Config-Datei:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-desktop-client -- --config /pfad/companytool-client.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ per Umgebungsvariablen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMPANYTOOL_CLIENT_CONFIG=/pfad/companytool-client.toml cargo run -p companytool-desktop-client
|
||||||
|
COMPANYTOOL_API_BASE_URL=http://127.0.0.1:8080 cargo run -p companytool-desktop-client
|
||||||
|
COMPANYTOOL_WS_URL=ws://127.0.0.1:8080/ws cargo run -p companytool-desktop-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Start mit Standardkonfiguration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-desktop-client
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nativen Client testen
|
||||||
|
|
||||||
|
Der native Client hat Headless-Tests. Dabei wird kein Fenster geöffnet.
|
||||||
|
|
||||||
|
### Kommunikation
|
||||||
|
|
||||||
|
Backend starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMMUNICATION_TEST_MODE=1 cargo run -p companytool-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Test ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-desktop-client -- --communication-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional gegen eine andere WebSocket-URL oder Config-Datei:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-desktop-client -- --communication-test --ws-url ws://127.0.0.1:18081/ws
|
||||||
|
cargo run -p companytool-desktop-client -- --communication-test --config /pfad/companytool-client.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartetes Ergebnis:
|
||||||
|
|
||||||
|
```text
|
||||||
|
native client communication test ok
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registrierung
|
||||||
|
|
||||||
|
Für den Registrierungstest muss das Backend mit PostgreSQL laufen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Test ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-desktop-client -- --registration-test --api-url http://127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional können Firmenname und E-Mail gesetzt werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-desktop-client -- --registration-test --api-url http://127.0.0.1:8080 --organization-name "Beispiel GmbH" --email admin@example.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartetes Ergebnis:
|
||||||
|
|
||||||
|
```text
|
||||||
|
native client registration test ok
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hinweise für Organisations-Installationen
|
||||||
|
|
||||||
|
Für Installationen bei Organisationen gelten zusätzlich:
|
||||||
|
|
||||||
|
- produktiv nur HTTPS/WSS verwenden
|
||||||
|
- PostgreSQL-Passwort individuell vergeben
|
||||||
|
- `.env` nicht an Dritte weitergeben
|
||||||
|
- regelmäßige Datenbank-Backups einrichten
|
||||||
|
- Zugriff auf PostgreSQL nicht öffentlich freigeben
|
||||||
|
- Backend hinter Reverse Proxy betreiben
|
||||||
|
- Zertifikate für lokale oder öffentliche Nutzung sauber einrichten
|
||||||
|
- spätere Versionen werden genau eine Organisation pro lokaler Installation
|
||||||
|
unterstützen
|
||||||
|
|
||||||
|
## Fehlerbehebung
|
||||||
|
|
||||||
|
PostgreSQL nicht erreichbar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_isready -h localhost -p 5432
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend kann Datenbank nicht öffnen:
|
||||||
|
|
||||||
|
- `DATABASE_URL` in `.env` prüfen
|
||||||
|
- PostgreSQL-Dienst prüfen
|
||||||
|
- Benutzer, Passwort und Datenbanknamen prüfen
|
||||||
|
|
||||||
|
Port bereits belegt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BACKEND_BIND=127.0.0.1:18080 cargo run -p companytool-backend
|
||||||
|
node scripts/communication-test.mjs ws://127.0.0.1:18080/ws
|
||||||
|
```
|
||||||
2334
PLANUNG.md
Normal file
2334
PLANUNG.md
Normal file
File diff suppressed because it is too large
Load Diff
76
README.md
Normal file
76
README.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Company Tool
|
||||||
|
|
||||||
|
Mehrprojekt-Struktur für eine firmeninterne Software mit Rust-Backend, PostgreSQL,
|
||||||
|
Webfrontend und klassischem Desktopclient.
|
||||||
|
|
||||||
|
## Projekte
|
||||||
|
|
||||||
|
- `backend/`: Rust-Backend mit PostgreSQL-Anbindung und WebSocket-Kommunikation
|
||||||
|
- `web-frontend/`: Browserbasiertes Frontend mit Vue 3, Vite und TypeScript
|
||||||
|
- `desktop-client/`: Nativer Client für Linux, Windows und macOS mit egui/eframe
|
||||||
|
- `shared-protocol/`: Gemeinsame Rust-Typen für Socket-Nachrichten
|
||||||
|
|
||||||
|
## Kommunikation
|
||||||
|
|
||||||
|
Das Backend stellt einen WebSocket unter `ws://localhost:8080/ws` bereit. Die
|
||||||
|
Verbindung beginnt mit einem `hello`-Handshake. Danach werden fachliche
|
||||||
|
Nachrichten als AES-256-GCM-verschlüsselte Envelopes übertragen.
|
||||||
|
|
||||||
|
## PostgreSQL starten
|
||||||
|
|
||||||
|
Ausführliche Installationsschritte stehen in [INSTALL.md](INSTALL.md).
|
||||||
|
Betriebsnotizen zu Schlüsseln, E-Mail, Backup und TLS stehen in
|
||||||
|
[BETRIEB.md](BETRIEB.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kommunikation testen
|
||||||
|
|
||||||
|
In einem Terminal Backend starten, dann in einem zweiten Terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMMUNICATION_TEST_MODE=1 cargo run -p companytool-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/communication-test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional gegen eine andere URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/communication-test.mjs ws://localhost:8080/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Test öffnet zwei Clients, führt den verschlüsselten Handshake aus,
|
||||||
|
entschlüsselt Snapshots, sendet eine verschlüsselte Ping-Nachricht und prüft, ob
|
||||||
|
beide Clients ein verschlüsseltes Pong-Event erhalten.
|
||||||
|
|
||||||
|
## Webfrontend starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web-frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Desktopclient starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-desktop-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit explizitem Backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p companytool-desktop-client -- --api-url http://127.0.0.1:8080 --ws-url ws://127.0.0.1:8080/ws
|
||||||
|
```
|
||||||
25
backend/Cargo.toml
Normal file
25
backend/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "companytool-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
argon2 = "0.5"
|
||||||
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
companytool-shared-protocol = { path = "../shared-protocol" }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
futures-util = "0.3"
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
13
backend/Dockerfile
Normal file
13
backend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM rust:1-bookworm AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release -p companytool-backend
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/target/release/companytool-backend /usr/local/bin/companytool-backend
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["companytool-backend"]
|
||||||
80
backend/company-migrations/0001_company_base.sql
Normal file
80
backend/company-migrations/0001_company_base.sql
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
-- Template migration for each organization schema.
|
||||||
|
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||||
|
|
||||||
|
create table if not exists {schema}.settings (
|
||||||
|
key text primary key,
|
||||||
|
value_ciphertext bytea not null,
|
||||||
|
value_nonce bytea not null,
|
||||||
|
value_key_id text not null,
|
||||||
|
updated_by_user_id uuid,
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.roles (
|
||||||
|
id uuid primary key,
|
||||||
|
code text not null unique,
|
||||||
|
name text not null,
|
||||||
|
description text,
|
||||||
|
is_system_role boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.permissions (
|
||||||
|
id uuid primary key,
|
||||||
|
code text not null unique,
|
||||||
|
description text
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.role_permissions (
|
||||||
|
role_id uuid not null references {schema}.roles(id) on delete cascade,
|
||||||
|
permission_id uuid not null references {schema}.permissions(id) on delete cascade,
|
||||||
|
primary key (role_id, permission_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.user_roles (
|
||||||
|
user_id uuid not null,
|
||||||
|
role_id uuid not null references {schema}.roles(id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
primary key (user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.number_ranges (
|
||||||
|
id uuid primary key,
|
||||||
|
code text not null unique,
|
||||||
|
pattern text not null,
|
||||||
|
counter_value bigint not null default 0,
|
||||||
|
counter_padding integer not null default 0,
|
||||||
|
reset_rule text,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint number_ranges_pattern_has_counter check (position('{counter}' in pattern) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.audit_log (
|
||||||
|
id uuid primary key,
|
||||||
|
actor_user_id uuid,
|
||||||
|
action text not null,
|
||||||
|
entity_type text not null,
|
||||||
|
entity_id uuid,
|
||||||
|
before_ciphertext bytea,
|
||||||
|
before_nonce bytea,
|
||||||
|
before_key_id text,
|
||||||
|
after_ciphertext bytea,
|
||||||
|
after_nonce bytea,
|
||||||
|
after_key_id text,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.change_log (
|
||||||
|
id uuid primary key,
|
||||||
|
sequence bigserial not null unique,
|
||||||
|
entity_type text not null,
|
||||||
|
entity_id uuid not null,
|
||||||
|
operation text not null,
|
||||||
|
payload_ciphertext bytea not null,
|
||||||
|
payload_nonce bytea not null,
|
||||||
|
payload_key_id text not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
created_by_user_id uuid
|
||||||
|
);
|
||||||
360
backend/company-migrations/0002_activity_price_invoice_rules.sql
Normal file
360
backend/company-migrations/0002_activity_price_invoice_rules.sql
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
-- Template migration for each organization schema.
|
||||||
|
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||||
|
|
||||||
|
create table if not exists {schema}.customers (
|
||||||
|
id uuid primary key,
|
||||||
|
customer_number text unique,
|
||||||
|
name_ciphertext bytea not null,
|
||||||
|
name_nonce bytea not null,
|
||||||
|
name_key_id text not null,
|
||||||
|
status text not null default 'active',
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint customers_status_valid check (status in ('active', 'inactive', 'blocked'))
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.suppliers (
|
||||||
|
id uuid primary key,
|
||||||
|
supplier_number text unique,
|
||||||
|
name_ciphertext bytea not null,
|
||||||
|
name_nonce bytea not null,
|
||||||
|
name_key_id text not null,
|
||||||
|
status text not null default 'active',
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint suppliers_status_valid check (status in ('active', 'inactive', 'blocked'))
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.items (
|
||||||
|
id uuid primary key,
|
||||||
|
item_number text not null unique,
|
||||||
|
name_ciphertext bytea not null,
|
||||||
|
name_nonce bytea not null,
|
||||||
|
name_key_id text not null,
|
||||||
|
unit text not null default 'Stk',
|
||||||
|
tax_rate numeric(7, 4) not null default 19.0,
|
||||||
|
default_purchase_price numeric(14, 4),
|
||||||
|
default_sales_price numeric(14, 4),
|
||||||
|
status text not null default 'active',
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint items_status_valid check (status in ('active', 'inactive', 'blocked')),
|
||||||
|
constraint items_tax_rate_non_negative check (tax_rate >= 0),
|
||||||
|
constraint items_default_purchase_price_non_negative check (
|
||||||
|
default_purchase_price is null or default_purchase_price >= 0
|
||||||
|
),
|
||||||
|
constraint items_default_sales_price_non_negative check (
|
||||||
|
default_sales_price is null or default_sales_price >= 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.cash_discount_terms (
|
||||||
|
id uuid primary key,
|
||||||
|
code text not null unique,
|
||||||
|
name text not null,
|
||||||
|
discount_percent numeric(7, 4) not null,
|
||||||
|
discount_days integer not null,
|
||||||
|
net_days integer,
|
||||||
|
valid_from date,
|
||||||
|
valid_until date,
|
||||||
|
is_default_customer_term boolean not null default false,
|
||||||
|
is_default_supplier_term boolean not null default false,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint cash_discount_terms_percent_valid check (
|
||||||
|
discount_percent >= 0 and discount_percent <= 100
|
||||||
|
),
|
||||||
|
constraint cash_discount_terms_days_valid check (
|
||||||
|
discount_days >= 0 and (net_days is null or net_days >= discount_days)
|
||||||
|
),
|
||||||
|
constraint cash_discount_terms_valid_range check (
|
||||||
|
valid_until is null or valid_from is null or valid_until >= valid_from
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists idx_cash_discount_terms_default_customer
|
||||||
|
on {schema}.cash_discount_terms (is_default_customer_term)
|
||||||
|
where is_default_customer_term;
|
||||||
|
|
||||||
|
create unique index if not exists idx_cash_discount_terms_default_supplier
|
||||||
|
on {schema}.cash_discount_terms (is_default_supplier_term)
|
||||||
|
where is_default_supplier_term;
|
||||||
|
|
||||||
|
create table if not exists {schema}.customer_price_terms (
|
||||||
|
id uuid primary key,
|
||||||
|
customer_id uuid not null references {schema}.customers(id) on delete cascade,
|
||||||
|
standard_discount_percent numeric(7, 4) not null default 0,
|
||||||
|
cash_discount_term_id uuid references {schema}.cash_discount_terms(id),
|
||||||
|
valid_from date,
|
||||||
|
valid_until date,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint customer_price_terms_discount_valid check (
|
||||||
|
standard_discount_percent >= 0 and standard_discount_percent <= 100
|
||||||
|
),
|
||||||
|
constraint customer_price_terms_valid_range check (
|
||||||
|
valid_until is null or valid_from is null or valid_until >= valid_from
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_customer_price_terms_customer_active
|
||||||
|
on {schema}.customer_price_terms (customer_id, is_active, valid_from, valid_until);
|
||||||
|
|
||||||
|
create table if not exists {schema}.supplier_price_terms (
|
||||||
|
id uuid primary key,
|
||||||
|
supplier_id uuid not null references {schema}.suppliers(id) on delete cascade,
|
||||||
|
standard_discount_percent numeric(7, 4) not null default 0,
|
||||||
|
cash_discount_term_id uuid references {schema}.cash_discount_terms(id),
|
||||||
|
payment_days integer,
|
||||||
|
valid_from date,
|
||||||
|
valid_until date,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint supplier_price_terms_discount_valid check (
|
||||||
|
standard_discount_percent >= 0 and standard_discount_percent <= 100
|
||||||
|
),
|
||||||
|
constraint supplier_price_terms_payment_days_valid check (
|
||||||
|
payment_days is null or payment_days >= 0
|
||||||
|
),
|
||||||
|
constraint supplier_price_terms_valid_range check (
|
||||||
|
valid_until is null or valid_from is null or valid_until >= valid_from
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_supplier_price_terms_supplier_active
|
||||||
|
on {schema}.supplier_price_terms (supplier_id, is_active, valid_from, valid_until);
|
||||||
|
|
||||||
|
create table if not exists {schema}.activities (
|
||||||
|
id uuid primary key,
|
||||||
|
activity_type text not null,
|
||||||
|
title_ciphertext bytea not null,
|
||||||
|
title_nonce bytea not null,
|
||||||
|
title_key_id text not null,
|
||||||
|
body_ciphertext bytea,
|
||||||
|
body_nonce bytea,
|
||||||
|
body_key_id text,
|
||||||
|
status text not null default 'open',
|
||||||
|
priority text not null default 'normal',
|
||||||
|
due_at timestamptz,
|
||||||
|
starts_at timestamptz,
|
||||||
|
ends_at timestamptz,
|
||||||
|
assigned_to_user_id uuid,
|
||||||
|
created_by_user_id uuid,
|
||||||
|
completed_by_user_id uuid,
|
||||||
|
completed_at timestamptz,
|
||||||
|
visibility text not null default 'internal',
|
||||||
|
system_source text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint activities_type_valid check (
|
||||||
|
activity_type in (
|
||||||
|
'email_note',
|
||||||
|
'phone_note',
|
||||||
|
'internal_note',
|
||||||
|
'task',
|
||||||
|
'follow_up',
|
||||||
|
'calendar_event',
|
||||||
|
'system_event',
|
||||||
|
'work_step'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
constraint activities_status_valid check (
|
||||||
|
status in ('open', 'in_progress', 'done', 'cancelled')
|
||||||
|
),
|
||||||
|
constraint activities_priority_valid check (
|
||||||
|
priority in ('low', 'normal', 'high', 'critical')
|
||||||
|
),
|
||||||
|
constraint activities_visibility_valid check (
|
||||||
|
visibility in ('internal', 'organization')
|
||||||
|
),
|
||||||
|
constraint activities_body_encryption_complete check (
|
||||||
|
(
|
||||||
|
body_ciphertext is null
|
||||||
|
and body_nonce is null
|
||||||
|
and body_key_id is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
body_ciphertext is not null
|
||||||
|
and body_nonce is not null
|
||||||
|
and body_key_id is not null
|
||||||
|
)
|
||||||
|
),
|
||||||
|
constraint activities_time_range_valid check (
|
||||||
|
ends_at is null or starts_at is null or ends_at >= starts_at
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_activities_status_due
|
||||||
|
on {schema}.activities (status, due_at);
|
||||||
|
|
||||||
|
create index if not exists idx_activities_assigned_status
|
||||||
|
on {schema}.activities (assigned_to_user_id, status);
|
||||||
|
|
||||||
|
create table if not exists {schema}.activity_links (
|
||||||
|
activity_id uuid not null references {schema}.activities(id) on delete cascade,
|
||||||
|
entity_type text not null,
|
||||||
|
entity_id uuid not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
primary key (activity_id, entity_type, entity_id),
|
||||||
|
constraint activity_links_entity_type_valid check (
|
||||||
|
entity_type in (
|
||||||
|
'customer',
|
||||||
|
'supplier',
|
||||||
|
'contact',
|
||||||
|
'quote',
|
||||||
|
'outgoing_invoice',
|
||||||
|
'incoming_invoice',
|
||||||
|
'item',
|
||||||
|
'document',
|
||||||
|
'import'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_activity_links_entity
|
||||||
|
on {schema}.activity_links (entity_type, entity_id);
|
||||||
|
|
||||||
|
create table if not exists {schema}.outgoing_invoices (
|
||||||
|
id uuid primary key,
|
||||||
|
invoice_number text unique,
|
||||||
|
customer_id uuid not null references {schema}.customers(id),
|
||||||
|
status text not null default 'draft',
|
||||||
|
cash_discount_term_id uuid references {schema}.cash_discount_terms(id),
|
||||||
|
issued_at date,
|
||||||
|
due_at date,
|
||||||
|
created_by_user_id uuid,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
finalized_at timestamptz,
|
||||||
|
constraint outgoing_invoices_status_valid check (
|
||||||
|
status in ('draft', 'finalized', 'sent', 'paid', 'cancelled', 'overdue')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_outgoing_invoices_customer_status
|
||||||
|
on {schema}.outgoing_invoices (customer_id, status);
|
||||||
|
|
||||||
|
create table if not exists {schema}.outgoing_invoice_items (
|
||||||
|
id uuid primary key,
|
||||||
|
invoice_id uuid not null references {schema}.outgoing_invoices(id) on delete cascade,
|
||||||
|
line_number integer not null,
|
||||||
|
item_id uuid not null references {schema}.items(id),
|
||||||
|
description_ciphertext bytea,
|
||||||
|
description_nonce bytea,
|
||||||
|
description_key_id text,
|
||||||
|
quantity numeric(14, 4) not null,
|
||||||
|
unit_price numeric(14, 4) not null,
|
||||||
|
original_unit_price numeric(14, 4),
|
||||||
|
discount_percent numeric(7, 4) not null default 0,
|
||||||
|
price_overridden boolean not null default false,
|
||||||
|
price_override_reason_ciphertext bytea,
|
||||||
|
price_override_reason_nonce bytea,
|
||||||
|
price_override_reason_key_id text,
|
||||||
|
price_overridden_by_user_id uuid,
|
||||||
|
price_overridden_at timestamptz,
|
||||||
|
tax_rate numeric(7, 4) not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (invoice_id, line_number),
|
||||||
|
constraint outgoing_invoice_items_quantity_positive check (quantity > 0),
|
||||||
|
constraint outgoing_invoice_items_unit_price_non_negative check (unit_price >= 0),
|
||||||
|
constraint outgoing_invoice_items_original_price_non_negative check (
|
||||||
|
original_unit_price is null or original_unit_price >= 0
|
||||||
|
),
|
||||||
|
constraint outgoing_invoice_items_discount_valid check (
|
||||||
|
discount_percent >= 0 and discount_percent <= 100
|
||||||
|
),
|
||||||
|
constraint outgoing_invoice_items_tax_rate_non_negative check (tax_rate >= 0),
|
||||||
|
constraint outgoing_invoice_items_description_encryption_complete check (
|
||||||
|
(
|
||||||
|
description_ciphertext is null
|
||||||
|
and description_nonce is null
|
||||||
|
and description_key_id is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
description_ciphertext is not null
|
||||||
|
and description_nonce is not null
|
||||||
|
and description_key_id is not null
|
||||||
|
)
|
||||||
|
),
|
||||||
|
constraint outgoing_invoice_items_override_reason_encryption_complete check (
|
||||||
|
(
|
||||||
|
price_override_reason_ciphertext is null
|
||||||
|
and price_override_reason_nonce is null
|
||||||
|
and price_override_reason_key_id is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
price_override_reason_ciphertext is not null
|
||||||
|
and price_override_reason_nonce is not null
|
||||||
|
and price_override_reason_key_id is not null
|
||||||
|
)
|
||||||
|
),
|
||||||
|
constraint outgoing_invoice_items_override_complete check (
|
||||||
|
(
|
||||||
|
price_overridden = false
|
||||||
|
and price_overridden_by_user_id is null
|
||||||
|
and price_overridden_at is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
price_overridden = true
|
||||||
|
and price_overridden_by_user_id is not null
|
||||||
|
and price_overridden_at is not null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_outgoing_invoice_items_item
|
||||||
|
on {schema}.outgoing_invoice_items (item_id);
|
||||||
|
|
||||||
|
create table if not exists {schema}.incoming_invoices (
|
||||||
|
id uuid primary key,
|
||||||
|
invoice_number text,
|
||||||
|
supplier_id uuid not null references {schema}.suppliers(id),
|
||||||
|
status text not null default 'draft',
|
||||||
|
cash_discount_term_id uuid references {schema}.cash_discount_terms(id),
|
||||||
|
invoice_date date,
|
||||||
|
due_at date,
|
||||||
|
created_by_user_id uuid,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint incoming_invoices_status_valid check (
|
||||||
|
status in ('draft', 'received', 'approved', 'paid', 'cancelled', 'overdue')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_incoming_invoices_supplier_status
|
||||||
|
on {schema}.incoming_invoices (supplier_id, status);
|
||||||
|
|
||||||
|
create table if not exists {schema}.incoming_invoice_items (
|
||||||
|
id uuid primary key,
|
||||||
|
invoice_id uuid not null references {schema}.incoming_invoices(id) on delete cascade,
|
||||||
|
line_number integer not null,
|
||||||
|
item_id uuid references {schema}.items(id),
|
||||||
|
description_ciphertext bytea,
|
||||||
|
description_nonce bytea,
|
||||||
|
description_key_id text,
|
||||||
|
quantity numeric(14, 4) not null,
|
||||||
|
unit_price numeric(14, 4) not null,
|
||||||
|
tax_rate numeric(7, 4) not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (invoice_id, line_number),
|
||||||
|
constraint incoming_invoice_items_quantity_positive check (quantity > 0),
|
||||||
|
constraint incoming_invoice_items_unit_price_non_negative check (unit_price >= 0),
|
||||||
|
constraint incoming_invoice_items_tax_rate_non_negative check (tax_rate >= 0),
|
||||||
|
constraint incoming_invoice_items_description_encryption_complete check (
|
||||||
|
(
|
||||||
|
description_ciphertext is null
|
||||||
|
and description_nonce is null
|
||||||
|
and description_key_id is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
description_ciphertext is not null
|
||||||
|
and description_nonce is not null
|
||||||
|
and description_key_id is not null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
45
backend/company-migrations/0003_customer_details.sql
Normal file
45
backend/company-migrations/0003_customer_details.sql
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
-- Additional encrypted customer fields for organization schemas.
|
||||||
|
|
||||||
|
alter table {schema}.customers
|
||||||
|
add column if not exists details_ciphertext bytea,
|
||||||
|
add column if not exists details_nonce bytea,
|
||||||
|
add column if not exists details_key_id text;
|
||||||
|
|
||||||
|
alter table {schema}.customers
|
||||||
|
drop constraint if exists customers_details_encryption_complete;
|
||||||
|
|
||||||
|
alter table {schema}.customers
|
||||||
|
add constraint customers_details_encryption_complete check (
|
||||||
|
(
|
||||||
|
details_ciphertext is null
|
||||||
|
and details_nonce is null
|
||||||
|
and details_key_id is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
details_ciphertext is not null
|
||||||
|
and details_nonce is not null
|
||||||
|
and details_key_id is not null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table {schema}.suppliers
|
||||||
|
add column if not exists details_ciphertext bytea,
|
||||||
|
add column if not exists details_nonce bytea,
|
||||||
|
add column if not exists details_key_id text;
|
||||||
|
|
||||||
|
alter table {schema}.suppliers
|
||||||
|
drop constraint if exists suppliers_details_encryption_complete;
|
||||||
|
|
||||||
|
alter table {schema}.suppliers
|
||||||
|
add constraint suppliers_details_encryption_complete check (
|
||||||
|
(
|
||||||
|
details_ciphertext is null
|
||||||
|
and details_nonce is null
|
||||||
|
and details_key_id is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
details_ciphertext is not null
|
||||||
|
and details_nonce is not null
|
||||||
|
and details_key_id is not null
|
||||||
|
)
|
||||||
|
);
|
||||||
22
backend/company-migrations/0004_item_price_history.sql
Normal file
22
backend/company-migrations/0004_item_price_history.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- Template migration for each organization schema.
|
||||||
|
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||||
|
|
||||||
|
create table if not exists {schema}.item_price_history (
|
||||||
|
id uuid primary key,
|
||||||
|
item_id uuid not null references {schema}.items(id) on delete cascade,
|
||||||
|
purchase_price numeric(14, 4),
|
||||||
|
sales_price numeric(14, 4),
|
||||||
|
source text not null default 'manual',
|
||||||
|
valid_from timestamptz not null default now(),
|
||||||
|
created_by_user_id uuid,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint item_price_history_purchase_price_non_negative check (
|
||||||
|
purchase_price is null or purchase_price >= 0
|
||||||
|
),
|
||||||
|
constraint item_price_history_sales_price_non_negative check (
|
||||||
|
sales_price is null or sales_price >= 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_item_price_history_item_valid_from
|
||||||
|
on {schema}.item_price_history (item_id, valid_from desc);
|
||||||
9
backend/company-migrations/0005_numbered_activities.sql
Normal file
9
backend/company-migrations/0005_numbered_activities.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Template migration for each organization schema.
|
||||||
|
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||||
|
|
||||||
|
alter table {schema}.activities
|
||||||
|
add column if not exists activity_number text;
|
||||||
|
|
||||||
|
create unique index if not exists idx_activities_activity_number
|
||||||
|
on {schema}.activities (activity_number)
|
||||||
|
where activity_number is not null;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Template migration for each organization schema.
|
||||||
|
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||||
|
|
||||||
|
update {schema}.number_ranges
|
||||||
|
set pattern = 'KU{counter}', updated_at = now()
|
||||||
|
where code = 'customers';
|
||||||
|
|
||||||
|
update {schema}.number_ranges
|
||||||
|
set pattern = 'LI{counter}', updated_at = now()
|
||||||
|
where code = 'suppliers';
|
||||||
|
|
||||||
|
update {schema}.number_ranges
|
||||||
|
set pattern = 'AR{counter}', updated_at = now()
|
||||||
|
where code = 'items';
|
||||||
|
|
||||||
|
update {schema}.number_ranges
|
||||||
|
set pattern = 'AK{counter}', updated_at = now()
|
||||||
|
where code = 'activities';
|
||||||
|
|
||||||
|
update {schema}.number_ranges
|
||||||
|
set pattern = 'AR{counter}', updated_at = now()
|
||||||
|
where code = 'outgoing_invoices';
|
||||||
82
backend/company-migrations/0007_quotes.sql
Normal file
82
backend/company-migrations/0007_quotes.sql
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
-- Template migration for each organization schema.
|
||||||
|
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||||
|
|
||||||
|
create table if not exists {schema}.quotes (
|
||||||
|
id uuid primary key,
|
||||||
|
quote_number text not null unique,
|
||||||
|
customer_id uuid not null references {schema}.customers(id),
|
||||||
|
status text not null default 'draft',
|
||||||
|
valid_until date,
|
||||||
|
cash_discount_term_id uuid references {schema}.cash_discount_terms(id),
|
||||||
|
customer_discount_percent numeric(7, 4) not null default 0,
|
||||||
|
notes_ciphertext bytea,
|
||||||
|
notes_nonce bytea,
|
||||||
|
notes_key_id text,
|
||||||
|
created_by_user_id uuid,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint quotes_status_valid check (
|
||||||
|
status in ('draft', 'sent', 'accepted', 'rejected', 'expired', 'cancelled')
|
||||||
|
),
|
||||||
|
constraint quotes_customer_discount_valid check (
|
||||||
|
customer_discount_percent >= 0 and customer_discount_percent <= 100
|
||||||
|
),
|
||||||
|
constraint quotes_notes_encryption_complete check (
|
||||||
|
(
|
||||||
|
notes_ciphertext is null
|
||||||
|
and notes_nonce is null
|
||||||
|
and notes_key_id is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
notes_ciphertext is not null
|
||||||
|
and notes_nonce is not null
|
||||||
|
and notes_key_id is not null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_quotes_customer_status
|
||||||
|
on {schema}.quotes (customer_id, status);
|
||||||
|
|
||||||
|
create table if not exists {schema}.quote_items (
|
||||||
|
id uuid primary key,
|
||||||
|
quote_id uuid not null references {schema}.quotes(id) on delete cascade,
|
||||||
|
line_number integer not null,
|
||||||
|
item_id uuid not null references {schema}.items(id),
|
||||||
|
description_ciphertext bytea,
|
||||||
|
description_nonce bytea,
|
||||||
|
description_key_id text,
|
||||||
|
quantity numeric(14, 4) not null,
|
||||||
|
unit_price numeric(14, 4) not null,
|
||||||
|
original_unit_price numeric(14, 4),
|
||||||
|
discount_percent numeric(7, 4) not null default 0,
|
||||||
|
price_overridden boolean not null default false,
|
||||||
|
tax_rate numeric(7, 4) not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (quote_id, line_number),
|
||||||
|
constraint quote_items_quantity_positive check (quantity > 0),
|
||||||
|
constraint quote_items_unit_price_non_negative check (unit_price >= 0),
|
||||||
|
constraint quote_items_original_price_non_negative check (
|
||||||
|
original_unit_price is null or original_unit_price >= 0
|
||||||
|
),
|
||||||
|
constraint quote_items_discount_valid check (
|
||||||
|
discount_percent >= 0 and discount_percent <= 100
|
||||||
|
),
|
||||||
|
constraint quote_items_tax_rate_non_negative check (tax_rate >= 0),
|
||||||
|
constraint quote_items_description_encryption_complete check (
|
||||||
|
(
|
||||||
|
description_ciphertext is null
|
||||||
|
and description_nonce is null
|
||||||
|
and description_key_id is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
description_ciphertext is not null
|
||||||
|
and description_nonce is not null
|
||||||
|
and description_key_id is not null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_quote_items_item
|
||||||
|
on {schema}.quote_items (item_id);
|
||||||
19
backend/company-migrations/0008_invoice_links.sql
Normal file
19
backend/company-migrations/0008_invoice_links.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Template migration for each organization schema.
|
||||||
|
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||||
|
|
||||||
|
alter table {schema}.outgoing_invoices
|
||||||
|
add column if not exists source_quote_id uuid references {schema}.quotes(id);
|
||||||
|
|
||||||
|
alter table {schema}.outgoing_invoices
|
||||||
|
add column if not exists customer_discount_percent numeric(7, 4) not null default 0;
|
||||||
|
|
||||||
|
alter table {schema}.outgoing_invoices
|
||||||
|
drop constraint if exists outgoing_invoices_customer_discount_valid;
|
||||||
|
|
||||||
|
alter table {schema}.outgoing_invoices
|
||||||
|
add constraint outgoing_invoices_customer_discount_valid check (
|
||||||
|
customer_discount_percent >= 0 and customer_discount_percent <= 100
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_outgoing_invoices_source_quote
|
||||||
|
on {schema}.outgoing_invoices (source_quote_id);
|
||||||
71
backend/company-migrations/0009_price_imports.sql
Normal file
71
backend/company-migrations/0009_price_imports.sql
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
-- Template migration for each organization schema.
|
||||||
|
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||||
|
|
||||||
|
create table if not exists {schema}.imports (
|
||||||
|
id uuid primary key,
|
||||||
|
import_type text not null,
|
||||||
|
source_name text not null,
|
||||||
|
status text not null default 'previewed',
|
||||||
|
total_rows integer not null default 0,
|
||||||
|
applied_rows integer not null default 0,
|
||||||
|
error_rows integer not null default 0,
|
||||||
|
created_by_user_id uuid,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
finished_at timestamptz,
|
||||||
|
constraint imports_type_valid check (import_type in ('price_list', 'api_price_sync')),
|
||||||
|
constraint imports_status_valid check (status in ('previewed', 'applied', 'failed'))
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.import_mappings (
|
||||||
|
id uuid primary key,
|
||||||
|
code text not null unique,
|
||||||
|
name text not null,
|
||||||
|
delimiter text not null default ';',
|
||||||
|
item_number_column text not null default 'item_number',
|
||||||
|
name_column text not null default 'name',
|
||||||
|
unit_column text not null default 'unit',
|
||||||
|
tax_rate_column text not null default 'tax_rate',
|
||||||
|
purchase_price_column text not null default 'purchase_price',
|
||||||
|
sales_price_column text not null default 'sales_price',
|
||||||
|
is_default boolean not null default false,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists idx_import_mappings_default
|
||||||
|
on {schema}.import_mappings (is_default)
|
||||||
|
where is_default;
|
||||||
|
|
||||||
|
create table if not exists {schema}.price_rules (
|
||||||
|
id uuid primary key,
|
||||||
|
code text not null unique,
|
||||||
|
name text not null,
|
||||||
|
source_type text not null default 'import',
|
||||||
|
source_id uuid,
|
||||||
|
markup_percent numeric(7, 4) not null default 0,
|
||||||
|
rounding_mode text not null default 'none',
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint price_rules_source_type_valid check (source_type in ('import', 'api', 'supplier')),
|
||||||
|
constraint price_rules_markup_valid check (markup_percent >= -100 and markup_percent <= 1000),
|
||||||
|
constraint price_rules_rounding_mode_valid check (rounding_mode in ('none', 'cent', 'five_cent', 'ten_cent', 'whole'))
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.api_connectors (
|
||||||
|
id uuid primary key,
|
||||||
|
code text not null unique,
|
||||||
|
name text not null,
|
||||||
|
connector_type text not null,
|
||||||
|
config_ciphertext bytea not null,
|
||||||
|
config_nonce bytea not null,
|
||||||
|
config_key_id text not null,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
sync_interval_minutes integer,
|
||||||
|
last_sync_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint api_connectors_interval_valid check (
|
||||||
|
sync_interval_minutes is null or sync_interval_minutes > 0
|
||||||
|
)
|
||||||
|
);
|
||||||
153
backend/company-migrations/0010_communications_documents.sql
Normal file
153
backend/company-migrations/0010_communications_documents.sql
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
-- Template migration for each organization schema.
|
||||||
|
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||||
|
|
||||||
|
create table if not exists {schema}.communications (
|
||||||
|
id uuid primary key,
|
||||||
|
communication_type text not null,
|
||||||
|
direction text not null,
|
||||||
|
subject_ciphertext bytea not null,
|
||||||
|
subject_nonce bytea not null,
|
||||||
|
subject_key_id text not null,
|
||||||
|
body_ciphertext bytea,
|
||||||
|
body_nonce bytea,
|
||||||
|
body_key_id text,
|
||||||
|
status text not null default 'open',
|
||||||
|
occurred_at timestamptz,
|
||||||
|
created_by_user_id uuid,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint communications_type_valid check (
|
||||||
|
communication_type in ('email', 'phone', 'letter', 'meeting', 'internal_note')
|
||||||
|
),
|
||||||
|
constraint communications_direction_valid check (
|
||||||
|
direction in ('inbound', 'outbound', 'internal')
|
||||||
|
),
|
||||||
|
constraint communications_status_valid check (
|
||||||
|
status in ('open', 'done', 'archived')
|
||||||
|
),
|
||||||
|
constraint communications_body_encryption_complete check (
|
||||||
|
(
|
||||||
|
body_ciphertext is null
|
||||||
|
and body_nonce is null
|
||||||
|
and body_key_id is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
body_ciphertext is not null
|
||||||
|
and body_nonce is not null
|
||||||
|
and body_key_id is not null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_communications_type_status
|
||||||
|
on {schema}.communications (communication_type, status, occurred_at desc);
|
||||||
|
|
||||||
|
create table if not exists {schema}.communication_links (
|
||||||
|
communication_id uuid not null references {schema}.communications(id) on delete cascade,
|
||||||
|
entity_type text not null,
|
||||||
|
entity_id uuid not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
primary key (communication_id, entity_type, entity_id),
|
||||||
|
constraint communication_links_entity_type_valid check (
|
||||||
|
entity_type in (
|
||||||
|
'customer',
|
||||||
|
'supplier',
|
||||||
|
'activity',
|
||||||
|
'quote',
|
||||||
|
'outgoing_invoice',
|
||||||
|
'incoming_invoice',
|
||||||
|
'item',
|
||||||
|
'document'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_communication_links_entity
|
||||||
|
on {schema}.communication_links (entity_type, entity_id);
|
||||||
|
|
||||||
|
create table if not exists {schema}.documents (
|
||||||
|
id uuid primary key,
|
||||||
|
title_ciphertext bytea not null,
|
||||||
|
title_nonce bytea not null,
|
||||||
|
title_key_id text not null,
|
||||||
|
description_ciphertext bytea,
|
||||||
|
description_nonce bytea,
|
||||||
|
description_key_id text,
|
||||||
|
status text not null default 'active',
|
||||||
|
created_by_user_id uuid,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint documents_status_valid check (status in ('active', 'archived', 'deleted')),
|
||||||
|
constraint documents_description_encryption_complete check (
|
||||||
|
(
|
||||||
|
description_ciphertext is null
|
||||||
|
and description_nonce is null
|
||||||
|
and description_key_id is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
description_ciphertext is not null
|
||||||
|
and description_nonce is not null
|
||||||
|
and description_key_id is not null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists {schema}.document_versions (
|
||||||
|
id uuid primary key,
|
||||||
|
document_id uuid not null references {schema}.documents(id) on delete cascade,
|
||||||
|
version_no integer not null,
|
||||||
|
file_name_ciphertext bytea not null,
|
||||||
|
file_name_nonce bytea not null,
|
||||||
|
file_name_key_id text not null,
|
||||||
|
content_type_ciphertext bytea not null,
|
||||||
|
content_type_nonce bytea not null,
|
||||||
|
content_type_key_id text not null,
|
||||||
|
file_size bigint not null,
|
||||||
|
storage_path text not null,
|
||||||
|
checksum_sha256 text not null,
|
||||||
|
uploaded_by_user_id uuid,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
unique (document_id, version_no),
|
||||||
|
constraint document_versions_file_size_valid check (file_size >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_document_versions_document_version
|
||||||
|
on {schema}.document_versions (document_id, version_no desc);
|
||||||
|
|
||||||
|
create table if not exists {schema}.document_links (
|
||||||
|
document_id uuid not null references {schema}.documents(id) on delete cascade,
|
||||||
|
entity_type text not null,
|
||||||
|
entity_id uuid not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
primary key (document_id, entity_type, entity_id),
|
||||||
|
constraint document_links_entity_type_valid check (
|
||||||
|
entity_type in (
|
||||||
|
'customer',
|
||||||
|
'supplier',
|
||||||
|
'activity',
|
||||||
|
'communication',
|
||||||
|
'quote',
|
||||||
|
'outgoing_invoice',
|
||||||
|
'incoming_invoice',
|
||||||
|
'item'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_document_links_entity
|
||||||
|
on {schema}.document_links (entity_type, entity_id);
|
||||||
|
|
||||||
|
create table if not exists {schema}.document_audit_log (
|
||||||
|
id uuid primary key,
|
||||||
|
document_id uuid not null references {schema}.documents(id) on delete cascade,
|
||||||
|
version_id uuid references {schema}.document_versions(id) on delete set null,
|
||||||
|
action text not null,
|
||||||
|
user_id uuid,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint document_audit_log_action_valid check (
|
||||||
|
action in ('upload', 'download', 'archive')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_document_audit_log_document
|
||||||
|
on {schema}.document_audit_log (document_id, created_at desc);
|
||||||
9
backend/company-migrations/0011_user_settings.sql
Normal file
9
backend/company-migrations/0011_user_settings.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
create table if not exists {schema}.user_settings (
|
||||||
|
user_id uuid not null,
|
||||||
|
key text not null,
|
||||||
|
value_ciphertext bytea not null,
|
||||||
|
value_nonce bytea not null,
|
||||||
|
value_key_id text not null,
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
primary key (user_id, key)
|
||||||
|
);
|
||||||
28
backend/company-migrations/0012_item_supplier_prices.sql
Normal file
28
backend/company-migrations/0012_item_supplier_prices.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
alter table {schema}.items
|
||||||
|
add column if not exists manufacturer_code text;
|
||||||
|
|
||||||
|
create table if not exists {schema}.item_supplier_prices (
|
||||||
|
id uuid primary key,
|
||||||
|
item_id uuid not null references {schema}.items(id) on delete cascade,
|
||||||
|
supplier_id uuid not null references {schema}.suppliers(id) on delete cascade,
|
||||||
|
external_item_number text not null,
|
||||||
|
purchase_price numeric(14, 4) not null,
|
||||||
|
currency text not null default 'EUR',
|
||||||
|
is_preferred boolean not null default false,
|
||||||
|
valid_from date,
|
||||||
|
valid_until date,
|
||||||
|
source text not null default 'manual',
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint item_supplier_prices_purchase_price_non_negative check (purchase_price >= 0),
|
||||||
|
constraint item_supplier_prices_currency_valid check (char_length(currency) = 3),
|
||||||
|
constraint item_supplier_prices_valid_range check (
|
||||||
|
valid_until is null or valid_from is null or valid_until >= valid_from
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists idx_item_supplier_prices_supplier_external
|
||||||
|
on {schema}.item_supplier_prices (supplier_id, external_item_number);
|
||||||
|
|
||||||
|
create index if not exists idx_item_supplier_prices_item
|
||||||
|
on {schema}.item_supplier_prices (item_id, purchase_price);
|
||||||
94
backend/migrations/20260521170000_public_core.sql
Normal file
94
backend/migrations/20260521170000_public_core.sql
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
create table if not exists users (
|
||||||
|
id uuid primary key,
|
||||||
|
email text not null unique,
|
||||||
|
display_name_ciphertext bytea,
|
||||||
|
display_name_nonce bytea,
|
||||||
|
display_name_key_id text,
|
||||||
|
password_hash text,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
must_change_password boolean not null default false,
|
||||||
|
initial_password_expires_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
last_login_at timestamptz,
|
||||||
|
constraint users_email_lowercase check (email = lower(email)),
|
||||||
|
constraint users_display_name_encryption_complete check (
|
||||||
|
(
|
||||||
|
display_name_ciphertext is null
|
||||||
|
and display_name_nonce is null
|
||||||
|
and display_name_key_id is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
display_name_ciphertext is not null
|
||||||
|
and display_name_nonce is not null
|
||||||
|
and display_name_key_id is not null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists organizations (
|
||||||
|
id uuid primary key,
|
||||||
|
display_name_ciphertext bytea,
|
||||||
|
display_name_nonce bytea,
|
||||||
|
display_name_key_id text,
|
||||||
|
schema_name text unique,
|
||||||
|
status text not null default 'pending_approval',
|
||||||
|
registration_email text not null,
|
||||||
|
setup_completed_at timestamptz,
|
||||||
|
approved_by_user_id uuid references users(id),
|
||||||
|
approved_at timestamptz,
|
||||||
|
rejected_by_user_id uuid references users(id),
|
||||||
|
rejected_at timestamptz,
|
||||||
|
rejection_reason text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint organizations_status_valid check (
|
||||||
|
status in ('pending_approval', 'approved', 'active', 'rejected', 'suspended')
|
||||||
|
),
|
||||||
|
constraint organizations_registration_email_lowercase check (registration_email = lower(registration_email)),
|
||||||
|
constraint organizations_schema_name_valid check (
|
||||||
|
schema_name is null or schema_name ~ '^company_[a-z0-9_]+$'
|
||||||
|
),
|
||||||
|
constraint organizations_display_name_encryption_complete check (
|
||||||
|
(
|
||||||
|
display_name_ciphertext is null
|
||||||
|
and display_name_nonce is null
|
||||||
|
and display_name_key_id is null
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
display_name_ciphertext is not null
|
||||||
|
and display_name_nonce is not null
|
||||||
|
and display_name_key_id is not null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists user_organizations (
|
||||||
|
user_id uuid not null references users(id) on delete cascade,
|
||||||
|
organization_id uuid not null references organizations(id) on delete cascade,
|
||||||
|
status text not null default 'pending_invitation',
|
||||||
|
invited_by_user_id uuid references users(id),
|
||||||
|
invited_at timestamptz,
|
||||||
|
accepted_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
primary key (user_id, organization_id),
|
||||||
|
constraint user_organizations_status_valid check (
|
||||||
|
status in ('pending_invitation', 'active', 'disabled')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists organization_domains (
|
||||||
|
id uuid primary key,
|
||||||
|
organization_id uuid not null references organizations(id) on delete cascade,
|
||||||
|
domain text not null unique,
|
||||||
|
is_primary boolean not null default false,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint organization_domains_domain_lowercase check (domain = lower(domain))
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_user_organizations_organization_id
|
||||||
|
on user_organizations (organization_id);
|
||||||
|
|
||||||
|
create index if not exists idx_organizations_status
|
||||||
|
on organizations (status);
|
||||||
68
backend/migrations/20260521171000_public_auth_sessions.sql
Normal file
68
backend/migrations/20260521171000_public_auth_sessions.sql
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
create table if not exists auth_identities (
|
||||||
|
id uuid primary key,
|
||||||
|
user_id uuid not null references users(id) on delete cascade,
|
||||||
|
provider text not null,
|
||||||
|
provider_subject text not null,
|
||||||
|
email_at_provider text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (provider, provider_subject)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists refresh_tokens (
|
||||||
|
id uuid primary key,
|
||||||
|
user_id uuid not null references users(id) on delete cascade,
|
||||||
|
organization_id uuid references organizations(id) on delete cascade,
|
||||||
|
token_hash text not null unique,
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
revoked_at timestamptz,
|
||||||
|
revoked_reason text,
|
||||||
|
user_agent text,
|
||||||
|
created_ip text,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists socket_tokens (
|
||||||
|
id uuid primary key,
|
||||||
|
user_id uuid not null references users(id) on delete cascade,
|
||||||
|
organization_id uuid not null references organizations(id) on delete cascade,
|
||||||
|
token_hash text not null unique,
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
used_at timestamptz,
|
||||||
|
revoked_at timestamptz,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists session_keys (
|
||||||
|
id uuid primary key,
|
||||||
|
user_id uuid not null references users(id) on delete cascade,
|
||||||
|
organization_id uuid not null references organizations(id) on delete cascade,
|
||||||
|
key_id text not null unique,
|
||||||
|
wrapped_key bytea,
|
||||||
|
algorithm text not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
revoked_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists idempotency_keys (
|
||||||
|
id uuid primary key,
|
||||||
|
user_id uuid not null references users(id) on delete cascade,
|
||||||
|
organization_id uuid references organizations(id) on delete cascade,
|
||||||
|
key text not null,
|
||||||
|
request_hash text not null,
|
||||||
|
response_status integer,
|
||||||
|
response_body_json jsonb,
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
unique (user_id, organization_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_refresh_tokens_user_id
|
||||||
|
on refresh_tokens (user_id);
|
||||||
|
|
||||||
|
create index if not exists idx_socket_tokens_user_organization
|
||||||
|
on socket_tokens (user_id, organization_id);
|
||||||
|
|
||||||
|
create index if not exists idx_session_keys_user_organization
|
||||||
|
on session_keys (user_id, organization_id);
|
||||||
61
backend/migrations/20260521172000_public_onboarding.sql
Normal file
61
backend/migrations/20260521172000_public_onboarding.sql
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
create table if not exists organization_registration_requests (
|
||||||
|
id uuid primary key,
|
||||||
|
organization_name_ciphertext bytea not null,
|
||||||
|
organization_name_nonce bytea not null,
|
||||||
|
organization_name_key_id text not null,
|
||||||
|
email text not null,
|
||||||
|
status text not null default 'pending_approval',
|
||||||
|
organization_id uuid references organizations(id),
|
||||||
|
requested_at timestamptz not null default now(),
|
||||||
|
decided_by_user_id uuid references users(id),
|
||||||
|
decided_at timestamptz,
|
||||||
|
decision_note text,
|
||||||
|
constraint organization_registration_requests_email_lowercase check (email = lower(email)),
|
||||||
|
constraint organization_registration_requests_status_valid check (
|
||||||
|
status in ('pending_approval', 'approved', 'active', 'rejected', 'suspended')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists user_invitations (
|
||||||
|
id uuid primary key,
|
||||||
|
organization_id uuid not null references organizations(id) on delete cascade,
|
||||||
|
email text not null,
|
||||||
|
invited_by_user_id uuid not null references users(id),
|
||||||
|
status text not null default 'pending',
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
accepted_at timestamptz,
|
||||||
|
created_user_id uuid references users(id),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint user_invitations_email_lowercase check (email = lower(email)),
|
||||||
|
constraint user_invitations_status_valid check (
|
||||||
|
status in ('pending', 'accepted', 'expired', 'revoked')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists email_outbox (
|
||||||
|
id uuid primary key,
|
||||||
|
recipient_email text not null,
|
||||||
|
template text not null,
|
||||||
|
payload_ciphertext bytea not null,
|
||||||
|
payload_nonce bytea not null,
|
||||||
|
payload_key_id text not null,
|
||||||
|
status text not null default 'pending',
|
||||||
|
attempt_count integer not null default 0,
|
||||||
|
last_error text,
|
||||||
|
send_after timestamptz not null default now(),
|
||||||
|
sent_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint email_outbox_recipient_email_lowercase check (recipient_email = lower(recipient_email)),
|
||||||
|
constraint email_outbox_status_valid check (
|
||||||
|
status in ('pending', 'sending', 'sent', 'failed')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_organization_registration_requests_status
|
||||||
|
on organization_registration_requests (status, requested_at);
|
||||||
|
|
||||||
|
create index if not exists idx_user_invitations_organization_status
|
||||||
|
on user_invitations (organization_id, status);
|
||||||
|
|
||||||
|
create index if not exists idx_email_outbox_status_send_after
|
||||||
|
on email_outbox (status, send_after);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
create table if not exists records (
|
||||||
|
id uuid primary key,
|
||||||
|
title text not null,
|
||||||
|
updated_at timestamptz not null
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into records (id, title, updated_at)
|
||||||
|
select '00000000-0000-0000-0000-000000000001'::uuid, 'Erster Datensatz', now()
|
||||||
|
where not exists (select 1 from records);
|
||||||
2
backend/migrations/20260521174000_registration_terms.sql
Normal file
2
backend/migrations/20260521174000_registration_terms.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
alter table organization_registration_requests
|
||||||
|
add column if not exists terms_accepted_at timestamptz;
|
||||||
23
backend/migrations/20260601190000_security_operations.sql
Normal file
23
backend/migrations/20260601190000_security_operations.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
alter table user_invitations
|
||||||
|
add column if not exists token_hash text,
|
||||||
|
add column if not exists accepted_by_user_id uuid references users(id);
|
||||||
|
|
||||||
|
create unique index if not exists idx_user_invitations_token_hash
|
||||||
|
on user_invitations (token_hash)
|
||||||
|
where token_hash is not null;
|
||||||
|
|
||||||
|
create table if not exists password_reset_tokens (
|
||||||
|
id uuid primary key,
|
||||||
|
user_id uuid not null references users(id) on delete cascade,
|
||||||
|
token_hash text not null unique,
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
used_at timestamptz,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_password_reset_tokens_user
|
||||||
|
on password_reset_tokens (user_id, expires_at);
|
||||||
|
|
||||||
|
alter table email_outbox
|
||||||
|
add column if not exists subject text,
|
||||||
|
add column if not exists delivered_via text;
|
||||||
6903
backend/src/api.rs
Normal file
6903
backend/src/api.rs
Normal file
File diff suppressed because it is too large
Load Diff
54
backend/src/crypto_at_rest.rs
Normal file
54
backend/src/crypto_at_rest.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use companytool_shared_protocol::crypto::SessionKey;
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DataCrypto {
|
||||||
|
key: SessionKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EncryptedField {
|
||||||
|
pub ciphertext: Vec<u8>,
|
||||||
|
pub nonce: Vec<u8>,
|
||||||
|
pub key_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataCrypto {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let key_id = std::env::var("COMPANYTOOL_DATA_KEY_ID")
|
||||||
|
.unwrap_or_else(|_| "dev-data-key-v1".to_string());
|
||||||
|
let key_base64 = std::env::var("COMPANYTOOL_DATA_KEY_BASE64").unwrap_or_else(|_| {
|
||||||
|
warn!("COMPANYTOOL_DATA_KEY_BASE64 fehlt; verwende nur für Entwicklung geeigneten festen Schlüssel");
|
||||||
|
STANDARD.encode([7_u8; 32])
|
||||||
|
});
|
||||||
|
let key = SessionKey::from_base64(key_id, &key_base64).expect("valid data encryption key");
|
||||||
|
|
||||||
|
Self { key }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt<T: Serialize>(&self, value: &T) -> anyhow::Result<EncryptedField> {
|
||||||
|
let envelope = self.key.encrypt(value)?;
|
||||||
|
Ok(EncryptedField {
|
||||||
|
ciphertext: STANDARD.decode(envelope.ciphertext)?,
|
||||||
|
nonce: STANDARD.decode(envelope.nonce)?,
|
||||||
|
key_id: envelope.key_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
ciphertext: &[u8],
|
||||||
|
nonce: &[u8],
|
||||||
|
key_id: &str,
|
||||||
|
) -> anyhow::Result<T> {
|
||||||
|
let envelope = companytool_shared_protocol::EncryptedEnvelope {
|
||||||
|
enc: "aes-256-gcm-v1".to_string(),
|
||||||
|
key_id: key_id.to_string(),
|
||||||
|
nonce: STANDARD.encode(nonce),
|
||||||
|
ciphertext: STANDARD.encode(ciphertext),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(self.key.decrypt(&envelope)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
472
backend/src/main.rs
Normal file
472
backend/src/main.rs
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
mod api;
|
||||||
|
mod crypto_at_rest;
|
||||||
|
mod models;
|
||||||
|
|
||||||
|
use std::{env, net::SocketAddr};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use axum::{
|
||||||
|
extract::{
|
||||||
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
|
State,
|
||||||
|
},
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{get, patch, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use companytool_shared_protocol::{
|
||||||
|
crypto::SessionKey, ClientMessage, HelloAckMessage, ProtocolErrorMessage, RecordSummary,
|
||||||
|
ServerMessage, WireMessage, PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
|
use crypto_at_rest::DataCrypto;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
db: Option<PgPool>,
|
||||||
|
crypto: DataCrypto,
|
||||||
|
events: broadcast::Sender<ServerMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
fn db(&self) -> Result<&PgPool, api::ApiError> {
|
||||||
|
self.db
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| api::ApiError::bad_request("Datenbank ist im Testmodus nicht aktiv"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let communication_test_mode = env::var("COMMUNICATION_TEST_MODE").as_deref() == Ok("1");
|
||||||
|
let bind: SocketAddr = env::var("BACKEND_BIND")
|
||||||
|
.unwrap_or_else(|_| "127.0.0.1:8080".to_string())
|
||||||
|
.parse()
|
||||||
|
.context("BACKEND_BIND ist keine gültige Adresse")?;
|
||||||
|
|
||||||
|
let db = if communication_test_mode {
|
||||||
|
warn!("COMMUNICATION_TEST_MODE aktiv: backend startet ohne datenbank");
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let database_url =
|
||||||
|
env::var("DATABASE_URL").context("DATABASE_URL fehlt, siehe .env.example")?;
|
||||||
|
let db = PgPool::connect(&database_url).await?;
|
||||||
|
migrate(&db).await?;
|
||||||
|
Some(db)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (events, _) = broadcast::channel(256);
|
||||||
|
let state = AppState {
|
||||||
|
db,
|
||||||
|
crypto: DataCrypto::from_env(),
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", get(health))
|
||||||
|
.route("/ws", get(ws_handler))
|
||||||
|
.route(
|
||||||
|
"/api/v1/dev/bootstrap-local",
|
||||||
|
post(api::dev_bootstrap_local),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/registration/organization",
|
||||||
|
post(api::register_organization),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/admin/organization-registrations",
|
||||||
|
get(api::list_organization_registrations),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/admin/organization-registrations/:id",
|
||||||
|
get(api::get_organization_registration),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/admin/organization-registrations/:id/approve",
|
||||||
|
post(api::approve_organization_registration),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/admin/organization-registrations/:id/reject",
|
||||||
|
post(api::reject_organization_registration),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/admin/organization-registrations/:id/resend-initial-email",
|
||||||
|
post(api::resend_initial_email),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/admin/organization-registrations/:id/retry-provisioning",
|
||||||
|
post(api::retry_provisioning),
|
||||||
|
)
|
||||||
|
.route("/api/v1/auth/login", post(api::login))
|
||||||
|
.route(
|
||||||
|
"/api/v1/auth/change-initial-password",
|
||||||
|
post(api::change_initial_password),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/auth/request-password-reset",
|
||||||
|
post(api::request_password_reset),
|
||||||
|
)
|
||||||
|
.route("/api/v1/auth/reset-password", post(api::reset_password))
|
||||||
|
.route(
|
||||||
|
"/api/v1/auth/accept-invitation",
|
||||||
|
post(api::accept_invitation),
|
||||||
|
)
|
||||||
|
.route("/api/v1/auth/organizations", get(api::auth_organizations))
|
||||||
|
.route(
|
||||||
|
"/api/v1/auth/select-organization",
|
||||||
|
post(api::select_organization),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/organizations/current/setup",
|
||||||
|
get(api::get_organization_setup).put(api::put_organization_setup),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/users/me/settings/navigation",
|
||||||
|
get(api::get_user_navigation_settings).put(api::put_user_navigation_settings),
|
||||||
|
)
|
||||||
|
.route("/api/v1/number-ranges", get(api::list_number_ranges))
|
||||||
|
.route(
|
||||||
|
"/api/v1/number-ranges/:code/next",
|
||||||
|
axum::routing::post(api::generate_next_number),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/number-ranges/:code",
|
||||||
|
axum::routing::put(api::update_number_range),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/customers",
|
||||||
|
get(api::list_customers).post(api::create_customer),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/customers/:customer_id",
|
||||||
|
axum::routing::put(api::update_customer).delete(api::delete_customer),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/suppliers",
|
||||||
|
get(api::list_suppliers).post(api::create_supplier),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/suppliers/:supplier_id",
|
||||||
|
axum::routing::put(api::update_supplier).delete(api::delete_supplier),
|
||||||
|
)
|
||||||
|
.route("/api/v1/items", get(api::list_items).post(api::create_item))
|
||||||
|
.route(
|
||||||
|
"/api/v1/items/:item_id/prices",
|
||||||
|
get(api::list_item_price_history),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/items/:item_id",
|
||||||
|
axum::routing::put(api::update_item).delete(api::delete_item),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/cash-discount-terms",
|
||||||
|
get(api::list_cash_discount_terms).post(api::create_cash_discount_term),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/cash-discount-terms/:term_id",
|
||||||
|
axum::routing::put(api::update_cash_discount_term)
|
||||||
|
.delete(api::delete_cash_discount_term),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/quotes",
|
||||||
|
get(api::list_quotes).post(api::create_quote),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/quotes/:quote_id",
|
||||||
|
axum::routing::put(api::update_quote).delete(api::delete_quote),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/quotes/:quote_id/convert-to-invoice",
|
||||||
|
post(api::convert_quote_to_outgoing_invoice),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/outgoing-invoices",
|
||||||
|
get(api::list_outgoing_invoices).post(api::create_outgoing_invoice),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/outgoing-invoices/:invoice_id",
|
||||||
|
axum::routing::put(api::update_outgoing_invoice).delete(api::delete_outgoing_invoice),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/outgoing-invoices/:invoice_id/finalize",
|
||||||
|
post(api::finalize_outgoing_invoice),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/incoming-invoices",
|
||||||
|
get(api::list_incoming_invoices).post(api::create_incoming_invoice),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/incoming-invoices/:invoice_id",
|
||||||
|
axum::routing::put(api::update_incoming_invoice).delete(api::delete_incoming_invoice),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/imports/price-list/preview",
|
||||||
|
post(api::preview_price_list_import),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/imports/price-list/apply",
|
||||||
|
post(api::apply_price_list_import),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/api-connectors",
|
||||||
|
get(api::list_api_connectors).post(api::create_api_connector),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/api-connectors/:connector_id",
|
||||||
|
axum::routing::put(api::update_api_connector).delete(api::delete_api_connector),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/api-connectors/:connector_id/sync",
|
||||||
|
post(api::sync_api_connector),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/price-rules",
|
||||||
|
get(api::list_price_rules).post(api::create_price_rule),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/price-rules/:rule_id",
|
||||||
|
axum::routing::put(api::update_price_rule).delete(api::delete_price_rule),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/activities",
|
||||||
|
get(api::list_activities).post(api::create_activity),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/activities/:activity_id",
|
||||||
|
axum::routing::put(api::update_activity).delete(api::delete_activity),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/communications",
|
||||||
|
get(api::list_communications).post(api::create_communication),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/communications/:communication_id",
|
||||||
|
axum::routing::put(api::update_communication).delete(api::delete_communication),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/documents",
|
||||||
|
get(api::list_documents).post(api::upload_document),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/documents/:document_id/download",
|
||||||
|
get(api::download_document),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/documents/:document_id/audit-log",
|
||||||
|
get(api::list_document_audit_log),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/documents/:document_id",
|
||||||
|
axum::routing::delete(api::delete_document),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/organizations/current/invitations",
|
||||||
|
post(api::invite_user),
|
||||||
|
)
|
||||||
|
.route("/api/v1/organizations/current/users", get(api::list_users))
|
||||||
|
.route(
|
||||||
|
"/api/v1/organizations/current/users/:user_id/roles",
|
||||||
|
patch(api::update_user_roles),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/organizations/current/users/:user_id/disable",
|
||||||
|
post(api::disable_user),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
.layer(CorsLayer::permissive())
|
||||||
|
.layer(TraceLayer::new_for_http());
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(bind).await?;
|
||||||
|
info!("backend lauscht auf http://{bind}");
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health() -> Json<serde_json::Value> {
|
||||||
|
Json(serde_json::json!({ "status": "ok" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||||
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
let mut events = state.events.subscribe();
|
||||||
|
let mut session_key: Option<SessionKey> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
Some(Ok(message)) = receiver.next() => {
|
||||||
|
if let Message::Text(text) = message {
|
||||||
|
if let Some(new_session_key) = handle_client_wire_message(&text, &state, &mut sender, session_key.as_ref()).await {
|
||||||
|
if let Ok(snapshot) = load_snapshot(state.db.as_ref()).await {
|
||||||
|
if send_encrypted_server_message(
|
||||||
|
&mut sender,
|
||||||
|
&new_session_key,
|
||||||
|
ServerMessage::Snapshot { records: snapshot },
|
||||||
|
).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session_key = Some(new_session_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(event) = events.recv() => {
|
||||||
|
if let Some(active_session_key) = session_key.as_ref() {
|
||||||
|
if let Err(error) = send_encrypted_server_message(&mut sender, active_session_key, event).await {
|
||||||
|
warn!(%error, "server message konnte nicht gesendet werden");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_client_wire_message(
|
||||||
|
text: &str,
|
||||||
|
state: &AppState,
|
||||||
|
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||||
|
session_key: Option<&SessionKey>,
|
||||||
|
) -> Option<SessionKey> {
|
||||||
|
match serde_json::from_str::<WireMessage>(text) {
|
||||||
|
Ok(WireMessage::Hello(hello)) => {
|
||||||
|
if hello.protocol_version != PROTOCOL_VERSION {
|
||||||
|
let _ = send_wire_error(sender, "nicht unterstützte Protokollversion").await;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match SessionKey::from_base64(hello.key_id.clone(), &hello.session_key) {
|
||||||
|
Ok(new_session_key) => {
|
||||||
|
let ack = WireMessage::HelloAck(HelloAckMessage {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
key_id: hello.key_id,
|
||||||
|
});
|
||||||
|
if send_wire_message(sender, ack).await.is_err() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(new_session_key)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ =
|
||||||
|
send_wire_error(sender, &format!("ungültiger Session-Key: {error}")).await;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(WireMessage::Encrypted(envelope)) => {
|
||||||
|
let Some(active_session_key) = session_key else {
|
||||||
|
let _ = send_wire_error(sender, "verschlüsselte Nachricht vor hello").await;
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
match active_session_key.decrypt::<ClientMessage>(&envelope) {
|
||||||
|
Ok(message) => handle_client_message(message, state).await,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = send_wire_error(
|
||||||
|
sender,
|
||||||
|
&format!("Nachricht konnte nicht entschlüsselt werden: {error}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Ok(WireMessage::HelloAck(_) | WireMessage::Error(_)) => None,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = send_wire_error(sender, &format!("ungültige Wire-Nachricht: {error}")).await;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_client_message(message: ClientMessage, state: &AppState) {
|
||||||
|
match message {
|
||||||
|
ClientMessage::Ping => {
|
||||||
|
let _ = state.events.send(ServerMessage::Pong);
|
||||||
|
}
|
||||||
|
ClientMessage::Subscribe { topic } => {
|
||||||
|
info!(%topic, "client hat topic abonniert");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_encrypted_server_message(
|
||||||
|
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||||
|
session_key: &SessionKey,
|
||||||
|
message: ServerMessage,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let envelope = session_key.encrypt(&message)?;
|
||||||
|
send_wire_message(sender, WireMessage::Encrypted(envelope)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_wire_error(
|
||||||
|
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||||
|
message: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
send_wire_message(
|
||||||
|
sender,
|
||||||
|
WireMessage::Error(ProtocolErrorMessage {
|
||||||
|
message: message.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_wire_message(
|
||||||
|
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||||
|
message: WireMessage,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let text = serde_json::to_string(&message)?;
|
||||||
|
sender.send(Message::Text(text)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrate(db: &PgPool) -> anyhow::Result<()> {
|
||||||
|
sqlx::migrate!("./migrations").run(db).await?;
|
||||||
|
api::sync_all_company_schemas(db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_snapshot(db: Option<&PgPool>) -> anyhow::Result<Vec<RecordSummary>> {
|
||||||
|
let Some(db) = db else {
|
||||||
|
return Ok(vec![RecordSummary {
|
||||||
|
id: Uuid::nil(),
|
||||||
|
title: "Kommunikationstest-Datensatz".to_string(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = sqlx::query("select id, title, updated_at from records order by updated_at desc")
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| RecordSummary {
|
||||||
|
id: row.get("id"),
|
||||||
|
title: row.get("title"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
}
|
||||||
210
backend/src/models.rs
Normal file
210
backend/src/models.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum OrganizationStatus {
|
||||||
|
PendingApproval,
|
||||||
|
Approved,
|
||||||
|
Active,
|
||||||
|
Rejected,
|
||||||
|
Suspended,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum UserOrganizationStatus {
|
||||||
|
PendingInvitation,
|
||||||
|
Active,
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RegistrationStatus {
|
||||||
|
PendingApproval,
|
||||||
|
Approved,
|
||||||
|
Active,
|
||||||
|
Rejected,
|
||||||
|
Suspended,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum InvitationStatus {
|
||||||
|
Pending,
|
||||||
|
Accepted,
|
||||||
|
Expired,
|
||||||
|
Revoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EmailOutboxStatus {
|
||||||
|
Pending,
|
||||||
|
Sending,
|
||||||
|
Sent,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub display_name_ciphertext: Option<Vec<u8>>,
|
||||||
|
pub display_name_nonce: Option<Vec<u8>>,
|
||||||
|
pub display_name_key_id: Option<String>,
|
||||||
|
pub password_hash: Option<String>,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub must_change_password: bool,
|
||||||
|
pub initial_password_expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub last_login_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Organization {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub display_name_ciphertext: Option<Vec<u8>>,
|
||||||
|
pub display_name_nonce: Option<Vec<u8>>,
|
||||||
|
pub display_name_key_id: Option<String>,
|
||||||
|
pub schema_name: Option<String>,
|
||||||
|
pub status: OrganizationStatus,
|
||||||
|
pub registration_email: String,
|
||||||
|
pub setup_completed_at: Option<DateTime<Utc>>,
|
||||||
|
pub approved_by_user_id: Option<Uuid>,
|
||||||
|
pub approved_at: Option<DateTime<Utc>>,
|
||||||
|
pub rejected_by_user_id: Option<Uuid>,
|
||||||
|
pub rejected_at: Option<DateTime<Utc>>,
|
||||||
|
pub rejection_reason: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct UserOrganization {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub status: UserOrganizationStatus,
|
||||||
|
pub invited_by_user_id: Option<Uuid>,
|
||||||
|
pub invited_at: Option<DateTime<Utc>>,
|
||||||
|
pub accepted_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct AuthIdentity {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub provider: String,
|
||||||
|
pub provider_subject: String,
|
||||||
|
pub email_at_provider: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct RefreshToken {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub organization_id: Option<Uuid>,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
|
pub revoked_reason: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
pub created_ip: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct SocketToken {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub used_at: Option<DateTime<Utc>>,
|
||||||
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct SessionKeyRecord {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub key_id: String,
|
||||||
|
pub wrapped_key: Option<Vec<u8>>,
|
||||||
|
pub algorithm: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct IdempotencyKey {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub organization_id: Option<Uuid>,
|
||||||
|
pub key: String,
|
||||||
|
pub request_hash: String,
|
||||||
|
pub response_status: Option<i32>,
|
||||||
|
pub response_body_json: Option<serde_json::Value>,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct OrganizationRegistrationRequest {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_name_ciphertext: Vec<u8>,
|
||||||
|
pub organization_name_nonce: Vec<u8>,
|
||||||
|
pub organization_name_key_id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub status: RegistrationStatus,
|
||||||
|
pub organization_id: Option<Uuid>,
|
||||||
|
pub requested_at: DateTime<Utc>,
|
||||||
|
pub decided_by_user_id: Option<Uuid>,
|
||||||
|
pub decided_at: Option<DateTime<Utc>>,
|
||||||
|
pub decision_note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct UserInvitation {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub invited_by_user_id: Uuid,
|
||||||
|
pub status: InvitationStatus,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub accepted_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_user_id: Option<Uuid>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct EmailOutboxItem {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub recipient_email: String,
|
||||||
|
pub template: String,
|
||||||
|
pub payload_ciphertext: Vec<u8>,
|
||||||
|
pub payload_nonce: Vec<u8>,
|
||||||
|
pub payload_key_id: String,
|
||||||
|
pub status: EmailOutboxStatus,
|
||||||
|
pub attempt_count: i32,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub send_after: DateTime<Utc>,
|
||||||
|
pub sent_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
32
deploy/nginx-companytool.conf
Normal file
32
deploy/nginx-companytool.conf
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name companytool.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/companytool.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/companytool.example.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://127.0.0.1:8080/ws;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name companytool.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
20
desktop-client/Cargo.toml
Normal file
20
desktop-client/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "companytool-desktop-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
companytool-shared-protocol = { path = "../shared-protocol" }
|
||||||
|
eframe = "0.29"
|
||||||
|
egui = "0.29"
|
||||||
|
futures-util = "0.3"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "sync"] }
|
||||||
|
tokio-tungstenite = "0.24"
|
||||||
|
toml = "0.8"
|
||||||
|
url = "2"
|
||||||
3
desktop-client/companytool-client.toml
Normal file
3
desktop-client/companytool-client.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[server]
|
||||||
|
api_base_url = "http://localhost:8080"
|
||||||
|
ws_url = "ws://localhost:8080/ws"
|
||||||
6792
desktop-client/src/main.rs
Normal file
6792
desktop-client/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: companytool
|
||||||
|
POSTGRES_PASSWORD: companytool
|
||||||
|
POSTGRES_DB: companytool
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- companytool-postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
profiles:
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://companytool:companytool@postgres:5432/companytool
|
||||||
|
BACKEND_BIND: 0.0.0.0:8080
|
||||||
|
COMPANYTOOL_EMAIL_TRANSPORT: outbox
|
||||||
|
COMPANYTOOL_DOCUMENT_STORAGE_DIR: /var/lib/companytool/documents
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- companytool-documents:/var/lib/companytool/documents
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
companytool-postgres:
|
||||||
|
companytool-documents:
|
||||||
BIN
images/icons/companytool-logo.png
Normal file
BIN
images/icons/companytool-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
images/icons/logo.png
Normal file
BIN
images/icons/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
495
scripts/api-onboarding-test.mjs
Normal file
495
scripts/api-onboarding-test.mjs
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const baseUrl = process.argv[2] ?? process.env.API_BASE_URL ?? "http://127.0.0.1:8080";
|
||||||
|
const email = `admin+${Date.now()}@example.com`;
|
||||||
|
|
||||||
|
async function request(method, path, body, token, options = {}) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? parseResponseBody(text) : {};
|
||||||
|
const expectedStatus = options.expectedStatus;
|
||||||
|
|
||||||
|
if (expectedStatus !== undefined) {
|
||||||
|
if (response.status !== expectedStatus) {
|
||||||
|
throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResponseBody(text) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { message: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`testing onboarding api via ${baseUrl}`);
|
||||||
|
|
||||||
|
const registration = await request("POST", "/api/v1/registration/organization", {
|
||||||
|
organization_name: "Muster GmbH",
|
||||||
|
email,
|
||||||
|
accept_terms: true,
|
||||||
|
});
|
||||||
|
assert(registration.id, "registration id missing");
|
||||||
|
console.log(`registered ${registration.id}`);
|
||||||
|
|
||||||
|
const registrations = await request("GET", "/api/v1/admin/organization-registrations");
|
||||||
|
assert(
|
||||||
|
registrations.some((item) => item.id === registration.id),
|
||||||
|
"registration not found in list",
|
||||||
|
);
|
||||||
|
|
||||||
|
const detail = await request(
|
||||||
|
"GET",
|
||||||
|
`/api/v1/admin/organization-registrations/${registration.id}`,
|
||||||
|
);
|
||||||
|
assert(detail.organization_name === "Muster GmbH", "organization name did not decrypt");
|
||||||
|
assert(detail.email === email, "registration email mismatch");
|
||||||
|
|
||||||
|
const approval = await request(
|
||||||
|
"POST",
|
||||||
|
`/api/v1/admin/organization-registrations/${registration.id}/approve`,
|
||||||
|
);
|
||||||
|
assert(approval.organization_id, "organization id missing");
|
||||||
|
assert(approval.schema_name?.startsWith("company_"), "schema name missing");
|
||||||
|
assert(approval.dev_initial_password, "dev initial password missing");
|
||||||
|
console.log(`approved ${approval.organization_id}`);
|
||||||
|
|
||||||
|
const login = await request("POST", "/api/v1/auth/login", {
|
||||||
|
email,
|
||||||
|
password: approval.dev_initial_password,
|
||||||
|
});
|
||||||
|
assert(login.must_change_password === true, "must_change_password should be true");
|
||||||
|
assert(login.access_token, "access token missing");
|
||||||
|
assert(login.organization_id, "selected organization missing");
|
||||||
|
assert(login.organizations.length >= 1, "login organizations missing");
|
||||||
|
const token = login.access_token;
|
||||||
|
|
||||||
|
const selected = await request(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/auth/select-organization",
|
||||||
|
{ organization_id: login.organization_id },
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
assert(selected.selected === true, "organization was not selected");
|
||||||
|
|
||||||
|
const users = await request("GET", "/api/v1/organizations/current/users", undefined, token);
|
||||||
|
assert(users.length >= 1, "users list is empty");
|
||||||
|
assert(users.some((user) => user.email === email), "owner user missing");
|
||||||
|
|
||||||
|
const setup = await request("PUT", "/api/v1/organizations/current/setup", {
|
||||||
|
display_name: "Muster GmbH",
|
||||||
|
legal_form: "GmbH",
|
||||||
|
street: "Musterstrasse 1",
|
||||||
|
postal_code: "12345",
|
||||||
|
city: "Musterstadt",
|
||||||
|
country: "Deutschland",
|
||||||
|
vat_id: "",
|
||||||
|
email: "info@example.com",
|
||||||
|
phone: "",
|
||||||
|
default_tax_rate: "19",
|
||||||
|
default_payment_days: "14",
|
||||||
|
}, token);
|
||||||
|
assert(setup.saved === true, "organization setup was not saved");
|
||||||
|
|
||||||
|
const loadedSetup = await request("GET", "/api/v1/organizations/current/setup", undefined, token);
|
||||||
|
assert(loadedSetup.setup?.display_name === "Muster GmbH", "organization setup was not loaded");
|
||||||
|
assert(loadedSetup.setup?.street === "Musterstrasse 1", "organization setup street mismatch");
|
||||||
|
|
||||||
|
const numberRanges = await request("GET", "/api/v1/number-ranges", undefined, token);
|
||||||
|
assert(numberRanges.some((range) => range.code === "customers" && range.pattern === "KU{counter}"), "customer number range missing");
|
||||||
|
assert(numberRanges.some((range) => range.code === "items" && range.pattern === "AR{counter}"), "item number range missing");
|
||||||
|
assert(numberRanges.some((range) => range.code === "activities" && range.pattern === "AK{counter}"), "activity number range missing");
|
||||||
|
assert(numberRanges.some((range) => range.code === "outgoing_invoices" && range.pattern === "AR{counter}"), "invoice number range missing");
|
||||||
|
|
||||||
|
const cashDiscountTerm = await request("POST", "/api/v1/cash-discount-terms", {
|
||||||
|
code: "2-10-30",
|
||||||
|
name: "2 % Skonto bei Zahlung innerhalb von 10 Tagen",
|
||||||
|
discount_percent: "2.00",
|
||||||
|
discount_days: 10,
|
||||||
|
net_days: 30,
|
||||||
|
valid_from: null,
|
||||||
|
valid_until: null,
|
||||||
|
is_default_customer_term: true,
|
||||||
|
is_default_supplier_term: true,
|
||||||
|
is_active: true,
|
||||||
|
}, token);
|
||||||
|
assert(cashDiscountTerm.id, "cash discount term id missing");
|
||||||
|
const cashDiscountTerms = await request("GET", "/api/v1/cash-discount-terms", undefined, token);
|
||||||
|
assert(cashDiscountTerms.some((term) => term.id === cashDiscountTerm.id), "cash discount term missing");
|
||||||
|
|
||||||
|
const createdCustomer = await request("POST", "/api/v1/customers", {
|
||||||
|
customer_number: "",
|
||||||
|
name: "Beispielkunde GmbH",
|
||||||
|
status: "active",
|
||||||
|
details: {
|
||||||
|
street: "Kundenstraße 4",
|
||||||
|
postal_code: "54321",
|
||||||
|
city: "Kundenstadt",
|
||||||
|
country: "Deutschland",
|
||||||
|
email: "kunde@example.com",
|
||||||
|
phone: "01234 56789",
|
||||||
|
},
|
||||||
|
standard_discount_percent: "5.50",
|
||||||
|
cash_discount_term_id: cashDiscountTerm.id,
|
||||||
|
}, token);
|
||||||
|
assert(createdCustomer.id, "customer id missing");
|
||||||
|
assert(/^KU\d{3}\.\d{3}\.\d{3}$/.test(createdCustomer.customer_number), "customer number was not generated");
|
||||||
|
|
||||||
|
const customers = await request("GET", "/api/v1/customers", undefined, token);
|
||||||
|
const listedCustomer = customers.find((customer) => customer.id === createdCustomer.id);
|
||||||
|
assert(listedCustomer?.name === "Beispielkunde GmbH", "customer name was not loaded");
|
||||||
|
assert(listedCustomer?.details.city === "Kundenstadt", "customer details were not loaded");
|
||||||
|
assert(listedCustomer?.standard_discount_percent.startsWith("5.5"), "customer discount missing");
|
||||||
|
assert(listedCustomer?.cash_discount_term_id === cashDiscountTerm.id, "customer cash discount missing");
|
||||||
|
|
||||||
|
const updatedCustomer = await request("PUT", `/api/v1/customers/${createdCustomer.id}`, {
|
||||||
|
...createdCustomer,
|
||||||
|
name: "Beispielkunde AG",
|
||||||
|
standard_discount_percent: "7.00",
|
||||||
|
}, token);
|
||||||
|
assert(updatedCustomer.name === "Beispielkunde AG", "customer update failed");
|
||||||
|
|
||||||
|
const supplier = await request("POST", "/api/v1/suppliers", {
|
||||||
|
supplier_number: "",
|
||||||
|
name: "Beispiellieferant GmbH",
|
||||||
|
status: "active",
|
||||||
|
details: { street: "Lieferweg 1", postal_code: "10115", city: "Berlin", country: "Deutschland", email: "lieferant@example.com", phone: "" },
|
||||||
|
standard_discount_percent: "2.00",
|
||||||
|
cash_discount_term_id: cashDiscountTerm.id,
|
||||||
|
payment_days: 30,
|
||||||
|
}, token);
|
||||||
|
assert(/^LI\d{3}\.\d{3}\.\d{3}$/.test(supplier.supplier_number), "supplier number was not generated");
|
||||||
|
const suppliers = await request("GET", "/api/v1/suppliers", undefined, token);
|
||||||
|
assert(suppliers.some((record) => record.id === supplier.id && record.details.city === "Berlin"), "supplier CRUD failed");
|
||||||
|
assert(suppliers.some((record) => record.id === supplier.id && record.cash_discount_term_id === cashDiscountTerm.id), "supplier cash discount missing");
|
||||||
|
|
||||||
|
const item = await request("POST", "/api/v1/items", {
|
||||||
|
item_number: "",
|
||||||
|
name: "Montagestunde",
|
||||||
|
unit: "Std",
|
||||||
|
tax_rate: "19",
|
||||||
|
default_purchase_price: "40.00",
|
||||||
|
default_sales_price: "85.00",
|
||||||
|
status: "active",
|
||||||
|
}, token);
|
||||||
|
assert(/^AR\d{3}\.\d{3}\.\d{3}$/.test(item.item_number), "item number was not generated");
|
||||||
|
const updatedItem = await request("PUT", `/api/v1/items/${item.id}`, { ...item, default_sales_price: "95.00" }, token);
|
||||||
|
assert(updatedItem.default_sales_price === "95.00", "item update failed");
|
||||||
|
const priceHistory = await request("GET", `/api/v1/items/${item.id}/prices`, undefined, token);
|
||||||
|
assert(priceHistory.length >= 2, "item price history missing");
|
||||||
|
assert(priceHistory.some((entry) => entry.sales_price?.startsWith("95")), "updated item price history missing");
|
||||||
|
|
||||||
|
const priceListContent = [
|
||||||
|
"item_number;name;unit;tax_rate;purchase_price;sales_price",
|
||||||
|
"IMP-100;Importartikel;Stk;19;10.00;25.00",
|
||||||
|
`${item.item_number};Montagestunde Import;Std;19;42.00;99.00`,
|
||||||
|
].join("\n");
|
||||||
|
const importPreview = await request("POST", "/api/v1/imports/price-list/preview", {
|
||||||
|
source_name: "api-test-price-list.csv",
|
||||||
|
delimiter: ";",
|
||||||
|
content: priceListContent,
|
||||||
|
}, token);
|
||||||
|
assert(importPreview.total_rows === 2, "price import preview row count mismatch");
|
||||||
|
assert(importPreview.valid_rows === 2, "price import preview valid rows mismatch");
|
||||||
|
assert(importPreview.rows.some((row) => row.item_number === "IMP-100" && row.action === "create"), "price import create action missing");
|
||||||
|
assert(importPreview.rows.some((row) => row.item_number === item.item_number && row.action === "update"), "price import update action missing");
|
||||||
|
|
||||||
|
const importApply = await request("POST", "/api/v1/imports/price-list/apply", {
|
||||||
|
source_name: "api-test-price-list.csv",
|
||||||
|
delimiter: ";",
|
||||||
|
content: priceListContent,
|
||||||
|
}, token);
|
||||||
|
assert(importApply.import_id, "price import id missing");
|
||||||
|
assert(importApply.applied_rows === 2, "price import applied rows mismatch");
|
||||||
|
assert(importApply.error_rows === 0, "price import errors mismatch");
|
||||||
|
|
||||||
|
const importedItems = await request("GET", "/api/v1/items", undefined, token);
|
||||||
|
assert(importedItems.some((record) => record.item_number === "IMP-100" && record.name === "Importartikel"), "imported item missing");
|
||||||
|
const reloadedItem = importedItems.find((record) => record.id === item.id);
|
||||||
|
assert(reloadedItem?.default_sales_price?.startsWith("99"), "imported item price update missing");
|
||||||
|
const importedPriceHistory = await request("GET", `/api/v1/items/${item.id}/prices`, undefined, token);
|
||||||
|
assert(importedPriceHistory.some((entry) => entry.source.startsWith("import:") && entry.sales_price?.startsWith("99")), "import price history missing");
|
||||||
|
|
||||||
|
const connector = await request("POST", "/api/v1/api-connectors", {
|
||||||
|
code: "demo_price_api",
|
||||||
|
name: "Demo Preis API",
|
||||||
|
connector_type: "demo",
|
||||||
|
config: {
|
||||||
|
base_url: "https://example.test/api",
|
||||||
|
token: "secret",
|
||||||
|
delimiter: ";",
|
||||||
|
price_list_csv: [
|
||||||
|
"item_number;name;unit;tax_rate;purchase_price;sales_price",
|
||||||
|
"IMP-100;Importartikel API;Stk;19;11.00;27.00",
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
is_active: true,
|
||||||
|
sync_interval_minutes: 60,
|
||||||
|
}, token);
|
||||||
|
assert(connector.id, "api connector id missing");
|
||||||
|
assert(connector.config?.token === "secret", "api connector config roundtrip failed");
|
||||||
|
const connectors = await request("GET", "/api/v1/api-connectors", undefined, token);
|
||||||
|
assert(connectors.some((record) => record.id === connector.id && record.config?.base_url === "https://example.test/api"), "api connector list missing");
|
||||||
|
const connectorSync = await request("POST", `/api/v1/api-connectors/${connector.id}/sync`, {}, token);
|
||||||
|
assert(connectorSync.synced === true, "api connector sync failed");
|
||||||
|
assert(connectorSync.applied_rows === 1, "api connector price sync did not apply rows");
|
||||||
|
const syncedItems = await request("GET", "/api/v1/items", undefined, token);
|
||||||
|
assert(syncedItems.some((record) => record.item_number === "IMP-100" && record.default_sales_price?.startsWith("27")), "api connector price update missing");
|
||||||
|
const deletedConnector = await request("DELETE", `/api/v1/api-connectors/${connector.id}`, undefined, token);
|
||||||
|
assert(deletedConnector.deleted === true, "api connector deactivate failed");
|
||||||
|
|
||||||
|
const priceRule = await request("POST", "/api/v1/price-rules", {
|
||||||
|
code: "standard_import_markup",
|
||||||
|
name: "Standardaufschlag Import",
|
||||||
|
source_type: "import",
|
||||||
|
source_id: null,
|
||||||
|
markup_percent: "25.00",
|
||||||
|
rounding_mode: "cent",
|
||||||
|
is_active: true,
|
||||||
|
}, token);
|
||||||
|
assert(priceRule.id, "price rule id missing");
|
||||||
|
const priceRules = await request("GET", "/api/v1/price-rules", undefined, token);
|
||||||
|
assert(priceRules.some((record) => record.id === priceRule.id && record.markup_percent.startsWith("25")), "price rule list missing");
|
||||||
|
const updatedPriceRule = await request("PUT", `/api/v1/price-rules/${priceRule.id}`, {
|
||||||
|
...priceRule,
|
||||||
|
markup_percent: "30.00",
|
||||||
|
rounding_mode: "five_cent",
|
||||||
|
}, token);
|
||||||
|
assert(updatedPriceRule.rounding_mode === "five_cent", "price rule update failed");
|
||||||
|
const deletedPriceRule = await request("DELETE", `/api/v1/price-rules/${priceRule.id}`, undefined, token);
|
||||||
|
assert(deletedPriceRule.deleted === true, "price rule deactivate failed");
|
||||||
|
|
||||||
|
const quote = await request("POST", "/api/v1/quotes", {
|
||||||
|
quote_number: "",
|
||||||
|
customer_id: createdCustomer.id,
|
||||||
|
status: "draft",
|
||||||
|
valid_until: null,
|
||||||
|
cash_discount_term_id: cashDiscountTerm.id,
|
||||||
|
customer_discount_percent: "7.00",
|
||||||
|
notes: "Erstes Testangebot.",
|
||||||
|
items: [{
|
||||||
|
item_id: item.id,
|
||||||
|
description: "Montagestunde mit Sonderpreis",
|
||||||
|
quantity: "2.00",
|
||||||
|
unit_price: "90.00",
|
||||||
|
original_unit_price: "95.00",
|
||||||
|
discount_percent: "0.00",
|
||||||
|
tax_rate: "19.00",
|
||||||
|
}],
|
||||||
|
}, token);
|
||||||
|
assert(/^AN\d{3}\.\d{3}\.\d{3}$/.test(quote.quote_number), "quote number was not generated");
|
||||||
|
assert(quote.items.length === 1, "quote item missing");
|
||||||
|
assert(quote.items[0].price_overridden === true, "quote price override missing");
|
||||||
|
|
||||||
|
const quotes = await request("GET", "/api/v1/quotes", undefined, token);
|
||||||
|
assert(quotes.some((record) => record.id === quote.id && record.notes.includes("Testangebot")), "quote was not loaded");
|
||||||
|
const updatedQuote = await request("PUT", `/api/v1/quotes/${quote.id}`, {
|
||||||
|
...quote,
|
||||||
|
status: "sent",
|
||||||
|
items: quote.items.map((line) => ({ ...line, quantity: "3.00" })),
|
||||||
|
}, token);
|
||||||
|
assert(updatedQuote.status === "sent", "quote update failed");
|
||||||
|
const convertedInvoice = await request("POST", `/api/v1/quotes/${quote.id}/convert-to-invoice`, undefined, token);
|
||||||
|
assert(/^AR\d{3}\.\d{3}\.\d{3}$/.test(convertedInvoice.invoice_number), "converted invoice number missing");
|
||||||
|
assert(convertedInvoice.source_quote_id === quote.id, "converted invoice quote link missing");
|
||||||
|
assert(convertedInvoice.items.length === 1, "converted invoice item missing");
|
||||||
|
const invoices = await request("GET", "/api/v1/outgoing-invoices", undefined, token);
|
||||||
|
assert(invoices.some((record) => record.id === convertedInvoice.id), "outgoing invoice was not loaded");
|
||||||
|
const finalized = await request("POST", `/api/v1/outgoing-invoices/${convertedInvoice.id}/finalize`, undefined, token);
|
||||||
|
assert(finalized.finalized === true, "outgoing invoice finalize failed");
|
||||||
|
const deletedQuote = await request("DELETE", `/api/v1/quotes/${quote.id}`, undefined, token);
|
||||||
|
assert(deletedQuote.deleted === true, "quote cancel failed");
|
||||||
|
|
||||||
|
const incomingInvoice = await request("POST", "/api/v1/incoming-invoices", {
|
||||||
|
invoice_number: "EXT-10001",
|
||||||
|
supplier_id: supplier.id,
|
||||||
|
status: "received",
|
||||||
|
cash_discount_term_id: cashDiscountTerm.id,
|
||||||
|
invoice_date: null,
|
||||||
|
due_at: null,
|
||||||
|
items: [{
|
||||||
|
item_id: item.id,
|
||||||
|
description: "Einkauf Montagestunde",
|
||||||
|
quantity: "1.00",
|
||||||
|
unit_price: "40.00",
|
||||||
|
tax_rate: "19.00",
|
||||||
|
}],
|
||||||
|
}, token);
|
||||||
|
assert(incomingInvoice.id, "incoming invoice id missing");
|
||||||
|
assert(incomingInvoice.cash_discount_term_id === cashDiscountTerm.id, "incoming invoice cash discount missing");
|
||||||
|
const incomingInvoices = await request("GET", "/api/v1/incoming-invoices", undefined, token);
|
||||||
|
assert(incomingInvoices.some((record) => record.id === incomingInvoice.id), "incoming invoice was not loaded");
|
||||||
|
const deletedIncomingInvoice = await request("DELETE", `/api/v1/incoming-invoices/${incomingInvoice.id}`, undefined, token);
|
||||||
|
assert(deletedIncomingInvoice.deleted === true, "incoming invoice cancel failed");
|
||||||
|
|
||||||
|
const deletedItem = await request("DELETE", `/api/v1/items/${item.id}`, undefined, token);
|
||||||
|
assert(deletedItem.deleted === true, "item deactivate failed");
|
||||||
|
|
||||||
|
const deletedSupplier = await request("DELETE", `/api/v1/suppliers/${supplier.id}`, undefined, token);
|
||||||
|
assert(deletedSupplier.deleted === true, "supplier deactivate failed");
|
||||||
|
|
||||||
|
const deletedCustomer = await request("DELETE", `/api/v1/customers/${createdCustomer.id}`, undefined, token);
|
||||||
|
assert(deletedCustomer.deleted === true, "customer deactivate failed");
|
||||||
|
|
||||||
|
const activity = await request("POST", "/api/v1/activities", {
|
||||||
|
activity_number: null,
|
||||||
|
activity_type: "task",
|
||||||
|
title: "Angebot prüfen",
|
||||||
|
body: "Preise mit Kunde abstimmen.",
|
||||||
|
status: "open",
|
||||||
|
priority: "high",
|
||||||
|
due_at: null,
|
||||||
|
}, token);
|
||||||
|
assert(/^AK\d{3}\.\d{3}\.\d{3}$/.test(activity.activity_number), "activity number was not generated");
|
||||||
|
const activities = await request("GET", "/api/v1/activities", undefined, token);
|
||||||
|
assert(activities.some((record) => record.id === activity.id && record.body.includes("Preise")), "activity CRUD failed");
|
||||||
|
const deletedActivity = await request("DELETE", `/api/v1/activities/${activity.id}`, undefined, token);
|
||||||
|
assert(deletedActivity.deleted === true, "activity cancel failed");
|
||||||
|
|
||||||
|
const communication = await request("POST", "/api/v1/communications", {
|
||||||
|
communication_type: "email",
|
||||||
|
direction: "outbound",
|
||||||
|
subject: "Rückfrage zum Angebot",
|
||||||
|
body: "Kunde bittet um aktualisierte Dokumente.",
|
||||||
|
status: "open",
|
||||||
|
occurred_at: null,
|
||||||
|
links: [{ entity_type: "customer", entity_id: createdCustomer.id }],
|
||||||
|
}, token);
|
||||||
|
assert(communication.id, "communication id missing");
|
||||||
|
assert(communication.subject === "Rückfrage zum Angebot", "communication subject mismatch");
|
||||||
|
assert(communication.links.some((link) => link.entity_id === createdCustomer.id), "communication link missing");
|
||||||
|
const communications = await request("GET", "/api/v1/communications", undefined, token);
|
||||||
|
assert(communications.some((record) => record.id === communication.id), "communication list missing");
|
||||||
|
|
||||||
|
const documentContent = Buffer.from("Dokumentinhalt für Phase 6", "utf8").toString("base64");
|
||||||
|
const documentRecord = await request("POST", "/api/v1/documents", {
|
||||||
|
title: "Testdokument",
|
||||||
|
description: "Dokument für Phase 6",
|
||||||
|
file_name: "phase-6.txt",
|
||||||
|
content_type: "text/plain",
|
||||||
|
content_base64: documentContent,
|
||||||
|
links: [{ entity_type: "communication", entity_id: communication.id }],
|
||||||
|
}, token);
|
||||||
|
assert(documentRecord.id, "document id missing");
|
||||||
|
assert(documentRecord.latest_version?.file_name === "phase-6.txt", "document metadata missing");
|
||||||
|
const documents = await request("GET", "/api/v1/documents", undefined, token);
|
||||||
|
assert(documents.some((record) => record.id === documentRecord.id), "document list missing");
|
||||||
|
const downloadedDocument = await request("GET", `/api/v1/documents/${documentRecord.id}/download`, undefined, token);
|
||||||
|
assert(downloadedDocument.content_base64 === documentContent, "document download content mismatch");
|
||||||
|
const auditLog = await request("GET", `/api/v1/documents/${documentRecord.id}/audit-log`, undefined, token);
|
||||||
|
assert(auditLog.some((entry) => entry.action === "upload"), "document upload audit missing");
|
||||||
|
assert(auditLog.some((entry) => entry.action === "download"), "document download audit missing");
|
||||||
|
const deletedDocument = await request("DELETE", `/api/v1/documents/${documentRecord.id}`, undefined, token);
|
||||||
|
assert(deletedDocument.deleted === true, "document archive failed");
|
||||||
|
|
||||||
|
const invitedEmail = `user+${Date.now()}@example.com`;
|
||||||
|
const invitation = await request("POST", "/api/v1/organizations/current/invitations", {
|
||||||
|
email: invitedEmail,
|
||||||
|
roles: ["viewer"],
|
||||||
|
}, token);
|
||||||
|
assert(invitation.id, "invitation id missing");
|
||||||
|
assert(invitation.dev_invitation_token, "dev invitation token missing");
|
||||||
|
|
||||||
|
const invitedUsers = await request("GET", "/api/v1/organizations/current/users", undefined, token);
|
||||||
|
const invitedUser = invitedUsers.find((user) => user.user_id === invitation.user_id);
|
||||||
|
assert(invitedUser, "invited user missing");
|
||||||
|
const acceptedInvitation = await request("POST", "/api/v1/auth/accept-invitation", {
|
||||||
|
token: invitation.dev_invitation_token,
|
||||||
|
new_password: "InvitePass123",
|
||||||
|
new_password_confirm: "InvitePass123",
|
||||||
|
});
|
||||||
|
assert(acceptedInvitation.accepted === true, "invitation accept failed");
|
||||||
|
const invitedLogin = await request("POST", "/api/v1/auth/login", {
|
||||||
|
email: invitedEmail,
|
||||||
|
password: "InvitePass123",
|
||||||
|
});
|
||||||
|
assert(invitedLogin.access_token, "invited user login failed");
|
||||||
|
assert(invitedLogin.organization_id, "invited user selected organization missing");
|
||||||
|
const invitedToken = invitedLogin.access_token;
|
||||||
|
|
||||||
|
await request(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/auth/select-organization",
|
||||||
|
{ organization_id: invitedLogin.organization_id },
|
||||||
|
invitedToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deniedCustomerWrite = await request("POST", "/api/v1/customers", {
|
||||||
|
customer_number: "",
|
||||||
|
name: "Nicht erlaubt GmbH",
|
||||||
|
status: "active",
|
||||||
|
details: {
|
||||||
|
street: "Sperrweg 1",
|
||||||
|
postal_code: "12345",
|
||||||
|
city: "Teststadt",
|
||||||
|
country: "Deutschland",
|
||||||
|
email: "nicht-erlaubt@example.test",
|
||||||
|
phone: "",
|
||||||
|
},
|
||||||
|
standard_discount_percent: "0",
|
||||||
|
cash_discount_term_id: null,
|
||||||
|
}, invitedToken, { expectedStatus: 403 });
|
||||||
|
assert(deniedCustomerWrite.message === "Berechtigung fehlt", "viewer customer write was not forbidden");
|
||||||
|
|
||||||
|
const deniedRoleWrite = await request(
|
||||||
|
"PATCH",
|
||||||
|
`/api/v1/organizations/current/users/${invitation.user_id}/roles`,
|
||||||
|
{ roles: ["admin"] },
|
||||||
|
invitedToken,
|
||||||
|
{ expectedStatus: 403 },
|
||||||
|
);
|
||||||
|
assert(deniedRoleWrite.message === "Berechtigung fehlt", "viewer role write was not forbidden");
|
||||||
|
|
||||||
|
await request(
|
||||||
|
"PATCH",
|
||||||
|
`/api/v1/organizations/current/users/${invitation.user_id}/roles`,
|
||||||
|
{ roles: ["sales", "viewer"] },
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
const updatedUsers = await request("GET", "/api/v1/organizations/current/users", undefined, token);
|
||||||
|
const updatedUser = updatedUsers.find((user) => user.user_id === invitation.user_id);
|
||||||
|
assert(updatedUser.roles.includes("sales"), "role change was not saved");
|
||||||
|
|
||||||
|
const resetRequest = await request("POST", "/api/v1/auth/request-password-reset", { email });
|
||||||
|
assert(resetRequest.queued === true, "password reset request failed");
|
||||||
|
assert(resetRequest.dev_reset_token, "dev reset token missing");
|
||||||
|
const resetPassword = await request("POST", "/api/v1/auth/reset-password", {
|
||||||
|
token: resetRequest.dev_reset_token,
|
||||||
|
new_password: "ResetPass123",
|
||||||
|
new_password_confirm: "ResetPass123",
|
||||||
|
});
|
||||||
|
assert(resetPassword.changed === true, "password reset failed");
|
||||||
|
const resetLogin = await request("POST", "/api/v1/auth/login", {
|
||||||
|
email,
|
||||||
|
password: "ResetPass123",
|
||||||
|
});
|
||||||
|
assert(resetLogin.access_token, "login after password reset failed");
|
||||||
|
|
||||||
|
console.log("onboarding api test ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
258
scripts/communication-test.mjs
Normal file
258
scripts/communication-test.mjs
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { webcrypto } from "node:crypto";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
const wsUrl = process.argv[2] ?? process.env.WS_URL ?? "ws://localhost:8080/ws";
|
||||||
|
const apiBaseUrl = process.argv[3] ?? process.env.API_BASE_URL;
|
||||||
|
const protocolVersion = 1;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
function bytesToBase64(bytes) {
|
||||||
|
return Buffer.from(bytes).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value) {
|
||||||
|
return Buffer.from(value, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSession() {
|
||||||
|
const key = await webcrypto.subtle.generateKey(
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
true,
|
||||||
|
["encrypt", "decrypt"],
|
||||||
|
);
|
||||||
|
const rawKey = await webcrypto.subtle.exportKey("raw", key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
keyId: webcrypto.randomUUID(),
|
||||||
|
exportedKey: bytesToBase64(new Uint8Array(rawKey)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptMessage(session, message) {
|
||||||
|
const nonce = webcrypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const plaintext = encoder.encode(JSON.stringify(message));
|
||||||
|
const ciphertext = await webcrypto.subtle.encrypt(
|
||||||
|
{ name: "AES-GCM", iv: nonce },
|
||||||
|
session.key,
|
||||||
|
plaintext,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enc: `aes-256-gcm-v${protocolVersion}`,
|
||||||
|
key_id: session.keyId,
|
||||||
|
nonce: bytesToBase64(nonce),
|
||||||
|
ciphertext: bytesToBase64(new Uint8Array(ciphertext)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptMessage(session, envelope) {
|
||||||
|
const plaintext = await webcrypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: base64ToBytes(envelope.nonce) },
|
||||||
|
session.key,
|
||||||
|
base64ToBytes(envelope.ciphertext),
|
||||||
|
);
|
||||||
|
|
||||||
|
return JSON.parse(decoder.decode(plaintext));
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForOpen(socket) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
socket.addEventListener("open", resolve, { once: true });
|
||||||
|
socket.addEventListener("error", reject, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForRawMessage(socket, timeoutMs = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
socket.removeEventListener("message", onMessage);
|
||||||
|
reject(new Error(`timeout after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
function onMessage(event) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(String(event.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.addEventListener("message", onMessage, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDecryptedType(client, expectedType, timeoutMs = 5000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const raw = await waitForRawMessage(client.socket, Math.max(100, deadline - Date.now()));
|
||||||
|
const wire = JSON.parse(raw);
|
||||||
|
|
||||||
|
assert.equal(wire.type, "encrypted", `${client.name}: expected encrypted wire message`);
|
||||||
|
assert.equal(wire.payload.key_id, client.session.keyId, `${client.name}: key id mismatch`);
|
||||||
|
assertNoPlaintext(raw, client.name);
|
||||||
|
|
||||||
|
const message = await decryptMessage(client.session, wire.payload);
|
||||||
|
if (message.type === expectedType) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`${client.name}: did not receive ${expectedType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRecordChanged(client, expectedTitle, timeoutMs = 5000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const message = await waitForDecryptedType(
|
||||||
|
client,
|
||||||
|
"record_changed",
|
||||||
|
Math.max(100, deadline - Date.now()),
|
||||||
|
);
|
||||||
|
if (!expectedTitle || message.payload.record?.title === expectedTitle) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`${client.name}: did not receive record_changed ${expectedTitle}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoPlaintext(raw, clientName) {
|
||||||
|
for (const forbidden of ["snapshot", "record_changed", "Erster Datensatz", "records"]) {
|
||||||
|
assert.equal(
|
||||||
|
raw.includes(forbidden),
|
||||||
|
false,
|
||||||
|
`${clientName}: raw frame leaked plaintext marker ${forbidden}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectClient(name) {
|
||||||
|
const session = await createSession();
|
||||||
|
const socket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
await waitForOpen(socket);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
payload: {
|
||||||
|
protocol_version: protocolVersion,
|
||||||
|
key_id: session.keyId,
|
||||||
|
session_key: session.exportedKey,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ack = JSON.parse(await waitForRawMessage(socket));
|
||||||
|
assert.equal(ack.type, "hello_ack", `${name}: expected hello_ack`);
|
||||||
|
assert.equal(ack.payload.protocol_version, protocolVersion, `${name}: protocol mismatch`);
|
||||||
|
assert.equal(ack.payload.key_id, session.keyId, `${name}: ack key mismatch`);
|
||||||
|
|
||||||
|
const client = { name, socket, session };
|
||||||
|
const snapshot = await waitForDecryptedType(client, "snapshot");
|
||||||
|
assert.ok(Array.isArray(snapshot.payload.records), `${name}: snapshot records missing`);
|
||||||
|
|
||||||
|
await sendEncrypted(client, {
|
||||||
|
type: "subscribe",
|
||||||
|
payload: { topic: "records" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEncrypted(client, message) {
|
||||||
|
const envelope = await encryptMessage(client.session, message);
|
||||||
|
client.socket.send(JSON.stringify({ type: "encrypted", payload: envelope }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeClient(client) {
|
||||||
|
if (client.socket.readyState === WebSocket.OPEN) {
|
||||||
|
client.socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(method, path, body, token) {
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? JSON.parse(text) : {};
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLiveEventViaApi() {
|
||||||
|
const email = `live-event-${Date.now()}@example.test`;
|
||||||
|
const bootstrap = await request("POST", "/api/v1/dev/bootstrap-local", {
|
||||||
|
organization_name: "Live Event Test GmbH",
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
const login = await request("POST", "/api/v1/auth/login", {
|
||||||
|
email,
|
||||||
|
password: bootstrap.password,
|
||||||
|
});
|
||||||
|
const token = login.access_token;
|
||||||
|
await request("POST", "/api/v1/auth/select-organization", {
|
||||||
|
organization_id: login.organization_id,
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
await request("POST", "/api/v1/activities", {
|
||||||
|
activity_number: null,
|
||||||
|
activity_type: "task",
|
||||||
|
title: "Live-Event testen",
|
||||||
|
body: "Änderung muss an alle Clients gehen.",
|
||||||
|
status: "open",
|
||||||
|
priority: "normal",
|
||||||
|
due_at: null,
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`testing encrypted communication via ${wsUrl}`);
|
||||||
|
|
||||||
|
const clientA = await connectClient("client-a");
|
||||||
|
console.log("client-a handshake, encrypted snapshot and subscribe ok");
|
||||||
|
|
||||||
|
const clientB = await connectClient("client-b");
|
||||||
|
console.log("client-b handshake, encrypted snapshot and subscribe ok");
|
||||||
|
|
||||||
|
await sendEncrypted(clientA, { type: "ping" });
|
||||||
|
const [pongA, pongB] = await Promise.all([
|
||||||
|
waitForDecryptedType(clientA, "pong"),
|
||||||
|
waitForDecryptedType(clientB, "pong"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(pongA.type, "pong");
|
||||||
|
assert.equal(pongB.type, "pong");
|
||||||
|
assert.notEqual(clientA.session.keyId, clientB.session.keyId, "clients must use different keys");
|
||||||
|
|
||||||
|
if (apiBaseUrl) {
|
||||||
|
console.log(`testing api-triggered live event via ${apiBaseUrl}`);
|
||||||
|
const waitA = waitForRecordChanged(clientA, "Aktivität angelegt");
|
||||||
|
const waitB = waitForRecordChanged(clientB, "Aktivität angelegt");
|
||||||
|
await createLiveEventViaApi();
|
||||||
|
await Promise.all([waitA, waitB]);
|
||||||
|
console.log("api-triggered live event reached both clients");
|
||||||
|
}
|
||||||
|
|
||||||
|
closeClient(clientA);
|
||||||
|
closeClient(clientB);
|
||||||
|
|
||||||
|
console.log("encrypted multi-client communication test ok");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
130
scripts/dev-seed.mjs
Normal file
130
scripts/dev-seed.mjs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const baseUrl = process.argv[2] ?? process.env.API_BASE_URL ?? "http://127.0.0.1:8080";
|
||||||
|
const stamp = Date.now();
|
||||||
|
const email = process.env.DEV_SEED_EMAIL ?? `seed-admin-${stamp}@example.test`;
|
||||||
|
|
||||||
|
async function request(method, path, body, token) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? JSON.parse(text) : {};
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`creating development seed data via ${baseUrl}`);
|
||||||
|
|
||||||
|
const bootstrap = await request("POST", "/api/v1/dev/bootstrap-local", {
|
||||||
|
organization_name: `Seed Firma ${stamp}`,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
assert(bootstrap.password, "dev bootstrap password missing");
|
||||||
|
|
||||||
|
const login = await request("POST", "/api/v1/auth/login", {
|
||||||
|
email,
|
||||||
|
password: bootstrap.password,
|
||||||
|
});
|
||||||
|
const token = login.access_token;
|
||||||
|
assert(token, "login token missing");
|
||||||
|
|
||||||
|
await request("POST", "/api/v1/auth/select-organization", {
|
||||||
|
organization_id: login.organization_id,
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
const cashDiscountTerm = await request("POST", "/api/v1/cash-discount-terms", {
|
||||||
|
code: `SEED-${stamp}`,
|
||||||
|
name: "2 % Skonto, 30 Tage netto",
|
||||||
|
discount_percent: "2.00",
|
||||||
|
discount_days: 10,
|
||||||
|
net_days: 30,
|
||||||
|
valid_from: null,
|
||||||
|
valid_until: null,
|
||||||
|
is_default_customer_term: true,
|
||||||
|
is_default_supplier_term: true,
|
||||||
|
is_active: true,
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
const customer = await request("POST", "/api/v1/customers", {
|
||||||
|
customer_number: "",
|
||||||
|
name: "Seed Kunde GmbH",
|
||||||
|
status: "active",
|
||||||
|
details: {
|
||||||
|
street: "Kundenweg 10",
|
||||||
|
postal_code: "60311",
|
||||||
|
city: "Frankfurt",
|
||||||
|
country: "Deutschland",
|
||||||
|
email: "kunde@example.test",
|
||||||
|
phone: "",
|
||||||
|
},
|
||||||
|
standard_discount_percent: "5.00",
|
||||||
|
cash_discount_term_id: cashDiscountTerm.id,
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
const supplier = await request("POST", "/api/v1/suppliers", {
|
||||||
|
supplier_number: "",
|
||||||
|
name: "Seed Lieferant GmbH",
|
||||||
|
status: "active",
|
||||||
|
details: {
|
||||||
|
street: "Lieferstraße 8",
|
||||||
|
postal_code: "10115",
|
||||||
|
city: "Berlin",
|
||||||
|
country: "Deutschland",
|
||||||
|
email: "lieferant@example.test",
|
||||||
|
phone: "",
|
||||||
|
},
|
||||||
|
standard_discount_percent: "0.00",
|
||||||
|
cash_discount_term_id: cashDiscountTerm.id,
|
||||||
|
payment_days: 30,
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
const item = await request("POST", "/api/v1/items", {
|
||||||
|
item_number: "",
|
||||||
|
name: "Seed Montagestunde",
|
||||||
|
unit: "Std",
|
||||||
|
tax_rate: "19.00",
|
||||||
|
default_purchase_price: "40.00",
|
||||||
|
default_sales_price: "85.00",
|
||||||
|
status: "active",
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
const activity = await request("POST", "/api/v1/activities", {
|
||||||
|
activity_number: null,
|
||||||
|
activity_type: "task",
|
||||||
|
title: "Seed Aktivität",
|
||||||
|
body: "Testdaten für lokale Entwicklung.",
|
||||||
|
status: "open",
|
||||||
|
priority: "normal",
|
||||||
|
due_at: null,
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
email,
|
||||||
|
password: bootstrap.password,
|
||||||
|
organization_id: login.organization_id,
|
||||||
|
customer_number: customer.customer_number,
|
||||||
|
supplier_number: supplier.supplier_number,
|
||||||
|
item_number: item.item_number,
|
||||||
|
activity_number: activity.activity_number,
|
||||||
|
}, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
92
scripts/schema-migration-test.mjs
Normal file
92
scripts/schema-migration-test.mjs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const baseUrl = process.argv[2] ?? process.env.API_BASE_URL ?? "http://127.0.0.1:8080";
|
||||||
|
const email = `schema-migration-${Date.now()}@example.test`;
|
||||||
|
|
||||||
|
async function request(method, path, body, token) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? JSON.parse(text) : {};
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`testing schema migration idempotency via ${baseUrl}`);
|
||||||
|
|
||||||
|
const registration = await request("POST", "/api/v1/registration/organization", {
|
||||||
|
organization_name: "Migrationstest GmbH",
|
||||||
|
email,
|
||||||
|
accept_terms: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const approval = await request(
|
||||||
|
"POST",
|
||||||
|
`/api/v1/admin/organization-registrations/${registration.id}/approve`,
|
||||||
|
);
|
||||||
|
assert(approval.schema_name, "schema name missing after approval");
|
||||||
|
|
||||||
|
const retry = await request(
|
||||||
|
"POST",
|
||||||
|
`/api/v1/admin/organization-registrations/${registration.id}/retry-provisioning`,
|
||||||
|
);
|
||||||
|
assert(retry.provisioned === true, "retry provisioning did not report success");
|
||||||
|
assert(retry.schema_name === approval.schema_name, "retry provisioning schema mismatch");
|
||||||
|
|
||||||
|
const login = await request("POST", "/api/v1/auth/login", {
|
||||||
|
email,
|
||||||
|
password: approval.dev_initial_password,
|
||||||
|
});
|
||||||
|
const token = login.access_token;
|
||||||
|
await request("POST", "/api/v1/auth/select-organization", {
|
||||||
|
organization_id: login.organization_id,
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
const ranges = await request("GET", "/api/v1/number-ranges", undefined, token);
|
||||||
|
for (const code of ["customers", "suppliers", "items", "activities", "outgoing_invoices", "incoming_invoices", "quotes"]) {
|
||||||
|
assert(ranges.some((range) => range.code === code), `number range missing after retry: ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await request("GET", "/api/v1/organizations/current/users", undefined, token);
|
||||||
|
const owner = users.find((user) => user.email === email);
|
||||||
|
assert(owner?.roles.includes("owner"), "owner role missing after retry");
|
||||||
|
assert(owner?.roles.includes("admin"), "admin role missing after retry");
|
||||||
|
|
||||||
|
const communication = await request("POST", "/api/v1/communications", {
|
||||||
|
communication_type: "internal_note",
|
||||||
|
direction: "internal",
|
||||||
|
subject: "Migrationstest",
|
||||||
|
body: "Kommunikationstabellen sind vorhanden.",
|
||||||
|
status: "open",
|
||||||
|
occurred_at: null,
|
||||||
|
links: [],
|
||||||
|
}, token);
|
||||||
|
assert(communication.id, "communication insert failed after retry");
|
||||||
|
|
||||||
|
const navigationSettings = await request("PUT", "/api/v1/users/me/settings/navigation", {
|
||||||
|
mode: "groups",
|
||||||
|
}, token);
|
||||||
|
assert(navigationSettings.mode === "groups", "navigation user setting was not saved");
|
||||||
|
|
||||||
|
console.log("schema migration idempotency test ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
26
scripts/standard-check.sh
Normal file
26
scripts/standard-check.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
echo "== Rust format =="
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
|
||||||
|
echo "== Rust workspace check =="
|
||||||
|
cargo check --workspace
|
||||||
|
|
||||||
|
echo "== Desktopclient headless unit tests =="
|
||||||
|
cargo test -p companytool-desktop-client
|
||||||
|
|
||||||
|
echo "== Node script syntax =="
|
||||||
|
node --check scripts/api-onboarding-test.mjs
|
||||||
|
node --check scripts/communication-test.mjs
|
||||||
|
node --check scripts/dev-seed.mjs
|
||||||
|
node --check scripts/schema-migration-test.mjs
|
||||||
|
|
||||||
|
echo "== Webfrontend build and type check =="
|
||||||
|
npm --prefix web-frontend run build
|
||||||
|
|
||||||
|
echo "standard check ok"
|
||||||
139
scripts/ws-smoke-test.mjs
Normal file
139
scripts/ws-smoke-test.mjs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { webcrypto } from "node:crypto";
|
||||||
|
|
||||||
|
const wsUrl = process.argv[2] ?? process.env.WS_URL ?? "ws://localhost:8080/ws";
|
||||||
|
const protocolVersion = 1;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
function bytesToBase64(bytes) {
|
||||||
|
return Buffer.from(bytes).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value) {
|
||||||
|
return Buffer.from(value, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSession() {
|
||||||
|
const key = await webcrypto.subtle.generateKey(
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
true,
|
||||||
|
["encrypt", "decrypt"],
|
||||||
|
);
|
||||||
|
const rawKey = await webcrypto.subtle.exportKey("raw", key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
keyId: webcrypto.randomUUID(),
|
||||||
|
exportedKey: bytesToBase64(new Uint8Array(rawKey)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptMessage(session, message) {
|
||||||
|
const nonce = webcrypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const plaintext = encoder.encode(JSON.stringify(message));
|
||||||
|
const ciphertext = await webcrypto.subtle.encrypt(
|
||||||
|
{ name: "AES-GCM", iv: nonce },
|
||||||
|
session.key,
|
||||||
|
plaintext,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enc: `aes-256-gcm-v${protocolVersion}`,
|
||||||
|
key_id: session.keyId,
|
||||||
|
nonce: bytesToBase64(nonce),
|
||||||
|
ciphertext: bytesToBase64(new Uint8Array(ciphertext)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptMessage(session, envelope) {
|
||||||
|
const plaintext = await webcrypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: base64ToBytes(envelope.nonce) },
|
||||||
|
session.key,
|
||||||
|
base64ToBytes(envelope.ciphertext),
|
||||||
|
);
|
||||||
|
|
||||||
|
return JSON.parse(decoder.decode(plaintext));
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForOpen(socket) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
socket.addEventListener("open", resolve, { once: true });
|
||||||
|
socket.addEventListener("error", reject, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForMessage(socket, timeoutMs = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
socket.removeEventListener("message", onMessage);
|
||||||
|
reject(new Error(`timeout after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
function onMessage(event) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(JSON.parse(event.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.addEventListener("message", onMessage, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const session = await createSession();
|
||||||
|
const socket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
console.log(`connecting ${wsUrl}`);
|
||||||
|
await waitForOpen(socket);
|
||||||
|
console.log("socket open");
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
payload: {
|
||||||
|
protocol_version: protocolVersion,
|
||||||
|
key_id: session.keyId,
|
||||||
|
session_key: session.exportedKey,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ack = await waitForMessage(socket);
|
||||||
|
if (ack.type !== "hello_ack") {
|
||||||
|
throw new Error(`expected hello_ack, got ${JSON.stringify(ack)}`);
|
||||||
|
}
|
||||||
|
console.log(`hello_ack protocol=${ack.payload.protocol_version} key=${ack.payload.key_id}`);
|
||||||
|
|
||||||
|
const firstEncrypted = await waitForMessage(socket);
|
||||||
|
if (firstEncrypted.type !== "encrypted") {
|
||||||
|
throw new Error(`expected encrypted snapshot, got ${JSON.stringify(firstEncrypted)}`);
|
||||||
|
}
|
||||||
|
const snapshot = await decryptMessage(session, firstEncrypted.payload);
|
||||||
|
console.log(`decrypted first server message: ${snapshot.type}`);
|
||||||
|
|
||||||
|
const subscribe = await encryptMessage(session, {
|
||||||
|
type: "subscribe",
|
||||||
|
payload: { topic: "records" },
|
||||||
|
});
|
||||||
|
socket.send(JSON.stringify({ type: "encrypted", payload: subscribe }));
|
||||||
|
console.log("sent encrypted subscribe");
|
||||||
|
|
||||||
|
const ping = await encryptMessage(session, { type: "ping" });
|
||||||
|
socket.send(JSON.stringify({ type: "encrypted", payload: ping }));
|
||||||
|
|
||||||
|
const pongEnvelope = await waitForMessage(socket);
|
||||||
|
if (pongEnvelope.type !== "encrypted") {
|
||||||
|
throw new Error(`expected encrypted pong, got ${JSON.stringify(pongEnvelope)}`);
|
||||||
|
}
|
||||||
|
const pong = await decryptMessage(session, pongEnvelope.payload);
|
||||||
|
console.log(`decrypted ping response: ${pong.type}`);
|
||||||
|
|
||||||
|
socket.close();
|
||||||
|
console.log("communication smoke test ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
14
shared-protocol/Cargo.toml
Normal file
14
shared-protocol/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "companytool-shared-protocol"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["serde", "v4"] }
|
||||||
202
shared-protocol/src/lib.rs
Normal file
202
shared-protocol/src/lib.rs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub const PROTOCOL_VERSION: u16 = 1;
|
||||||
|
pub const SESSION_KEY_BYTES: usize = 32;
|
||||||
|
pub const AES_GCM_NONCE_BYTES: usize = 12;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
|
||||||
|
pub enum WireMessage {
|
||||||
|
Hello(HelloMessage),
|
||||||
|
HelloAck(HelloAckMessage),
|
||||||
|
Encrypted(EncryptedEnvelope),
|
||||||
|
Error(ProtocolErrorMessage),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HelloMessage {
|
||||||
|
pub protocol_version: u16,
|
||||||
|
pub key_id: String,
|
||||||
|
pub session_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HelloAckMessage {
|
||||||
|
pub protocol_version: u16,
|
||||||
|
pub key_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EncryptedEnvelope {
|
||||||
|
pub enc: String,
|
||||||
|
pub key_id: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub ciphertext: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProtocolErrorMessage {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
|
||||||
|
pub enum ClientMessage {
|
||||||
|
Subscribe { topic: String },
|
||||||
|
Ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
|
||||||
|
pub enum ServerMessage {
|
||||||
|
Snapshot { records: Vec<RecordSummary> },
|
||||||
|
RecordChanged { record: RecordSummary },
|
||||||
|
Pong,
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RecordSummary {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod crypto {
|
||||||
|
use aes_gcm::{
|
||||||
|
aead::{Aead, AeadCore, KeyInit, OsRng},
|
||||||
|
Aes256Gcm, Key, Nonce,
|
||||||
|
};
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use rand_core::RngCore;
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
|
||||||
|
use crate::{EncryptedEnvelope, AES_GCM_NONCE_BYTES, PROTOCOL_VERSION, SESSION_KEY_BYTES};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CryptoError {
|
||||||
|
InvalidBase64(base64::DecodeError),
|
||||||
|
InvalidKeyLength(usize),
|
||||||
|
InvalidNonceLength(usize),
|
||||||
|
Serialize(serde_json::Error),
|
||||||
|
Deserialize(serde_json::Error),
|
||||||
|
Encrypt,
|
||||||
|
Decrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CryptoError {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidBase64(error) => write!(formatter, "invalid base64: {error}"),
|
||||||
|
Self::InvalidKeyLength(length) => write!(formatter, "invalid key length: {length}"),
|
||||||
|
Self::InvalidNonceLength(length) => {
|
||||||
|
write!(formatter, "invalid nonce length: {length}")
|
||||||
|
}
|
||||||
|
Self::Serialize(error) => write!(formatter, "serialize failed: {error}"),
|
||||||
|
Self::Deserialize(error) => write!(formatter, "deserialize failed: {error}"),
|
||||||
|
Self::Encrypt => write!(formatter, "encryption failed"),
|
||||||
|
Self::Decrypt => write!(formatter, "decryption failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CryptoError {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionKey {
|
||||||
|
key_id: String,
|
||||||
|
key: [u8; SESSION_KEY_BYTES],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionKey {
|
||||||
|
pub fn generate() -> Self {
|
||||||
|
let mut key = [0_u8; SESSION_KEY_BYTES];
|
||||||
|
OsRng.fill_bytes(&mut key);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
key_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_base64(key_id: String, value: &str) -> Result<Self, CryptoError> {
|
||||||
|
let decoded = STANDARD.decode(value).map_err(CryptoError::InvalidBase64)?;
|
||||||
|
if decoded.len() != SESSION_KEY_BYTES {
|
||||||
|
return Err(CryptoError::InvalidKeyLength(decoded.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut key = [0_u8; SESSION_KEY_BYTES];
|
||||||
|
key.copy_from_slice(&decoded);
|
||||||
|
|
||||||
|
Ok(Self { key_id, key })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key_id(&self) -> &str {
|
||||||
|
&self.key_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_base64(&self) -> String {
|
||||||
|
STANDARD.encode(self.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt<T: Serialize>(&self, value: &T) -> Result<EncryptedEnvelope, CryptoError> {
|
||||||
|
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&self.key));
|
||||||
|
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||||
|
let plaintext = serde_json::to_vec(value).map_err(CryptoError::Serialize)?;
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(&nonce, plaintext.as_ref())
|
||||||
|
.map_err(|_| CryptoError::Encrypt)?;
|
||||||
|
|
||||||
|
Ok(EncryptedEnvelope {
|
||||||
|
enc: format!("aes-256-gcm-v{PROTOCOL_VERSION}"),
|
||||||
|
key_id: self.key_id.clone(),
|
||||||
|
nonce: STANDARD.encode(nonce),
|
||||||
|
ciphertext: STANDARD.encode(ciphertext),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
envelope: &EncryptedEnvelope,
|
||||||
|
) -> Result<T, CryptoError> {
|
||||||
|
let nonce_bytes = STANDARD
|
||||||
|
.decode(&envelope.nonce)
|
||||||
|
.map_err(CryptoError::InvalidBase64)?;
|
||||||
|
if nonce_bytes.len() != AES_GCM_NONCE_BYTES {
|
||||||
|
return Err(CryptoError::InvalidNonceLength(nonce_bytes.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ciphertext = STANDARD
|
||||||
|
.decode(&envelope.ciphertext)
|
||||||
|
.map_err(CryptoError::InvalidBase64)?;
|
||||||
|
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&self.key));
|
||||||
|
let plaintext = cipher
|
||||||
|
.decrypt(Nonce::from_slice(&nonce_bytes), ciphertext.as_ref())
|
||||||
|
.map_err(|_| CryptoError::Decrypt)?;
|
||||||
|
|
||||||
|
serde_json::from_slice(&plaintext).map_err(CryptoError::Deserialize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{crypto::SessionKey, ClientMessage};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_key_encrypts_and_decrypts_client_message() {
|
||||||
|
let session_key = SessionKey::generate();
|
||||||
|
let message = ClientMessage::Subscribe {
|
||||||
|
topic: "customers".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let envelope = session_key.encrypt(&message).expect("encrypt message");
|
||||||
|
let decrypted = session_key
|
||||||
|
.decrypt::<ClientMessage>(&envelope)
|
||||||
|
.expect("decrypt message");
|
||||||
|
|
||||||
|
assert_eq!(decrypted, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
test-zugang
Normal file
6
test-zugang
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Login: admin@example.test
|
||||||
|
Passwort: 4FjYIivIPzbATWAUxN4KSmc0
|
||||||
|
User: 43477791-1b8e-4307-adee-ec70d2a79a93
|
||||||
|
Firma: ceb30710-10a5-4ad8-a63d-24b859652ad3
|
||||||
|
Schema: company_ceb3071010a54ad8a63d24b859652ad3
|
||||||
|
Dev-Modus: true
|
||||||
13
web-frontend/index.html
Normal file
13
web-frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/companytool-logo.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Company Tool</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="app"></main>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1463
web-frontend/package-lock.json
generated
Normal file
1463
web-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
web-frontend/package.json
Normal file
21
web-frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "companytool-web-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web-frontend/public/companytool-logo.png
Normal file
BIN
web-frontend/public/companytool-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
399
web-frontend/src/App.vue
Normal file
399
web-frontend/src/App.vue
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, watch } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { authState, clearAuthSession } from "./auth";
|
||||||
|
import { ensureConnection, stopConnection } from "./realtime";
|
||||||
|
import { privateRoutes, publicRoutes } from "./router";
|
||||||
|
import { loadUserSettings, saveNavigationMode, userSettings } from "./user-settings";
|
||||||
|
import logoUrl from "../../images/icons/companytool-logo.png";
|
||||||
|
import AdminRegistrationsPage from "./views/AdminRegistrationsPage.vue";
|
||||||
|
import OrganizationSetupPage from "./views/OrganizationSetupPage.vue";
|
||||||
|
import UsersPage from "./views/UsersPage.vue";
|
||||||
|
import CustomersPage from "./views/CustomersPage.vue";
|
||||||
|
import SuppliersPage from "./views/SuppliersPage.vue";
|
||||||
|
import ItemsPage from "./views/ItemsPage.vue";
|
||||||
|
import ActivitiesPage from "./views/ActivitiesPage.vue";
|
||||||
|
import CashDiscountTermsPage from "./views/CashDiscountTermsPage.vue";
|
||||||
|
import NumberRangesPage from "./views/NumberRangesPage.vue";
|
||||||
|
import QuotesPage from "./views/QuotesPage.vue";
|
||||||
|
import OutgoingInvoicesPage from "./views/OutgoingInvoicesPage.vue";
|
||||||
|
import IncomingInvoicesPage from "./views/IncomingInvoicesPage.vue";
|
||||||
|
import PriceImportsPage from "./views/PriceImportsPage.vue";
|
||||||
|
import ApiConnectorsPage from "./views/ApiConnectorsPage.vue";
|
||||||
|
import PriceRulesPage from "./views/PriceRulesPage.vue";
|
||||||
|
import CommunicationsPage from "./views/CommunicationsPage.vue";
|
||||||
|
import DocumentsPage from "./views/DocumentsPage.vue";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const routes = computed(() => (authState.session ? privateRoutes : publicRoutes));
|
||||||
|
const activeNavGroup = reactive<{ label: string | null }>({ label: null });
|
||||||
|
const privateRouteGroups = computed(() => {
|
||||||
|
const groups: Array<{ label: string; routes: typeof privateRoutes }> = [];
|
||||||
|
for (const route of privateRoutes) {
|
||||||
|
const groupLabel = route.group;
|
||||||
|
let group = groups.find((item) => item.label === groupLabel);
|
||||||
|
if (!group) {
|
||||||
|
group = { label: groupLabel, routes: [] };
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
group.routes.push(route);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
const windowComponents = {
|
||||||
|
"/admin/organization-registrations": AdminRegistrationsPage,
|
||||||
|
"/setup/organization": OrganizationSetupPage,
|
||||||
|
"/settings/users": UsersPage,
|
||||||
|
"/settings/number-ranges": NumberRangesPage,
|
||||||
|
"/master-data/customers": CustomersPage,
|
||||||
|
"/master-data/suppliers": SuppliersPage,
|
||||||
|
"/master-data/items": ItemsPage,
|
||||||
|
"/settings/cash-discount-terms": CashDiscountTermsPage,
|
||||||
|
"/quotes": QuotesPage,
|
||||||
|
"/outgoing-invoices": OutgoingInvoicesPage,
|
||||||
|
"/incoming-invoices": IncomingInvoicesPage,
|
||||||
|
"/imports/price-lists": PriceImportsPage,
|
||||||
|
"/settings/api-connectors": ApiConnectorsPage,
|
||||||
|
"/settings/price-rules": PriceRulesPage,
|
||||||
|
"/communications": CommunicationsPage,
|
||||||
|
"/documents": DocumentsPage,
|
||||||
|
"/activities": ActivitiesPage
|
||||||
|
};
|
||||||
|
type WindowPath = keyof typeof windowComponents;
|
||||||
|
type AppWindow = {
|
||||||
|
path: WindowPath;
|
||||||
|
title: string;
|
||||||
|
z: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
minimized: boolean;
|
||||||
|
};
|
||||||
|
const openWindows = reactive<AppWindow[]>([]);
|
||||||
|
let nextZ = 10;
|
||||||
|
let activeDrag:
|
||||||
|
| {
|
||||||
|
path: WindowPath;
|
||||||
|
mode: "move" | "resize";
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
windowX: number;
|
||||||
|
windowY: number;
|
||||||
|
windowWidth: number;
|
||||||
|
windowHeight: number;
|
||||||
|
}
|
||||||
|
| null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
ensureConnection();
|
||||||
|
if (authState.session) {
|
||||||
|
loadUserSettings();
|
||||||
|
}
|
||||||
|
restoreWindows();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authState.session?.userId,
|
||||||
|
() => {
|
||||||
|
if (authState.session) {
|
||||||
|
loadUserSettings();
|
||||||
|
}
|
||||||
|
restoreWindows();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
openWindows.splice(0);
|
||||||
|
clearAuthSession();
|
||||||
|
stopConnection();
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAppWindow(route: { path: string; label: string }) {
|
||||||
|
activeNavGroup.label = null;
|
||||||
|
if (route.path === "/") {
|
||||||
|
router.push("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(route.path in windowComponents)) {
|
||||||
|
router.push(route.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const path = route.path as keyof typeof windowComponents;
|
||||||
|
const existing = openWindows.find((window) => window.path === path);
|
||||||
|
if (existing) {
|
||||||
|
existing.minimized = false;
|
||||||
|
focusWindow(existing);
|
||||||
|
persistWindows();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openWindows.push(defaultWindow(path, route.label, openWindows.length));
|
||||||
|
persistWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(label: string) {
|
||||||
|
activeNavGroup.label = activeNavGroup.label === label ? null : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAppWindow(path: WindowPath) {
|
||||||
|
const index = openWindows.findIndex((window) => window.path === path);
|
||||||
|
if (index >= 0) {
|
||||||
|
openWindows.splice(index, 1);
|
||||||
|
persistWindows();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function minimizeAppWindow(path: WindowPath) {
|
||||||
|
const item = openWindows.find((window) => window.path === path);
|
||||||
|
if (!item) return;
|
||||||
|
item.minimized = true;
|
||||||
|
persistWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreAppWindow(item: AppWindow) {
|
||||||
|
item.minimized = false;
|
||||||
|
focusWindow(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusWindow(item: AppWindow) {
|
||||||
|
if (item.minimized) return;
|
||||||
|
item.z = ++nextZ;
|
||||||
|
persistWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMove(event: PointerEvent, item: AppWindow) {
|
||||||
|
if ((event.target as HTMLElement).closest("button")) return;
|
||||||
|
activeDrag = {
|
||||||
|
path: item.path,
|
||||||
|
mode: "move",
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
windowX: item.x,
|
||||||
|
windowY: item.y,
|
||||||
|
windowWidth: item.width,
|
||||||
|
windowHeight: item.height
|
||||||
|
};
|
||||||
|
focusWindow(item);
|
||||||
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(event: PointerEvent, item: AppWindow) {
|
||||||
|
activeDrag = {
|
||||||
|
path: item.path,
|
||||||
|
mode: "resize",
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
windowX: item.x,
|
||||||
|
windowY: item.y,
|
||||||
|
windowWidth: item.width,
|
||||||
|
windowHeight: item.height
|
||||||
|
};
|
||||||
|
focusWindow(item);
|
||||||
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePointer(event: PointerEvent) {
|
||||||
|
if (!activeDrag || activeDrag.pointerId !== event.pointerId) return;
|
||||||
|
const item = openWindows.find((window) => window.path === activeDrag?.path);
|
||||||
|
if (!item) return;
|
||||||
|
const deltaX = event.clientX - activeDrag.startX;
|
||||||
|
const deltaY = event.clientY - activeDrag.startY;
|
||||||
|
if (activeDrag.mode === "move") {
|
||||||
|
item.x = clamp(activeDrag.windowX + deltaX, 252, window.innerWidth - 220);
|
||||||
|
item.y = clamp(activeDrag.windowY + deltaY, 8, window.innerHeight - 80);
|
||||||
|
} else {
|
||||||
|
item.width = clamp(activeDrag.windowWidth + deltaX, 560, window.innerWidth - item.x - 16);
|
||||||
|
item.height = clamp(activeDrag.windowHeight + deltaY, 360, window.innerHeight - item.y - 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endPointer(event: PointerEvent) {
|
||||||
|
if (!activeDrag || activeDrag.pointerId !== event.pointerId) return;
|
||||||
|
activeDrag = null;
|
||||||
|
persistWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultWindow(path: WindowPath, title: string, index: number): AppWindow {
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
title,
|
||||||
|
z: ++nextZ,
|
||||||
|
x: 280 + index * 28,
|
||||||
|
y: 42 + index * 24,
|
||||||
|
width: 900,
|
||||||
|
height: 680,
|
||||||
|
minimized: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowStorageKey() {
|
||||||
|
return `companytool.windows.${authState.session?.userId ?? "anonymous"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistWindows() {
|
||||||
|
if (!authState.session) return;
|
||||||
|
window.localStorage.setItem(
|
||||||
|
windowStorageKey(),
|
||||||
|
JSON.stringify(
|
||||||
|
openWindows.map(({ path, title, z, x, y, width, height, minimized }) => ({
|
||||||
|
path,
|
||||||
|
title,
|
||||||
|
z,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
minimized
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreWindows() {
|
||||||
|
openWindows.splice(0);
|
||||||
|
if (!authState.session) return;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(windowStorageKey());
|
||||||
|
if (!raw) return;
|
||||||
|
const saved = JSON.parse(raw) as AppWindow[];
|
||||||
|
for (const item of saved) {
|
||||||
|
if (item.path in windowComponents) {
|
||||||
|
openWindows.push({
|
||||||
|
...item,
|
||||||
|
z: ++nextZ,
|
||||||
|
x: clamp(item.x, 252, window.innerWidth - 220),
|
||||||
|
y: clamp(item.y, 8, window.innerHeight - 80),
|
||||||
|
width: clamp(item.width, 560, window.innerWidth - item.x - 16),
|
||||||
|
height: clamp(item.height, 360, window.innerHeight - item.y - 64),
|
||||||
|
minimized: item.minimized === true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
openWindows.splice(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(value, Math.max(min, max)));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<img class="brand-mark" :src="logoUrl" alt="Company Tool Logo" />
|
||||||
|
<div>
|
||||||
|
<strong>Company Tool</strong>
|
||||||
|
<span>Organisationen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="nav" :class="`nav-${userSettings.navigationMode}`">
|
||||||
|
<template v-if="!authState.session">
|
||||||
|
<RouterLink
|
||||||
|
v-for="route in routes"
|
||||||
|
:key="route.path"
|
||||||
|
:to="route.path"
|
||||||
|
class="nav-link"
|
||||||
|
>
|
||||||
|
{{ route.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
<template v-if="authState.session && userSettings.navigationMode === 'groups'">
|
||||||
|
<template v-for="group in privateRouteGroups" :key="group.label">
|
||||||
|
<div class="nav-dropdown">
|
||||||
|
<button type="button" class="nav-group-toggle" @click="toggleGroup(group.label)">
|
||||||
|
<span>{{ group.label }}</span>
|
||||||
|
<span>{{ activeNavGroup.label === group.label ? "^" : "v" }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="activeNavGroup.label === group.label" class="nav-dropdown-menu">
|
||||||
|
<button
|
||||||
|
v-for="route in group.routes"
|
||||||
|
:key="route.path"
|
||||||
|
type="button"
|
||||||
|
class="nav-link nav-button"
|
||||||
|
@click="openAppWindow(route)"
|
||||||
|
>
|
||||||
|
{{ route.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="authState.session">
|
||||||
|
<template v-for="group in privateRouteGroups" :key="group.label">
|
||||||
|
<div class="nav-group-title">{{ group.label }}</div>
|
||||||
|
<button
|
||||||
|
v-for="route in group.routes"
|
||||||
|
:key="route.path"
|
||||||
|
type="button"
|
||||||
|
class="nav-link nav-button"
|
||||||
|
:class="{ active: route.path === '/' }"
|
||||||
|
@click="openAppWindow(route)"
|
||||||
|
>
|
||||||
|
{{ route.label }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
<div v-if="authState.session" class="session-box">
|
||||||
|
<label class="field settings-field">
|
||||||
|
<span>Menü</span>
|
||||||
|
<select
|
||||||
|
:value="userSettings.navigationMode"
|
||||||
|
@change="saveNavigationMode(($event.target as HTMLSelectElement).value as 'scroll' | 'groups')"
|
||||||
|
>
|
||||||
|
<option value="scroll">Scrollbar</option>
|
||||||
|
<option value="groups">Gruppen einklappen</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<small v-if="userSettings.status">{{ userSettings.status }}</small>
|
||||||
|
<span>{{ authState.session.email }}</span>
|
||||||
|
<button type="button" class="secondary" @click="logout">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="main">
|
||||||
|
<RouterView />
|
||||||
|
<section
|
||||||
|
v-for="item in openWindows"
|
||||||
|
v-show="!item.minimized"
|
||||||
|
:key="item.path"
|
||||||
|
class="app-window"
|
||||||
|
:style="{ zIndex: item.z, left: `${item.x}px`, top: `${item.y}px`, width: `${item.width}px`, height: `${item.height}px` }"
|
||||||
|
@mousedown="focusWindow(item)"
|
||||||
|
@pointermove="updatePointer"
|
||||||
|
@pointerup="endPointer"
|
||||||
|
@pointercancel="endPointer"
|
||||||
|
>
|
||||||
|
<header class="app-window-title" @pointerdown="startMove($event, item)">
|
||||||
|
<strong>{{ item.title }}</strong>
|
||||||
|
<span class="app-window-actions">
|
||||||
|
<button type="button" class="secondary icon-button" @click="minimizeAppWindow(item.path)">_</button>
|
||||||
|
<button type="button" class="secondary icon-button" @click="closeAppWindow(item.path)">×</button>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div class="app-window-body">
|
||||||
|
<component :is="windowComponents[item.path]" />
|
||||||
|
</div>
|
||||||
|
<span class="app-window-resize" @pointerdown="startResize($event, item)" />
|
||||||
|
</section>
|
||||||
|
<footer v-if="openWindows.length" class="taskbar">
|
||||||
|
<button
|
||||||
|
v-for="item in openWindows"
|
||||||
|
:key="item.path"
|
||||||
|
type="button"
|
||||||
|
class="taskbar-button"
|
||||||
|
:class="{ minimized: item.minimized }"
|
||||||
|
@click="restoreAppWindow(item)"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
51
web-frontend/src/api.ts
Normal file
51
web-frontend/src/api.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ApiResult } from "./types";
|
||||||
|
import { authState } from "./auth";
|
||||||
|
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
|
|
||||||
|
export function apiGet<T>(path: string): Promise<ApiResult<T>> {
|
||||||
|
return apiRequest<T>("GET", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiPost<T>(path: string, body: unknown): Promise<ApiResult<T>> {
|
||||||
|
return apiRequest<T>("POST", path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiPut<T>(path: string, body: unknown): Promise<ApiResult<T>> {
|
||||||
|
return apiRequest<T>("PUT", path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiPatch<T>(path: string, body: unknown): Promise<ApiResult<T>> {
|
||||||
|
return apiRequest<T>("PATCH", path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiDelete<T>(path: string): Promise<ApiResult<T>> {
|
||||||
|
return apiRequest<T>("DELETE", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRequest<T>(method: string, path: string, body?: unknown): Promise<ApiResult<T>> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(authState.session?.accessToken ? { Authorization: `Bearer ${authState.session.accessToken}` } : {})
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? JSON.parse(text) : {};
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { ok: false, message: data.message ?? `HTTP ${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, data };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
44
web-frontend/src/auth.ts
Normal file
44
web-frontend/src/auth.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { reactive } from "vue";
|
||||||
|
import type { AuthSession } from "./types";
|
||||||
|
|
||||||
|
const authStorageKey = "companytool.auth";
|
||||||
|
|
||||||
|
export const authState = reactive<{
|
||||||
|
session: AuthSession | null;
|
||||||
|
}>({
|
||||||
|
session: loadAuthSession()
|
||||||
|
});
|
||||||
|
|
||||||
|
export function setAuthSession(session: AuthSession) {
|
||||||
|
authState.session = session;
|
||||||
|
window.localStorage.setItem(authStorageKey, JSON.stringify(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAuthSession(partial: Partial<AuthSession>) {
|
||||||
|
if (!authState.session) return;
|
||||||
|
setAuthSession({ ...authState.session, ...partial });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthSession() {
|
||||||
|
authState.session = null;
|
||||||
|
window.localStorage.removeItem(authStorageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAuthSession(): AuthSession | null {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(authStorageKey);
|
||||||
|
if (!raw) return null;
|
||||||
|
const session = JSON.parse(raw) as Partial<AuthSession>;
|
||||||
|
if (!session.email || !session.userId) return null;
|
||||||
|
if (!session.accessToken) return null;
|
||||||
|
return {
|
||||||
|
email: session.email,
|
||||||
|
userId: session.userId,
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
organizationId: session.organizationId ?? null,
|
||||||
|
mustChangePassword: session.mustChangePassword === true
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
web-frontend/src/components/FormStatus.vue
Normal file
10
web-frontend/src/components/FormStatus.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
message: string;
|
||||||
|
kind?: "info" | "success" | "error";
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<output v-if="message" :class="['form-status', kind ?? 'info']">{{ message }}</output>
|
||||||
|
</template>
|
||||||
15
web-frontend/src/components/PageHeader.vue
Normal file
15
web-frontend/src/components/PageHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<p>{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
75
web-frontend/src/components/SearchSelect.vue
Normal file
75
web-frontend/src/components/SearchSelect.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue?: string | null;
|
||||||
|
options: Array<{ id: string; number?: string | null; name: string }>;
|
||||||
|
placeholder?: string;
|
||||||
|
allowEmpty?: boolean;
|
||||||
|
emptyLabel?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: string | null];
|
||||||
|
change: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const query = ref("");
|
||||||
|
|
||||||
|
const selected = computed(() => props.options.find((option) => option.id === props.modelValue));
|
||||||
|
const normalizedQuery = computed(() => query.value.trim().toLowerCase());
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
const activeQuery = normalizedQuery.value;
|
||||||
|
const matches = activeQuery
|
||||||
|
? props.options.filter((option) => {
|
||||||
|
const haystack = `${option.number ?? ""} ${option.name}`.toLowerCase();
|
||||||
|
return haystack.includes(activeQuery);
|
||||||
|
})
|
||||||
|
: props.options;
|
||||||
|
|
||||||
|
return matches.slice(0, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
function select(value: string | null) {
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
emit("change");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-select">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="search"
|
||||||
|
:placeholder="placeholder ?? 'Nach Nummer oder Name suchen'"
|
||||||
|
:required="required && !modelValue"
|
||||||
|
/>
|
||||||
|
<div v-if="selected" class="search-select-current">
|
||||||
|
Ausgewählt: <strong>{{ selected.number ? `${selected.number} - ${selected.name}` : selected.name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="search-select-options">
|
||||||
|
<button
|
||||||
|
v-if="allowEmpty"
|
||||||
|
type="button"
|
||||||
|
class="search-option"
|
||||||
|
:class="{ selected: modelValue === null || modelValue === '' }"
|
||||||
|
@click="select(null)"
|
||||||
|
>
|
||||||
|
{{ emptyLabel ?? "Keine Auswahl" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="option in filteredOptions"
|
||||||
|
:key="option.id"
|
||||||
|
type="button"
|
||||||
|
class="search-option"
|
||||||
|
:class="{ selected: modelValue === option.id }"
|
||||||
|
@click="select(option.id)"
|
||||||
|
>
|
||||||
|
<span>{{ option.number }}</span>
|
||||||
|
<strong>{{ option.name }}</strong>
|
||||||
|
</button>
|
||||||
|
<p v-if="filteredOptions.length === 0" class="muted">Keine Treffer.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
6
web-frontend/src/main.ts
Normal file
6
web-frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import "./styles.css";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import { router } from "./router";
|
||||||
|
|
||||||
|
createApp(App).use(router).mount("#app");
|
||||||
17
web-frontend/src/number-ranges.ts
Normal file
17
web-frontend/src/number-ranges.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { apiPost } from "./api";
|
||||||
|
|
||||||
|
type NextNumberResponse = {
|
||||||
|
code: string;
|
||||||
|
number: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function reserveNextNumber(code: string): Promise<string | null> {
|
||||||
|
const result = await apiPost<NextNumberResponse>(`/api/v1/number-ranges/${encodeURIComponent(code)}/next`, {});
|
||||||
|
return result.ok ? result.data.number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesObjectSearch(number: string | null | undefined, title: string | null | undefined, query: string) {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
if (!needle) return true;
|
||||||
|
return `${number ?? ""} ${title ?? ""}`.toLowerCase().includes(needle);
|
||||||
|
}
|
||||||
179
web-frontend/src/realtime.ts
Normal file
179
web-frontend/src/realtime.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { reactive } from "vue";
|
||||||
|
import type {
|
||||||
|
ClientMessage,
|
||||||
|
EncryptedEnvelope,
|
||||||
|
RecordSummary,
|
||||||
|
ServerMessage,
|
||||||
|
SessionCrypto,
|
||||||
|
WireMessage
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const protocolVersion = 1;
|
||||||
|
export const wsUrl = import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws";
|
||||||
|
|
||||||
|
export const connectionState = reactive<{
|
||||||
|
records: RecordSummary[];
|
||||||
|
status: string;
|
||||||
|
}>({
|
||||||
|
records: [],
|
||||||
|
status: "Nicht verbunden"
|
||||||
|
});
|
||||||
|
|
||||||
|
export const liveUpdateState = reactive<{
|
||||||
|
revision: number;
|
||||||
|
lastTitle: string;
|
||||||
|
lastUpdatedAt: string | null;
|
||||||
|
}>({
|
||||||
|
revision: 0,
|
||||||
|
lastTitle: "",
|
||||||
|
lastUpdatedAt: null
|
||||||
|
});
|
||||||
|
|
||||||
|
let connectionStarted = false;
|
||||||
|
let reconnectTimer: number | undefined;
|
||||||
|
|
||||||
|
export function ensureConnection() {
|
||||||
|
if (connectionStarted) return;
|
||||||
|
connectionStarted = true;
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopConnection() {
|
||||||
|
connectionStarted = false;
|
||||||
|
if (reconnectTimer !== undefined) {
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = undefined;
|
||||||
|
}
|
||||||
|
connectionState.records = [];
|
||||||
|
connectionState.status = "Nicht verbunden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const socket = new WebSocket(wsUrl);
|
||||||
|
let session: SessionCrypto | null = null;
|
||||||
|
|
||||||
|
socket.addEventListener("open", async () => {
|
||||||
|
session = await createSessionCrypto();
|
||||||
|
connectionState.status = "Handshake...";
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
payload: {
|
||||||
|
protocol_version: protocolVersion,
|
||||||
|
key_id: session.keyId,
|
||||||
|
session_key: session.exportedKey
|
||||||
|
}
|
||||||
|
} satisfies WireMessage));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", async (event) => {
|
||||||
|
const wireMessage = JSON.parse(event.data) as WireMessage;
|
||||||
|
if (wireMessage.type === "hello_ack") {
|
||||||
|
connectionState.status = "Verbunden";
|
||||||
|
if (session) {
|
||||||
|
const envelope = await encryptMessage(session, {
|
||||||
|
type: "subscribe",
|
||||||
|
payload: { topic: "records" }
|
||||||
|
});
|
||||||
|
socket.send(JSON.stringify({ type: "encrypted", payload: envelope } satisfies WireMessage));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wireMessage.type === "encrypted" && session) {
|
||||||
|
const message = await decryptMessage<ServerMessage>(session, wireMessage.payload);
|
||||||
|
applyMessage(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wireMessage.type === "error") {
|
||||||
|
connectionState.status = wireMessage.payload.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
connectionStarted = false;
|
||||||
|
connectionState.status = "Verbindung getrennt, neuer Versuch...";
|
||||||
|
reconnectTimer = window.setTimeout(ensureConnection, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
connectionState.status = "Socket-Fehler";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSessionCrypto(): Promise<SessionCrypto> {
|
||||||
|
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
|
||||||
|
"encrypt",
|
||||||
|
"decrypt"
|
||||||
|
]);
|
||||||
|
const rawKey = await crypto.subtle.exportKey("raw", key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
keyId: crypto.randomUUID(),
|
||||||
|
exportedKey: bytesToBase64(new Uint8Array(rawKey))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptMessage(session: SessionCrypto, message: ClientMessage) {
|
||||||
|
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const encoded = new TextEncoder().encode(JSON.stringify(message));
|
||||||
|
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, session.key, encoded);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enc: `aes-256-gcm-v${protocolVersion}`,
|
||||||
|
key_id: session.keyId,
|
||||||
|
nonce: bytesToBase64(nonce),
|
||||||
|
ciphertext: bytesToBase64(new Uint8Array(ciphertext))
|
||||||
|
} satisfies EncryptedEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptMessage<T>(session: SessionCrypto, envelope: EncryptedEnvelope): Promise<T> {
|
||||||
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: base64ToBytes(envelope.nonce) },
|
||||||
|
session.key,
|
||||||
|
base64ToBytes(envelope.ciphertext)
|
||||||
|
);
|
||||||
|
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array) {
|
||||||
|
let binary = "";
|
||||||
|
bytes.forEach((byte) => {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
});
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value: string) {
|
||||||
|
const binary = atob(value);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMessage(message: ServerMessage) {
|
||||||
|
if (message.type === "snapshot") {
|
||||||
|
connectionState.records = message.payload.records;
|
||||||
|
connectionState.status = "Verbunden";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "record_changed") {
|
||||||
|
const index = connectionState.records.findIndex((record) => record.id === message.payload.record.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
connectionState.records[index] = message.payload.record;
|
||||||
|
} else {
|
||||||
|
connectionState.records.unshift(message.payload.record);
|
||||||
|
}
|
||||||
|
connectionState.status = "Aktualisiert";
|
||||||
|
liveUpdateState.revision += 1;
|
||||||
|
liveUpdateState.lastTitle = message.payload.record.title;
|
||||||
|
liveUpdateState.lastUpdatedAt = message.payload.record.updated_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "error") {
|
||||||
|
connectionState.status = message.payload.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
web-frontend/src/router.ts
Normal file
105
web-frontend/src/router.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import { authState } from "./auth";
|
||||||
|
import DashboardPage from "./views/DashboardPage.vue";
|
||||||
|
import LoginPage from "./views/LoginPage.vue";
|
||||||
|
import RegisterPage from "./views/RegisterPage.vue";
|
||||||
|
import ChangeInitialPasswordPage from "./views/ChangeInitialPasswordPage.vue";
|
||||||
|
import PasswordResetPage from "./views/PasswordResetPage.vue";
|
||||||
|
import AcceptInvitationPage from "./views/AcceptInvitationPage.vue";
|
||||||
|
import AdminRegistrationsPage from "./views/AdminRegistrationsPage.vue";
|
||||||
|
import AdminRegistrationDetailPage from "./views/AdminRegistrationDetailPage.vue";
|
||||||
|
import OrganizationSetupPage from "./views/OrganizationSetupPage.vue";
|
||||||
|
import UsersPage from "./views/UsersPage.vue";
|
||||||
|
import CustomersPage from "./views/CustomersPage.vue";
|
||||||
|
import SuppliersPage from "./views/SuppliersPage.vue";
|
||||||
|
import ItemsPage from "./views/ItemsPage.vue";
|
||||||
|
import ActivitiesPage from "./views/ActivitiesPage.vue";
|
||||||
|
import CashDiscountTermsPage from "./views/CashDiscountTermsPage.vue";
|
||||||
|
import NumberRangesPage from "./views/NumberRangesPage.vue";
|
||||||
|
import QuotesPage from "./views/QuotesPage.vue";
|
||||||
|
import OutgoingInvoicesPage from "./views/OutgoingInvoicesPage.vue";
|
||||||
|
import IncomingInvoicesPage from "./views/IncomingInvoicesPage.vue";
|
||||||
|
import PriceImportsPage from "./views/PriceImportsPage.vue";
|
||||||
|
import ApiConnectorsPage from "./views/ApiConnectorsPage.vue";
|
||||||
|
import PriceRulesPage from "./views/PriceRulesPage.vue";
|
||||||
|
import CommunicationsPage from "./views/CommunicationsPage.vue";
|
||||||
|
import DocumentsPage from "./views/DocumentsPage.vue";
|
||||||
|
|
||||||
|
export const publicRoutes = [
|
||||||
|
{ path: "/login", label: "Login" },
|
||||||
|
{ path: "/register", label: "Registrierung" },
|
||||||
|
{ path: "/password-reset", label: "Passwort zurücksetzen" },
|
||||||
|
{ path: "/accept-invitation", label: "Einladung annehmen" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const privateRoutes = [
|
||||||
|
{ path: "/", label: "Dashboard", group: "Vorgänge" },
|
||||||
|
{ path: "/outgoing-invoices", label: "Ausgangsrechnungen", group: "Vorgänge" },
|
||||||
|
{ path: "/quotes", label: "Angebote", group: "Vorgänge" },
|
||||||
|
{ path: "/incoming-invoices", label: "Eingangsrechnungen", group: "Vorgänge" },
|
||||||
|
{ path: "/master-data/customers", label: "Kunden", group: "Stammdaten" },
|
||||||
|
{ path: "/master-data/suppliers", label: "Lieferanten", group: "Stammdaten" },
|
||||||
|
{ path: "/master-data/items", label: "Artikel", group: "Stammdaten" },
|
||||||
|
{ path: "/activities", label: "Aktivitäten", group: "Stammdaten" },
|
||||||
|
{ path: "/imports/price-lists", label: "Preislisten", group: "Arbeitsdaten" },
|
||||||
|
{ path: "/communications", label: "Kommunikation", group: "Arbeitsdaten" },
|
||||||
|
{ path: "/documents", label: "Dokumente", group: "Arbeitsdaten" },
|
||||||
|
{ path: "/settings/price-rules", label: "Preisregeln", group: "Einstellungen" },
|
||||||
|
{ path: "/settings/api-connectors", label: "Preis-APIs", group: "Einstellungen" },
|
||||||
|
{ path: "/setup/organization", label: "Firmendaten", group: "Einstellungen" },
|
||||||
|
{ path: "/settings/users", label: "Benutzerrechte", group: "Einstellungen" },
|
||||||
|
{ path: "/settings/number-ranges", label: "Nummernkreise", group: "Einstellungen" },
|
||||||
|
{ path: "/settings/cash-discount-terms", label: "Skonto", group: "Einstellungen" },
|
||||||
|
{ path: "/admin/organization-registrations", label: "Freischaltung", group: "Einstellungen" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: "/", component: DashboardPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/login", component: LoginPage, meta: { publicOnly: true } },
|
||||||
|
{ path: "/register", component: RegisterPage, meta: { publicOnly: true } },
|
||||||
|
{ path: "/password-reset", component: PasswordResetPage, meta: { publicOnly: true } },
|
||||||
|
{ path: "/accept-invitation", component: AcceptInvitationPage, meta: { publicOnly: true } },
|
||||||
|
{
|
||||||
|
path: "/change-initial-password",
|
||||||
|
component: ChangeInitialPasswordPage,
|
||||||
|
meta: { requiresAuth: true, passwordChange: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/admin/organization-registrations",
|
||||||
|
component: AdminRegistrationsPage,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/admin/organization-registrations/:id",
|
||||||
|
component: AdminRegistrationDetailPage,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{ path: "/setup/organization", component: OrganizationSetupPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/settings/users", component: UsersPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/settings/number-ranges", component: NumberRangesPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/master-data/customers", component: CustomersPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/master-data/suppliers", component: SuppliersPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/master-data/items", component: ItemsPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/settings/cash-discount-terms", component: CashDiscountTermsPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/quotes", component: QuotesPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/outgoing-invoices", component: OutgoingInvoicesPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/incoming-invoices", component: IncomingInvoicesPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/imports/price-lists", component: PriceImportsPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/settings/api-connectors", component: ApiConnectorsPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/settings/price-rules", component: PriceRulesPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/communications", component: CommunicationsPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/documents", component: DocumentsPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/activities", component: ActivitiesPage, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/:pathMatch(.*)*", redirect: "/login" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const session = authState.session;
|
||||||
|
if (!session && to.meta.requiresAuth) return "/login";
|
||||||
|
if (session?.mustChangePassword && !to.meta.passwordChange) return "/change-initial-password";
|
||||||
|
if (session && to.meta.publicOnly) return "/";
|
||||||
|
return true;
|
||||||
|
});
|
||||||
788
web-frontend/src/styles.css
Normal file
788
web-frontend/src/styles.css
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
:root {
|
||||||
|
color: #172026;
|
||||||
|
background: #f6faf9;
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #118a7f;
|
||||||
|
border: 1px solid #118a7f;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #c9d3d6;
|
||||||
|
color: #26343b;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
filter: brightness(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #118a7f;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #e8edef;
|
||||||
|
border: 1px solid #ccd6d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #26343b;
|
||||||
|
padding: 4px 7px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 244px minmax(0, 1fr);
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: #ffffff;
|
||||||
|
border-right: 1px solid #dbe3e6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 22px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: block;
|
||||||
|
height: 34px;
|
||||||
|
object-fit: contain;
|
||||||
|
width: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong,
|
||||||
|
.brand span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span {
|
||||||
|
color: #6a787d;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
color: #172026;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group-title {
|
||||||
|
color: #6a787d;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
margin: 0 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link + .nav-link {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link + .nav-group-title {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #334349;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
background: #10545c;
|
||||||
|
border: 1px solid #10545c;
|
||||||
|
color: #eefbf8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:hover {
|
||||||
|
background: #118a7f;
|
||||||
|
border-color: #118a7f;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button.active {
|
||||||
|
background: #def4f0;
|
||||||
|
border-color: #def4f0;
|
||||||
|
color: #10545c;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-groups {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group-toggle {
|
||||||
|
align-items: center;
|
||||||
|
background: #f4f7f8;
|
||||||
|
border-color: #d8e2e5;
|
||||||
|
color: #334349;
|
||||||
|
display: flex;
|
||||||
|
font-size: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-groups .nav-dropdown:not(:first-child) {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-menu {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #cfd9dc;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 14px 34px rgb(28 43 48 / 16%);
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
left: 0;
|
||||||
|
min-width: 190px;
|
||||||
|
padding: 6px;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
z-index: 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-menu .nav-link {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.2;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active,
|
||||||
|
.nav-link:hover {
|
||||||
|
background: #def4f0;
|
||||||
|
color: #10545c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-box {
|
||||||
|
border-top: 1px solid #dbe3e6;
|
||||||
|
display: grid;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 22px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-box span {
|
||||||
|
color: #435258;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-box button {
|
||||||
|
background: #10545c;
|
||||||
|
border-color: #10545c;
|
||||||
|
color: #eefbf8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
min-height: 34px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field span,
|
||||||
|
.session-box small {
|
||||||
|
color: #65757b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field select {
|
||||||
|
background: #10545c;
|
||||||
|
border: 1px solid #10545c;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
overflow: auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 28px 28px 64px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-window {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #cfd9dc;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 18px 50px rgb(28 43 48 / 18%);
|
||||||
|
overflow: hidden;
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-window-title {
|
||||||
|
align-items: center;
|
||||||
|
background: #eef8f6;
|
||||||
|
border-bottom: 1px solid #dbe3e6;
|
||||||
|
cursor: move;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 9px 12px;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-window-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-window-body {
|
||||||
|
height: calc(100% - 49px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-window-resize {
|
||||||
|
border-bottom: 12px solid #8fa0a6;
|
||||||
|
border-left: 12px solid transparent;
|
||||||
|
bottom: 5px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
touch-action: none;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar {
|
||||||
|
align-items: center;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #cfd9dc;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
left: 244px;
|
||||||
|
min-height: 48px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 7px 12px;
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar-button {
|
||||||
|
background: #10545c;
|
||||||
|
border-color: #10545c;
|
||||||
|
color: #ffffff;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-height: 32px;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar-button.minimized {
|
||||||
|
background: #def4f0;
|
||||||
|
border-color: #c8ebe6;
|
||||||
|
color: #10545c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p,
|
||||||
|
.muted {
|
||||||
|
color: #65757b;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d9e1e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.compact {
|
||||||
|
padding: 15px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-panel {
|
||||||
|
border-top: 1px solid #dbe3e6;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-panel h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-row {
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #dbe3e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: minmax(180px, 1.2fr) repeat(3, minmax(70px, 0.6fr));
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-row small {
|
||||||
|
color: #65757b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-line {
|
||||||
|
border: 1px solid #dbe3e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-panel {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-panel.wide {
|
||||||
|
max-width: 1040px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-split {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: 280px minmax(420px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-panel,
|
||||||
|
.detail-panel {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: #26343b;
|
||||||
|
display: grid;
|
||||||
|
font-weight: 400;
|
||||||
|
gap: 3px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row:hover,
|
||||||
|
.list-row.selected {
|
||||||
|
background: #def4f0;
|
||||||
|
border-color: #c6d9d8;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row span {
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row small {
|
||||||
|
color: #65757b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-row,
|
||||||
|
.section-title,
|
||||||
|
.button-row,
|
||||||
|
.form-actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-row,
|
||||||
|
.section-title {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title h2,
|
||||||
|
.split-row h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span,
|
||||||
|
.check-row span {
|
||||||
|
color: #435258;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #cbd5d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #172026;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-value {
|
||||||
|
align-items: center;
|
||||||
|
background: #f6faf9;
|
||||||
|
border: 1px solid #cbd5d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #172026;
|
||||||
|
display: flex;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-search {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
select[multiple] {
|
||||||
|
min-height: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select-current {
|
||||||
|
color: #435258;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select-options {
|
||||||
|
border: 1px solid #dbe3e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-option {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: #26343b;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 7px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-option span {
|
||||||
|
color: #65757b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-option strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-option:hover,
|
||||||
|
.search-option.selected {
|
||||||
|
background: #def4f0;
|
||||||
|
border-color: #c6d9d8;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row input {
|
||||||
|
min-height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row.compact {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row.compact span {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-checks {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions,
|
||||||
|
.button-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status {
|
||||||
|
border-radius: 6px;
|
||||||
|
display: block;
|
||||||
|
min-height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status.info,
|
||||||
|
.form-status.success,
|
||||||
|
.form-status.error {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status.info {
|
||||||
|
background: #eef8f6;
|
||||||
|
color: #334349;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status.success {
|
||||||
|
background: #e6f4ea;
|
||||||
|
color: #1f5d38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status.error {
|
||||||
|
background: #fdeceb;
|
||||||
|
color: #8a2521;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
border: 1px solid #e0e7ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table.two-cols {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table.registrations {
|
||||||
|
grid-template-columns: minmax(140px, 1.2fr) minmax(170px, 1fr) 140px 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table.users {
|
||||||
|
grid-template-columns: minmax(180px, 1fr) 140px minmax(260px, 1.2fr) 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table > * {
|
||||||
|
border-bottom: 1px solid #edf1f3;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head {
|
||||||
|
background: #eef8f6;
|
||||||
|
color: #526268;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
background: #def4f0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #118a7f;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
height: 28px;
|
||||||
|
line-height: 28px;
|
||||||
|
margin: 8px 12px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid dt,
|
||||||
|
.detail-grid dd {
|
||||||
|
border-bottom: 1px solid #edf1f3;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid dt {
|
||||||
|
color: #65757b;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
background: #f7f9fa;
|
||||||
|
border: 1px dashed #cbd5d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #65757b;
|
||||||
|
margin: 0;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-bottom: 1px solid #dbe3e6;
|
||||||
|
border-right: 0;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskbar {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid,
|
||||||
|
.workspace-split,
|
||||||
|
.data-table.two-cols,
|
||||||
|
.data-table.registrations,
|
||||||
|
.data-table.users,
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-row,
|
||||||
|
.section-title {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
348
web-frontend/src/types.ts
Normal file
348
web-frontend/src/types.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
export type ApiResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; message: string };
|
||||||
|
|
||||||
|
export type OrganizationRegistration = {
|
||||||
|
id: string;
|
||||||
|
organization_name?: string;
|
||||||
|
email: string;
|
||||||
|
status: string;
|
||||||
|
requested_at: string;
|
||||||
|
decided_at?: string | null;
|
||||||
|
decided_by_user_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationRegistrationDetail = OrganizationRegistration & {
|
||||||
|
organization_id?: string | null;
|
||||||
|
schema_name?: string | null;
|
||||||
|
provisioning_error?: string | null;
|
||||||
|
decision_note?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationUser = {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
status: string;
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Customer = {
|
||||||
|
id: string;
|
||||||
|
customer_number: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
details: {
|
||||||
|
street: string;
|
||||||
|
postal_code: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
standard_discount_percent: string;
|
||||||
|
cash_discount_term_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Supplier = Omit<Customer, "customer_number"> & {
|
||||||
|
supplier_number: string;
|
||||||
|
payment_days?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Item = {
|
||||||
|
id: string;
|
||||||
|
item_number: string;
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
tax_rate: string;
|
||||||
|
default_purchase_price?: string | null;
|
||||||
|
default_sales_price?: string | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ItemPriceHistory = {
|
||||||
|
id: string;
|
||||||
|
item_id: string;
|
||||||
|
purchase_price?: string | null;
|
||||||
|
sales_price?: string | null;
|
||||||
|
source: string;
|
||||||
|
valid_from: string;
|
||||||
|
created_by_user_id?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CashDiscountTerm = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
discount_percent: string;
|
||||||
|
discount_days: number;
|
||||||
|
net_days?: number | null;
|
||||||
|
valid_from?: string | null;
|
||||||
|
valid_until?: string | null;
|
||||||
|
is_default_customer_term: boolean;
|
||||||
|
is_default_supplier_term: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Activity = {
|
||||||
|
id: string;
|
||||||
|
activity_number?: string | null;
|
||||||
|
activity_type: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
due_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NumberRange = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
pattern: string;
|
||||||
|
counter_value: number;
|
||||||
|
counter_padding: number;
|
||||||
|
reset_rule?: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuoteItem = {
|
||||||
|
id?: string;
|
||||||
|
line_number?: number;
|
||||||
|
item_id: string;
|
||||||
|
description: string;
|
||||||
|
quantity: string;
|
||||||
|
unit_price: string;
|
||||||
|
original_unit_price?: string | null;
|
||||||
|
discount_percent: string;
|
||||||
|
tax_rate: string;
|
||||||
|
price_overridden?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Quote = {
|
||||||
|
id: string;
|
||||||
|
quote_number: string;
|
||||||
|
customer_id: string;
|
||||||
|
status: string;
|
||||||
|
valid_until?: string | null;
|
||||||
|
cash_discount_term_id?: string | null;
|
||||||
|
customer_discount_percent: string;
|
||||||
|
notes: string;
|
||||||
|
items: QuoteItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OutgoingInvoiceItem = QuoteItem;
|
||||||
|
|
||||||
|
export type OutgoingInvoice = {
|
||||||
|
id: string;
|
||||||
|
invoice_number: string;
|
||||||
|
customer_id: string;
|
||||||
|
status: string;
|
||||||
|
cash_discount_term_id?: string | null;
|
||||||
|
customer_discount_percent: string;
|
||||||
|
issued_at?: string | null;
|
||||||
|
due_at?: string | null;
|
||||||
|
source_quote_id?: string | null;
|
||||||
|
finalized_at?: string | null;
|
||||||
|
items: OutgoingInvoiceItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IncomingInvoiceItem = {
|
||||||
|
id?: string;
|
||||||
|
line_number?: number;
|
||||||
|
item_id?: string | null;
|
||||||
|
description: string;
|
||||||
|
quantity: string;
|
||||||
|
unit_price: string;
|
||||||
|
tax_rate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IncomingInvoice = {
|
||||||
|
id: string;
|
||||||
|
invoice_number: string;
|
||||||
|
supplier_id: string;
|
||||||
|
status: string;
|
||||||
|
cash_discount_term_id?: string | null;
|
||||||
|
invoice_date?: string | null;
|
||||||
|
due_at?: string | null;
|
||||||
|
items: IncomingInvoiceItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PriceListImportRow = {
|
||||||
|
row_number: number;
|
||||||
|
item_number: string;
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
tax_rate: string;
|
||||||
|
purchase_price?: string | null;
|
||||||
|
sales_price?: string | null;
|
||||||
|
action: string;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PriceListImportPreview = {
|
||||||
|
rows: PriceListImportRow[];
|
||||||
|
total_rows: number;
|
||||||
|
valid_rows: number;
|
||||||
|
error_rows: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PriceListImportApplyResponse = {
|
||||||
|
import_id: string;
|
||||||
|
applied_rows: number;
|
||||||
|
error_rows: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiConnector = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
connector_type: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
is_active: boolean;
|
||||||
|
sync_interval_minutes?: number | null;
|
||||||
|
last_sync_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PriceRule = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
source_type: "import" | "api" | "supplier";
|
||||||
|
source_id?: string | null;
|
||||||
|
markup_percent: string;
|
||||||
|
rounding_mode: "none" | "cent" | "five_cent" | "ten_cent" | "whole";
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityLink = {
|
||||||
|
entity_type: string;
|
||||||
|
entity_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Communication = {
|
||||||
|
id: string;
|
||||||
|
communication_type: string;
|
||||||
|
direction: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
status: string;
|
||||||
|
occurred_at?: string | null;
|
||||||
|
links: EntityLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentVersion = {
|
||||||
|
id: string;
|
||||||
|
version_no: number;
|
||||||
|
file_name: string;
|
||||||
|
content_type: string;
|
||||||
|
file_size: number;
|
||||||
|
checksum_sha256: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentRecord = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
latest_version?: DocumentVersion | null;
|
||||||
|
links: EntityLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentDownload = {
|
||||||
|
document_id: string;
|
||||||
|
version_id: string;
|
||||||
|
file_name: string;
|
||||||
|
content_type: string;
|
||||||
|
content_base64: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentAuditLogEntry = {
|
||||||
|
id: string;
|
||||||
|
document_id: string;
|
||||||
|
version_id?: string | null;
|
||||||
|
action: string;
|
||||||
|
user_id?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginResponse = {
|
||||||
|
user_id: string;
|
||||||
|
access_token: string;
|
||||||
|
organization_id?: string | null;
|
||||||
|
must_change_password: boolean;
|
||||||
|
organizations: Array<{
|
||||||
|
id: string;
|
||||||
|
schema_name?: string | null;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthSession = {
|
||||||
|
email: string;
|
||||||
|
userId: string;
|
||||||
|
accessToken: string;
|
||||||
|
organizationId?: string | null;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigationMode = "scroll" | "groups";
|
||||||
|
|
||||||
|
export type UserNavigationSettings = {
|
||||||
|
mode: NavigationMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecordSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServerMessage =
|
||||||
|
| { type: "snapshot"; payload: { records: RecordSummary[] } }
|
||||||
|
| { type: "record_changed"; payload: { record: RecordSummary } }
|
||||||
|
| { type: "pong" }
|
||||||
|
| { type: "error"; payload: { message: string } };
|
||||||
|
|
||||||
|
export type ClientMessage =
|
||||||
|
| { type: "subscribe"; payload: { topic: string } }
|
||||||
|
| { type: "ping" };
|
||||||
|
|
||||||
|
export type WireMessage =
|
||||||
|
| { type: "hello"; payload: HelloMessage }
|
||||||
|
| { type: "hello_ack"; payload: HelloAckMessage }
|
||||||
|
| { type: "encrypted"; payload: EncryptedEnvelope }
|
||||||
|
| { type: "error"; payload: { message: string } };
|
||||||
|
|
||||||
|
export type HelloMessage = {
|
||||||
|
protocol_version: number;
|
||||||
|
key_id: string;
|
||||||
|
session_key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HelloAckMessage = {
|
||||||
|
protocol_version: number;
|
||||||
|
key_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EncryptedEnvelope = {
|
||||||
|
enc: string;
|
||||||
|
key_id: string;
|
||||||
|
nonce: string;
|
||||||
|
ciphertext: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionCrypto = {
|
||||||
|
keyId: string;
|
||||||
|
key: CryptoKey;
|
||||||
|
exportedKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevBootstrapResponse = {
|
||||||
|
organization_id: string;
|
||||||
|
schema_name: string;
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
dev_mode: boolean;
|
||||||
|
};
|
||||||
32
web-frontend/src/user-settings.ts
Normal file
32
web-frontend/src/user-settings.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { reactive } from "vue";
|
||||||
|
import { apiGet, apiPut } from "./api";
|
||||||
|
import type { NavigationMode, UserNavigationSettings } from "./types";
|
||||||
|
|
||||||
|
export const userSettings = reactive({
|
||||||
|
navigationMode: "scroll" as NavigationMode,
|
||||||
|
status: "",
|
||||||
|
loaded: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function loadUserSettings() {
|
||||||
|
const result = await apiGet<UserNavigationSettings>("/api/v1/users/me/settings/navigation");
|
||||||
|
userSettings.loaded = true;
|
||||||
|
if (result.ok) {
|
||||||
|
userSettings.navigationMode = result.data.mode;
|
||||||
|
userSettings.status = "";
|
||||||
|
} else {
|
||||||
|
userSettings.status = result.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveNavigationMode(mode: NavigationMode) {
|
||||||
|
userSettings.navigationMode = mode;
|
||||||
|
userSettings.status = "Speichere Menü...";
|
||||||
|
const result = await apiPut<UserNavigationSettings>("/api/v1/users/me/settings/navigation", { mode });
|
||||||
|
if (result.ok) {
|
||||||
|
userSettings.navigationMode = result.data.mode;
|
||||||
|
userSettings.status = "Menü gespeichert.";
|
||||||
|
} else {
|
||||||
|
userSettings.status = result.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
web-frontend/src/utils.ts
Normal file
4
web-frontend/src/utils.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export function formatDate(value?: string | null) {
|
||||||
|
if (!value) return "-";
|
||||||
|
return new Date(value).toLocaleString("de-DE");
|
||||||
|
}
|
||||||
32
web-frontend/src/views/AcceptInvitationPage.vue
Normal file
32
web-frontend/src/views/AcceptInvitationPage.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { apiPost } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const form = reactive({ token: "", new_password: "", new_password_confirm: "" });
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const result = await apiPost("/api/v1/auth/accept-invitation", form);
|
||||||
|
status.value = result.ok ? "Einladung angenommen. Anmeldung ist jetzt möglich." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) setTimeout(() => router.push("/login"), 800);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Einladung annehmen" description="Einladungstoken einlösen und eigenes Passwort setzen." />
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<label class="field"><span>Einladungstoken</span><input v-model="form.token" required /></label>
|
||||||
|
<label class="field"><span>Passwort</span><input v-model="form.new_password" type="password" required /></label>
|
||||||
|
<label class="field"><span>Passwort wiederholen</span><input v-model="form.new_password_confirm" type="password" required /></label>
|
||||||
|
<div class="form-actions"><button type="submit">Einladung annehmen</button></div>
|
||||||
|
<FormStatus :message="status" :kind="kind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
39
web-frontend/src/views/ActivitiesPage.vue
Normal file
39
web-frontend/src/views/ActivitiesPage.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
import type { Activity } from "../types";
|
||||||
|
const emptyForm = () => ({ activity_number: null as string | null, activity_type: "task", title: "", body: "", status: "open", priority: "normal", due_at: null as string | null });
|
||||||
|
const records = ref<Activity[]>([]); const selectedId = ref<string | null>(null); const form = reactive(emptyForm()); const status = ref(""); const kind = ref<"info" | "success" | "error">("info"); const search = ref("");
|
||||||
|
const filteredRecords = computed(() =>
|
||||||
|
records.value.filter((record) => matchesObjectSearch(record.activity_number, record.title, search.value))
|
||||||
|
);
|
||||||
|
async function load() { const r = await apiGet<Activity[]>("/api/v1/activities"); if (r.ok) records.value = r.data; else { status.value = r.message; kind.value = "error"; } }
|
||||||
|
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.activity_number = await reserveNextNumber("activities"); }
|
||||||
|
function select(record: Activity) { selectedId.value = record.id; Object.assign(form, record); }
|
||||||
|
async function save() { const r = selectedId.value ? await apiPut<Activity>(`/api/v1/activities/${selectedId.value}`, form) : await apiPost<Activity>("/api/v1/activities", form); status.value = r.ok ? "Aktivität gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; await load(); } }
|
||||||
|
async function cancel() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/activities/${selectedId.value}`); status.value = r.ok ? "Aktivität storniert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) await load(); }
|
||||||
|
onMounted(load); watch(() => liveUpdateState.revision, load);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Aktivitäten" description="Aufgaben, Wiedervorlagen und Gesprächsnotizen." />
|
||||||
|
<div class="workspace-split">
|
||||||
|
<section class="panel list-panel"><div class="section-title"><h2>Aktivitäten</h2><button type="button" @click="createNew">Neu</button></div>
|
||||||
|
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Aktivitätsnummer oder Titel" /></label>
|
||||||
|
<button v-for="record in filteredRecords" :key="record.id" type="button" class="list-row" :class="{ selected: selectedId === record.id }" @click="select(record)"><strong>{{ record.activity_number ?? record.activity_type }}</strong><span>{{ record.title }}</span><small>{{ record.status }}</small></button>
|
||||||
|
<p v-if="records.length === 0" class="empty">Keine Aktivitäten vorhanden.</p>
|
||||||
|
<p v-else-if="filteredRecords.length === 0" class="empty">Keine Treffer.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid">
|
||||||
|
<label class="field"><span>Aktivitätsnummer</span><div class="readonly-value">{{ form.activity_number || "wird automatisch vergeben" }}</div></label>
|
||||||
|
<label class="field"><span>Typ</span><select v-model="form.activity_type"><option value="task">Aufgabe</option><option value="follow_up">Wiedervorlage</option><option value="phone_note">Telefonnotiz</option><option value="internal_note">Interne Notiz</option></select></label>
|
||||||
|
<label class="field"><span>Titel</span><input v-model="form.title" required /></label>
|
||||||
|
<label class="field"><span>Status</span><select v-model="form.status"><option value="open">Offen</option><option value="in_progress">In Bearbeitung</option><option value="done">Erledigt</option><option value="cancelled">Storniert</option></select></label>
|
||||||
|
<label class="field"><span>Priorität</span><select v-model="form.priority"><option value="low">Niedrig</option><option value="normal">Normal</option><option value="high">Hoch</option><option value="critical">Kritisch</option></select></label>
|
||||||
|
<label class="field full-width"><span>Beschreibung</span><textarea v-model="form.body" rows="5" /></label>
|
||||||
|
</div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="cancel">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
64
web-frontend/src/views/AdminRegistrationDetailPage.vue
Normal file
64
web-frontend/src/views/AdminRegistrationDetailPage.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { apiGet, apiPost } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import type { OrganizationRegistrationDetail } from "../types";
|
||||||
|
import { formatDate } from "../utils";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const detail = ref<OrganizationRegistrationDetail | null>(null);
|
||||||
|
const emptyMessage = ref("Details noch nicht geladen.");
|
||||||
|
const status = ref("");
|
||||||
|
const statusKind = ref<"info" | "success" | "error">("info");
|
||||||
|
|
||||||
|
const id = String(route.params.id ?? "");
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const result = await apiGet<OrganizationRegistrationDetail>(`/api/v1/admin/organization-registrations/${encodeURIComponent(id)}`);
|
||||||
|
if (!result.ok) {
|
||||||
|
detail.value = null;
|
||||||
|
emptyMessage.value = result.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
detail.value = result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(action: "approve" | "reject" | "resend-initial-email" | "retry-provisioning") {
|
||||||
|
const result = await apiPost(`/api/v1/admin/organization-registrations/${encodeURIComponent(id)}/${action}`, {});
|
||||||
|
status.value = result.ok ? "Aktion ausgeführt." : result.message;
|
||||||
|
statusKind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) await load();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Registrierungsdetail" description="Prüfen, freischalten oder ablehnen." />
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>Details</h2>
|
||||||
|
<button type="button" @click="load">Laden</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!detail" class="empty">{{ emptyMessage }}</p>
|
||||||
|
<dl v-else class="detail-grid">
|
||||||
|
<dt>Firma</dt><dd>{{ detail.organization_name ?? "-" }}</dd>
|
||||||
|
<dt>E-Mail</dt><dd>{{ detail.email }}</dd>
|
||||||
|
<dt>Status</dt><dd>{{ detail.status }}</dd>
|
||||||
|
<dt>Organization-ID</dt><dd><code>{{ detail.organization_id ?? "-" }}</code></dd>
|
||||||
|
<dt>Schema</dt><dd><code>{{ detail.schema_name ?? "-" }}</code></dd>
|
||||||
|
<dt>Angefragt</dt><dd>{{ formatDate(detail.requested_at) }}</dd>
|
||||||
|
<dt>Entschieden</dt><dd>{{ formatDate(detail.decided_at) }}</dd>
|
||||||
|
<dt>Provisioning</dt><dd>{{ detail.provisioning_error ?? "-" }}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="button" @click="runAction('approve')">Freischalten</button>
|
||||||
|
<button type="button" class="secondary" @click="runAction('reject')">Ablehnen</button>
|
||||||
|
<button type="button" class="secondary" @click="runAction('resend-initial-email')">E-Mail erneut senden</button>
|
||||||
|
<button type="button" class="secondary" @click="runAction('retry-provisioning')">Schema-Erstellung wiederholen</button>
|
||||||
|
</div>
|
||||||
|
<FormStatus :message="status" :kind="statusKind" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
56
web-frontend/src/views/AdminRegistrationsPage.vue
Normal file
56
web-frontend/src/views/AdminRegistrationsPage.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { apiGet } from "../api";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import type { OrganizationRegistration } from "../types";
|
||||||
|
import { formatDate } from "../utils";
|
||||||
|
|
||||||
|
const registrations = ref<OrganizationRegistration[]>([]);
|
||||||
|
const status = ref("Noch keine Daten geladen.");
|
||||||
|
const loaded = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
status.value = "Lade Registrierungen...";
|
||||||
|
const result = await apiGet<OrganizationRegistration[]>("/api/v1/admin/organization-registrations");
|
||||||
|
loaded.value = true;
|
||||||
|
loading.value = false;
|
||||||
|
if (!result.ok) {
|
||||||
|
registrations.value = [];
|
||||||
|
status.value = result.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registrations.value = result.data;
|
||||||
|
status.value = "Keine Registrierungen vorhanden.";
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Freischaltungen" description="Offene und entschiedene Organization-Registrierungen." />
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>Registrierungen</h2>
|
||||||
|
<button type="button" :disabled="loading" @click="load">
|
||||||
|
{{ loading ? "Lädt..." : "Aktualisieren" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!loaded || registrations.length === 0" class="empty">{{ status }}</p>
|
||||||
|
<div v-else class="data-table registrations">
|
||||||
|
<div class="table-head">Firma</div>
|
||||||
|
<div class="table-head">E-Mail</div>
|
||||||
|
<div class="table-head">Status</div>
|
||||||
|
<div class="table-head">Eingegangen</div>
|
||||||
|
<template v-for="item in registrations" :key="item.id">
|
||||||
|
<RouterLink :to="`/admin/organization-registrations/${encodeURIComponent(item.id)}`">
|
||||||
|
{{ item.organization_name ?? item.id }}
|
||||||
|
</RouterLink>
|
||||||
|
<div>{{ item.email }}</div>
|
||||||
|
<span class="status-pill">{{ item.status }}</span>
|
||||||
|
<time>{{ formatDate(item.requested_at) }}</time>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
31
web-frontend/src/views/ApiConnectorsPage.vue
Normal file
31
web-frontend/src/views/ApiConnectorsPage.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import type { ApiConnector } from "../types";
|
||||||
|
|
||||||
|
const connectors = ref<ApiConnector[]>([]);
|
||||||
|
const selectedId = ref<string | null>(null);
|
||||||
|
const form = reactive({ code: "", name: "", connector_type: "generic_price_api", config: "{\n \"base_url\": \"https://example.invalid\",\n \"api_key\": \"dev\"\n}", is_active: true, sync_interval_minutes: 1440 as number | null });
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
async function load() { const result = await apiGet<ApiConnector[]>("/api/v1/api-connectors"); if (result.ok) connectors.value = result.data; else { status.value = result.message; kind.value = "error"; } }
|
||||||
|
function createNew() { selectedId.value = null; Object.assign(form, { code: "", name: "", connector_type: "generic_price_api", config: "{\n \"base_url\": \"https://example.invalid\",\n \"api_key\": \"dev\"\n}", is_active: true, sync_interval_minutes: 1440 }); }
|
||||||
|
function select(connector: ApiConnector) { selectedId.value = connector.id; Object.assign(form, { code: connector.code, name: connector.name, connector_type: connector.connector_type, config: JSON.stringify(connector.config, null, 2), is_active: connector.is_active, sync_interval_minutes: connector.sync_interval_minutes ?? null }); }
|
||||||
|
function payload() { return { ...form, config: JSON.parse(form.config || "{}") }; }
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
const result = selectedId.value ? await apiPut<ApiConnector>(`/api/v1/api-connectors/${selectedId.value}`, payload()) : await apiPost<ApiConnector>("/api/v1/api-connectors", payload());
|
||||||
|
status.value = result.ok ? "Connector gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); }
|
||||||
|
} catch { status.value = "Konfiguration ist kein gültiges JSON."; kind.value = "error"; }
|
||||||
|
}
|
||||||
|
async function sync() { if (!selectedId.value) return; const result = await apiPost(`/api/v1/api-connectors/${selectedId.value}/sync`, {}); status.value = result.ok ? "Abgleich ausgeführt." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
|
||||||
|
async function deactivate() { if (!selectedId.value) return; const result = await apiDelete(`/api/v1/api-connectors/${selectedId.value}`); status.value = result.ok ? "Connector deaktiviert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Preis-APIs" description="Externe Preisquellen konfigurieren und manuell abgleichen." />
|
||||||
|
<div class="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Connectoren</h2><button type="button" @click="createNew">Neu</button></div><button v-for="connector in connectors" :key="connector.id" type="button" class="list-row" :class="{ selected: selectedId === connector.id }" @click="select(connector)"><strong>{{ connector.code }}</strong><span>{{ connector.name }}</span><small>{{ connector.last_sync_at ?? "kein Abgleich" }}</small></button></section>
|
||||||
|
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid"><label class="field"><span>Code</span><input v-model="form.code" required /></label><label class="field"><span>Name</span><input v-model="form.name" required /></label><label class="field"><span>Typ</span><input v-model="form.connector_type" required /></label><label class="field"><span>Intervall Minuten</span><input v-model="form.sync_interval_minutes" type="number" min="1" /></label><label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label><label class="field full-width"><span>Konfiguration JSON</span><textarea v-model="form.config" rows="8" /></label></div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="sync">Abgleichen</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div><FormStatus :message="status" :kind="kind" /></form></section></div>
|
||||||
|
</template>
|
||||||
107
web-frontend/src/views/CashDiscountTermsPage.vue
Normal file
107
web-frontend/src/views/CashDiscountTermsPage.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
import type { CashDiscountTerm } from "../types";
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
discount_percent: "2",
|
||||||
|
discount_days: 10,
|
||||||
|
net_days: 30 as number | null,
|
||||||
|
valid_from: null as string | null,
|
||||||
|
valid_until: null as string | null,
|
||||||
|
is_default_customer_term: false,
|
||||||
|
is_default_supplier_term: false,
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const terms = ref<CashDiscountTerm[]>([]);
|
||||||
|
const selectedId = ref<string | null>(null);
|
||||||
|
const form = reactive(emptyForm());
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const result = await apiGet<CashDiscountTerm[]>("/api/v1/cash-discount-terms");
|
||||||
|
if (result.ok) terms.value = result.data;
|
||||||
|
else { status.value = result.message; kind.value = "error"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNew() {
|
||||||
|
selectedId.value = null;
|
||||||
|
Object.assign(form, emptyForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(term: CashDiscountTerm) {
|
||||||
|
selectedId.value = term.id;
|
||||||
|
Object.assign(form, {
|
||||||
|
code: term.code,
|
||||||
|
name: term.name,
|
||||||
|
discount_percent: term.discount_percent,
|
||||||
|
discount_days: term.discount_days,
|
||||||
|
net_days: term.net_days ?? null,
|
||||||
|
valid_from: term.valid_from ?? null,
|
||||||
|
valid_until: term.valid_until ?? null,
|
||||||
|
is_default_customer_term: term.is_default_customer_term,
|
||||||
|
is_default_supplier_term: term.is_default_supplier_term,
|
||||||
|
is_active: term.is_active
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const result = selectedId.value
|
||||||
|
? await apiPut<CashDiscountTerm>(`/api/v1/cash-discount-terms/${selectedId.value}`, form)
|
||||||
|
: await apiPost<CashDiscountTerm>("/api/v1/cash-discount-terms", form);
|
||||||
|
status.value = result.ok ? "Skonto-Regel gespeichert." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) { selectedId.value = result.data.id; await load(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivate() {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
const result = await apiDelete(`/api/v1/cash-discount-terms/${selectedId.value}`);
|
||||||
|
status.value = result.ok ? "Skonto-Regel deaktiviert." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(() => liveUpdateState.revision, load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Skonto" description="Zahlungsbedingungen für Kunden, Lieferanten und Belege." />
|
||||||
|
<div class="workspace-split">
|
||||||
|
<section class="panel list-panel">
|
||||||
|
<div class="section-title"><h2>Regeln</h2><button type="button" @click="createNew">Neu</button></div>
|
||||||
|
<button v-for="term in terms" :key="term.id" type="button" class="list-row" :class="{ selected: selectedId === term.id }" @click="select(term)">
|
||||||
|
<strong>{{ term.code }}</strong>
|
||||||
|
<span>{{ term.name }}</span>
|
||||||
|
<small>{{ term.discount_percent }} % / {{ term.discount_days }} Tage</small>
|
||||||
|
</button>
|
||||||
|
<p v-if="terms.length === 0" class="empty">Keine Skonto-Regeln vorhanden.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail-panel">
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>Code</span><input v-model="form.code" required /></label>
|
||||||
|
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
|
||||||
|
<label class="field"><span>Skonto %</span><input v-model="form.discount_percent" type="number" min="0" max="100" step="0.01" required /></label>
|
||||||
|
<label class="field"><span>Skontofrist Tage</span><input v-model="form.discount_days" type="number" min="0" required /></label>
|
||||||
|
<label class="field"><span>Nettoziel Tage</span><input v-model="form.net_days" type="number" min="0" /></label>
|
||||||
|
<label class="field"><span>Gültig ab</span><input v-model="form.valid_from" type="date" /></label>
|
||||||
|
<label class="field"><span>Gültig bis</span><input v-model="form.valid_until" type="date" /></label>
|
||||||
|
<label class="check-row"><input v-model="form.is_default_customer_term" type="checkbox" /><span>Standard für Kunden</span></label>
|
||||||
|
<label class="check-row"><input v-model="form.is_default_supplier_term" type="checkbox" /><span>Standard für Lieferanten</span></label>
|
||||||
|
<label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div>
|
||||||
|
<FormStatus :message="status" :kind="kind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
69
web-frontend/src/views/ChangeInitialPasswordPage.vue
Normal file
69
web-frontend/src/views/ChangeInitialPasswordPage.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { apiPost } from "../api";
|
||||||
|
import { authState, updateAuthSession } from "../auth";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const form = reactive({
|
||||||
|
current_password: "",
|
||||||
|
new_password: "",
|
||||||
|
new_password_confirm: ""
|
||||||
|
});
|
||||||
|
const pending = ref(false);
|
||||||
|
const status = ref("");
|
||||||
|
const statusKind = ref<"info" | "success" | "error">("info");
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (form.new_password !== form.new_password_confirm) {
|
||||||
|
status.value = "Die neuen Passwörter stimmen nicht überein.";
|
||||||
|
statusKind.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.value = true;
|
||||||
|
status.value = "Sende Anfrage...";
|
||||||
|
statusKind.value = "info";
|
||||||
|
|
||||||
|
const result = await apiPost("/api/v1/auth/change-initial-password", {
|
||||||
|
email: authState.session?.email ?? "",
|
||||||
|
...form
|
||||||
|
});
|
||||||
|
pending.value = false;
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
status.value = result.message;
|
||||||
|
statusKind.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAuthSession({ mustChangePassword: false });
|
||||||
|
router.push("/setup/organization");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Initialpasswort ändern" description="Nach dem ersten Login muss ein eigenes Passwort gesetzt werden." />
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<label class="field">
|
||||||
|
<span>Aktuelles Passwort</span>
|
||||||
|
<input v-model="form.current_password" type="password" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Neues Passwort</span>
|
||||||
|
<input v-model="form.new_password" type="password" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Neues Passwort wiederholen</span>
|
||||||
|
<input v-model="form.new_password_confirm" type="password" required />
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" :disabled="pending">Passwort speichern</button>
|
||||||
|
</div>
|
||||||
|
<FormStatus :message="status" :kind="statusKind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
112
web-frontend/src/views/CommunicationsPage.vue
Normal file
112
web-frontend/src/views/CommunicationsPage.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
import type { Communication, EntityLink } from "../types";
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
communication_type: "internal_note",
|
||||||
|
direction: "internal",
|
||||||
|
subject: "",
|
||||||
|
body: "",
|
||||||
|
status: "open",
|
||||||
|
occurred_at: null as string | null,
|
||||||
|
links: [] as EntityLink[]
|
||||||
|
});
|
||||||
|
|
||||||
|
const records = ref<Communication[]>([]);
|
||||||
|
const selectedId = ref<string | null>(null);
|
||||||
|
const form = reactive(emptyForm());
|
||||||
|
const linkType = ref("customer");
|
||||||
|
const linkId = ref("");
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const result = await apiGet<Communication[]>("/api/v1/communications");
|
||||||
|
if (result.ok) records.value = result.data;
|
||||||
|
else { status.value = result.message; kind.value = "error"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNew() {
|
||||||
|
selectedId.value = null;
|
||||||
|
Object.assign(form, emptyForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(record: Communication) {
|
||||||
|
selectedId.value = record.id;
|
||||||
|
Object.assign(form, { ...record, links: [...record.links] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLink() {
|
||||||
|
if (!linkId.value.trim()) return;
|
||||||
|
form.links.push({ entity_type: linkType.value, entity_id: linkId.value.trim() });
|
||||||
|
linkId.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLink(index: number) {
|
||||||
|
form.links.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const result = selectedId.value
|
||||||
|
? await apiPut<Communication>(`/api/v1/communications/${selectedId.value}`, form)
|
||||||
|
: await apiPost<Communication>("/api/v1/communications", form);
|
||||||
|
status.value = result.ok ? "Kommunikation gespeichert." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) { selectedId.value = result.data.id; await load(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveRecord() {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
const result = await apiDelete(`/api/v1/communications/${selectedId.value}`);
|
||||||
|
status.value = result.ok ? "Kommunikation archiviert." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(() => liveUpdateState.revision, load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Kommunikation" description="E-Mails, Telefonate, Briefe, Besprechungen und interne Notizen." />
|
||||||
|
<div class="workspace-split">
|
||||||
|
<section class="panel list-panel">
|
||||||
|
<div class="section-title"><h2>Verlauf</h2><button type="button" @click="createNew">Neu</button></div>
|
||||||
|
<button v-for="record in records" :key="record.id" type="button" class="list-row" :class="{ selected: selectedId === record.id }" @click="select(record)">
|
||||||
|
<strong>{{ record.subject }}</strong>
|
||||||
|
<span>{{ record.communication_type }} · {{ record.direction }}</span>
|
||||||
|
<small>{{ record.status }}</small>
|
||||||
|
</button>
|
||||||
|
<p v-if="records.length === 0" class="empty">Keine Kommunikation vorhanden.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail-panel">
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>Typ</span><select v-model="form.communication_type"><option value="email">E-Mail</option><option value="phone">Telefon</option><option value="letter">Brief</option><option value="meeting">Besprechung</option><option value="internal_note">Interne Notiz</option></select></label>
|
||||||
|
<label class="field"><span>Richtung</span><select v-model="form.direction"><option value="inbound">Eingehend</option><option value="outbound">Ausgehend</option><option value="internal">Intern</option></select></label>
|
||||||
|
<label class="field"><span>Status</span><select v-model="form.status"><option value="open">Offen</option><option value="done">Erledigt</option><option value="archived">Archiviert</option></select></label>
|
||||||
|
<label class="field"><span>Zeitpunkt</span><input v-model="form.occurred_at" placeholder="optional, ISO-Zeit" /></label>
|
||||||
|
<label class="field full-width"><span>Betreff</span><input v-model="form.subject" required /></label>
|
||||||
|
<label class="field full-width"><span>Inhalt</span><textarea v-model="form.body" rows="8" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="sub-panel">
|
||||||
|
<h2>Bezüge</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>Typ</span><select v-model="linkType"><option value="customer">Kunde</option><option value="supplier">Lieferant</option><option value="activity">Aktivität</option><option value="quote">Angebot</option><option value="outgoing_invoice">Ausgangsrechnung</option><option value="incoming_invoice">Eingangsrechnung</option><option value="item">Artikel</option><option value="document">Dokument</option></select></label>
|
||||||
|
<label class="field"><span>ID</span><input v-model="linkId" /></label>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="secondary" @click="addLink">Bezug hinzufügen</button>
|
||||||
|
<div v-for="(link, index) in form.links" :key="`${link.entity_type}-${link.entity_id}`" class="data-row">
|
||||||
|
<strong>{{ link.entity_type }}</strong><span>{{ link.entity_id }}</span><button type="button" class="secondary" @click="removeLink(index)">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="archiveRecord">Archivieren</button></div>
|
||||||
|
<FormStatus :message="status" :kind="kind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
171
web-frontend/src/views/CustomersPage.vue
Normal file
171
web-frontend/src/views/CustomersPage.vue
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
import type { CashDiscountTerm, Customer } from "../types";
|
||||||
|
|
||||||
|
const emptyCustomer = () => ({
|
||||||
|
customer_number: "",
|
||||||
|
name: "",
|
||||||
|
status: "active",
|
||||||
|
details: {
|
||||||
|
street: "",
|
||||||
|
postal_code: "",
|
||||||
|
city: "",
|
||||||
|
country: "Deutschland",
|
||||||
|
email: "",
|
||||||
|
phone: ""
|
||||||
|
},
|
||||||
|
standard_discount_percent: "0",
|
||||||
|
cash_discount_term_id: null as string | null
|
||||||
|
});
|
||||||
|
|
||||||
|
const customers = ref<Customer[]>([]);
|
||||||
|
const cashDiscountTerms = ref<CashDiscountTerm[]>([]);
|
||||||
|
const selectedId = ref<string | null>(null);
|
||||||
|
const form = reactive(emptyCustomer());
|
||||||
|
const status = ref("");
|
||||||
|
const statusKind = ref<"info" | "success" | "error">("info");
|
||||||
|
const pending = ref(false);
|
||||||
|
const search = ref("");
|
||||||
|
const filteredCustomers = computed(() =>
|
||||||
|
customers.value.filter((customer) => matchesObjectSearch(customer.customer_number, customer.name, search.value))
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadCustomers() {
|
||||||
|
const result = await apiGet<Customer[]>("/api/v1/customers");
|
||||||
|
if (!result.ok) {
|
||||||
|
status.value = result.message;
|
||||||
|
statusKind.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customers.value = result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCashDiscountTerms() {
|
||||||
|
const result = await apiGet<CashDiscountTerm[]>("/api/v1/cash-discount-terms");
|
||||||
|
if (result.ok) cashDiscountTerms.value = result.data.filter((term) => term.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newCustomer() {
|
||||||
|
selectedId.value = null;
|
||||||
|
Object.assign(form, emptyCustomer());
|
||||||
|
form.details = { ...emptyCustomer().details };
|
||||||
|
form.customer_number = (await reserveNextNumber("customers")) ?? "";
|
||||||
|
status.value = "Neuer Kunde.";
|
||||||
|
statusKind.value = "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCustomer(customer: Customer) {
|
||||||
|
selectedId.value = customer.id;
|
||||||
|
Object.assign(form, {
|
||||||
|
customer_number: customer.customer_number,
|
||||||
|
name: customer.name,
|
||||||
|
status: customer.status,
|
||||||
|
details: { ...customer.details },
|
||||||
|
standard_discount_percent: customer.standard_discount_percent,
|
||||||
|
cash_discount_term_id: customer.cash_discount_term_id ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
pending.value = true;
|
||||||
|
const result = selectedId.value
|
||||||
|
? await apiPut<Customer>(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`, form)
|
||||||
|
: await apiPost<Customer>("/api/v1/customers", form);
|
||||||
|
pending.value = false;
|
||||||
|
if (!result.ok) {
|
||||||
|
status.value = result.message;
|
||||||
|
statusKind.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedId.value = result.data.id;
|
||||||
|
status.value = "Kunde gespeichert.";
|
||||||
|
statusKind.value = "success";
|
||||||
|
await loadCustomers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivate() {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
const result = await apiDelete(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`);
|
||||||
|
status.value = result.ok ? "Kunde deaktiviert." : result.message;
|
||||||
|
statusKind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) {
|
||||||
|
form.status = "inactive";
|
||||||
|
await loadCustomers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadCustomers(), loadCashDiscountTerms()]);
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
() => liveUpdateState.revision,
|
||||||
|
() => Promise.all([loadCustomers(), loadCashDiscountTerms()])
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Kunden" description="Kundenstamm, Rabatt und Kontaktdaten." />
|
||||||
|
<div class="workspace-split">
|
||||||
|
<section class="panel list-panel">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>Kundenliste</h2>
|
||||||
|
<button type="button" @click="newCustomer">Neu</button>
|
||||||
|
</div>
|
||||||
|
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Kundennummer oder Name" /></label>
|
||||||
|
<button
|
||||||
|
v-for="customer in filteredCustomers"
|
||||||
|
:key="customer.id"
|
||||||
|
type="button"
|
||||||
|
class="list-row"
|
||||||
|
:class="{ selected: selectedId === customer.id }"
|
||||||
|
@click="selectCustomer(customer)"
|
||||||
|
>
|
||||||
|
<strong>{{ customer.customer_number }}</strong>
|
||||||
|
<span>{{ customer.name }}</span>
|
||||||
|
<small>{{ customer.status }}</small>
|
||||||
|
</button>
|
||||||
|
<p v-if="customers.length === 0" class="empty">Keine Kunden vorhanden.</p>
|
||||||
|
<p v-else-if="filteredCustomers.length === 0" class="empty">Keine Treffer.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail-panel">
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>Kundennummer</span><div class="readonly-value">{{ form.customer_number || "wird automatisch vergeben" }}</div></label>
|
||||||
|
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select v-model="form.status">
|
||||||
|
<option value="active">Aktiv</option>
|
||||||
|
<option value="inactive">Inaktiv</option>
|
||||||
|
<option value="blocked">Gesperrt</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>Standardrabatt %</span><input v-model="form.standard_discount_percent" type="number" min="0" max="100" step="0.01" required /></label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Skonto-Regel</span>
|
||||||
|
<select v-model="form.cash_discount_term_id">
|
||||||
|
<option :value="null">Keine</option>
|
||||||
|
<option v-for="term in cashDiscountTerms" :key="term.id" :value="term.id">{{ term.code }} - {{ term.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>Straße</span><input v-model="form.details.street" /></label>
|
||||||
|
<label class="field"><span>PLZ</span><input v-model="form.details.postal_code" /></label>
|
||||||
|
<label class="field"><span>Ort</span><input v-model="form.details.city" /></label>
|
||||||
|
<label class="field"><span>Land</span><input v-model="form.details.country" /></label>
|
||||||
|
<label class="field"><span>E-Mail</span><input v-model="form.details.email" type="email" /></label>
|
||||||
|
<label class="field"><span>Telefon</span><input v-model="form.details.phone" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" :disabled="pending">Speichern</button>
|
||||||
|
<button v-if="selectedId" type="button" class="secondary" :disabled="pending" @click="deactivate">Deaktivieren</button>
|
||||||
|
</div>
|
||||||
|
<FormStatus :message="status" :kind="statusKind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
32
web-frontend/src/views/DashboardPage.vue
Normal file
32
web-frontend/src/views/DashboardPage.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import { connectionState, wsUrl } from "../realtime";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Dashboard" description="Übersicht über Backend-Verbindung und aktuelle Vorgänge." />
|
||||||
|
<section class="panel compact">
|
||||||
|
<div class="split-row">
|
||||||
|
<div>
|
||||||
|
<h2>Live-Verbindung</h2>
|
||||||
|
<p class="muted">{{ connectionState.status }}</p>
|
||||||
|
</div>
|
||||||
|
<code>{{ wsUrl }}</code>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>Aktuelle Datensätze</h2>
|
||||||
|
<span>{{ connectionState.records.length }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="connectionState.records.length === 0" class="empty">Keine Datensätze vorhanden.</p>
|
||||||
|
<div v-else class="data-table two-cols">
|
||||||
|
<div class="table-head">Titel</div>
|
||||||
|
<div class="table-head">Aktualisiert</div>
|
||||||
|
<template v-for="record in connectionState.records" :key="record.id">
|
||||||
|
<div>{{ record.title }}</div>
|
||||||
|
<time>{{ new Date(record.updated_at).toLocaleString("de-DE") }}</time>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
147
web-frontend/src/views/DocumentsPage.vue
Normal file
147
web-frontend/src/views/DocumentsPage.vue
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
import type { DocumentAuditLogEntry, DocumentDownload, DocumentRecord, EntityLink } from "../types";
|
||||||
|
|
||||||
|
const documents = ref<DocumentRecord[]>([]);
|
||||||
|
const auditLog = ref<DocumentAuditLogEntry[]>([]);
|
||||||
|
const selectedId = ref<string | null>(null);
|
||||||
|
const form = reactive({ title: "", description: "", file_name: "", content_type: "text/plain", content_base64: "", links: [] as EntityLink[] });
|
||||||
|
const linkType = ref("customer");
|
||||||
|
const linkId = ref("");
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const result = await apiGet<DocumentRecord[]>("/api/v1/documents");
|
||||||
|
if (result.ok) documents.value = result.data;
|
||||||
|
else { status.value = result.message; kind.value = "error"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNew() {
|
||||||
|
selectedId.value = null;
|
||||||
|
Object.assign(form, { title: "", description: "", file_name: "", content_type: "text/plain", content_base64: "", links: [] });
|
||||||
|
auditLog.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function select(record: DocumentRecord) {
|
||||||
|
selectedId.value = record.id;
|
||||||
|
Object.assign(form, {
|
||||||
|
title: record.title,
|
||||||
|
description: record.description,
|
||||||
|
file_name: record.latest_version?.file_name ?? "",
|
||||||
|
content_type: record.latest_version?.content_type ?? "text/plain",
|
||||||
|
content_base64: "",
|
||||||
|
links: [...record.links]
|
||||||
|
});
|
||||||
|
await loadAuditLog(record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFileChange(event: Event) {
|
||||||
|
const file = (event.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
form.file_name = file.name;
|
||||||
|
form.content_type = file.type || "application/octet-stream";
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
form.content_base64 = btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLink() {
|
||||||
|
if (!linkId.value.trim()) return;
|
||||||
|
form.links.push({ entity_type: linkType.value, entity_id: linkId.value.trim() });
|
||||||
|
linkId.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLink(index: number) {
|
||||||
|
form.links.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
const result = await apiPost<DocumentRecord>("/api/v1/documents", form);
|
||||||
|
status.value = result.ok ? "Dokument hochgeladen." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) { selectedId.value = result.data.id; await load(); await loadAuditLog(result.data.id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download() {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
const result = await apiGet<DocumentDownload>(`/api/v1/documents/${selectedId.value}/download`);
|
||||||
|
if (!result.ok) { status.value = result.message; kind.value = "error"; return; }
|
||||||
|
const bytes = Uint8Array.from(atob(result.data.content_base64), (char) => char.charCodeAt(0));
|
||||||
|
const blob = new Blob([bytes], { type: result.data.content_type });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = result.data.file_name;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
status.value = "Download vorbereitet.";
|
||||||
|
kind.value = "success";
|
||||||
|
await loadAuditLog(selectedId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveDocument() {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
const result = await apiDelete(`/api/v1/documents/${selectedId.value}`);
|
||||||
|
status.value = result.ok ? "Dokument archiviert." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAuditLog(documentId: string) {
|
||||||
|
const result = await apiGet<DocumentAuditLogEntry[]>(`/api/v1/documents/${documentId}/audit-log`);
|
||||||
|
if (result.ok) auditLog.value = result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(() => liveUpdateState.revision, load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Dokumente" description="Dokumente verschlüsselt ablegen, zuordnen und herunterladen." />
|
||||||
|
<div class="workspace-split">
|
||||||
|
<section class="panel list-panel">
|
||||||
|
<div class="section-title"><h2>Dokumente</h2><button type="button" @click="createNew">Neu</button></div>
|
||||||
|
<button v-for="record in documents" :key="record.id" type="button" class="list-row" :class="{ selected: selectedId === record.id }" @click="select(record)">
|
||||||
|
<strong>{{ record.title }}</strong>
|
||||||
|
<span>{{ record.latest_version?.file_name ?? "ohne Datei" }}</span>
|
||||||
|
<small>{{ record.status }}</small>
|
||||||
|
</button>
|
||||||
|
<p v-if="documents.length === 0" class="empty">Keine Dokumente vorhanden.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail-panel">
|
||||||
|
<form @submit.prevent="upload">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>Titel</span><input v-model="form.title" required /></label>
|
||||||
|
<label class="field"><span>Content-Type</span><input v-model="form.content_type" required /></label>
|
||||||
|
<label class="field full-width"><span>Beschreibung</span><textarea v-model="form.description" rows="4" /></label>
|
||||||
|
<label class="field full-width"><span>Datei</span><input type="file" @change="onFileChange" /></label>
|
||||||
|
<label class="field full-width"><span>Dateiname</span><input v-model="form.file_name" required /></label>
|
||||||
|
</div>
|
||||||
|
<div class="sub-panel">
|
||||||
|
<h2>Bezüge</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>Typ</span><select v-model="linkType"><option value="customer">Kunde</option><option value="supplier">Lieferant</option><option value="activity">Aktivität</option><option value="communication">Kommunikation</option><option value="quote">Angebot</option><option value="outgoing_invoice">Ausgangsrechnung</option><option value="incoming_invoice">Eingangsrechnung</option><option value="item">Artikel</option></select></label>
|
||||||
|
<label class="field"><span>ID</span><input v-model="linkId" /></label>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="secondary" @click="addLink">Bezug hinzufügen</button>
|
||||||
|
<div v-for="(link, index) in form.links" :key="`${link.entity_type}-${link.entity_id}`" class="data-row">
|
||||||
|
<strong>{{ link.entity_type }}</strong><span>{{ link.entity_id }}</span><button type="button" class="secondary" @click="removeLink(index)">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions"><button type="submit">Hochladen</button><button v-if="selectedId" type="button" class="secondary" @click="download">Herunterladen</button><button v-if="selectedId" type="button" class="secondary" @click="archiveDocument">Archivieren</button></div>
|
||||||
|
<FormStatus :message="status" :kind="kind" />
|
||||||
|
</form>
|
||||||
|
<div v-if="auditLog.length > 0" class="sub-panel">
|
||||||
|
<h2>Audit-Log</h2>
|
||||||
|
<div v-for="entry in auditLog" :key="entry.id" class="data-row"><strong>{{ entry.action }}</strong><span>{{ new Date(entry.created_at).toLocaleString("de-DE") }}</span><small>{{ entry.user_id ?? "-" }}</small></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
38
web-frontend/src/views/IncomingInvoicesPage.vue
Normal file
38
web-frontend/src/views/IncomingInvoicesPage.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import SearchSelect from "../components/SearchSelect.vue";
|
||||||
|
import { reserveNextNumber } from "../number-ranges";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
import type { IncomingInvoice, IncomingInvoiceItem, Item, Supplier } from "../types";
|
||||||
|
|
||||||
|
const emptyItem = (): IncomingInvoiceItem => ({ item_id: null, description: "", quantity: "1", unit_price: "0", tax_rate: "19" });
|
||||||
|
const emptyForm = () => ({ invoice_number: "", supplier_id: "", status: "received", cash_discount_term_id: null as string | null, invoice_date: null as string | null, due_at: null as string | null, items: [emptyItem()] });
|
||||||
|
const invoices = ref<IncomingInvoice[]>([]); const suppliers = ref<Supplier[]>([]); const items = ref<Item[]>([]);
|
||||||
|
const selectedId = ref<string | null>(null); const form = reactive(emptyForm()); const status = ref(""); const kind = ref<"info" | "success" | "error">("info"); const search = ref("");
|
||||||
|
const supplierName = (supplierId: string) => suppliers.value.find((supplier) => supplier.id === supplierId)?.name ?? supplierId;
|
||||||
|
const filteredInvoices = computed(() => {
|
||||||
|
const needle = search.value.trim().toLowerCase();
|
||||||
|
if (!needle) return invoices.value;
|
||||||
|
return invoices.value.filter((invoice) =>
|
||||||
|
`${invoice.invoice_number} ${supplierName(invoice.supplier_id)} ${invoice.status}`.toLowerCase().includes(needle)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
async function load() { const [ir, sr, itemr] = await Promise.all([apiGet<IncomingInvoice[]>("/api/v1/incoming-invoices"), apiGet<Supplier[]>("/api/v1/suppliers"), apiGet<Item[]>("/api/v1/items")]); if (ir.ok) invoices.value = ir.data; else { status.value = ir.message; kind.value = "error"; } if (sr.ok) suppliers.value = sr.data.filter((supplier) => supplier.status === "active"); if (itemr.ok) items.value = itemr.data.filter((item) => item.status === "active"); }
|
||||||
|
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.invoice_number = (await reserveNextNumber("incoming_invoices")) ?? ""; }
|
||||||
|
function select(invoice: IncomingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item })) }); }
|
||||||
|
function addLine() { form.items.push(emptyItem()); }
|
||||||
|
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); }
|
||||||
|
async function save() { const result = selectedId.value ? await apiPut<IncomingInvoice>(`/api/v1/incoming-invoices/${selectedId.value}`, form) : await apiPost<IncomingInvoice>("/api/v1/incoming-invoices", form); status.value = result.ok ? "Eingangsrechnung gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); } }
|
||||||
|
async function cancelInvoice() { if (!selectedId.value) return; const result = await apiDelete(`/api/v1/incoming-invoices/${selectedId.value}`); status.value = result.ok ? "Eingangsrechnung storniert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
|
||||||
|
onMounted(load); watch(() => liveUpdateState.revision, load);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Eingangsrechnungen" description="Lieferantenrechnungen mit Skonto-Bezug und Positionen." />
|
||||||
|
<div class="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Eingangsrechnungen</h2><button type="button" @click="createNew">Neu</button></div><label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Rechnungsnummer, Lieferant oder Status" /></label><button v-for="invoice in filteredInvoices" :key="invoice.id" type="button" class="list-row" :class="{ selected: selectedId === invoice.id }" @click="select(invoice)"><strong>{{ invoice.invoice_number }}</strong><span>{{ supplierName(invoice.supplier_id) }}</span><small>{{ invoice.status }}</small></button><p v-if="invoices.length === 0" class="empty">Keine Eingangsrechnungen vorhanden.</p><p v-else-if="filteredInvoices.length === 0" class="empty">Keine Treffer.</p></section>
|
||||||
|
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid"><label class="field"><span>Rechnungsnummer</span><div class="readonly-value">{{ form.invoice_number || "wird automatisch vergeben" }}</div></label><label class="field"><span>Lieferant</span><SearchSelect v-model="form.supplier_id" :options="suppliers.map((supplier) => ({ id: supplier.id, number: supplier.supplier_number, name: supplier.name }))" placeholder="Lieferanten-Nr. oder Name suchen" required /></label><label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="received">Erhalten</option><option value="approved">Freigegeben</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option><option value="overdue">Überfällig</option></select></label><label class="field"><span>Datum</span><input v-model="form.invoice_date" type="date" /></label><label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" /></label></div>
|
||||||
|
<div class="sub-panel"><div class="section-title"><h2>Positionen</h2><button type="button" @click="addLine">Position</button></div><div v-for="(line, index) in form.items" :key="index" class="quote-line"><label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" allow-empty empty-label="Kein Artikel" /></label><label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" /></label><label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" /></label><label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button><label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label></div></div>
|
||||||
|
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="cancelInvoice">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section></div>
|
||||||
|
</template>
|
||||||
67
web-frontend/src/views/ItemsPage.vue
Normal file
67
web-frontend/src/views/ItemsPage.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
import type { Item, ItemPriceHistory } from "../types";
|
||||||
|
|
||||||
|
const emptyForm = () => ({ item_number: "", name: "", unit: "Stk", tax_rate: "19", default_purchase_price: null as string | null, default_sales_price: null as string | null, status: "active" });
|
||||||
|
const items = ref<Item[]>([]);
|
||||||
|
const priceHistory = ref<ItemPriceHistory[]>([]);
|
||||||
|
const selectedId = ref<string | null>(null);
|
||||||
|
const form = reactive(emptyForm());
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
const search = ref("");
|
||||||
|
const filteredItems = computed(() =>
|
||||||
|
items.value.filter((item) => matchesObjectSearch(item.item_number, item.name, search.value))
|
||||||
|
);
|
||||||
|
async function load() { const r = await apiGet<Item[]>("/api/v1/items"); if (r.ok) items.value = r.data; else { status.value = r.message; kind.value = "error"; } }
|
||||||
|
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.item_number = (await reserveNextNumber("items")) ?? ""; priceHistory.value = []; }
|
||||||
|
async function select(item: Item) { selectedId.value = item.id; Object.assign(form, item); await loadPriceHistory(item.id); }
|
||||||
|
async function loadPriceHistory(itemId: string) {
|
||||||
|
const result = await apiGet<ItemPriceHistory[]>(`/api/v1/items/${itemId}/prices`);
|
||||||
|
if (result.ok) priceHistory.value = result.data;
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
const r = selectedId.value ? await apiPut<Item>(`/api/v1/items/${selectedId.value}`, form) : await apiPost<Item>("/api/v1/items", form);
|
||||||
|
status.value = r.ok ? "Artikel gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; await Promise.all([load(), loadPriceHistory(r.data.id)]); }
|
||||||
|
}
|
||||||
|
async function deactivate() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/items/${selectedId.value}`); status.value = r.ok ? "Artikel deaktiviert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) await load(); }
|
||||||
|
onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(); if (selectedId.value) await loadPriceHistory(selectedId.value); });
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Artikel" description="Artikelstamm und aktuelle Standardpreise." />
|
||||||
|
<div class="workspace-split">
|
||||||
|
<section class="panel list-panel">
|
||||||
|
<div class="section-title"><h2>Artikel</h2><button type="button" @click="createNew">Neu</button></div>
|
||||||
|
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Artikelnummer oder Bezeichnung" /></label>
|
||||||
|
<button v-for="item in filteredItems" :key="item.id" type="button" class="list-row" :class="{ selected: selectedId === item.id }" @click="select(item)"><strong>{{ item.item_number }}</strong><span>{{ item.name }}</span><small>{{ item.status }}</small></button>
|
||||||
|
<p v-if="items.length === 0" class="empty">Keine Artikel vorhanden.</p>
|
||||||
|
<p v-else-if="filteredItems.length === 0" class="empty">Keine Treffer.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail-panel">
|
||||||
|
<form @submit.prevent="save"><div class="form-grid">
|
||||||
|
<label class="field"><span>Artikelnummer</span><div class="readonly-value">{{ form.item_number || "wird automatisch vergeben" }}</div></label>
|
||||||
|
<label class="field"><span>Bezeichnung</span><input v-model="form.name" required /></label>
|
||||||
|
<label class="field"><span>Einheit</span><input v-model="form.unit" required /></label>
|
||||||
|
<label class="field"><span>Steuersatz %</span><input v-model="form.tax_rate" type="number" min="0" step="0.01" required /></label>
|
||||||
|
<label class="field"><span>Einkaufspreis</span><input v-model="form.default_purchase_price" type="number" min="0" step="0.01" /></label>
|
||||||
|
<label class="field"><span>Verkaufspreis</span><input v-model="form.default_sales_price" type="number" min="0" step="0.01" /></label>
|
||||||
|
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Inaktiv</option><option value="blocked">Gesperrt</option></select></label>
|
||||||
|
</div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div><FormStatus :message="status" :kind="kind" /></form>
|
||||||
|
<div v-if="selectedId" class="sub-panel">
|
||||||
|
<h2>Preishistorie</h2>
|
||||||
|
<div v-for="entry in priceHistory" :key="entry.id" class="data-row">
|
||||||
|
<strong>{{ new Date(entry.valid_from).toLocaleString("de-DE") }}</strong>
|
||||||
|
<span>EK {{ entry.purchase_price ?? "-" }}</span>
|
||||||
|
<span>VK {{ entry.sales_price ?? "-" }}</span>
|
||||||
|
<small>{{ entry.source }}</small>
|
||||||
|
</div>
|
||||||
|
<p v-if="priceHistory.length === 0" class="empty">Noch keine Preisänderung vorhanden.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
122
web-frontend/src/views/LoginPage.vue
Normal file
122
web-frontend/src/views/LoginPage.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { apiPost } from "../api";
|
||||||
|
import { setAuthSession } from "../auth";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import { ensureConnection } from "../realtime";
|
||||||
|
import type { DevBootstrapResponse, LoginResponse } from "../types";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const form = reactive({
|
||||||
|
email: "",
|
||||||
|
password: ""
|
||||||
|
});
|
||||||
|
const pending = ref(false);
|
||||||
|
const status = ref("");
|
||||||
|
const statusKind = ref<"info" | "success" | "error">("info");
|
||||||
|
const devForm = reactive({
|
||||||
|
organization_name: "Lokale Testfirma",
|
||||||
|
email: "admin@example.test"
|
||||||
|
});
|
||||||
|
const devPending = ref(false);
|
||||||
|
const devStatus = ref("");
|
||||||
|
const devStatusKind = ref<"info" | "success" | "error">("info");
|
||||||
|
const devCredentials = ref<DevBootstrapResponse | null>(null);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
pending.value = true;
|
||||||
|
status.value = "Sende Anfrage...";
|
||||||
|
statusKind.value = "info";
|
||||||
|
|
||||||
|
const result = await apiPost<LoginResponse>("/api/v1/auth/login", form);
|
||||||
|
pending.value = false;
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
status.value = result.message;
|
||||||
|
statusKind.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthSession({
|
||||||
|
email: form.email,
|
||||||
|
userId: result.data.user_id,
|
||||||
|
accessToken: result.data.access_token,
|
||||||
|
organizationId: result.data.organization_id ?? result.data.organizations[0]?.id ?? null,
|
||||||
|
mustChangePassword: result.data.must_change_password
|
||||||
|
});
|
||||||
|
ensureConnection();
|
||||||
|
router.push(result.data.must_change_password ? "/change-initial-password" : "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapDev() {
|
||||||
|
devPending.value = true;
|
||||||
|
devStatus.value = "Lege lokale Testfirma an...";
|
||||||
|
devStatusKind.value = "info";
|
||||||
|
devCredentials.value = null;
|
||||||
|
|
||||||
|
const result = await apiPost<DevBootstrapResponse>("/api/v1/dev/bootstrap-local", devForm);
|
||||||
|
devPending.value = false;
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
devStatus.value = result.message;
|
||||||
|
devStatusKind.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
devCredentials.value = result.data;
|
||||||
|
form.email = result.data.email;
|
||||||
|
form.password = result.data.password;
|
||||||
|
devStatus.value = "Testfirma und User wurden angelegt.";
|
||||||
|
devStatusKind.value = "success";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Login" description="Anmeldung mit E-Mail-Adresse und Passwort." />
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<label class="field">
|
||||||
|
<span>E-Mail-Adresse</span>
|
||||||
|
<input v-model="form.email" name="email" type="email" placeholder="admin@example.com" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Passwort</span>
|
||||||
|
<input v-model="form.password" name="password" type="password" required />
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" :disabled="pending">Einloggen</button>
|
||||||
|
</div>
|
||||||
|
<FormStatus :message="status" :kind="statusKind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<div class="section-title">
|
||||||
|
<div>
|
||||||
|
<h2>Dev-Bootstrap</h2>
|
||||||
|
<p class="muted">Lokale Testfirma mit erstem User anlegen, ohne E-Mail-Versand.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="bootstrapDev">
|
||||||
|
<label class="field">
|
||||||
|
<span>Firmenname</span>
|
||||||
|
<input v-model="devForm.organization_name" type="text" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>E-Mail-Adresse</span>
|
||||||
|
<input v-model="devForm.email" type="email" required />
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" :disabled="devPending">Testfirma und User anlegen</button>
|
||||||
|
</div>
|
||||||
|
<FormStatus :message="devStatus" :kind="devStatusKind" />
|
||||||
|
</form>
|
||||||
|
<dl v-if="devCredentials" class="detail-grid dev-credentials">
|
||||||
|
<dt>Login</dt><dd>{{ devCredentials.email }}</dd>
|
||||||
|
<dt>Passwort</dt><dd><code>{{ devCredentials.password }}</code></dd>
|
||||||
|
<dt>Firma</dt><dd><code>{{ devCredentials.organization_id }}</code></dd>
|
||||||
|
<dt>Schema</dt><dd><code>{{ devCredentials.schema_name }}</code></dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
91
web-frontend/src/views/NumberRangesPage.vue
Normal file
91
web-frontend/src/views/NumberRangesPage.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiGet, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
import type { NumberRange } from "../types";
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
items: "Artikel",
|
||||||
|
activities: "Aktivitäten",
|
||||||
|
outgoing_invoices: "Rechnungen",
|
||||||
|
incoming_invoices: "Eingangsrechnungen",
|
||||||
|
customers: "Kunden",
|
||||||
|
suppliers: "Lieferanten",
|
||||||
|
quotes: "Angebote"
|
||||||
|
};
|
||||||
|
|
||||||
|
const ranges = ref<NumberRange[]>([]);
|
||||||
|
const selectedCode = ref<string | null>(null);
|
||||||
|
const form = reactive({
|
||||||
|
pattern: "",
|
||||||
|
counter_value: 0,
|
||||||
|
counter_padding: 9,
|
||||||
|
reset_rule: null as string | null,
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const result = await apiGet<NumberRange[]>("/api/v1/number-ranges");
|
||||||
|
if (!result.ok) {
|
||||||
|
status.value = result.message;
|
||||||
|
kind.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ranges.value = result.data;
|
||||||
|
if (!selectedCode.value && ranges.value[0]) select(ranges.value[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(range: NumberRange) {
|
||||||
|
selectedCode.value = range.code;
|
||||||
|
Object.assign(form, {
|
||||||
|
pattern: range.pattern,
|
||||||
|
counter_value: range.counter_value,
|
||||||
|
counter_padding: range.counter_padding,
|
||||||
|
reset_rule: range.reset_rule ?? null,
|
||||||
|
is_active: range.is_active
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!selectedCode.value) return;
|
||||||
|
const result = await apiPut<NumberRange>(`/api/v1/number-ranges/${selectedCode.value}`, form);
|
||||||
|
status.value = result.ok ? "Nummernkreis gespeichert." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(() => liveUpdateState.revision, load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Nummernkreise" description="Individuelle Nummern für Stammdaten, Angebote und Rechnungen." />
|
||||||
|
<div class="workspace-split">
|
||||||
|
<section class="panel list-panel">
|
||||||
|
<h2>Bereiche</h2>
|
||||||
|
<button v-for="range in ranges" :key="range.code" type="button" class="list-row" :class="{ selected: selectedCode === range.code }" @click="select(range)">
|
||||||
|
<strong>{{ labels[range.code] ?? range.code }}</strong>
|
||||||
|
<span>{{ range.pattern }}</span>
|
||||||
|
<small>Zähler {{ range.counter_value }}</small>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail-panel">
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>Muster</span><input v-model="form.pattern" required /></label>
|
||||||
|
<label class="field"><span>Zählerstand</span><input v-model="form.counter_value" type="number" min="0" required /></label>
|
||||||
|
<label class="field"><span>Zählerlänge</span><input v-model="form.counter_padding" type="number" min="1" max="18" required /></label>
|
||||||
|
<label class="field"><span>Reset-Regel</span><input v-model="form.reset_rule" /></label>
|
||||||
|
<label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Das Muster muss <code>{counter}</code> enthalten. Der Zähler wird bei 9 Stellen als 000.000.001 formatiert.</p>
|
||||||
|
<div class="form-actions"><button type="submit" :disabled="!selectedCode">Speichern</button></div>
|
||||||
|
<FormStatus :message="status" :kind="kind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
115
web-frontend/src/views/OrganizationSetupPage.vue
Normal file
115
web-frontend/src/views/OrganizationSetupPage.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiGet, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
|
||||||
|
type OrganizationSetupForm = {
|
||||||
|
display_name: string;
|
||||||
|
legal_form: string;
|
||||||
|
street: string;
|
||||||
|
postal_code: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
vat_id: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
default_tax_rate: string;
|
||||||
|
default_payment_days: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrganizationSetupResponse = {
|
||||||
|
organization_id: string;
|
||||||
|
schema_name: string;
|
||||||
|
setup: OrganizationSetupForm | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = reactive<OrganizationSetupForm>({
|
||||||
|
display_name: "",
|
||||||
|
legal_form: "",
|
||||||
|
street: "",
|
||||||
|
postal_code: "",
|
||||||
|
city: "",
|
||||||
|
country: "Deutschland",
|
||||||
|
vat_id: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
default_tax_rate: "19",
|
||||||
|
default_payment_days: "14"
|
||||||
|
});
|
||||||
|
const pending = ref(false);
|
||||||
|
const status = ref("");
|
||||||
|
const statusKind = ref<"info" | "success" | "error">("info");
|
||||||
|
const loaded = ref(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
pending.value = true;
|
||||||
|
status.value = "Lade Firmendaten...";
|
||||||
|
statusKind.value = "info";
|
||||||
|
const result = await apiGet<OrganizationSetupResponse>("/api/v1/organizations/current/setup");
|
||||||
|
pending.value = false;
|
||||||
|
if (!result.ok) {
|
||||||
|
status.value = result.message;
|
||||||
|
statusKind.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data.setup) {
|
||||||
|
Object.assign(form, result.data.setup);
|
||||||
|
status.value = "Firmendaten geladen.";
|
||||||
|
statusKind.value = "info";
|
||||||
|
loaded.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.value = "Noch keine Firmendaten gespeichert.";
|
||||||
|
statusKind.value = "info";
|
||||||
|
loaded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
pending.value = true;
|
||||||
|
status.value = "Sende Anfrage...";
|
||||||
|
statusKind.value = "info";
|
||||||
|
|
||||||
|
const result = await apiPut("/api/v1/organizations/current/setup", form);
|
||||||
|
pending.value = false;
|
||||||
|
status.value = result.ok ? "Gespeichert." : result.message;
|
||||||
|
statusKind.value = result.ok ? "success" : "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(
|
||||||
|
() => liveUpdateState.revision,
|
||||||
|
() => {
|
||||||
|
if (loaded.value) load();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Firmendaten" description="Stammdaten der aktiven Organization erfassen." />
|
||||||
|
<section class="panel form-panel wide">
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>Firmenname</span><input v-model="form.display_name" type="text" placeholder="Muster GmbH" required /></label>
|
||||||
|
<label class="field"><span>Rechtsform</span><input v-model="form.legal_form" type="text" placeholder="GmbH" /></label>
|
||||||
|
<label class="field"><span>Straße und Hausnummer</span><input v-model="form.street" type="text" required /></label>
|
||||||
|
<label class="field"><span>PLZ</span><input v-model="form.postal_code" type="text" required /></label>
|
||||||
|
<label class="field"><span>Ort</span><input v-model="form.city" type="text" required /></label>
|
||||||
|
<label class="field"><span>Land</span><input v-model="form.country" type="text" required /></label>
|
||||||
|
<label class="field"><span>USt-IdNr.</span><input v-model="form.vat_id" type="text" /></label>
|
||||||
|
<label class="field"><span>E-Mail der Firma</span><input v-model="form.email" type="email" placeholder="info@example.com" required /></label>
|
||||||
|
<label class="field"><span>Telefon</span><input v-model="form.phone" type="tel" /></label>
|
||||||
|
<label class="field"><span>Standard-Steuersatz</span><input v-model="form.default_tax_rate" type="number" required /></label>
|
||||||
|
<label class="field"><span>Standard-Zahlungsziel</span><input v-model="form.default_payment_days" type="number" required /></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" :disabled="pending">Firmendaten speichern</button>
|
||||||
|
<button type="button" class="secondary" :disabled="pending" @click="load">Neu laden</button>
|
||||||
|
</div>
|
||||||
|
<FormStatus :message="status" :kind="statusKind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
82
web-frontend/src/views/OutgoingInvoicesPage.vue
Normal file
82
web-frontend/src/views/OutgoingInvoicesPage.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import SearchSelect from "../components/SearchSelect.vue";
|
||||||
|
import { reserveNextNumber } from "../number-ranges";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
import type { Customer, Item, OutgoingInvoice, OutgoingInvoiceItem, Quote } from "../types";
|
||||||
|
|
||||||
|
const emptyItem = (): OutgoingInvoiceItem => ({ item_id: "", description: "", quantity: "1", unit_price: "0", original_unit_price: null, discount_percent: "0", tax_rate: "19" });
|
||||||
|
const emptyForm = () => ({ invoice_number: "", customer_id: "", status: "draft", cash_discount_term_id: null as string | null, customer_discount_percent: "0", issued_at: null as string | null, due_at: null as string | null, source_quote_id: null as string | null, items: [emptyItem()] });
|
||||||
|
const invoices = ref<OutgoingInvoice[]>([]);
|
||||||
|
const customers = ref<Customer[]>([]);
|
||||||
|
const items = ref<Item[]>([]);
|
||||||
|
const quotes = ref<Quote[]>([]);
|
||||||
|
const selectedId = ref<string | null>(null);
|
||||||
|
const form = reactive(emptyForm());
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
const search = ref("");
|
||||||
|
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
|
||||||
|
const filteredInvoices = computed(() => {
|
||||||
|
const needle = search.value.trim().toLowerCase();
|
||||||
|
if (!needle) return invoices.value;
|
||||||
|
return invoices.value.filter((invoice) =>
|
||||||
|
`${invoice.invoice_number} ${customerName(invoice.customer_id)} ${invoice.status}`.toLowerCase().includes(needle)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const [invoiceResult, customerResult, itemResult, quoteResult] = await Promise.all([
|
||||||
|
apiGet<OutgoingInvoice[]>("/api/v1/outgoing-invoices"), apiGet<Customer[]>("/api/v1/customers"), apiGet<Item[]>("/api/v1/items"), apiGet<Quote[]>("/api/v1/quotes")
|
||||||
|
]);
|
||||||
|
if (invoiceResult.ok) invoices.value = invoiceResult.data; else { status.value = invoiceResult.message; kind.value = "error"; }
|
||||||
|
if (customerResult.ok) customers.value = customerResult.data.filter((customer) => customer.status === "active");
|
||||||
|
if (itemResult.ok) items.value = itemResult.data.filter((item) => item.status === "active");
|
||||||
|
if (quoteResult.ok) quotes.value = quoteResult.data;
|
||||||
|
}
|
||||||
|
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.invoice_number = (await reserveNextNumber("outgoing_invoices")) ?? ""; }
|
||||||
|
function select(invoice: OutgoingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item })) }); }
|
||||||
|
function addLine() { form.items.push(emptyItem()); }
|
||||||
|
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); }
|
||||||
|
function applyItemDefaults(line: OutgoingInvoiceItem) {
|
||||||
|
const item = items.value.find((record) => record.id === line.item_id);
|
||||||
|
if (!item) return;
|
||||||
|
line.description = item.name; line.unit_price = item.default_sales_price ?? "0"; line.original_unit_price = item.default_sales_price ?? null; line.tax_rate = item.tax_rate;
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
const result = selectedId.value ? await apiPut<OutgoingInvoice>(`/api/v1/outgoing-invoices/${selectedId.value}`, form) : await apiPost<OutgoingInvoice>("/api/v1/outgoing-invoices", form);
|
||||||
|
status.value = result.ok ? "Rechnung gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); }
|
||||||
|
}
|
||||||
|
async function finalize() { if (!selectedId.value) return; const result = await apiPost(`/api/v1/outgoing-invoices/${selectedId.value}/finalize`, {}); status.value = result.ok ? "Rechnung abgeschlossen." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
|
||||||
|
async function cancelInvoice() { if (!selectedId.value) return; const result = await apiDelete(`/api/v1/outgoing-invoices/${selectedId.value}`); status.value = result.ok ? "Rechnung storniert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
|
||||||
|
async function convertQuote(quoteId: string) { const result = await apiPost<OutgoingInvoice>(`/api/v1/quotes/${quoteId}/convert-to-invoice`, {}); status.value = result.ok ? "Angebot umgewandelt." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); } }
|
||||||
|
onMounted(load); watch(() => liveUpdateState.revision, load);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Ausgangsrechnungen" description="Rechnungen erstellen, aus Angeboten übernehmen und abschließen." />
|
||||||
|
<div class="workspace-split">
|
||||||
|
<section class="panel list-panel"><div class="section-title"><h2>Rechnungen</h2><button type="button" @click="createNew">Neu</button></div>
|
||||||
|
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Rechnungsnummer, Kunde oder Status" /></label>
|
||||||
|
<button v-for="invoice in filteredInvoices" :key="invoice.id" type="button" class="list-row" :class="{ selected: selectedId === invoice.id }" @click="select(invoice)"><strong>{{ invoice.invoice_number }}</strong><span>{{ customerName(invoice.customer_id) }}</span><small>{{ invoice.status }}</small></button>
|
||||||
|
<p v-if="invoices.length === 0" class="empty">Keine Rechnungen vorhanden.</p>
|
||||||
|
<p v-else-if="filteredInvoices.length === 0" class="empty">Keine Treffer.</p>
|
||||||
|
<div class="sub-panel"><h2>Aus Angebot</h2><button v-for="quote in quotes" :key="quote.id" type="button" class="secondary" @click="convertQuote(quote.id)">{{ quote.quote_number }}</button></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid">
|
||||||
|
<label class="field"><span>Rechnungsnummer</span><div class="readonly-value">{{ form.invoice_number || "wird automatisch vergeben" }}</div></label>
|
||||||
|
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required /></label>
|
||||||
|
<label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="finalized">Abgeschlossen</option><option value="sent">Gesendet</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option><option value="overdue">Überfällig</option></select></label>
|
||||||
|
<label class="field"><span>Ausgestellt</span><input v-model="form.issued_at" type="date" /></label>
|
||||||
|
<label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" /></label>
|
||||||
|
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" type="number" min="0" max="100" step="0.01" /></label>
|
||||||
|
</div><div class="sub-panel"><div class="section-title"><h2>Positionen</h2><button type="button" @click="addLine">Position</button></div>
|
||||||
|
<div v-for="(line, index) in form.items" :key="index" class="quote-line">
|
||||||
|
<label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" required @change="applyItemDefaults(line)" /></label>
|
||||||
|
<label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" /></label><label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" /></label><label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" type="number" min="0" max="100" step="0.01" /></label><label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button>
|
||||||
|
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label>
|
||||||
|
</div></div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="finalize">Abschließen</button><button v-if="selectedId" type="button" class="secondary" @click="cancelInvoice">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
44
web-frontend/src/views/PasswordResetPage.vue
Normal file
44
web-frontend/src/views/PasswordResetPage.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from "vue";
|
||||||
|
import { apiPost } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
|
||||||
|
const requestForm = reactive({ email: "" });
|
||||||
|
const resetForm = reactive({ token: "", new_password: "", new_password_confirm: "" });
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
|
||||||
|
async function requestReset() {
|
||||||
|
const result = await apiPost<{ queued: boolean; dev_reset_token?: string }>("/api/v1/auth/request-password-reset", requestForm);
|
||||||
|
status.value = result.ok
|
||||||
|
? `Reset-E-Mail wurde vorbereitet.${result.data.dev_reset_token ? ` Dev-Token: ${result.data.dev_reset_token}` : ""}`
|
||||||
|
: result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword() {
|
||||||
|
const result = await apiPost("/api/v1/auth/reset-password", resetForm);
|
||||||
|
status.value = result.ok ? "Passwort wurde geändert." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Passwort zurücksetzen" description="Reset-Link anfordern und neues Passwort setzen." />
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<form @submit.prevent="requestReset">
|
||||||
|
<label class="field"><span>E-Mail-Adresse</span><input v-model="requestForm.email" type="email" required /></label>
|
||||||
|
<div class="form-actions"><button type="submit">Reset anfordern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<form @submit.prevent="resetPassword">
|
||||||
|
<label class="field"><span>Reset-Token</span><input v-model="resetForm.token" required /></label>
|
||||||
|
<label class="field"><span>Neues Passwort</span><input v-model="resetForm.new_password" type="password" required /></label>
|
||||||
|
<label class="field"><span>Neues Passwort wiederholen</span><input v-model="resetForm.new_password_confirm" type="password" required /></label>
|
||||||
|
<div class="form-actions"><button type="submit">Passwort setzen</button></div>
|
||||||
|
<FormStatus :message="status" :kind="kind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
51
web-frontend/src/views/PriceImportsPage.vue
Normal file
51
web-frontend/src/views/PriceImportsPage.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { apiPost } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import type { PriceListImportApplyResponse, PriceListImportPreview } from "../types";
|
||||||
|
|
||||||
|
const sourceName = ref("Preisliste.csv");
|
||||||
|
const delimiter = ref(";");
|
||||||
|
const content = ref("item_number;name;unit;tax_rate;purchase_price;sales_price\nAR-IMPORT-1;Importartikel;Stk;19;10.00;25.00");
|
||||||
|
const preview = ref<PriceListImportPreview | null>(null);
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
|
||||||
|
function payload() {
|
||||||
|
return { source_name: sourceName.value, delimiter: delimiter.value, content: content.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPreview() {
|
||||||
|
const result = await apiPost<PriceListImportPreview>("/api/v1/imports/price-list/preview", payload());
|
||||||
|
if (result.ok) { preview.value = result.data; status.value = "Vorschau erstellt."; kind.value = "success"; }
|
||||||
|
else { status.value = result.message; kind.value = "error"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyImport() {
|
||||||
|
const result = await apiPost<PriceListImportApplyResponse>("/api/v1/imports/price-list/apply", payload());
|
||||||
|
if (result.ok) { status.value = `Import ${result.data.import_id}: ${result.data.applied_rows} Zeilen übernommen.`; kind.value = "success"; await runPreview(); }
|
||||||
|
else { status.value = result.message; kind.value = "error"; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Preislistenimport" description="CSV-Preislisten prüfen und Artikelpreise historisiert übernehmen." />
|
||||||
|
<section class="panel">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>Quelle</span><input v-model="sourceName" /></label>
|
||||||
|
<label class="field"><span>Trennzeichen</span><input v-model="delimiter" /></label>
|
||||||
|
<label class="field full-width"><span>CSV-Inhalt</span><textarea v-model="content" rows="10" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions"><button type="button" @click="runPreview">Vorschau</button><button type="button" @click="applyImport">Importieren</button></div>
|
||||||
|
<FormStatus :message="status" :kind="kind" />
|
||||||
|
</section>
|
||||||
|
<section v-if="preview" class="panel">
|
||||||
|
<div class="section-title"><h2>Vorschau</h2><span>{{ preview.valid_rows }} gültig, {{ preview.error_rows }} Fehler</span></div>
|
||||||
|
<div v-for="row in preview.rows" :key="row.row_number" class="data-row">
|
||||||
|
<strong>{{ row.row_number }} {{ row.action }}</strong>
|
||||||
|
<span>{{ row.item_number }}</span>
|
||||||
|
<span>{{ row.name }}</span>
|
||||||
|
<small>{{ row.error ?? `${row.purchase_price ?? "-"} / ${row.sales_price ?? "-"}` }}</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
98
web-frontend/src/views/PriceRulesPage.vue
Normal file
98
web-frontend/src/views/PriceRulesPage.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import type { PriceRule } from "../types";
|
||||||
|
|
||||||
|
const rules = ref<PriceRule[]>([]);
|
||||||
|
const selectedId = ref<string | null>(null);
|
||||||
|
const form = reactive({
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
source_type: "import" as PriceRule["source_type"],
|
||||||
|
source_id: "",
|
||||||
|
markup_percent: "0.00",
|
||||||
|
rounding_mode: "none" as PriceRule["rounding_mode"],
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
|
||||||
|
function payload() {
|
||||||
|
return { ...form, source_id: form.source_id.trim() || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNew() {
|
||||||
|
selectedId.value = null;
|
||||||
|
Object.assign(form, {
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
source_type: "import",
|
||||||
|
source_id: "",
|
||||||
|
markup_percent: "0.00",
|
||||||
|
rounding_mode: "none",
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(rule: PriceRule) {
|
||||||
|
selectedId.value = rule.id;
|
||||||
|
Object.assign(form, { ...rule, source_id: rule.source_id ?? "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const result = await apiGet<PriceRule[]>("/api/v1/price-rules");
|
||||||
|
if (result.ok) rules.value = result.data;
|
||||||
|
else { status.value = result.message; kind.value = "error"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const result = selectedId.value
|
||||||
|
? await apiPut<PriceRule>(`/api/v1/price-rules/${selectedId.value}`, payload())
|
||||||
|
: await apiPost<PriceRule>("/api/v1/price-rules", payload());
|
||||||
|
status.value = result.ok ? "Preisregel gespeichert." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) { selectedId.value = result.data.id; await load(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivate() {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
const result = await apiDelete(`/api/v1/price-rules/${selectedId.value}`);
|
||||||
|
status.value = result.ok ? "Preisregel deaktiviert." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Preisregeln" description="Aufschläge und Rundung je Preisquelle festlegen." />
|
||||||
|
<div class="workspace-split">
|
||||||
|
<section class="panel list-panel">
|
||||||
|
<div class="section-title"><h2>Regeln</h2><button type="button" @click="createNew">Neu</button></div>
|
||||||
|
<button v-for="rule in rules" :key="rule.id" type="button" class="list-row" :class="{ selected: selectedId === rule.id }" @click="select(rule)">
|
||||||
|
<strong>{{ rule.code }}</strong>
|
||||||
|
<span>{{ rule.name }}</span>
|
||||||
|
<small>{{ rule.source_type }} · {{ rule.markup_percent }} %</small>
|
||||||
|
</button>
|
||||||
|
<p v-if="rules.length === 0" class="empty">Keine Preisregeln vorhanden.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail-panel">
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>Code</span><input v-model="form.code" required /></label>
|
||||||
|
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
|
||||||
|
<label class="field"><span>Quellentyp</span><select v-model="form.source_type"><option value="import">Import</option><option value="api">API</option><option value="supplier">Lieferant</option></select></label>
|
||||||
|
<label class="field"><span>Quell-ID</span><input v-model="form.source_id" placeholder="optional" /></label>
|
||||||
|
<label class="field"><span>Aufschlag %</span><input v-model="form.markup_percent" type="number" min="-100" max="1000" step="0.0001" required /></label>
|
||||||
|
<label class="field"><span>Rundung</span><select v-model="form.rounding_mode"><option value="none">Keine</option><option value="cent">Cent</option><option value="five_cent">5 Cent</option><option value="ten_cent">10 Cent</option><option value="whole">Ganze Beträge</option></select></label>
|
||||||
|
<label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div>
|
||||||
|
<FormStatus :message="status" :kind="kind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
133
web-frontend/src/views/QuotesPage.vue
Normal file
133
web-frontend/src/views/QuotesPage.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import SearchSelect from "../components/SearchSelect.vue";
|
||||||
|
import { reserveNextNumber } from "../number-ranges";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
import type { Customer, Item, Quote, QuoteItem } from "../types";
|
||||||
|
|
||||||
|
const emptyItem = (): QuoteItem => ({ item_id: "", description: "", quantity: "1", unit_price: "0", original_unit_price: null, discount_percent: "0", tax_rate: "19" });
|
||||||
|
const emptyForm = () => ({ quote_number: "", customer_id: "", status: "draft", valid_until: null as string | null, cash_discount_term_id: null as string | null, customer_discount_percent: "0", notes: "", items: [emptyItem()] });
|
||||||
|
|
||||||
|
const quotes = ref<Quote[]>([]);
|
||||||
|
const customers = ref<Customer[]>([]);
|
||||||
|
const items = ref<Item[]>([]);
|
||||||
|
const selectedId = ref<string | null>(null);
|
||||||
|
const form = reactive(emptyForm());
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
const selectedQuote = computed(() => quotes.value.find((quote) => quote.id === selectedId.value));
|
||||||
|
const search = ref("");
|
||||||
|
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
|
||||||
|
const filteredQuotes = computed(() => {
|
||||||
|
const needle = search.value.trim().toLowerCase();
|
||||||
|
if (!needle) return quotes.value;
|
||||||
|
return quotes.value.filter((quote) =>
|
||||||
|
`${quote.quote_number} ${customerName(quote.customer_id)} ${quote.status}`.toLowerCase().includes(needle)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const [quoteResult, customerResult, itemResult] = await Promise.all([
|
||||||
|
apiGet<Quote[]>("/api/v1/quotes"),
|
||||||
|
apiGet<Customer[]>("/api/v1/customers"),
|
||||||
|
apiGet<Item[]>("/api/v1/items")
|
||||||
|
]);
|
||||||
|
if (quoteResult.ok) quotes.value = quoteResult.data; else { status.value = quoteResult.message; kind.value = "error"; }
|
||||||
|
if (customerResult.ok) customers.value = customerResult.data.filter((customer) => customer.status === "active");
|
||||||
|
if (itemResult.ok) items.value = itemResult.data.filter((item) => item.status === "active");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNew() {
|
||||||
|
selectedId.value = null;
|
||||||
|
Object.assign(form, emptyForm());
|
||||||
|
form.quote_number = (await reserveNextNumber("quotes")) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(quote: Quote) {
|
||||||
|
selectedId.value = quote.id;
|
||||||
|
Object.assign(form, { ...quote, items: quote.items.map((item) => ({ ...item })) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
form.items.push(emptyItem());
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(index: number) {
|
||||||
|
if (form.items.length > 1) form.items.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyItemDefaults(line: QuoteItem) {
|
||||||
|
const item = items.value.find((record) => record.id === line.item_id);
|
||||||
|
if (!item) return;
|
||||||
|
line.description = item.name;
|
||||||
|
line.unit_price = item.default_sales_price ?? "0";
|
||||||
|
line.original_unit_price = item.default_sales_price ?? null;
|
||||||
|
line.tax_rate = item.tax_rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const result = selectedId.value
|
||||||
|
? await apiPut<Quote>(`/api/v1/quotes/${selectedId.value}`, form)
|
||||||
|
: await apiPost<Quote>("/api/v1/quotes", form);
|
||||||
|
status.value = result.ok ? "Angebot gespeichert." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) { selectedId.value = result.data.id; await load(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelQuote() {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
const result = await apiDelete(`/api/v1/quotes/${selectedId.value}`);
|
||||||
|
status.value = result.ok ? "Angebot storniert." : result.message;
|
||||||
|
kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(() => liveUpdateState.revision, load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Angebote" description="Angebote mit festen Artikelpositionen und individuellen Preisen." />
|
||||||
|
<div class="workspace-split">
|
||||||
|
<section class="panel list-panel">
|
||||||
|
<div class="section-title"><h2>Angebote</h2><button type="button" @click="createNew">Neu</button></div>
|
||||||
|
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Angebotsnummer, Kunde oder Status" /></label>
|
||||||
|
<button v-for="quote in filteredQuotes" :key="quote.id" type="button" class="list-row" :class="{ selected: selectedId === quote.id }" @click="select(quote)">
|
||||||
|
<strong>{{ quote.quote_number }}</strong>
|
||||||
|
<span>{{ customerName(quote.customer_id) }}</span>
|
||||||
|
<small>{{ quote.status }}</small>
|
||||||
|
</button>
|
||||||
|
<p v-if="quotes.length === 0" class="empty">Keine Angebote vorhanden.</p>
|
||||||
|
<p v-else-if="filteredQuotes.length === 0" class="empty">Keine Treffer.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail-panel">
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>Angebotsnummer</span><div class="readonly-value">{{ form.quote_number || "wird automatisch vergeben" }}</div></label>
|
||||||
|
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required /></label>
|
||||||
|
<label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="sent">Gesendet</option><option value="accepted">Angenommen</option><option value="rejected">Abgelehnt</option><option value="expired">Abgelaufen</option><option value="cancelled">Storniert</option></select></label>
|
||||||
|
<label class="field"><span>Gültig bis</span><input v-model="form.valid_until" type="date" /></label>
|
||||||
|
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" type="number" min="0" max="100" step="0.01" /></label>
|
||||||
|
<label class="field full-width"><span>Notizen</span><textarea v-model="form.notes" rows="3" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="sub-panel">
|
||||||
|
<div class="section-title"><h2>Positionen</h2><button type="button" @click="addLine">Position</button></div>
|
||||||
|
<div v-for="(line, index) in form.items" :key="index" class="quote-line">
|
||||||
|
<label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" required @change="applyItemDefaults(line)" /></label>
|
||||||
|
<label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" required /></label>
|
||||||
|
<label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" required /></label>
|
||||||
|
<label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" type="number" min="0" max="100" step="0.01" /></label>
|
||||||
|
<label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label>
|
||||||
|
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label>
|
||||||
|
<button type="button" class="secondary" @click="removeLine(index)">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedQuote" type="button" class="secondary" @click="cancelQuote">Stornieren</button></div>
|
||||||
|
<FormStatus :message="status" :kind="kind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
52
web-frontend/src/views/RegisterPage.vue
Normal file
52
web-frontend/src/views/RegisterPage.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from "vue";
|
||||||
|
import { apiPost } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
organization_name: "",
|
||||||
|
email: "",
|
||||||
|
accept_terms: false
|
||||||
|
});
|
||||||
|
const pending = ref(false);
|
||||||
|
const status = ref("");
|
||||||
|
const statusKind = ref<"info" | "success" | "error">("info");
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
pending.value = true;
|
||||||
|
status.value = "Sende Anfrage...";
|
||||||
|
statusKind.value = "info";
|
||||||
|
|
||||||
|
const result = await apiPost("/api/v1/registration/organization", form);
|
||||||
|
pending.value = false;
|
||||||
|
status.value = result.ok
|
||||||
|
? "Registrierung eingegangen. Nach Freischaltung erhalten Sie eine E-Mail."
|
||||||
|
: result.message;
|
||||||
|
statusKind.value = result.ok ? "success" : "error";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Firma registrieren" description="Neue Organization für den SaaS-Betrieb vormerken." />
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<label class="field">
|
||||||
|
<span>Firmenname</span>
|
||||||
|
<input v-model="form.organization_name" name="organization_name" type="text" placeholder="Muster GmbH" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>E-Mail-Adresse</span>
|
||||||
|
<input v-model="form.email" name="email" type="email" placeholder="admin@example.com" required />
|
||||||
|
</label>
|
||||||
|
<label class="check-row">
|
||||||
|
<input v-model="form.accept_terms" name="accept_terms" type="checkbox" required />
|
||||||
|
<span>Nutzungsbedingungen akzeptieren</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" :disabled="pending">Registrierung absenden</button>
|
||||||
|
</div>
|
||||||
|
<FormStatus :message="status" :kind="statusKind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
98
web-frontend/src/views/SuppliersPage.vue
Normal file
98
web-frontend/src/views/SuppliersPage.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
import type { CashDiscountTerm, Supplier } from "../types";
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
supplier_number: "", name: "", status: "active",
|
||||||
|
details: { street: "", postal_code: "", city: "", country: "Deutschland", email: "", phone: "" },
|
||||||
|
standard_discount_percent: "0", cash_discount_term_id: null as string | null, payment_days: 14 as number | null
|
||||||
|
});
|
||||||
|
const suppliers = ref<Supplier[]>([]);
|
||||||
|
const cashDiscountTerms = ref<CashDiscountTerm[]>([]);
|
||||||
|
const selectedId = ref<string | null>(null);
|
||||||
|
const form = reactive(emptyForm());
|
||||||
|
const status = ref("");
|
||||||
|
const kind = ref<"info" | "success" | "error">("info");
|
||||||
|
const search = ref("");
|
||||||
|
const filteredSuppliers = computed(() =>
|
||||||
|
suppliers.value.filter((supplier) => matchesObjectSearch(supplier.supplier_number, supplier.name, search.value))
|
||||||
|
);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const result = await apiGet<Supplier[]>("/api/v1/suppliers");
|
||||||
|
if (result.ok) suppliers.value = result.data;
|
||||||
|
else { status.value = result.message; kind.value = "error"; }
|
||||||
|
}
|
||||||
|
async function loadCashDiscountTerms() {
|
||||||
|
const result = await apiGet<CashDiscountTerm[]>("/api/v1/cash-discount-terms");
|
||||||
|
if (result.ok) cashDiscountTerms.value = result.data.filter((term) => term.is_active);
|
||||||
|
}
|
||||||
|
async function createNew() {
|
||||||
|
selectedId.value = null;
|
||||||
|
Object.assign(form, emptyForm());
|
||||||
|
form.details = { ...emptyForm().details };
|
||||||
|
form.supplier_number = (await reserveNextNumber("suppliers")) ?? "";
|
||||||
|
}
|
||||||
|
function select(item: Supplier) { selectedId.value = item.id; Object.assign(form, { ...item, details: { ...item.details } }); }
|
||||||
|
async function save() {
|
||||||
|
const result = selectedId.value
|
||||||
|
? await apiPut<Supplier>(`/api/v1/suppliers/${selectedId.value}`, form)
|
||||||
|
: await apiPost<Supplier>("/api/v1/suppliers", form);
|
||||||
|
status.value = result.ok ? "Lieferant gespeichert." : result.message; kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) { selectedId.value = result.data.id; await load(); }
|
||||||
|
}
|
||||||
|
async function deactivate() {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
const result = await apiDelete(`/api/v1/suppliers/${selectedId.value}`);
|
||||||
|
status.value = result.ok ? "Lieferant deaktiviert." : result.message; kind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) await load();
|
||||||
|
}
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([load(), loadCashDiscountTerms()]);
|
||||||
|
});
|
||||||
|
watch(() => liveUpdateState.revision, () => Promise.all([load(), loadCashDiscountTerms()]));
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Lieferanten" description="Lieferantenstamm und Zahlungskonditionen." />
|
||||||
|
<div class="workspace-split">
|
||||||
|
<section class="panel list-panel">
|
||||||
|
<div class="section-title"><h2>Lieferanten</h2><button type="button" @click="createNew">Neu</button></div>
|
||||||
|
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Lieferantennummer oder Name" /></label>
|
||||||
|
<button v-for="item in filteredSuppliers" :key="item.id" type="button" class="list-row" :class="{ selected: selectedId === item.id }" @click="select(item)">
|
||||||
|
<strong>{{ item.supplier_number }}</strong><span>{{ item.name }}</span><small>{{ item.status }}</small>
|
||||||
|
</button>
|
||||||
|
<p v-if="suppliers.length === 0" class="empty">Keine Lieferanten vorhanden.</p>
|
||||||
|
<p v-else-if="filteredSuppliers.length === 0" class="empty">Keine Treffer.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail-panel">
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>Lieferantennummer</span><div class="readonly-value">{{ form.supplier_number || "wird automatisch vergeben" }}</div></label>
|
||||||
|
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
|
||||||
|
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Inaktiv</option><option value="blocked">Gesperrt</option></select></label>
|
||||||
|
<label class="field"><span>Rabatt %</span><input v-model="form.standard_discount_percent" type="number" min="0" max="100" step="0.01" /></label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Skonto-Regel</span>
|
||||||
|
<select v-model="form.cash_discount_term_id">
|
||||||
|
<option :value="null">Keine</option>
|
||||||
|
<option v-for="term in cashDiscountTerms" :key="term.id" :value="term.id">{{ term.code }} - {{ term.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>Zahlungsziel Tage</span><input v-model="form.payment_days" type="number" min="0" /></label>
|
||||||
|
<label class="field"><span>Straße</span><input v-model="form.details.street" /></label>
|
||||||
|
<label class="field"><span>PLZ</span><input v-model="form.details.postal_code" /></label>
|
||||||
|
<label class="field"><span>Ort</span><input v-model="form.details.city" /></label>
|
||||||
|
<label class="field"><span>E-Mail</span><input v-model="form.details.email" type="email" /></label>
|
||||||
|
<label class="field"><span>Telefon</span><input v-model="form.details.phone" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div>
|
||||||
|
<FormStatus :message="status" :kind="kind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
134
web-frontend/src/views/UsersPage.vue
Normal file
134
web-frontend/src/views/UsersPage.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { apiGet, apiPatch, apiPost } from "../api";
|
||||||
|
import { authState } from "../auth";
|
||||||
|
import FormStatus from "../components/FormStatus.vue";
|
||||||
|
import PageHeader from "../components/PageHeader.vue";
|
||||||
|
import { liveUpdateState } from "../realtime";
|
||||||
|
import type { OrganizationUser } from "../types";
|
||||||
|
|
||||||
|
const availableRoles = [
|
||||||
|
{ code: "owner", label: "Besitzer" },
|
||||||
|
{ code: "admin", label: "Admin" },
|
||||||
|
{ code: "sales", label: "Vertrieb" },
|
||||||
|
{ code: "accounting", label: "Buchhaltung" },
|
||||||
|
{ code: "viewer", label: "Lesen" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
email: "",
|
||||||
|
roles: ["viewer"]
|
||||||
|
});
|
||||||
|
const users = ref<OrganizationUser[]>([]);
|
||||||
|
const listMessage = ref("Noch keine Benutzer geladen.");
|
||||||
|
const status = ref("");
|
||||||
|
const statusKind = ref<"info" | "success" | "error">("info");
|
||||||
|
const roleDrafts = ref<Record<string, string[]>>({});
|
||||||
|
const savingUserId = ref<string | null>(null);
|
||||||
|
const currentUser = computed(() => users.value.find((user) => user.user_id === authState.session?.userId));
|
||||||
|
const canManageRoles = computed(() => currentUser.value?.roles.includes("owner") === true);
|
||||||
|
|
||||||
|
async function invite() {
|
||||||
|
const result = await apiPost<{ dev_invitation_token?: string }>("/api/v1/organizations/current/invitations", form);
|
||||||
|
status.value = result.ok
|
||||||
|
? `Einladung angelegt.${result.data.dev_invitation_token ? ` Dev-Token: ${result.data.dev_invitation_token}` : ""}`
|
||||||
|
: result.message;
|
||||||
|
statusKind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) {
|
||||||
|
form.email = "";
|
||||||
|
form.roles = ["viewer"];
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const result = await apiGet<OrganizationUser[]>("/api/v1/organizations/current/users");
|
||||||
|
if (!result.ok) {
|
||||||
|
users.value = [];
|
||||||
|
listMessage.value = result.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
users.value = result.data;
|
||||||
|
roleDrafts.value = Object.fromEntries(result.data.map((user) => [user.user_id, [...user.roles]]));
|
||||||
|
listMessage.value = "Keine Benutzer vorhanden.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRoles(user: OrganizationUser) {
|
||||||
|
const roles = roleDrafts.value[user.user_id] ?? [];
|
||||||
|
savingUserId.value = user.user_id;
|
||||||
|
const result = await apiPatch(`/api/v1/organizations/current/users/${encodeURIComponent(user.user_id)}/roles`, { roles });
|
||||||
|
savingUserId.value = null;
|
||||||
|
status.value = result.ok ? "Benutzerrechte gespeichert." : result.message;
|
||||||
|
statusKind.value = result.ok ? "success" : "error";
|
||||||
|
if (result.ok) await loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(code: string) {
|
||||||
|
return availableRoles.find((role) => role.code === code)?.label ?? code;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadUsers);
|
||||||
|
watch(
|
||||||
|
() => liveUpdateState.revision,
|
||||||
|
() => {
|
||||||
|
if (users.value.length > 0) loadUsers();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Benutzerrechte" description="Benutzer einladen und Rollen verwalten." />
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<form @submit.prevent="invite">
|
||||||
|
<label class="field">
|
||||||
|
<span>E-Mail-Adresse</span>
|
||||||
|
<input v-model="form.email" type="email" placeholder="kollege@example.com" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Rollen</span>
|
||||||
|
<select v-model="form.roles" multiple>
|
||||||
|
<option v-for="role in availableRoles" :key="role.code" :value="role.code">{{ role.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" :disabled="!canManageRoles">Einladung senden</button>
|
||||||
|
<button type="button" class="secondary" @click="loadUsers">Benutzer laden</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!canManageRoles" class="muted">Nur der Besitzer der Firma darf Rechte vergeben oder ändern.</p>
|
||||||
|
<FormStatus :message="status" :kind="statusKind" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>Benutzerliste</h2>
|
||||||
|
</div>
|
||||||
|
<p v-if="users.length === 0" class="empty">{{ listMessage }}</p>
|
||||||
|
<div v-else class="data-table users">
|
||||||
|
<div class="table-head">E-Mail</div>
|
||||||
|
<div class="table-head">Status</div>
|
||||||
|
<div class="table-head">Rollen</div>
|
||||||
|
<div class="table-head">Aktion</div>
|
||||||
|
<template v-for="user in users" :key="user.user_id">
|
||||||
|
<div>{{ user.email }}</div>
|
||||||
|
<span class="status-pill">{{ user.status }}</span>
|
||||||
|
<div v-if="canManageRoles" class="role-checks">
|
||||||
|
<label v-for="role in availableRoles" :key="role.code" class="check-row compact">
|
||||||
|
<input v-model="roleDrafts[user.user_id]" type="checkbox" :value="role.code" />
|
||||||
|
<span>{{ role.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-else>{{ user.roles.map(roleLabel).join(", ") }}</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="!canManageRoles || savingUserId === user.user_id"
|
||||||
|
@click="saveRoles(user)"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
2
web-frontend/src/vite-env.d.ts
vendored
Normal file
2
web-frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
17
web-frontend/tsconfig.json
Normal file
17
web-frontend/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
||||||
25
web-frontend/vite.config.ts
Normal file
25
web-frontend/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig, loadEnv } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
|
const backendOrigin = env.VITE_BACKEND_ORIGIN ?? "http://127.0.0.1:8080";
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5175,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: backendOrigin,
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
"/ws": {
|
||||||
|
target: backendOrigin,
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user