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:
Torsten Schulz (local)
2026-06-02 15:28:38 +02:00
commit 0e539710c0
95 changed files with 31882 additions and 0 deletions

7
.env.example Normal file
View 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
View File

@@ -0,0 +1,5 @@
/target/
/web-frontend/node_modules/
/web-frontend/dist/
.env

97
BETRIEB.md Normal file
View 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

File diff suppressed because it is too large Load Diff

12
Cargo.toml Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

76
README.md Normal file
View 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
View 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
View 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"]

View 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
);

View 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
)
)
);

View 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
)
);

View 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);

View 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;

View File

@@ -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';

View 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);

View 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);

View 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
)
);

View 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);

View 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)
);

View 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);

View 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);

View 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);

View 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);

View File

@@ -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);

View File

@@ -0,0 +1,2 @@
alter table organization_registration_requests
add column if not exists terms_accepted_at timestamptz;

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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>,
}

View 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
View 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"

View 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

File diff suppressed because it is too large Load Diff

33
docker-compose.yml Normal file
View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
images/icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View 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);
});

View 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
View 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);
});

View 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
View 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
View 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);
});

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

21
web-frontend/package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

399
web-frontend/src/App.vue Normal file
View 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
View 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
View 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;
}
}

View 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>

View 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>

View 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
View 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");

View 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);
}

View 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
View 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
View 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
View 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;
};

View 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;
}
}

View File

@@ -0,0 +1,4 @@
export function formatDate(value?: string | null) {
if (!value) return "-";
return new Date(value).toLocaleString("de-DE");
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

View 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"]
}

View 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
}
}
}
};
});