Compare commits

...

91 Commits

Author SHA1 Message Date
0f5275b870 Ausgangsrechnungen zeigen korrektes Belegdatum
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m34s
Build and Push Docker Images / build-frontend (push) Successful in 1m12s
Build and Push Docker Images / build-docs (push) Successful in 17s
2026-05-08 20:24:58 +02:00
4f37811dcc Suche im zentralen Logbuch hinzufügen 2026-05-08 20:20:44 +02:00
d7eced3e77 Vertragstypen für Änderungsanfragen pflegen 2026-05-08 20:09:24 +02:00
6a5c1e844d Vertragstypen im Kundenportal freigeben 2026-05-08 20:04:26 +02:00
5dc44e571f Kundenportal Vertragsanfragen ergänzen 2026-05-08 20:01:57 +02:00
2b1a9a456b Ausgangsbelege nach offenen Belegen filterbar machen 2026-05-08 19:36:48 +02:00
bf5d7aaed2 Dateien in Organisation verschoben 2026-05-08 19:27:00 +02:00
e166248c0d Mitarbeiternavigation neu gruppiert 2026-05-08 19:25:07 +02:00
cba4ea52e8 Bearbeiten-Aktion im Plantafel-Mitarbeitermodal ergänzen 2026-04-29 16:37:12 +02:00
0f14f7ac3d Verfügbarkeitshinweise für Mitarbeiter und Plantafel-Details ergänzen 2026-04-29 16:33:39 +02:00
2d26cedaa3 Benutzeranlage direkt aus Mitarbeiterprofil ermöglichen 2026-04-29 16:23:35 +02:00
d5aed2140e Plantafel-Modal für Profile ohne Benutzerzuordnung absichern 2026-04-29 16:19:03 +02:00
cfc5efb556 Plantafel um Mitarbeiterdetails mit Resturlaub erweitern 2026-04-29 16:12:13 +02:00
898a5459fa Ergänze Team-Zuordnung im Mitarbeiterimport 2026-04-29 15:58:32 +02:00
3b7bcb7940 Erweitere Dry-Run-Ausgabe für Mitarbeiterimport 2026-04-29 15:51:07 +02:00
2aaff0088e Füge Branch-Mapping für Mitarbeiterlisten-Import von Tenant 41 hinzu 2026-04-29 15:41:42 +02:00
e9bbc196f7 Füge Importskript für Mitarbeiterlisten mit Tenant- und Niederlassungszuordnung hinzu 2026-04-28 14:06:09 +02:00
20818beb3a Deaktivated Verify Docs Sync
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
Build and Push Docker Images / build-docs (push) Successful in 1m25s
2026-04-27 09:20:32 +02:00
6aa69cb68b Deaktivated Verify Docs Sync
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-docs (push) Has been cancelled
2026-04-27 09:19:33 +02:00
a021d3d15c Time Changes
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Failing after 9s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Has been skipped
Build and Push Docker Images / build-docs (push) Has been skipped
2026-04-27 09:17:36 +02:00
bb61caed6d Unterkostenstellen in der Kostenstellenauswertung korrekt auflösen 2026-04-24 23:03:23 +02:00
d3ab03da7e Kostenstellenhierarchie und Auswertung mit Unterkostenstellen ergänzt 2026-04-24 22:56:33 +02:00
5869f88c1a Vereinheitliche Empty States in Tabellen 2026-04-24 14:42:53 +02:00
50c76b67c7 Serienvorlagen in Liquiditätsprognose einplanen 2026-04-23 22:15:50 +02:00
f4edcc2d44 Rechnungsentwürfe optional wie Rechnungen verwenden 2026-04-23 21:49:45 +02:00
35ef3a7cf8 USt-Auswertung in Liquiditätsprognose integrieren 2026-04-23 21:44:59 +02:00
4783971000 Rechnungsentwürfe optional in Prognose anzeigen
Zeigt Rechnungsentwürfe eingeklammert als optionalen Einfluss in der Liquiditätsprognose an, ohne den eigentlichen Endstand zu verändern.
2026-04-23 21:29:14 +02:00
c085b1e4d5 Liquiditätsprognose besser nachvollziehbar machen
Ergänzt Herleitung, Einflussfaktoren, größte Treiber und eine Tagesaufschlüsselung, damit die Prognosewerte Schritt für Schritt nachvollzogen werden können.
2026-04-23 21:21:15 +02:00
46b08b29b9 Regelmäßige Bewegungen auf Ausgaben begrenzen
Berücksichtigt in der Liquiditätsprognose nur noch negative, also ausgehende, wiederkehrende Bankbewegungen aus Heuristik und KI-Erkennung.
2026-04-23 21:18:11 +02:00
5cc41f9a2d Manuelle Buchungen in Liquiditätsprognose korrekt verrechnen
Berechnet offene Eingangsbelege jetzt mit Absolutbeträgen, damit manuelle Buchungen und Bankzuordnungen unabhängig vom gespeicherten Vorzeichen korrekt in der Prognose erscheinen.
2026-04-23 21:13:04 +02:00
edec670ee0 Eingangsbelege um offenen Betrag ergänzen 2026-04-23 21:06:44 +02:00
41e5a4021b Manuelle Buchungen um zuweisbare Eingangsbelege erweitern 2026-04-23 17:52:56 +02:00
9c608cbf71 Manuelle Buchungen im Menü hinter Bank verschieben 2026-04-23 17:39:22 +02:00
543952dbf8 Manuelle Buchungen Select ohne Leerwert absichern 2026-04-23 17:36:32 +02:00
2f7819e309 Manuelle Buchungen um funktionsfähigen Steuerschlüssel und volle Beschreibungsbreite korrigieren 2026-04-23 17:35:32 +02:00
7799cbce80 Manuelle Buchungen um volle Beschreibungsbreite und einklappbare Kontenlisten ergänzen 2026-04-23 17:33:35 +02:00
0284ea8726 Manuelle Buchungen um DATEV-Steuerschlüssel und getrennte Soll/Haben-Auswahl erweitern 2026-04-23 17:24:58 +02:00
743bf0660c Manuelle Buchungen in Statementallocations integrieren 2026-04-23 16:28:44 +02:00
df4b591be4 Liquiditätsprognose zwischenspeichern
Lädt die Liquiditätsprognose aus einem lokalen Cache und erstellt sie nur noch manuell über den Refresh-Button in der Toolbar neu.
2026-04-23 16:15:04 +02:00
86e0743cbb Bezahlte Eingangsbelege aus Liquiditätsprognose entfernen
Berücksichtigt das Bezahlt-Kennzeichen und korrigiert die Verrechnung von Bankzuordnungen bei offenen Eingangsbelegen.
2026-04-23 16:06:57 +02:00
aaf91ea15e Belege und Bankmuster in Liquiditätsprognose verwalten
Ermöglicht das Öffnen von Belegen aus der Liquiditätsprognose und das Abschließen erkannter regelmäßiger Bankbewegungen, die anschließend aus der Prognose herausgerechnet werden.
2026-04-23 16:03:37 +02:00
cb71e9d294 Archivierte Belege aus Liquiditätsprognose ausschließen
Sichert die Liquiditätsprognose zusätzlich gegen archivierte Ausgangs- und Eingangsbelege sowie deren Zuordnungen ab.
2026-04-23 15:56:46 +02:00
75148b2718 Liquiditätsprognose für Auswertungen ergänzt
Erstellt einen KI-gestützten Backend-Endpunkt für die Liquiditätsprognose, ergänzt die Auswertungsseite mit Verlauf, offenen Belegen und regelmäßigen Bankbewegungen und verlinkt die Funktion in der Navigation.
2026-04-23 15:52:41 +02:00
81b4eee1e8 Vereinfache Bedienungsdoku und ergänze Bankportal-Anleitung 2026-04-22 19:43:37 +02:00
0fbda27609 Verschlanke Dokumentation auf Bedienung und leite Einstieg direkt dorthin 2026-04-22 19:27:17 +02:00
3562d55a12 Richte Nutzerdoku auf Bedienungsanleitung mit Frontend-Seitenkategorie aus 2026-04-22 19:21:32 +02:00
6224a25c38 Gestalte Docs-Landing und Header im Nuxt-UI-Template-Stil 2026-04-22 19:13:11 +02:00
63b1c563c1 Stelle docs-site auf Nuxt-UI-Docs-Template-Stil um 2026-04-22 19:07:15 +02:00
76f86e87c1 Behebe 404 im Nuxt-Devserver durch _path-basierte Content-Abfrage 2026-04-22 18:59:11 +02:00
8c458f4953 Ersetze Docusaurus vollständig durch Nuxt-Content-Docs-App
All checks were successful
Build and Push Docker Images / verify-docs-sync (push) Successful in 9s
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 13s
Build and Push Docker Images / build-docs (push) Successful in 1m47s
2026-04-22 18:16:00 +02:00
d704e343fc Behebe Docs-Dockerbuild durch korrekten Build-Pfad
All checks were successful
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-frontend (push) Successful in 14s
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-docs (push) Successful in 42s
2026-04-22 17:57:15 +02:00
4882da0d35 Stabilisiere Docs-Auslieferung unter /docs mit Traefik StripPrefix
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Successful in 7s
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-docs (push) Failing after 41s
Build and Push Docker Images / build-frontend (push) Successful in 14s
2026-04-22 16:06:02 +02:00
1908a6441d Behebe Nginx Redirect-Loop für Docusaurus unter /docs
All checks were successful
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 14s
Build and Push Docker Images / build-docs (push) Successful in 43s
2026-04-22 15:25:42 +02:00
a4735818fb Behebe Docs-Build in CI durch stabile Webpack-Overrides
All checks were successful
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 14s
Build and Push Docker Images / build-docs (push) Successful in 1m48s
2026-04-22 15:09:12 +02:00
4fb3d3c8a0 Stabilisiere Workflow-Imagepfad auf flfeders/fedeo für Compose-Kompatibilität
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 14s
Build and Push Docker Images / build-docs (push) Failing after 20s
2026-04-22 14:58:30 +02:00
30dc99e4e0 Integriere Docusaurus in Haupt-Compose mit TLS unter /docs 2026-04-22 14:48:11 +02:00
9fea18b215 Behebe fehlschlagenden Docs-Workflow durch deterministische Doku-Generierung
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-backend (push) Successful in 2m50s
Build and Push Docker Images / build-frontend (push) Successful in 3m18s
Build and Push Docker Images / build-docs (push) Failing after 1m29s
2026-04-22 14:38:51 +02:00
75c15c14c4 Commit Doku
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Failing after 8s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Has been skipped
Build and Push Docker Images / build-docs (push) Has been skipped
2026-04-22 14:35:22 +02:00
b27b00f59c Erweitere CI-Workflow um Doku-Sync-Prüfung und Docs-Image-Deploy
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Failing after 10s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Has been skipped
Build and Push Docker Images / build-docs (push) Has been skipped
2026-04-21 21:40:55 +02:00
1637d4bd91 Bereite Docusaurus-Deploy mit Docker und eigener Docs-Site vor 2026-04-21 19:45:16 +02:00
8114a8c645 Füge versionierbare Funktionsdokumentation mit automatischer Synchronisierung hinzu 2026-04-21 19:35:55 +02:00
0b7d20d946 Fix materialComp und PWA DEVOptions
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 1m2s
2026-04-15 10:38:50 +02:00
849e24092e Added Teams
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 26s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
Minor Rework of Plantafel
2026-04-14 21:17:05 +02:00
6fcaf3f65c Fix Export Date Select
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m52s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-04-08 20:24:33 +02:00
dce0046e63 Fix Ausgangsbeleg kopieren 2026-04-08 20:24:15 +02:00
02b5769049 Fix BWA Calc 2026-04-08 18:52:16 +02:00
f125617af0 Kundenportal arbeiten 2026-04-08 18:52:04 +02:00
d9e5df07bf serialinvoice fix
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m3s
2026-04-01 20:32:16 +02:00
7996c746c3 Add External Link
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Successful in 1m3s
Fix Plantafel
2026-03-27 21:41:20 +01:00
f679eb3624 Fix Serial invoice
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 2m58s
2026-03-26 23:00:56 +01:00
7ad44544cf Fix #119
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m36s
Build and Push Docker Images / build-frontend (push) Successful in 1m5s
Fix #153
2026-03-25 17:20:47 +01:00
669bcd93ab Tidying in TenantDropdown.vue 2026-03-25 17:14:15 +01:00
aee45e29fd Added Abschreibungen 2026-03-25 17:14:03 +01:00
42e0d7b35e Added Abschreibungen 2026-03-25 17:13:59 +01:00
f6c9875320 Fix #144
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m40s
Build and Push Docker Images / build-frontend (push) Successful in 1m2s
2026-03-25 16:04:17 +01:00
05f3b678c4 Fix for Incoming Invoices 2026-03-25 16:03:54 +01:00
eb718021fd Added Kostenschätzung und Packschein
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 22s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-25 15:38:28 +01:00
01b4d0f973 Fix for missing Phases
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-25 15:19:33 +01:00
c29494dc0d redone admin
added branches
2026-03-25 14:59:44 +01:00
809a37a410 fix workflows 2026-03-25 14:59:32 +01:00
232e3f3260 Fix copy created document modal 2026-03-25 14:59:19 +01:00
b2657f5d52 Fix Worklow Form 2026-03-25 14:58:55 +01:00
cee0e1fa7d Fix Phases
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-25 08:02:24 +01:00
7dea2de7f3 Fix unit select
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 13s
Build and Push Docker Images / build-frontend (push) Successful in 58s
2026-03-23 14:11:40 +01:00
4db753d34a Fix Banking and Profiles
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-23 08:29:54 +01:00
e0e99ba6f5 Fix #146 2026-03-23 08:26:21 +01:00
ace2213cc4 Fix #145 2026-03-23 08:16:44 +01:00
7e6c5cc189 Fix Changelog
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 21s
Build and Push Docker Images / build-frontend (push) Successful in 14s
2026-03-22 22:14:17 +01:00
7c644c941a fix #141
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 57s
fix #142
2026-03-22 22:10:41 +01:00
11a242d70d 4. Zwischenstand
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 1m0s
2026-03-22 17:43:41 +01:00
9f665fc3b8 3. Zwischenstand 2026-03-22 13:53:29 +01:00
174 changed files with 43036 additions and 2438 deletions

View File

@@ -1,5 +1,5 @@
name: Build and Push Docker Images name: Build and Push Docker Images
run-name: Build Backend & Frontend by @${{ github.actor }} run-name: Build Backend, Frontend & Docs by @${{ github.actor }}
on: [push] on: [push]
@@ -8,12 +8,38 @@ env:
# Wenn du die Gitea-interne Registry nutzt, ist es meist einfach der Hostname deiner Gitea-Instanz. # Wenn du die Gitea-interne Registry nutzt, ist es meist einfach der Hostname deiner Gitea-Instanz.
# Beispiel: gitea.deine-domain.de # Beispiel: gitea.deine-domain.de
REGISTRY_HOST: git.federspiel.tech REGISTRY_HOST: git.federspiel.tech
# Der Name des Repos (z.B. user/repo) # Der Name des Repos (z.B. user/repo).
IMAGE_NAME: ${{ github.repository }} # Explizit in lowercase gesetzt, damit es exakt zu den Compose-Imagepfaden passt.
IMAGE_NAME: flfeders/fedeo
ACTOR: flfeders ACTOR: flfeders
jobs: jobs:
# verify-docs-sync:
# runs-on: ubuntu-latest
# steps:
# - name: Check out repository code
# uses: actions/checkout@v3
#
# - name: Prüfe Node-Version
# uses: actions/setup-node@v4
# with:
# node-version: 20
#
# - name: Synchronisiere Funktionsdokumentation
# run: node docs/scripts/sync-funktionsdoku.mjs
#
# - name: Breche ab, wenn Doku nicht aktuell committed ist
# run: |
# if [ -n "$(git status --porcelain docs/)" ]; then
# echo "Die generierte Dokumentation ist nicht aktuell."
# echo "Bitte lokal ausführen: node docs/scripts/sync-funktionsdoku.mjs"
# echo "Danach die Änderungen committen."
# git status --short docs/
# exit 1
# fi
build-backend: build-backend:
#needs: verify-docs-sync
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repository code - name: Check out repository code
@@ -46,6 +72,7 @@ jobs:
labels: ${{ steps.meta-backend.outputs.labels }} labels: ${{ steps.meta-backend.outputs.labels }}
build-frontend: build-frontend:
#needs: verify-docs-sync
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repository code - name: Check out repository code
@@ -74,4 +101,37 @@ jobs:
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend') context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend')
push: true push: true
tags: ${{ steps.meta-frontend.outputs.tags }} tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }} labels: ${{ steps.meta-frontend.outputs.labels }}
build-docs:
needs: verify-docs-sync
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Log in to Docker Registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ env.ACTOR }}
password: ${{ vars.CI_TOKEN }}
- name: Extract metadata (tags, labels) for Docs
id: meta-docs
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/docs
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docs
uses: docker/build-push-action@v4
with:
context: .
file: ./docs-site/Dockerfile
push: true
tags: ${{ steps.meta-docs.outputs.tags }}
labels: ${{ steps.meta-docs.outputs.labels }}

View File

@@ -6,10 +6,16 @@ import {secrets} from "../src/utils/secrets";
console.log("[DB INIT] 1. Suche Connection String..."); console.log("[DB INIT] 1. Suche Connection String...");
const fallbackConnectionString = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
// Checken woher die URL kommt // Checken woher die URL kommt
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL; let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL || fallbackConnectionString;
if (connectionString) { if (process.env.DATABASE_URL) {
console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL"); console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL");
} else if (secrets.DATABASE_URL) {
console.log("[DB INIT] -> Gefunden in secrets.DATABASE_URL");
} else if (connectionString) {
console.log("[DB INIT] -> Nutze Fallback aus dem Projekt");
} else { } else {
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?"); console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
} }
@@ -24,4 +30,4 @@ pool.query('SELECT NOW()')
.then(res => console.log(`[DB INIT] ✅ VERBINDUNG ERFOLGREICH! Zeit auf DB: ${res.rows[0].now}`)) .then(res => console.log(`[DB INIT] ✅ VERBINDUNG ERFOLGREICH! Zeit auf DB: ${res.rows[0].now}`))
.catch(err => console.error(`[DB INIT] ❌ VERBINDUNGSFEHLER:`, err.message)); .catch(err => console.error(`[DB INIT] ❌ VERBINDUNGSFEHLER:`, err.message));
export const db = drizzle(pool, { schema }); export const db = drizzle(pool, { schema });

View File

@@ -0,0 +1,37 @@
CREATE TABLE "branches" (
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"number" text,
"description" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "branches" ADD CONSTRAINT "branches_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "branches" ADD CONSTRAINT "branches_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "costcentres" ADD COLUMN "branch" bigint;
--> statement-breakpoint
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profiles" ADD COLUMN "branch_id" bigint;
--> statement-breakpoint
ALTER TABLE "auth_profiles" ADD CONSTRAINT "auth_profiles_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
CREATE TABLE "auth_profile_branches" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"profile_id" uuid NOT NULL,
"branch_id" bigint NOT NULL,
"created_by" uuid,
CONSTRAINT "auth_profile_branches_profile_id_branch_id_pk" PRIMARY KEY("profile_id","branch_id")
);
--> statement-breakpoint
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "statementallocations"
ADD COLUMN "booking_mode" text DEFAULT 'expense' NOT NULL,
ADD COLUMN "depreciation_months" integer,
ADD COLUMN "depreciation_start_date" text,
ADD COLUMN "depreciation_label" text,
ADD COLUMN "depreciation_group" text;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "statementallocations"
ADD COLUMN "depreciation_method" text,
ADD COLUMN "residual_value" double precision;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "products"
ADD COLUMN "supplierLink" text;

View File

@@ -0,0 +1,31 @@
CREATE TABLE "teams" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "teams_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"branch" bigint,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "auth_profile_teams" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"profile_id" uuid NOT NULL,
"team_id" bigint NOT NULL,
"created_by" uuid,
CONSTRAINT "auth_profile_teams_profile_id_team_id_pk" PRIMARY KEY("profile_id","team_id")
);
--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "events" ADD COLUMN "quick" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1,10 @@
ALTER TABLE "statementallocations" ALTER COLUMN "bs_id" DROP NOT NULL;
ALTER TABLE "statementallocations" ADD COLUMN "manual_booking_date" text;
ALTER TABLE "statementallocations" ADD COLUMN "contra_account" bigint;
ALTER TABLE "statementallocations" ADD COLUMN "contra_ownaccount" uuid;
ALTER TABLE "statementallocations" ADD COLUMN "contra_customer" bigint;
ALTER TABLE "statementallocations" ADD COLUMN "contra_vendor" bigint;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_account_accounts_id_fk" FOREIGN KEY ("contra_account") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_ownaccount_ownaccounts_id_fk" FOREIGN KEY ("contra_ownaccount") REFERENCES "public"."ownaccounts"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_customer_customers_id_fk" FOREIGN KEY ("contra_customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_vendor_vendors_id_fk" FOREIGN KEY ("contra_vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "statementallocations" ADD COLUMN "datev_tax_key" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "statementallocations" ADD COLUMN "manual_invoice_side" text;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "costcentres" ADD COLUMN "parent_costcentre" uuid;
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_parent_costcentre_costcentres_id_fk" FOREIGN KEY ("parent_costcentre") REFERENCES "public"."costcentres"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth_profiles"
ADD COLUMN "availability_note" text;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "historyitems" ADD COLUMN "contract" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_contract_contracts_id_fk" FOREIGN KEY ("contract") REFERENCES "public"."contracts"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "contracts" ADD COLUMN "allowedContracttypes" jsonb DEFAULT '[]'::jsonb NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -138,30 +138,121 @@
{ {
"idx": 19, "idx": 19,
"version": "7", "version": "7",
"when": 1773489600000,
"tag": "0019_custom_surcharge_percentage_decimal",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1773572400000, "when": 1773572400000,
"tag": "0020_file_extracted_text", "tag": "0020_file_extracted_text",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 20, "idx": 21,
"version": "7", "version": "7",
"when": 1773835200000, "when": 1773835200000,
"tag": "0021_admin_user_flag", "tag": "0021_admin_user_flag",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 21, "idx": 22,
"version": "7", "version": "7",
"when": 1773925200000, "when": 1773925200000,
"tag": "0022_task_dependencies", "tag": "0022_task_dependencies",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 22, "idx": 23,
"version": "7", "version": "7",
"when": 1774080000000, "when": 1774080000000,
"tag": "0023_tax_evaluation_period", "tag": "0023_tax_evaluation_period",
"breakpoints": true "breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1774393200000,
"tag": "0024_tenant_branches",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1774393201000,
"tag": "0025_statementallocation_depreciation",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1774393202000,
"tag": "0026_statementallocation_depreciation_method",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1774602000000,
"tag": "0027_product_supplier_link",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1776124800000,
"tag": "0028_teams",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1776211200000,
"tag": "0029_events_quick",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1776297600000,
"tag": "0030_manual_statementallocations",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1776298200000,
"tag": "0031_manual_statementallocations_tax_key",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1776298800000,
"tag": "0032_manual_statementallocations_invoice_side",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1777003200000,
"tag": "0033_costcentres_parent",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1778191200000,
"tag": "0035_contract_history",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1778194800000,
"tag": "0036_allowed_contracttypes",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1,30 @@
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
import { authProfiles } from "./auth_profiles"
import { branches } from "./branches"
import { authUsers } from "./auth_users"
export const authProfileBranches = pgTable(
"auth_profile_branches",
{
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
profile_id: uuid("profile_id")
.notNull()
.references(() => authProfiles.id, { onDelete: "cascade" }),
branch_id: bigint("branch_id", { mode: "number" })
.notNull()
.references(() => branches.id, { onDelete: "cascade" }),
created_by: uuid("created_by").references(() => authUsers.id),
},
(table) => ({
primaryKey: [table.profile_id, table.branch_id],
})
)
export type AuthProfileBranch = typeof authProfileBranches.$inferSelect
export type NewAuthProfileBranch = typeof authProfileBranches.$inferInsert

View File

@@ -0,0 +1,30 @@
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
import { authProfiles } from "./auth_profiles"
import { teams } from "./teams"
import { authUsers } from "./auth_users"
export const authProfileTeams = pgTable(
"auth_profile_teams",
{
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
profile_id: uuid("profile_id")
.notNull()
.references(() => authProfiles.id, { onDelete: "cascade" }),
team_id: bigint("team_id", { mode: "number" })
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
created_by: uuid("created_by").references(() => authUsers.id),
},
(table) => ({
primaryKey: [table.profile_id, table.team_id],
})
)
export type AuthProfileTeam = typeof authProfileTeams.$inferSelect
export type NewAuthProfileTeam = typeof authProfileTeams.$inferInsert

View File

@@ -10,6 +10,7 @@ import {
jsonb, jsonb,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
import { authUsers } from "./auth_users" import { authUsers } from "./auth_users"
import { branches } from "./branches"
export const authProfiles = pgTable("auth_profiles", { export const authProfiles = pgTable("auth_profiles", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
@@ -18,6 +19,8 @@ export const authProfiles = pgTable("auth_profiles", {
tenant_id: bigint("tenant_id", { mode: "number" }).notNull(), tenant_id: bigint("tenant_id", { mode: "number" }).notNull(),
branch_id: bigint("branch_id", { mode: "number" }).references(() => branches.id),
created_at: timestamp("created_at", { withTimezone: true }) created_at: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
@@ -71,6 +74,7 @@ export const authProfiles = pgTable("auth_profiles", {
contract_type: text("contract_type"), contract_type: text("contract_type"),
position: text("position"), position: text("position"),
qualification: text("qualification"), qualification: text("qualification"),
availability_note: text("availability_note"),
address_street: text("address_street"), address_street: text("address_street"),
address_zip: text("address_zip"), address_zip: text("address_zip"),

View File

@@ -0,0 +1,37 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const branches = pgTable("branches", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
number: text("number"),
description: text("description"),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Branch = typeof branches.$inferSelect
export type NewBranch = typeof branches.$inferInsert

View File

@@ -52,6 +52,7 @@ export const contracts = pgTable(
contracttype: bigint("contracttype", { mode: "number" }).references( contracttype: bigint("contracttype", { mode: "number" }).references(
() => contracttypes.id () => contracttypes.id
), ),
allowedContracttypes: jsonb("allowedContracttypes").notNull().default([]),
bankingIban: text("bankingIban"), bankingIban: text("bankingIban"),
bankingBIC: text("bankingBIC"), bankingBIC: text("bankingBIC"),

View File

@@ -13,6 +13,7 @@ import { inventoryitems } from "./inventoryitems"
import { projects } from "./projects" import { projects } from "./projects"
import { vehicles } from "./vehicles" import { vehicles } from "./vehicles"
import { authUsers } from "./auth_users" import { authUsers } from "./auth_users"
import { branches } from "./branches"
export const costcentres = pgTable("costcentres", { export const costcentres = pgTable("costcentres", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
@@ -28,10 +29,14 @@ export const costcentres = pgTable("costcentres", {
number: text("number").notNull(), number: text("number").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
parentCostcentre: uuid("parent_costcentre").references(() => costcentres.id),
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id), vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
project: bigint("project", { mode: "number" }).references(() => projects.id), project: bigint("project", { mode: "number" }).references(() => projects.id),
branch: bigint("branch", { mode: "number" }).references(() => branches.id),
inventoryitem: bigint("inventoryitem", { mode: "number" }).references( inventoryitem: bigint("inventoryitem", { mode: "number" }).references(
() => inventoryitems.id () => inventoryitems.id
), ),

View File

@@ -31,6 +31,7 @@ export const events = pgTable(
endDate: timestamp("endDate", { withTimezone: true }), endDate: timestamp("endDate", { withTimezone: true }),
eventtype: text("eventtype").default("Umsetzung"), eventtype: text("eventtype").default("Umsetzung"),
quick: boolean("quick").notNull().default(false),
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists

View File

@@ -35,6 +35,7 @@ import { inventoryitemgroups } from "./inventoryitemgroups"
import { authUsers } from "./auth_users" import { authUsers } from "./auth_users"
import {files} from "./files"; import {files} from "./files";
import { memberrelations } from "./memberrelations"; import { memberrelations } from "./memberrelations";
import { contracts } from "./contracts";
export const historyitems = pgTable("historyitems", { export const historyitems = pgTable("historyitems", {
id: bigint("id", { mode: "number" }) id: bigint("id", { mode: "number" })
@@ -52,6 +53,11 @@ export const historyitems = pgTable("historyitems", {
{ onDelete: "cascade" } { onDelete: "cascade" }
), ),
contract: bigint("contract", { mode: "number" }).references(
() => contracts.id,
{ onDelete: "cascade" }
),
tenant: bigint("tenant", { mode: "number" }) tenant: bigint("tenant", { mode: "number" })
.notNull() .notNull()
.references(() => tenants.id), .references(() => tenants.id),

View File

@@ -1,5 +1,7 @@
export * from "./accounts" export * from "./accounts"
export * from "./auth_profiles" export * from "./auth_profiles"
export * from "./auth_profile_branches"
export * from "./auth_profile_teams"
export * from "./auth_role_permisssions" export * from "./auth_role_permisssions"
export * from "./auth_roles" export * from "./auth_roles"
export * from "./auth_tenant_users" export * from "./auth_tenant_users"
@@ -8,6 +10,7 @@ export * from "./auth_users"
export * from "./bankaccounts" export * from "./bankaccounts"
export * from "./bankrequisitions" export * from "./bankrequisitions"
export * from "./bankstatements" export * from "./bankstatements"
export * from "./branches"
export * from "./checkexecutions" export * from "./checkexecutions"
export * from "./checks" export * from "./checks"
export * from "./citys" export * from "./citys"
@@ -67,6 +70,7 @@ export * from "./staff_time_entry_connects"
export * from "./staff_zeitstromtimestamps" export * from "./staff_zeitstromtimestamps"
export * from "./statementallocations" export * from "./statementallocations"
export * from "./tasks" export * from "./tasks"
export * from "./teams"
export * from "./taxtypes" export * from "./taxtypes"
export * from "./tenants" export * from "./tenants"
export * from "./texttemplates" export * from "./texttemplates"

View File

@@ -50,6 +50,7 @@ export const products = pgTable("products", {
vendor_allocation: jsonb("vendorAllocation").default([]), vendor_allocation: jsonb("vendorAllocation").default([]),
article_number: text("articleNumber"), article_number: text("articleNumber"),
supplier_link: text("supplierLink"),
barcodes: text("barcodes").array().notNull().default([]), barcodes: text("barcodes").array().notNull().default([]),

View File

@@ -23,9 +23,7 @@ export const statementallocations = pgTable("statementallocations", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
// foreign keys // foreign keys
bankstatement: integer("bs_id") bankstatement: integer("bs_id").references(() => bankstatements.id),
.notNull()
.references(() => bankstatements.id),
createddocument: integer("cd_id").references(() => createddocuments.id), createddocument: integer("cd_id").references(() => createddocuments.id),
@@ -34,6 +32,7 @@ export const statementallocations = pgTable("statementallocations", {
incominginvoice: bigint("ii_id", { mode: "number" }).references( incominginvoice: bigint("ii_id", { mode: "number" }).references(
() => incominginvoices.id () => incominginvoices.id
), ),
manualInvoiceSide: text("manual_invoice_side"),
tenant: bigint("tenant", { mode: "number" }) tenant: bigint("tenant", { mode: "number" })
.notNull() .notNull()
@@ -43,20 +42,43 @@ export const statementallocations = pgTable("statementallocations", {
() => accounts.id () => accounts.id
), ),
contraAccount: bigint("contra_account", { mode: "number" }).references(
() => accounts.id
),
created_at: timestamp("created_at", { created_at: timestamp("created_at", {
withTimezone: false, withTimezone: false,
}).defaultNow(), }).defaultNow(),
ownaccount: uuid("ownaccount").references(() => ownaccounts.id), ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
contraOwnaccount: uuid("contra_ownaccount").references(() => ownaccounts.id),
description: text("description"), description: text("description"),
manualBookingDate: text("manual_booking_date"),
datevTaxKey: text("datev_tax_key"),
bookingMode: text("booking_mode").notNull().default("expense"),
depreciationMonths: integer("depreciation_months"),
depreciationStartDate: text("depreciation_start_date"),
depreciationMethod: text("depreciation_method"),
depreciationLabel: text("depreciation_label"),
depreciationGroup: text("depreciation_group"),
residualValue: doublePrecision("residual_value"),
customer: bigint("customer", { mode: "number" }).references( customer: bigint("customer", { mode: "number" }).references(
() => customers.id () => customers.id
), ),
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id), vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
contraCustomer: bigint("contra_customer", { mode: "number" }).references(
() => customers.id
),
contraVendor: bigint("contra_vendor", { mode: "number" }).references(() => vendors.id),
updated_at: timestamp("updated_at", { withTimezone: true }), updated_at: timestamp("updated_at", { withTimezone: true }),
updated_by: uuid("updated_by").references(() => authUsers.id), updated_by: uuid("updated_by").references(() => authUsers.id),

View File

@@ -0,0 +1,40 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { branches } from "./branches"
export const teams = pgTable("teams", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
description: text("description"),
branch: bigint("branch", { mode: "number" })
.references(() => branches.id),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Team = typeof teams.$inferSelect
export type NewTeam = typeof teams.$inferInsert

View File

@@ -92,6 +92,8 @@ export const tenants = pgTable(
serialInvoice: true, serialInvoice: true,
incomingInvoices: true, incomingInvoices: true,
costcentres: true, costcentres: true,
branches: true,
teams: true,
accounts: true, accounts: true,
ownaccounts: true, ownaccounts: true,
banking: true, banking: true,
@@ -127,8 +129,11 @@ export const tenants = pgTable(
customers: { prefix: "", suffix: "", nextNumber: 10000 }, customers: { prefix: "", suffix: "", nextNumber: 10000 },
products: { prefix: "AT-", suffix: "", nextNumber: 1000 }, products: { prefix: "AT-", suffix: "", nextNumber: 1000 },
quotes: { prefix: "AN-", suffix: "", nextNumber: 1000 }, quotes: { prefix: "AN-", suffix: "", nextNumber: 1000 },
costEstimates: { prefix: "KS-", suffix: "", nextNumber: 1000 },
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 }, confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 }, invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 },
deliveryNotes: { prefix: "LS-", suffix: "", nextNumber: 1000 },
packingSlips: { prefix: "PS-", suffix: "", nextNumber: 1000 },
spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 }, spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 },
customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 }, customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 },
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 }, inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },

View File

@@ -1,11 +1,14 @@
import "dotenv/config"
import { defineConfig } from "drizzle-kit" import { defineConfig } from "drizzle-kit"
import {secrets} from "./src/utils/secrets";
const fallbackDatabaseUrl = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
const databaseUrl = process.env.DATABASE_URL || fallbackDatabaseUrl
export default defineConfig({ export default defineConfig({
dialect: "postgresql", dialect: "postgresql",
schema: "./db/schema", schema: "./db/schema",
out: "./db/migrations", out: "./db/migrations",
dbCredentials: { dbCredentials: {
url: secrets.DATABASE_URL || "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo", url: databaseUrl,
}, },
}) })

View File

@@ -5,6 +5,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"migrate": "tsx scripts/migrate.ts",
"fill": "ts-node src/webdav/fill-file-sizes.ts", "fill": "ts-node src/webdav/fill-file-sizes.ts",
"dev:dav": "tsx watch src/webdav/server.ts", "dev:dav": "tsx watch src/webdav/server.ts",
"build": "tsc", "build": "tsc",
@@ -12,6 +13,7 @@
"schema:index": "ts-node scripts/generate-schema-index.ts", "schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts", "bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
"members:import:csv": "tsx scripts/import-members-csv.ts", "members:import:csv": "tsx scripts/import-members-csv.ts",
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts" "accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
}, },
"repository": { "repository": {

View File

@@ -0,0 +1,589 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { execFileSync } from "node:child_process"
import { and, eq } from "drizzle-orm"
import {
authProfileBranches,
authProfiles,
authProfileTeams,
branches,
teams,
tenants,
} from "../db/schema"
type ImportRow = {
rowNumber: number
mitarbeiter: string
betrieb: string
anstellung: string
position: string
bereich: string
stundenMonat: number | null
urlaub: number | null
}
type CliOptions = {
workbookPath: string
tenantId: number
dryRun: boolean
defaultBranchId: number | null
branchMap: Record<string, number>
}
type ImportAction = "create" | "update"
function printHelp() {
console.log(`
Importiert die Excel-Datei "Mitarbeiterliste.xlsm" in auth_profiles.
Verwendung:
npm run profiles:import:mitarbeiterliste -- --tenant-id=12 --branch-map-file=./branch-map.json /pfad/zur/Mitarbeiterliste.xlsm
Optionen:
--tenant-id=ID Pflicht. Tenant-ID für den Import.
--branch-map='{"Name":1}' Optional. JSON-Mapping Betrieb -> branchId.
--branch-map-file=DATEI Optional. JSON-Datei mit Betrieb -> branchId.
--default-branch-id=ID Optional. Fallback-Branch-ID für nicht gemappte Betriebe.
--dry-run Führt keine Schreiboperationen aus.
--help Zeigt diese Hilfe an.
Beispiel branch-map.json:
{
"Strandcafé": 10,
"1848 Pütt": 11,
"Oceans11": 12,
"Winnys": 13
}
`.trim())
}
function parseArgs(argv: string[]): CliOptions | null {
const options: CliOptions = {
workbookPath: "/Users/florianfederspiel/Downloads/Mitarbeiterliste.xlsm",
tenantId: Number.NaN,
dryRun: false,
defaultBranchId: null,
branchMap: {},
}
for (const arg of argv) {
if (arg === "--help" || arg === "-h") {
return null
}
if (arg === "--dry-run") {
options.dryRun = true
continue
}
if (arg.startsWith("--tenant-id=")) {
options.tenantId = Number(arg.slice("--tenant-id=".length))
continue
}
if (arg.startsWith("--default-branch-id=")) {
options.defaultBranchId = Number(arg.slice("--default-branch-id=".length))
continue
}
if (arg.startsWith("--branch-map=")) {
options.branchMap = parseBranchMap(arg.slice("--branch-map=".length), "CLI")
continue
}
if (arg.startsWith("--branch-map-file=")) {
const branchMapPath = path.resolve(arg.slice("--branch-map-file=".length))
options.branchMap = parseBranchMap(fs.readFileSync(branchMapPath, "utf8"), branchMapPath)
continue
}
if (!arg.startsWith("--")) {
options.workbookPath = path.resolve(arg)
continue
}
throw new Error(`Unbekanntes Argument: ${arg}`)
}
if (!Number.isFinite(options.tenantId)) {
throw new Error("Bitte --tenant-id=... angeben.")
}
if (options.defaultBranchId != null && !Number.isFinite(options.defaultBranchId)) {
throw new Error("--default-branch-id muss numerisch sein.")
}
return options
}
function parseBranchMap(raw: string, sourceLabel: string): Record<string, number> {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (error) {
throw new Error(`Branch-Mapping aus ${sourceLabel} konnte nicht gelesen werden: ${(error as Error).message}`)
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Branch-Mapping aus ${sourceLabel} muss ein JSON-Objekt sein.`)
}
const normalizedEntries = Object.entries(parsed).map(([key, value]) => {
const branchId = Number(value)
if (!Number.isFinite(branchId)) {
throw new Error(`Ungültige Branch-ID für "${key}" in ${sourceLabel}.`)
}
return [normalizeKey(key), branchId] as const
})
return Object.fromEntries(normalizedEntries)
}
function normalizeKey(value: string) {
return String(value || "")
.trim()
.replace(/\s+/g, " ")
.toLocaleLowerCase("de-DE")
}
function decodeXmlText(value: string) {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
}
function getWorkbookXml(workbookPath: string, innerPath: string) {
return execFileSync("unzip", ["-p", workbookPath, innerPath], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
})
}
function readSharedStrings(workbookPath: string) {
const xml = getWorkbookXml(workbookPath, "xl/sharedStrings.xml")
return [...xml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map((match) => {
const parts = [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) => decodeXmlText(part[1]))
return parts.join("")
})
}
function readSheetRows(workbookPath: string, sharedStrings: string[]) {
const sheetXml = getWorkbookXml(workbookPath, "xl/worksheets/sheet1.xml")
const rows: Record<string, string>[] = []
for (const rowMatch of sheetXml.matchAll(/<row\b[^>]*r="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)) {
const cellMap: Record<string, string> = {}
const rowXml = rowMatch[2]
for (const cellMatch of rowXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
const attrs = cellMatch[1]
const cellXml = cellMatch[2]
const refMatch = attrs.match(/r="([A-Z]+)\d+"/)
if (!refMatch) continue
const column = refMatch[1]
const typeMatch = attrs.match(/t="([^"]+)"/)
const type = typeMatch?.[1] || ""
const valueMatch = cellXml.match(/<v>([\s\S]*?)<\/v>/)
const inlineTextMatch = cellXml.match(/<t\b[^>]*>([\s\S]*?)<\/t>/)
let value = ""
if (type === "s" && valueMatch) {
value = sharedStrings[Number(valueMatch[1])] || ""
} else if (inlineTextMatch) {
value = decodeXmlText(inlineTextMatch[1])
} else if (valueMatch) {
value = decodeXmlText(valueMatch[1])
}
cellMap[column] = value.trim()
}
rows.push(cellMap)
}
return rows
}
function parseNumber(value: string) {
const normalized = String(value || "").trim().replace(",", ".")
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
function parseWorkbook(workbookPath: string): ImportRow[] {
const sharedStrings = readSharedStrings(workbookPath)
const rows = readSheetRows(workbookPath, sharedStrings)
if (!rows.length) {
throw new Error("Die Arbeitsmappe enthält keine Zeilen.")
}
const header = rows[0]
if (header.A !== "Mitarbeiter" || header.B !== "Betrieb") {
throw new Error("Unerwartetes Format der Excel-Datei. Erwartet wurden die Spalten 'Mitarbeiter' und 'Betrieb'.")
}
return rows
.slice(1)
.map((row, index) => ({
rowNumber: index + 2,
mitarbeiter: row.A || "",
betrieb: row.B || "",
anstellung: row.C || "",
position: row.D || "",
bereich: row.E || "",
stundenMonat: parseNumber(row.F || ""),
urlaub: parseNumber(row.G || ""),
}))
.filter((row) => row.mitarbeiter)
}
function splitFullName(fullName: string) {
const parts = fullName.trim().split(/\s+/).filter(Boolean)
if (parts.length <= 1) {
return {
firstName: fullName.trim(),
lastName: "Unbekannt",
}
}
return {
firstName: parts.slice(0, -1).join(" "),
lastName: parts[parts.length - 1],
}
}
function toWeeklyHours(monthlyHours: number | null) {
if (monthlyHours == null) return null
return Math.round(((monthlyHours * 12) / 52) * 100) / 100
}
function formatValue(value: unknown) {
if (value == null) return "-"
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
function mapEmploymentCategory(value: string) {
const normalized = normalizeKey(value)
if (normalized === "aushilfe") return "Aushilfen"
if (normalized === "teilzeit" || normalized === "vollzeit") return "Festangestellte"
return null
}
function buildTeamName(bereich: string, anstellung: string) {
const employmentCategory = mapEmploymentCategory(anstellung)
const normalizedBereich = String(bereich || "").trim()
if (!normalizedBereich || !employmentCategory) return null
return `${normalizedBereich} ${employmentCategory}`
}
function collectFieldChanges(existing: any, nextPayload: Record<string, unknown>) {
if (!existing) return []
const changes: string[] = []
for (const [field, nextValue] of Object.entries(nextPayload)) {
const currentValue = existing[field]
const currentFormatted = formatValue(currentValue)
const nextFormatted = formatValue(nextValue)
if (currentFormatted !== nextFormatted) {
changes.push(`${field}: ${currentFormatted} -> ${nextFormatted}`)
}
}
return changes
}
async function validateTenantAndBranches(
db: any,
tenantId: number,
branchIds: number[]
) {
const [tenant] = await db
.select({ id: tenants.id, name: tenants.name })
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1)
if (!tenant) {
throw new Error(`Tenant ${tenantId} wurde nicht gefunden.`)
}
const branchRows = await db
.select({ id: branches.id, name: branches.name })
.from(branches)
.where(eq(branches.tenant, tenantId))
const validBranchIds = new Set(branchRows.map((branch: any) => Number(branch.id)))
for (const branchId of branchIds) {
if (!validBranchIds.has(branchId)) {
throw new Error(`Branch-ID ${branchId} gehört nicht zum Tenant ${tenantId}.`)
}
}
return {
tenant,
branchRows,
}
}
async function loadTenantTeams(db: any, tenantId: number) {
const teamRows = await db
.select({
id: teams.id,
name: teams.name,
branch: teams.branch,
archived: teams.archived,
})
.from(teams)
.where(eq(teams.tenant, tenantId))
return new Map(
teamRows
.filter((team: any) => !team.archived)
.map((team: any) => [`${team.branch}::${normalizeKey(team.name)}`, team])
)
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (!options) {
printHelp()
return
}
if (!fs.existsSync(options.workbookPath)) {
throw new Error(`Excel-Datei nicht gefunden: ${options.workbookPath}`)
}
const rows = parseWorkbook(options.workbookPath)
if (!rows.length) {
throw new Error("Keine importierbaren Mitarbeiter gefunden.")
}
const mappedBranchIds = [
...new Set(
Object.values(options.branchMap)
.concat(options.defaultBranchId != null ? [options.defaultBranchId] : [])
),
]
const { db, pool } = await import("../db")
try {
const { tenant } = await validateTenantAndBranches(db, options.tenantId, mappedBranchIds)
const teamByBranchAndName = await loadTenantTeams(db, options.tenantId)
const existingProfiles = await db
.select()
.from(authProfiles)
.where(eq(authProfiles.tenant_id, options.tenantId))
const existingByName = new Map<string, any>()
for (const profile of existingProfiles) {
const key = normalizeKey(`${profile.first_name} ${profile.last_name}`)
if (existingByName.has(key)) {
throw new Error(`Mehrdeutiger bestehender Mitarbeiter: ${profile.first_name} ${profile.last_name}`)
}
existingByName.set(key, profile)
}
const duplicateImportNames = new Set<string>()
const seenImportNames = new Set<string>()
for (const row of rows) {
const key = normalizeKey(row.mitarbeiter)
if (seenImportNames.has(key)) duplicateImportNames.add(row.mitarbeiter)
seenImportNames.add(key)
}
if (duplicateImportNames.size) {
throw new Error(`Die Excel-Datei enthält doppelte Mitarbeiternamen: ${[...duplicateImportNames].join(", ")}`)
}
const missingBranchMappings = new Set<string>()
const missingTeams = new Set<string>()
const preparedRows = rows.map((row) => {
const branchKey = normalizeKey(row.betrieb)
const branchId = options.branchMap[branchKey] ?? options.defaultBranchId ?? null
if (!branchId) {
missingBranchMappings.add(row.betrieb || `(Zeile ${row.rowNumber})`)
}
const teamName = buildTeamName(row.bereich, row.anstellung)
const team = branchId && teamName
? (teamByBranchAndName.get(`${branchId}::${normalizeKey(teamName)}`) as any) || null
: null
if (branchId && teamName && !team) {
missingTeams.add(`${row.betrieb} | ${teamName}`)
}
return {
...row,
branchId,
teamId: team?.id ?? null,
teamName,
weeklyHours: toWeeklyHours(row.stundenMonat),
}
})
if (missingBranchMappings.size) {
throw new Error(
`Für folgende Betriebe fehlt eine Branch-ID: ${[...missingBranchMappings].join(", ")}`
)
}
if (missingTeams.size) {
throw new Error(
`Für folgende Niederlassung-/Bereich-Kombinationen fehlen Teams: ${[...missingTeams].join(", ")}`
)
}
let createdProfiles = 0
let updatedProfiles = 0
const actionLogs: string[] = []
for (const row of preparedRows) {
const nameParts = splitFullName(row.mitarbeiter)
const nameKey = normalizeKey(row.mitarbeiter)
const existing = existingByName.get(nameKey)
const tempConfig = {
...((existing?.temp_config && typeof existing.temp_config === "object") ? existing.temp_config : {}),
mitarbeiterImport: {
betrieb: row.betrieb,
bereich: row.bereich,
stundenMonat: row.stundenMonat,
urlaub: row.urlaub,
quelle: path.basename(options.workbookPath),
importiertAm: new Date().toISOString(),
},
}
const payload = {
tenant_id: options.tenantId,
branch_id: row.branchId,
first_name: nameParts.firstName,
last_name: nameParts.lastName,
contract_type: row.anstellung || null,
position: row.position || null,
qualification: row.bereich || null,
weekly_working_hours: row.weeklyHours ?? existing?.weekly_working_hours ?? 0,
annual_paid_leave_days: row.urlaub != null ? Math.round(row.urlaub) : existing?.annual_paid_leave_days ?? null,
temp_config: tempConfig,
active: existing?.active ?? true,
}
const action: ImportAction = existing ? "update" : "create"
const fieldChanges = existing ? collectFieldChanges(existing, payload) : []
const actionPrefix = action === "create" ? "ERSTELLEN" : "AKTUALISIEREN"
const branchLabel = `${row.betrieb} -> ${row.branchId}`
const teamLabel = row.teamName ? `${row.teamName} -> ${row.teamId}` : "-"
if (action === "create") {
actionLogs.push(
`[${actionPrefix}] ${row.mitarbeiter} | Branch ${branchLabel} | Team ${teamLabel} | Vertrag ${row.anstellung || "-"} | Position ${row.position || "-"}`
)
} else {
actionLogs.push(
`[${actionPrefix}] ${row.mitarbeiter} | Branch ${branchLabel} | Team ${teamLabel} | Änderungen: ${fieldChanges.length ? fieldChanges.join("; ") : "keine"}`
)
}
if (!existing) {
if (!options.dryRun) {
const [created] = await db
.insert(authProfiles)
.values(payload)
.returning()
if (!created) {
throw new Error(`Profil für "${row.mitarbeiter}" konnte nicht erstellt werden.`)
}
await db.insert(authProfileBranches).values({
profile_id: created.id,
branch_id: row.branchId,
})
if (row.teamId) {
await db.insert(authProfileTeams).values({
profile_id: created.id,
team_id: row.teamId,
})
}
existingByName.set(nameKey, created)
}
createdProfiles += 1
continue
}
if (!options.dryRun) {
await db
.update(authProfiles)
.set(payload)
.where(
and(
eq(authProfiles.id, existing.id),
eq(authProfiles.tenant_id, options.tenantId)
)
)
await db
.delete(authProfileBranches)
.where(eq(authProfileBranches.profile_id, existing.id))
await db.insert(authProfileBranches).values({
profile_id: existing.id,
branch_id: row.branchId,
})
await db
.delete(authProfileTeams)
.where(eq(authProfileTeams.profile_id, existing.id))
if (row.teamId) {
await db.insert(authProfileTeams).values({
profile_id: existing.id,
team_id: row.teamId,
})
}
}
updatedProfiles += 1
}
console.log("")
console.log(`[IMPORT MITARBEITER] Tenant: ${tenant.id} (${tenant.name})`)
console.log(`[IMPORT MITARBEITER] Datei: ${options.workbookPath}`)
console.log(`[IMPORT MITARBEITER] Dry-Run: ${options.dryRun ? "JA" : "NEIN"}`)
console.log(`[IMPORT MITARBEITER] Zeilen gelesen: ${rows.length}`)
console.log(`[IMPORT MITARBEITER] Profile erstellt: ${createdProfiles}`)
console.log(`[IMPORT MITARBEITER] Profile aktualisiert: ${updatedProfiles}`)
if (actionLogs.length) {
console.log("[IMPORT MITARBEITER] Details:")
for (const logLine of actionLogs) {
console.log(` ${logLine}`)
}
}
console.log("")
} finally {
await pool.end()
}
}
main().catch((error) => {
console.error("[IMPORT MITARBEITER] Fehler:", error)
process.exitCode = 1
})

View File

@@ -0,0 +1,47 @@
import "dotenv/config"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { drizzle } from "drizzle-orm/node-postgres"
import { migrate } from "drizzle-orm/node-postgres/migrator"
import { Pool } from "pg"
import { loadSecrets, secrets } from "../src/utils/secrets"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
async function run() {
let connectionString = process.env.DATABASE_URL
if (!connectionString) {
await loadSecrets()
connectionString = secrets.DATABASE_URL
}
if (!connectionString) {
throw new Error("DATABASE_URL not configured")
}
const pool = new Pool({
connectionString,
max: 1,
})
try {
const db = drizzle(pool)
await migrate(db, {
migrationsFolder: path.resolve(__dirname, "../db/migrations"),
})
console.log("✅ Drizzle-Migrationen erfolgreich angewendet")
} finally {
await pool.end()
}
}
run().catch((err) => {
console.error("❌ Migration fehlgeschlagen")
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,7 @@
{
"1848 Pütt": 1,
"Strandcafé": 3,
"Oceans11": 4,
"Oceans 11": 4,
"Winnys": 5
}

View File

@@ -29,6 +29,7 @@ import staffTimeConnectRoutes from "./routes/staff/timeconnects";
import userRoutes from "./routes/auth/user"; import userRoutes from "./routes/auth/user";
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated"; import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
import wikiRoutes from "./routes/wiki"; import wikiRoutes from "./routes/wiki";
import portalContractRoutes from "./routes/portal/contracts";
//Public Links //Public Links
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated"; import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
@@ -146,6 +147,7 @@ async function main() {
await subApp.register(publiclinksAuthenticatedRoutes); await subApp.register(publiclinksAuthenticatedRoutes);
await subApp.register(resourceRoutes); await subApp.register(resourceRoutes);
await subApp.register(wikiRoutes); await subApp.register(wikiRoutes);
await subApp.register(portalContractRoutes);
},{prefix: "/api"}) },{prefix: "/api"})

View File

@@ -9,12 +9,15 @@ export type DerivedSpan = {
sourceEventIds: string[]; sourceEventIds: string[];
status: SpanStatus; status: SpanStatus;
statusActorId?: string; statusActorId?: string;
payload?: Record<string, any> | null;
description?: string;
}; };
type TimeEvent = { type TimeEvent = {
id: string; id: string;
eventtype: string; eventtype: string;
eventtime: Date; eventtime: Date;
payload?: Record<string, any> | null;
}; };
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen // Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
@@ -45,9 +48,17 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
let currentStart: Date | null = null; let currentStart: Date | null = null;
let currentType: DerivedSpan["type"] | null = null; let currentType: DerivedSpan["type"] | null = null;
let sourceEventIds: string[] = []; let sourceEventIds: string[] = [];
let currentPayload: Record<string, any> | null = null;
const closeSpan = (end: Date) => { const closeSpan = (end: Date) => {
if (!currentStart || !currentType) return; if (!currentStart || !currentType) return;
if (end.getTime() <= currentStart.getTime()) {
currentStart = null;
currentType = null;
sourceEventIds = [];
currentPayload = null;
return;
}
spans.push({ spans.push({
type: currentType, type: currentType,
@@ -55,12 +66,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
endedAt: end, endedAt: end,
sourceEventIds: [...sourceEventIds], sourceEventIds: [...sourceEventIds],
// Standardstatus ist "factual", wird später angereichert // Standardstatus ist "factual", wird später angereichert
status: "factual" status: "factual",
payload: currentPayload,
description: currentPayload?.description || ""
}); });
currentStart = null; currentStart = null;
currentType = null; currentType = null;
sourceEventIds = []; sourceEventIds = [];
currentPayload = null;
}; };
const closeOpenSpanAsRunning = () => { const closeOpenSpanAsRunning = () => {
@@ -72,12 +86,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
endedAt: null, endedAt: null,
sourceEventIds: [...sourceEventIds], sourceEventIds: [...sourceEventIds],
// Standardstatus ist "factual", wird später angereichert // Standardstatus ist "factual", wird später angereichert
status: "factual" status: "factual",
payload: currentPayload,
description: currentPayload?.description || ""
}); });
currentStart = null; currentStart = null;
currentType = null; currentType = null;
sourceEventIds = []; sourceEventIds = [];
currentPayload = null;
}; };
for (const event of events) { for (const event of events) {
@@ -96,6 +113,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "WORKING"; state = "WORKING";
currentStart = event.eventtime; currentStart = event.eventtime;
currentType = "work"; currentType = "work";
currentPayload = event.payload || null;
break; break;
case "pause_start": case "pause_start":
@@ -104,6 +122,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "PAUSED"; state = "PAUSED";
currentStart = event.eventtime; currentStart = event.eventtime;
currentType = "pause"; currentType = "pause";
currentPayload = event.payload || null;
} }
break; break;
@@ -113,6 +132,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "WORKING"; state = "WORKING";
currentStart = event.eventtime; currentStart = event.eventtime;
currentType = "work"; currentType = "work";
currentPayload = event.payload || null;
} }
break; break;
@@ -140,6 +160,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "ABSENT"; state = "ABSENT";
currentStart = event.eventtime; currentStart = event.eventtime;
currentType = newType; currentType = newType;
currentPayload = event.payload || null;
break; break;
case "vacation_end": case "vacation_end":
@@ -162,4 +183,4 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
} }
return spans; return spans;
} }

View File

@@ -1,7 +1,7 @@
// src/services/loadValidEvents.ts // src/services/loadValidEvents.ts
import { stafftimeevents } from "../../../db/schema"; import { stafftimeevents } from "../../../db/schema";
import {sql, and, eq, gte, lte, inArray} from "drizzle-orm"; import {sql, and, eq, gte, lte, inArray, asc} from "drizzle-orm";
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end"; export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
@@ -12,11 +12,43 @@ export type TimeEvent = {
id: string; id: string;
eventtype: string; eventtype: string;
eventtime: Date; eventtime: Date;
actoruser_id: string; actoruser_id?: string;
related_event_id: string | null; related_event_id: string | null;
// Fügen Sie hier alle weiteren Felder hinzu, die Sie benötigen payload?: Record<string, any> | null;
created_at?: Date | null;
}; };
const EVENT_TYPE_ORDER: Record<string, number> = {
auto_stop: 10,
work_end: 10,
pause_end: 10,
vacation_end: 10,
sick_end: 10,
overtime_compensation_end: 10,
work_start: 20,
pause_start: 20,
vacation_start: 20,
sick_start: 20,
overtime_compensation_start: 20,
submitted: 30,
approved: 30,
rejected: 30,
invalidated: 40,
};
export function compareTimeEvents(a: TimeEvent, b: TimeEvent) {
const eventTimeDiff = a.eventtime.getTime() - b.eventtime.getTime();
if (eventTimeDiff !== 0) return eventTimeDiff;
const typeOrderDiff = (EVENT_TYPE_ORDER[a.eventtype] ?? 999) - (EVENT_TYPE_ORDER[b.eventtype] ?? 999);
if (typeOrderDiff !== 0) return typeOrderDiff;
const createdAtDiff = (a.created_at?.getTime() ?? 0) - (b.created_at?.getTime() ?? 0);
if (createdAtDiff !== 0) return createdAtDiff;
return a.id.localeCompare(b.id);
}
export async function loadValidEvents( export async function loadValidEvents(
server: FastifyInstance, server: FastifyInstance,
tenantId: number, tenantId: number,
@@ -62,10 +94,9 @@ export async function loadValidEvents(
) )
) )
.orderBy( .orderBy(
// Wichtig für die deriveTimeSpans Logik: Event-Zeit muss primär sein asc(baseEvents.eventtime),
baseEvents.eventtime, asc(baseEvents.created_at),
baseEvents.created_at, // Wenn Eventtime identisch ist, das später erstellte zuerst asc(baseEvents.id)
baseEvents.id
); );
// Mapping auf den sauberen TimeEvent Typ // Mapping auf den sauberen TimeEvent Typ
@@ -73,8 +104,10 @@ export async function loadValidEvents(
id: e.id, id: e.id,
eventtype: e.eventtype, eventtype: e.eventtype,
eventtime: e.eventtime, eventtime: e.eventtime,
// Fügen Sie hier alle weiteren benötigten Felder hinzu (z.B. metadata, actoruser_id) actoruser_id: e.actoruser_id,
// ... related_event_id: e.related_event_id,
payload: e.payload,
created_at: e.created_at,
})) as TimeEvent[]; })) as TimeEvent[];
} }
@@ -99,7 +132,11 @@ export async function loadRelatedAdminEvents(server, eventIds) {
) )
) )
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen! // Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
.orderBy(stafftimeevents.eventtime); .orderBy(
asc(stafftimeevents.eventtime),
asc(stafftimeevents.created_at),
asc(stafftimeevents.id)
);
return adminEvents; return adminEvents as TimeEvent[];
} }

View File

@@ -4,6 +4,7 @@ import { and, eq, inArray, isNull } from "drizzle-orm";
import { import {
authTenantUsers, authTenantUsers,
authProfiles, authProfiles,
customers,
authRoles, authRoles,
authUserRoles, authUserRoles,
authUsers, authUsers,
@@ -12,6 +13,7 @@ import {
tenants, tenants,
} from "../../db/schema"; } from "../../db/schema";
import { generateRandomPassword, hashPassword } from "../utils/password"; import { generateRandomPassword, hashPassword } from "../utils/password";
import { sendMail } from "../utils/mailer";
export default async function adminRoutes(server: FastifyInstance) { export default async function adminRoutes(server: FastifyInstance) {
const deriveNameFromEmail = (email: string) => { const deriveNameFromEmail = (email: string) => {
@@ -255,6 +257,33 @@ export default async function adminRoutes(server: FastifyInstance) {
return currentUser; return currentUser;
}; };
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
const existingRoles = await server.db
.select({
id: authRoles.id,
name: authRoles.name,
})
.from(authRoles)
.where(eq(authRoles.tenant_id, tenantId));
const portalRole = existingRoles.find((role) => role.name === "Kundenportal");
if (portalRole) return portalRole.id;
const [createdRole] = await server.db
.insert(authRoles)
.values({
name: "Kundenportal",
description: "Automatisch angelegte Rolle für eingeladene Kundenportal-Benutzer",
tenant_id: tenantId,
created_by: createdBy,
})
.returning({
id: authRoles.id,
});
return createdRole.id;
};
// ------------------------------------------------------------- // -------------------------------------------------------------
// GET /admin/overview // GET /admin/overview
// ------------------------------------------------------------- // -------------------------------------------------------------
@@ -422,6 +451,343 @@ export default async function adminRoutes(server: FastifyInstance) {
} }
}); });
// -------------------------------------------------------------
// POST /admin/profiles/:profileId/create-user
// -------------------------------------------------------------
server.post("/admin/profiles/:profileId/create-user", async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const { profileId } = req.params as { profileId: string };
const body = req.body as { email?: string };
const email = body.email?.trim().toLowerCase();
if (!email) {
return reply.code(400).send({ error: "email required" });
}
const [profile] = await server.db
.select({
id: authProfiles.id,
tenant_id: authProfiles.tenant_id,
user_id: authProfiles.user_id,
first_name: authProfiles.first_name,
last_name: authProfiles.last_name,
email: authProfiles.email,
})
.from(authProfiles)
.where(eq(authProfiles.id, profileId))
.limit(1);
if (!profile) {
return reply.code(404).send({ error: "Profile not found" });
}
if (profile.user_id) {
return reply.code(409).send({ error: "Profile already linked to a user" });
}
const existingUsers = await server.db
.select({ id: authUsers.id })
.from(authUsers)
.where(eq(authUsers.email, email))
.limit(1);
if (existingUsers.length) {
return reply.code(409).send({ error: "User with this email already exists" });
}
const initialPassword = generateRandomPassword(14);
const passwordHash = await hashPassword(initialPassword);
const result = await server.db.transaction(async (tx) => {
const [createdUser] = await tx
.insert(authUsers)
.values({
email,
passwordHash,
is_admin: false,
multiTenant: true,
must_change_password: true,
updatedAt: new Date(),
})
.returning({
id: authUsers.id,
email: authUsers.email,
must_change_password: authUsers.must_change_password,
is_admin: authUsers.is_admin,
multiTenant: authUsers.multiTenant,
created_at: authUsers.created_at,
});
await tx
.insert(authTenantUsers)
.values({
tenant_id: profile.tenant_id,
user_id: createdUser.id,
created_by: currentUser.id,
});
const [updatedProfile] = await tx
.update(authProfiles)
.set({
user_id: createdUser.id,
email,
})
.where(eq(authProfiles.id, profile.id))
.returning({
id: authProfiles.id,
tenant_id: authProfiles.tenant_id,
user_id: authProfiles.user_id,
first_name: authProfiles.first_name,
last_name: authProfiles.last_name,
email: authProfiles.email,
});
return {
user: createdUser,
profile: updatedProfile,
};
});
return {
...result,
initialPassword,
};
} catch (err) {
console.error("ERROR /admin/profiles/:profileId/create-user:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
server.post("/admin/customers/:customerId/invite-portal-user", async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const tenantId = Number(req.user?.tenant_id);
const { customerId } = req.params as { customerId: string };
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" });
}
const [tenantRecord] = await server.db
.select({
id: tenants.id,
name: tenants.name,
portalDomain: tenants.portalDomain,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1);
const [customerRecord] = await server.db
.select()
.from(customers)
.where(and(eq(customers.id, Number(customerId)), eq(customers.tenant, tenantId)))
.limit(1);
if (!customerRecord) {
return reply.code(404).send({ error: "Customer not found" });
}
const customerInfo = customerRecord.infoData && typeof customerRecord.infoData === "object" ? customerRecord.infoData as Record<string, any> : {};
const email = String(customerInfo.email || customerInfo.invoiceEmail || "").trim().toLowerCase();
if (!email) {
return reply.code(400).send({ error: "Customer has no email address" });
}
const generatedPassword = generateRandomPassword(14);
const passwordHash = await hashPassword(generatedPassword);
const [existingUser] = await server.db
.select({
id: authUsers.id,
email: authUsers.email,
is_admin: authUsers.is_admin,
})
.from(authUsers)
.where(eq(authUsers.email, email))
.limit(1);
const derivedName = deriveNameFromEmail(email);
const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
let userId = existingUser?.id || null;
let createdNewUser = false;
if (existingUser) {
const [existingProfile] = await server.db
.select({
id: authProfiles.id,
customer_for_portal: authProfiles.customer_for_portal,
})
.from(authProfiles)
.where(and(
eq(authProfiles.user_id, existingUser.id),
eq(authProfiles.tenant_id, tenantId)
))
.limit(1);
if (existingUser.is_admin) {
return reply.code(409).send({ error: "Email address is already used by an admin user" });
}
if (!existingProfile) {
return reply.code(409).send({ error: "Email address is already used by another user" });
}
if (existingProfile.customer_for_portal && existingProfile.customer_for_portal !== customerRecord.id) {
return reply.code(409).send({ error: "Email address is already assigned to another portal customer" });
}
await server.db
.update(authUsers)
.set({
passwordHash,
must_change_password: true,
multiTenant: false,
updatedAt: new Date(),
})
.where(eq(authUsers.id, existingUser.id));
userId = existingUser.id;
} else {
const [createdUser] = await server.db
.insert(authUsers)
.values({
email,
passwordHash,
is_admin: false,
multiTenant: false,
must_change_password: true,
updatedAt: new Date(),
})
.returning({
id: authUsers.id,
});
userId = createdUser.id;
createdNewUser = true;
}
const portalRoleId = await ensurePortalRoleForTenant(tenantId, currentUser.id);
const existingMemberships = await server.db
.select()
.from(authTenantUsers)
.where(and(
eq(authTenantUsers.user_id, userId!),
eq(authTenantUsers.tenant_id, tenantId)
))
.limit(1);
if (!existingMemberships.length) {
await server.db
.insert(authTenantUsers)
.values({
tenant_id: tenantId,
user_id: userId!,
created_by: currentUser.id,
});
}
const existingPortalRoleAssignment = await server.db
.select()
.from(authUserRoles)
.where(and(
eq(authUserRoles.user_id, userId!),
eq(authUserRoles.tenant_id, tenantId),
eq(authUserRoles.role_id, portalRoleId)
))
.limit(1);
if (!existingPortalRoleAssignment.length) {
await server.db
.insert(authUserRoles)
.values({
user_id: userId!,
tenant_id: tenantId,
role_id: portalRoleId,
created_by: currentUser.id,
});
}
const [existingTenantProfile] = await server.db
.select({
id: authProfiles.id,
user_id: authProfiles.user_id,
customer_for_portal: authProfiles.customer_for_portal,
})
.from(authProfiles)
.where(and(
eq(authProfiles.user_id, userId!),
eq(authProfiles.tenant_id, tenantId)
))
.limit(1);
if (existingTenantProfile) {
await server.db
.update(authProfiles)
.set({
first_name: firstName,
last_name: lastName,
email,
customer_for_portal: customerRecord.id,
active: true,
})
.where(eq(authProfiles.id, existingTenantProfile.id));
} else {
await server.db
.insert(authProfiles)
.values({
user_id: userId!,
tenant_id: tenantId,
first_name: firstName,
last_name: lastName,
email,
customer_for_portal: customerRecord.id,
active: true,
});
}
const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null;
const mailResult = await sendMail(
email,
`FEDEO | Einladung ins Kundenportal`,
`
<p>Hallo${customerRecord.name ? ` ${customerRecord.name}` : ""},</p>
<p>für Sie wurde ein Zugang zum FEDEO Kundenportal eingerichtet.</p>
<p><strong>E-Mail:</strong> ${email}</p>
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</a></p>` : ""}
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
`
);
if (!mailResult.success) {
return reply.code(500).send({ error: "Invitation email could not be sent" });
}
return {
success: true,
createdNewUser,
email,
initialPassword: generatedPassword,
portalUrl,
};
} catch (err) {
console.error("ERROR /admin/customers/:customerId/invite-portal-user:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
// ------------------------------------------------------------- // -------------------------------------------------------------
// POST /admin/tenants // POST /admin/tenants
// ------------------------------------------------------------- // -------------------------------------------------------------

View File

@@ -9,6 +9,7 @@ import {
authRolePermissions, authRolePermissions,
} from "../../../db/schema" } from "../../../db/schema"
import { eq, and, or, isNull } from "drizzle-orm" import { eq, and, or, isNull } from "drizzle-orm"
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
export default async function meRoutes(server: FastifyInstance) { export default async function meRoutes(server: FastifyInstance) {
server.get("/me", async (req, reply) => { server.get("/me", async (req, reply) => {
@@ -51,6 +52,7 @@ export default async function meRoutes(server: FastifyInstance) {
id: tenants.id, id: tenants.id,
name: tenants.name, name: tenants.name,
short: tenants.short, short: tenants.short,
hasActiveLicense: tenants.hasActiveLicense,
locked: tenants.locked, locked: tenants.locked,
features: tenants.features, features: tenants.features,
extraModules: tenants.extraModules, extraModules: tenants.extraModules,
@@ -89,7 +91,8 @@ export default async function meRoutes(server: FastifyInstance) {
) )
.limit(1) .limit(1)
profile = profileResult?.[0] ?? null const enrichedProfiles = await enrichProfilesWithBranches(server, profileResult)
profile = enrichedProfiles?.[0] ?? null
} }
// ---------------------------------------------------- // ----------------------------------------------------

View File

@@ -11,10 +11,12 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
import { import {
bankrequisitions, bankrequisitions,
bankstatements, bankstatements,
accounts,
createddocuments, createddocuments,
customers, customers,
entitybankaccounts, entitybankaccounts,
incominginvoices, incominginvoices,
ownaccounts,
statementallocations, statementallocations,
vendors, vendors,
} from "../../db/schema" } from "../../db/schema"
@@ -22,10 +24,71 @@ import {
import { import {
eq, eq,
and, and,
isNull,
aliasedTable,
} from "drizzle-orm" } from "drizzle-orm"
export default async function bankingRoutes(server: FastifyInstance) { export default async function bankingRoutes(server: FastifyInstance) {
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
const ContraCustomers = aliasedTable(customers, "contra_customers")
const ContraVendors = aliasedTable(vendors, "contra_vendors")
const ContraOwnaccounts = aliasedTable(ownaccounts, "contra_ownaccounts")
const ManualInvoices = aliasedTable(incominginvoices, "manual_invoices")
const ManualInvoiceVendors = aliasedTable(vendors, "manual_invoice_vendors")
const normalizeManualSide = (payload: any, keys: string[]) =>
keys.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== "")
const prepareStatementAllocationPayload = (payload: any) => {
const next = { ...payload }
const isManualBooking = !next.bankstatement
if (!isManualBooking) {
next.manualBookingDate = null
next.contraAccount = null
next.contraCustomer = null
next.contraVendor = null
next.contraOwnaccount = null
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
next.manualInvoiceSide = null
return { data: next }
}
const debitKeys = ["account", "customer", "vendor", "ownaccount"]
const creditKeys = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"]
const hasManualInvoice = next.incominginvoice !== null && next.incominginvoice !== undefined && next.incominginvoice !== ""
const debitSide = normalizeManualSide(next, debitKeys)
const creditSide = normalizeManualSide(next, creditKeys)
if (hasManualInvoice) {
if (next.manualInvoiceSide === "debit") debitSide.push("incominginvoice")
else if (next.manualInvoiceSide === "credit") creditSide.push("incominginvoice")
else return { error: "Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein." }
} else {
next.manualInvoiceSide = null
}
if (!next.manualBookingDate || !dayjs(next.manualBookingDate).isValid()) {
return { error: "Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich." }
}
if (!Number.isFinite(Number(next.amount)) || Number(next.amount) <= 0) {
return { error: "Für manuelle Buchungen muss der Betrag größer als 0 sein." }
}
if (debitSide.length !== 1 || creditSide.length !== 1) {
return { error: "Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden." }
}
next.amount = Math.abs(Number(next.amount))
next.bankstatement = null
next.manualBookingDate = dayjs(next.manualBookingDate).format("YYYY-MM-DD")
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
return { data: next }
}
const normalizeIban = (value?: string | null) => const normalizeIban = (value?: string | null) =>
String(value || "").replace(/\s+/g, "").toUpperCase() String(value || "").replace(/\s+/g, "").toUpperCase()
@@ -677,6 +740,64 @@ export default async function bankingRoutes(server: FastifyInstance) {
} }
}) })
// ------------------------------------------------------------------
// 📒 List Manual Statement Allocations
// ------------------------------------------------------------------
server.get("/banking/manual-bookings", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const rows = await server.db.select({
allocation: statementallocations,
account: accounts,
customer: customers,
vendor: vendors,
ownaccount: ownaccounts,
contraAccount: ContraAccounts,
contraCustomer: ContraCustomers,
contraVendor: ContraVendors,
contraOwnaccount: ContraOwnaccounts,
incominginvoice: ManualInvoices,
incominginvoiceVendor: ManualInvoiceVendors,
})
.from(statementallocations)
.leftJoin(accounts, eq(statementallocations.account, accounts.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
.leftJoin(ContraAccounts, eq(statementallocations.contraAccount, ContraAccounts.id))
.leftJoin(ContraCustomers, eq(statementallocations.contraCustomer, ContraCustomers.id))
.leftJoin(ContraVendors, eq(statementallocations.contraVendor, ContraVendors.id))
.leftJoin(ContraOwnaccounts, eq(statementallocations.contraOwnaccount, ContraOwnaccounts.id))
.leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id))
.leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id))
.where(and(
eq(statementallocations.tenant, req.user.tenant_id),
eq(statementallocations.archived, false),
isNull(statementallocations.bankstatement)
))
return reply.send(rows.map((row) => ({
...row.allocation,
account: row.account,
customer: row.customer,
vendor: row.vendor,
ownaccount: row.ownaccount,
contraAccount: row.contraAccount,
contraCustomer: row.contraCustomer,
contraVendor: row.contraVendor,
contraOwnaccount: row.contraOwnaccount,
incominginvoice: row.incominginvoice ? {
...row.incominginvoice,
vendor: row.incominginvoiceVendor,
} : null,
})))
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Failed to load manual bookings" })
}
})
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 💰 Create Statement Allocation // 💰 Create Statement Allocation
@@ -686,9 +807,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { data: payload } = req.body as { data: any } const { data: payload } = req.body as { data: any }
const prepared = prepareStatementAllocationPayload(payload)
if (prepared.error) return reply.code(400).send({ error: prepared.error })
const inserted = await server.db.insert(statementallocations).values({ const inserted = await server.db.insert(statementallocations).values({
...payload, ...prepared.data,
tenant: req.user.tenant_id tenant: req.user.tenant_id
}).returning() }).returning()
@@ -720,16 +843,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
} }
} }
await insertHistoryItem(server, { if (createdRecord.bankstatement) {
entity: "bankstatements", await insertHistoryItem(server, {
entityId: Number(createdRecord.bankstatement), entity: "bankstatements",
action: "created", entityId: Number(createdRecord.bankstatement),
created_by: req.user.user_id, action: "created",
tenant_id: req.user.tenant_id, created_by: req.user.user_id,
oldVal: null, tenant_id: req.user.tenant_id,
newVal: createdRecord, oldVal: null,
text: "Buchung erstellt", newVal: createdRecord,
}) text: "Buchung erstellt",
})
}
return reply.send(createdRecord) return reply.send(createdRecord)
@@ -763,16 +888,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
.delete(statementallocations) .delete(statementallocations)
.where(eq(statementallocations.id, id)) .where(eq(statementallocations.id, id))
await insertHistoryItem(server, { if (old.bankstatement) {
entity: "bankstatements", await insertHistoryItem(server, {
entityId: Number(old.bankstatement), entity: "bankstatements",
action: "deleted", entityId: Number(old.bankstatement),
created_by: req.user.user_id, action: "deleted",
tenant_id: req.user.tenant_id, created_by: req.user.user_id,
oldVal: old, tenant_id: req.user.tenant_id,
newVal: null, oldVal: old,
text: "Buchung gelöscht", newVal: null,
}) text: "Buchung gelöscht",
})
}
return reply.send({ success: true }) return reply.send({ success: true })

View File

@@ -10,7 +10,9 @@ import { secrets } from "../utils/secrets"
import { saveFile } from "../utils/files" import { saveFile } from "../utils/files"
import { eq, inArray } from "drizzle-orm" import { eq, inArray } from "drizzle-orm"
import { and } from "drizzle-orm"
import { import {
authProfiles,
files, files,
createddocuments, createddocuments,
customers customers
@@ -18,6 +20,55 @@ import {
export default async function fileRoutes(server: FastifyInstance) { export default async function fileRoutes(server: FastifyInstance) {
const getPortalCustomerId = async (req: any) => {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
const loadSingleFileForRequest = async (req: any, id: string) => {
const tenantId = req.user?.tenant_id
if (!tenantId) return null
const portalCustomerId = await getPortalCustomerId(req)
if (!portalCustomerId) {
const rows = await server.db
.select()
.from(files)
.where(and(eq(files.id, id), eq(files.tenant, tenantId)))
return rows[0] || null
}
const rows = await server.db
.select({
file: files,
})
.from(files)
.leftJoin(createddocuments, eq(files.createddocument, createddocuments.id))
.where(and(
eq(files.id, id),
eq(files.tenant, tenantId),
eq(createddocuments.customer, portalCustomerId),
eq(createddocuments.availableInPortal, true)
))
.limit(1)
return rows[0]?.file || null
}
// ------------------------------------------------------------- // -------------------------------------------------------------
// MULTIPART INIT // MULTIPART INIT
@@ -80,12 +131,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// 🔹 EINZELNE DATEI // 🔹 EINZELNE DATEI
if (id) { if (id) {
const rows = await server.db const file = await loadSingleFileForRequest(req, id)
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
if (!file) return reply.code(404).send({ error: "Not found" }) if (!file) return reply.code(404).send({ error: "Not found" })
return file return file
@@ -135,12 +181,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// 1⃣ SINGLE DOWNLOAD // 1⃣ SINGLE DOWNLOAD
// ------------------------------------------------- // -------------------------------------------------
if (id) { if (id) {
const rows = await server.db const file = await loadSingleFileForRequest(req, id)
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
if (!file) return reply.code(404).send({ error: "File not found" }) if (!file) return reply.code(404).send({ error: "File not found" })
const command = new GetObjectCommand({ const command = new GetObjectCommand({
@@ -217,12 +258,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// SINGLE FILE PRESIGNED URL // SINGLE FILE PRESIGNED URL
// ------------------------------------------------- // -------------------------------------------------
if (id) { if (id) {
const rows = await server.db const file = await loadSingleFileForRequest(req, id)
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
if (!file) return reply.code(404).send({ error: "Not found" }) if (!file) return reply.code(404).send({ error: "Not found" })
const url = await getSignedUrl( const url = await getSignedUrl(

View File

@@ -3,7 +3,7 @@ import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions"; import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
import { GetObjectCommand } from "@aws-sdk/client-s3"; import { GetObjectCommand } from "@aws-sdk/client-s3";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -24,6 +24,7 @@ import {executeManualGeneration, finishManualGeneration} from "../modules/serial
import { s3 } from "../utils/s3"; import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets"; import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText"; import { storeExtractedTextForFile } from "../utils/documentText";
import { generateLiquidityForecast } from "../utils/liquidityForecast";
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(isoWeek) dayjs.extend(isoWeek)
dayjs.extend(isBetween) dayjs.extend(isBetween)
@@ -57,6 +58,42 @@ function resolveGitRoot() {
return null return null
} }
function getDeploymentChangelogFallback() {
const backendPackagePath = path.resolve(process.cwd(), "package.json")
let version = "unbekannt"
if (existsSync(backendPackagePath)) {
try {
const packageJson = JSON.parse(readFileSync(backendPackagePath, "utf-8"))
version = packageJson?.version || version
} catch (err) {
console.error("Could not read backend package.json for changelog fallback", err)
}
}
const commitHash =
process.env.RAILWAY_GIT_COMMIT_SHA ||
process.env.VERCEL_GIT_COMMIT_SHA ||
process.env.GITHUB_SHA ||
process.env.COMMIT_SHA ||
process.env.SOURCE_COMMIT ||
null
const committedAt =
process.env.BUILD_DATE ||
process.env.RENDER_GIT_COMMIT_DATE ||
process.env.VERCEL_GIT_COMMIT_DATE ||
new Date().toISOString()
return [{
hash: commitHash || `version-${version}`,
shortHash: commitHash ? commitHash.slice(0, 7) : `v${version}`,
subject: `Bereitgestellte Version ${version}`,
authorName: "Deployment",
committedAt
}]
}
export default async function functionRoutes(server: FastifyInstance) { export default async function functionRoutes(server: FastifyInstance) {
const streamToBuffer = async (stream: any): Promise<Buffer> => const streamToBuffer = async (stream: any): Promise<Buffer> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
@@ -201,7 +238,11 @@ export default async function functionRoutes(server: FastifyInstance) {
const gitRoot = resolveGitRoot() const gitRoot = resolveGitRoot()
if (!gitRoot) { if (!gitRoot) {
return reply.code(500).send({ error: 'Git repository not found' }) return reply.send({
repositoryRoot: null,
source: 'deployment',
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
})
} }
try { try {
@@ -232,11 +273,16 @@ export default async function functionRoutes(server: FastifyInstance) {
return reply.send({ return reply.send({
repositoryRoot: gitRoot, repositoryRoot: gitRoot,
source: 'git',
entries entries
}) })
} catch (err) { } catch (err) {
req.log.error(err) req.log.error(err)
return reply.code(500).send({ error: 'Failed to load changelog' }) return reply.send({
repositoryRoot: gitRoot,
source: 'deployment',
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
})
} }
}) })
@@ -261,6 +307,21 @@ export default async function functionRoutes(server: FastifyInstance) {
await server.services.prepareIncomingInvoices.run(req.user.tenant_id) await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
}) })
server.get('/functions/liquidity-forecast', async (req, reply) => {
const { ignoredRecurringKeys } = req.query as { ignoredRecurringKeys?: string }
const ignoredKeys = String(ignoredRecurringKeys || "")
.split(",")
.map((key) => key.trim())
.filter(Boolean)
try {
return await generateLiquidityForecast(server, req.user.tenant_id, ignoredKeys)
} catch (err) {
req.log.error(err)
return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." })
}
})
server.post('/functions/services/backfillfiletext', async (req, reply) => { server.post('/functions/services/backfillfiletext', async (req, reply) => {
const tenantId = req.user.tenant_id const tenantId = req.user.tenant_id

View File

@@ -5,6 +5,7 @@ import { authProfiles, historyitems } from "../../db/schema";
const columnMap: Record<string, any> = { const columnMap: Record<string, any> = {
customers: historyitems.customer, customers: historyitems.customer,
contracts: historyitems.contract,
members: historyitems.customer, members: historyitems.customer,
vendors: historyitems.vendor, vendors: historyitems.vendor,
projects: historyitems.project, projects: historyitems.project,
@@ -30,6 +31,7 @@ const columnMap: Record<string, any> = {
const insertFieldMap: Record<string, string> = { const insertFieldMap: Record<string, string> = {
customers: "customer", customers: "customer",
contracts: "contract",
members: "customer", members: "customer",
vendors: "vendor", vendors: "vendor",
projects: "project", projects: "project",

View File

@@ -8,6 +8,8 @@ import {
} from "../../../db/schema" } from "../../../db/schema"
import {and, eq, inArray} from "drizzle-orm" import {and, eq, inArray} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
import { enrichProfilesWithTeams } from "../../utils/profileTeams"
export default async function tenantRoutesInternal(server: FastifyInstance) { export default async function tenantRoutesInternal(server: FastifyInstance) {
@@ -53,7 +55,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
.where(inArray(authUsers.id, userIds)) .where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden // 3) auth_profiles pro Tenant laden
const profiles = await server.db const profileRows = await server.db
.select() .select()
.from(authProfiles) .from(authProfiles)
.where( .where(
@@ -61,6 +63,8 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
eq(authProfiles.tenant_id, tenantId), eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds) inArray(authProfiles.user_id, userIds)
)) ))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
const combined = users.map(u => { const combined = users.map(u => {
const profile = profiles.find(p => p.user_id === u.id) const profile = profiles.find(p => p.user_id === u.id)
@@ -91,12 +95,13 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
const tenantId = req.params.id const tenantId = req.params.id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const data = await server.db const profileRows = await server.db
.select() .select()
.from(authProfiles) .from(authProfiles)
.where(eq(authProfiles.tenant_id, tenantId)) .where(eq(authProfiles.tenant_id, tenantId))
return data const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
return await enrichProfilesWithTeams(server, profilesWithBranches)
} catch (err) { } catch (err) {
console.error("/tenant/profiles ERROR:", err) console.error("/tenant/profiles ERROR:", err)

View File

@@ -11,7 +11,7 @@ import {
desc desc
} from "drizzle-orm" } from "drizzle-orm"
import {stafftimeevents} from "../../../db/schema/staff_time_events"; import {stafftimeevents} from "../../../db/schema/staff_time_events";
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service"; import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service"; import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service"; import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
import {z} from "zod"; import {z} from "zod";
@@ -102,7 +102,7 @@ export default async function staffTimeRoutesInternal(server: FastifyInstance) {
const combinedEvents = [ const combinedEvents = [
...factualEvents, ...factualEvents,
...relatedAdminEvents, ...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime()); ].sort(compareTimeEvents);
// SCHRITT 5: Spans ableiten // SCHRITT 5: Spans ableiten
const derivedSpans = deriveTimeSpans(combinedEvents); const derivedSpans = deriveTimeSpans(combinedEvents);

View File

@@ -0,0 +1,230 @@
import { FastifyInstance } from "fastify"
import { and, eq } from "drizzle-orm"
import { authProfiles, contracts, contracttypes } from "../../../db/schema"
import { insertHistoryItem } from "../../utils/history"
async function getPortalCustomerId(server: FastifyInstance, req: any) {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
async function getPortalContract(server: FastifyInstance, req: any, contractId: number) {
const portalCustomerId = await getPortalCustomerId(server, req)
if (!portalCustomerId) return null
const [contract] = await server.db
.select({
id: contracts.id,
name: contracts.name,
tenant: contracts.tenant,
customer: contracts.customer,
contracttype: contracts.contracttype,
allowedContracttypes: contracts.allowedContracttypes,
archived: contracts.archived,
})
.from(contracts)
.where(and(
eq(contracts.id, contractId),
eq(contracts.tenant, req.user?.tenant_id),
eq(contracts.customer, portalCustomerId),
eq(contracts.archived, false)
))
.limit(1)
return contract || null
}
function normalizeMessage(message: unknown) {
if (typeof message !== "string") return ""
return message.trim()
}
function appendMessage(text: string, message: string) {
return message ? `${text} Nachricht: ${message}` : text
}
function formatDateForHistory(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
}).format(date)
}
export default async function portalContractRoutes(server: FastifyInstance) {
server.post<{
Params: { id: string }
Body: { contracttype?: number | string; message?: string }
}>("/portal/contracts/:id/change-request", {
schema: {
tags: ["Portal"],
summary: "Request contract type change from customer portal",
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
},
},
body: {
type: "object",
required: ["contracttype"],
properties: {
contracttype: { anyOf: [{ type: "number" }, { type: "string" }] },
message: { type: "string", nullable: true },
},
},
},
}, async (req, reply) => {
const contractId = Number(req.params.id)
const requestedContracttypeId = Number(req.body.contracttype)
if (!Number.isInteger(contractId) || !Number.isInteger(requestedContracttypeId)) {
return reply.code(400).send({ error: "Ungültige Anfrage" })
}
const contract = await getPortalContract(server, req, contractId)
if (!contract) {
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
}
const [requestedContracttype] = await server.db
.select({
id: contracttypes.id,
name: contracttypes.name,
})
.from(contracttypes)
.where(and(
eq(contracttypes.id, requestedContracttypeId),
eq(contracttypes.tenant, req.user?.tenant_id),
eq(contracttypes.archived, false)
))
.limit(1)
if (!requestedContracttype) {
return reply.code(400).send({ error: "Ungültiger Vertragstyp" })
}
const allowedContracttypes = Array.isArray(contract.allowedContracttypes)
? contract.allowedContracttypes.map((id) => Number(id)).filter((id) => Number.isInteger(id))
: []
if (!allowedContracttypes.includes(requestedContracttype.id)) {
return reply.code(400).send({ error: "Dieser Vertragstyp steht für diesen Vertrag nicht zur Auswahl" })
}
const [currentContracttype] = contract.contracttype
? await server.db
.select({
id: contracttypes.id,
name: contracttypes.name,
})
.from(contracttypes)
.where(and(
eq(contracttypes.id, contract.contracttype),
eq(contracttypes.tenant, req.user?.tenant_id)
))
.limit(1)
: []
const message = normalizeMessage(req.body.message)
const oldName = currentContracttype?.name || "Ohne Vertragstyp"
const newName = requestedContracttype.name
const text = appendMessage(
`Kundenportal: Änderung des Vertragstyps von "${oldName}" auf "${newName}" angefragt.`,
message
)
await insertHistoryItem(server, {
tenant_id: req.user?.tenant_id,
created_by: req.user?.user_id || null,
entity: "contracts",
entityId: contract.id,
action: "unchanged",
oldVal: { contracttype: contract.contracttype, name: oldName },
newVal: { contracttype: requestedContracttype.id, name: newName },
text,
})
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
})
server.post<{
Params: { id: string }
Body: { requestedEndDate?: string; message?: string }
}>("/portal/contracts/:id/cancellation-request", {
schema: {
tags: ["Portal"],
summary: "Request contract cancellation from customer portal",
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
},
},
body: {
type: "object",
required: ["requestedEndDate"],
properties: {
requestedEndDate: { type: "string" },
message: { type: "string", nullable: true },
},
},
},
}, async (req, reply) => {
const contractId = Number(req.params.id)
const requestedEndDate = typeof req.body.requestedEndDate === "string"
? req.body.requestedEndDate.trim()
: ""
if (!Number.isInteger(contractId) || !requestedEndDate) {
return reply.code(400).send({ error: "Ungültige Anfrage" })
}
const parsedDate = new Date(requestedEndDate)
if (Number.isNaN(parsedDate.getTime())) {
return reply.code(400).send({ error: "Ungültiges Kündigungsdatum" })
}
const contract = await getPortalContract(server, req, contractId)
if (!contract) {
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
}
const message = normalizeMessage(req.body.message)
const text = appendMessage(
`Kundenportal: Kündigung zum ${formatDateForHistory(requestedEndDate)} angefragt.`,
message
)
await insertHistoryItem(server, {
tenant_id: req.user?.tenant_id,
created_by: req.user?.user_id || null,
entity: "contracts",
entityId: contract.id,
action: "unchanged",
newVal: { requestedEndDate },
text,
})
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
})
}

View File

@@ -4,6 +4,16 @@ import { eq, and } from "drizzle-orm";
import { import {
authProfiles, authProfiles,
} from "../../db/schema"; } from "../../db/schema";
import {
loadProfileWithBranches,
resolveTenantBranchIds,
syncProfileBranches,
} from "../utils/profileBranches";
import {
enrichProfilesWithTeams,
resolveTenantTeamIds,
syncProfileTeams,
} from "../utils/profileTeams";
export default async function authProfilesRoutes(server: FastifyInstance) { export default async function authProfilesRoutes(server: FastifyInstance) {
@@ -19,22 +29,16 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
return reply.code(400).send({ error: "No tenant selected" }); return reply.code(400).send({ error: "No tenant selected" });
} }
const rows = await server.db const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
.select() const [profile] = profileWithBranches
.from(authProfiles) ? await enrichProfilesWithTeams(server, [profileWithBranches])
.where( : [null]
and(
eq(authProfiles.id, id),
eq(authProfiles.tenant_id, tenantId)
)
)
.limit(1);
if (!rows.length) { if (!profile) {
return reply.code(404).send({ error: "User not found or not in tenant" }); return reply.code(404).send({ error: "User not found or not in tenant" });
} }
return rows[0]; return profile;
} catch (error) { } catch (error) {
console.error("GET /profiles/:id ERROR:", error); console.error("GET /profiles/:id ERROR:", error);
@@ -48,7 +52,8 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
// ❌ Systemfelder entfernen // ❌ Systemfelder entfernen
const forbidden = [ const forbidden = [
"id", "user_id", "tenant_id", "created_at", "updated_at", "id", "user_id", "tenant_id", "created_at", "updated_at",
"updatedAt", "updatedBy", "old_profile_id", "full_name" "updatedAt", "updatedBy", "old_profile_id", "full_name",
"branch"
] ]
forbidden.forEach(f => delete cleaned[f]) forbidden.forEach(f => delete cleaned[f])
@@ -89,8 +94,32 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
// Clean + Normalize // Clean + Normalize
body = sanitizeProfileUpdate(body) body = sanitizeProfileUpdate(body)
const { primaryBranchId, branchIds } = await resolveTenantBranchIds(
server,
tenantId,
[
...(Array.isArray(body.branch_ids) ? body.branch_ids : []),
...(Array.isArray(body.branches) ? body.branches : []),
],
body.branch_id ?? body.branch?.id ?? null
)
const teamIds = await resolveTenantTeamIds(
server,
tenantId,
[
...(Array.isArray(body.team_ids) ? body.team_ids : []),
...(Array.isArray(body.teams) ? body.teams : []),
],
)
delete body.branch_ids
delete body.branches
delete body.team_ids
delete body.teams
const updateData = { const updateData = {
...body, ...body,
branch_id: primaryBranchId,
updatedAt: new Date(), updatedAt: new Date(),
updatedBy: userId updatedBy: userId
} }
@@ -110,10 +139,23 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
return reply.code(404).send({ error: "User not found or not in tenant" }) return reply.code(404).send({ error: "User not found or not in tenant" })
} }
return updated[0] await syncProfileBranches(server, id, branchIds, userId)
await syncProfileTeams(server, id, teamIds, userId)
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
const [profile] = profileWithBranches
? await enrichProfilesWithTeams(server, [profileWithBranches])
: [null]
return profile || updated[0]
} catch (err) { } catch (err) {
console.error("PUT /profiles/:id ERROR:", err) console.error("PUT /profiles/:id ERROR:", err)
if (err instanceof Error && ["INVALID_BRANCH_SELECTION", "INVALID_PRIMARY_BRANCH"].includes(err.message)) {
return reply.code(400).send({ error: "Ungültige Niederlassungsauswahl" })
}
if (err instanceof Error && err.message === "INVALID_TEAM_SELECTION") {
return reply.code(400).send({ error: "Ungültige Teamauswahl" })
}
return reply.code(500).send({ error: "Internal Server Error" }) return reply.code(500).send({ error: "Internal Server Error" })
} }
}) })

View File

@@ -11,6 +11,7 @@ import {
sql, sql,
} from "drizzle-orm" } from "drizzle-orm"
import { authProfiles, costcentres } from "../../../db/schema";
import { resourceConfig } from "../../utils/resource.config"; import { resourceConfig } from "../../utils/resource.config";
import { useNextNumberRangeNumber } from "../../utils/functions"; import { useNextNumberRangeNumber } from "../../utils/functions";
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history"; import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
@@ -18,6 +19,9 @@ import { diffObjects } from "../../utils/diff";
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"; import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
import { decrypt, encrypt } from "../../utils/crypt"; import { decrypt, encrypt } from "../../utils/crypt";
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments", "contracttypes"])
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
// ------------------------------------------------------------- // -------------------------------------------------------------
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen) // SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
// ------------------------------------------------------------- // -------------------------------------------------------------
@@ -130,12 +134,92 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any)
return whereCond return whereCond
} }
async function getPortalCustomerId(server: FastifyInstance, req: any) {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
function applyPortalScope(resource: string, table: any, whereCond: any, portalCustomerId: number | null) {
if (!portalCustomerId) return whereCond
if (!PORTAL_ALLOWED_RESOURCES.has(resource)) {
return null
}
if (resource === "customers") {
return and(whereCond, eq(table.id, portalCustomerId))
}
if (resource === "contracts") {
return and(whereCond, eq(table.customer, portalCustomerId))
}
if (resource === "createddocuments") {
return and(
whereCond,
eq(table.customer, portalCustomerId),
eq(table.availableInPortal, true),
inArray(table.type, PORTAL_VISIBLE_DOCUMENT_TYPES)
)
}
return whereCond
}
function sanitizePortalCustomerUpdate(payload: Record<string, any>) {
const nextInfoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
return {
name: payload.name,
firstname: payload.firstname,
lastname: payload.lastname,
salutation: payload.salutation,
title: payload.title,
nameAddition: payload.nameAddition,
infoData: nextInfoData,
}
}
function getTenantColumn(resource: string, table: any) { function getTenantColumn(resource: string, table: any) {
const config = resourceConfig[resource] const config = resourceConfig[resource]
const tenantKey = config?.tenantKey || "tenant" const tenantKey = config?.tenantKey || "tenant"
return table[tenantKey] return table[tenantKey]
} }
function getRelationConfig(relation: string) {
const candidateKeys = [
relation,
`${relation}s`,
]
if (relation.endsWith("y")) {
candidateKeys.push(`${relation.slice(0, -1)}ies`)
}
if (/(s|x|z|ch|sh)$/.test(relation)) {
candidateKeys.push(`${relation}es`)
}
for (const key of candidateKeys) {
if (resourceConfig[key]) return resourceConfig[key]
}
return null
}
function isDateLikeField(key: string) { function isDateLikeField(key: string) {
if (key === "deliveryDateType") return false if (key === "deliveryDateType") return false
if (key.includes("_at") || key.endsWith("At")) return true if (key.includes("_at") || key.endsWith("At")) return true
@@ -176,6 +260,59 @@ function validateMemberPayload(payload: Record<string, any>) {
return null return null
} }
async function validateCostCentreParent(
server: FastifyInstance,
tenantId: number,
costCentreId: string | null,
parentCostcentreId: string | null
) {
if (!parentCostcentreId) {
return null
}
const hierarchyRows = await server.db
.select({
id: costcentres.id,
parentCostcentre: costcentres.parentCostcentre,
})
.from(costcentres)
.where(eq(costcentres.tenant, tenantId))
const hierarchyMap = new Map(
hierarchyRows.map((row) => [row.id, row.parentCostcentre || null])
)
if (!hierarchyMap.has(parentCostcentreId)) {
return "Die übergeordnete Kostenstelle wurde nicht gefunden."
}
if (costCentreId && parentCostcentreId === costCentreId) {
return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben."
}
if (!costCentreId) {
return null
}
let currentParentId: string | null = parentCostcentreId
const visited = new Set<string>()
while (currentParentId) {
if (currentParentId === costCentreId) {
return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen."
}
if (visited.has(currentParentId)) {
break
}
visited.add(currentParentId)
currentParentId = hierarchyMap.get(currentParentId) || null
}
return null
}
function maskIban(iban: string) { function maskIban(iban: string) {
if (!iban) return "" if (!iban) return ""
const cleaned = iban.replace(/\s+/g, "") const cleaned = iban.replace(/\s+/g, "")
@@ -250,18 +387,23 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!config) { if (!config) {
return reply.code(404).send({ error: "Unknown resource" }) return reply.code(404).send({ error: "Unknown resource" })
} }
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = config.table const table = config.table
const tenantColumn = getTenantColumn(resource, table) const tenantColumn = getTenantColumn(resource, table)
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
whereCond = applyResourceWhereFilters(resource, table, whereCond) whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
let q = server.db.select().from(table).$dynamic() let q = server.db.select().from(table).$dynamic()
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]) const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
if (config.mtoLoad) { if (config.mtoLoad) {
config.mtoLoad.forEach(rel => { config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel] const relConfig = getRelationConfig(rel)
if (relConfig) { if (relConfig) {
const relTable = relConfig.table const relTable = relConfig.table
@@ -307,7 +449,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))]; ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
}) })
for await (const rel of config.mtoLoad) { for await (const rel of config.mtoLoad) {
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel]; const relConf = getRelationConfig(rel)
if (!relConf) continue
const relTab = relConf.table const relTab = relConf.table
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : [] lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i])); maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
@@ -358,6 +501,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!config) { if (!config) {
return reply.code(404).send({ error: "Unknown resource" }); return reply.code(404).send({ error: "Unknown resource" });
} }
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = config.table; const table = config.table;
const { queryConfig } = req; const { queryConfig } = req;
@@ -367,6 +514,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const tenantColumn = getTenantColumn(resource, table); const tenantColumn = getTenantColumn(resource, table);
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined; let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
whereCond = applyResourceWhereFilters(resource, table, whereCond) whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]); const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])]; const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
const parsedFilters: Array<{ key: string; value: any }> = [] const parsedFilters: Array<{ key: string; value: any }> = []
@@ -376,7 +524,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (config.mtoLoad) { if (config.mtoLoad) {
config.mtoLoad.forEach(rel => { config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel]; const relConfig = getRelationConfig(rel)
if (relConfig) { if (relConfig) {
const relTable = relConfig.table; const relTable = relConfig.table;
@@ -457,7 +605,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
let distinctQuery = server.db.select({ v: col }).from(table).$dynamic(); let distinctQuery = server.db.select({ v: col }).from(table).$dynamic();
if (config.mtoLoad) { if (config.mtoLoad) {
config.mtoLoad.forEach(rel => { config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel]; const relConfig = getRelationConfig(rel)
if (!relConfig) return; if (!relConfig) return;
const relTable = relConfig.table; const relTable = relConfig.table;
if (relTable !== table) { if (relTable !== table) {
@@ -467,6 +615,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
} }
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond) distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
if (search) { if (search) {
const searchCond = buildSearchCondition(searchCols, search.trim()) const searchCond = buildSearchCondition(searchCols, search.trim())
@@ -496,7 +645,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))]; ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
}); });
for await (const rel of config.mtoLoad) { for await (const rel of config.mtoLoad) {
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel]; const relConf = getRelationConfig(rel)
if (!relConf) continue
const relTab = relConf.table; const relTab = relConf.table;
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []; lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : [];
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i])); maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
@@ -547,10 +697,15 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean } const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = resourceConfig[resource].table const table = resourceConfig[resource].table
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId)) let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
whereCond = applyResourceWhereFilters(resource, table, whereCond) whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const projRows = await server.db const projRows = await server.db
.select() .select()
@@ -567,7 +722,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (resourceConfig[resource].mtoLoad) { if (resourceConfig[resource].mtoLoad) {
for await (const relation of resourceConfig[resource].mtoLoad) { for await (const relation of resourceConfig[resource].mtoLoad) {
if (data[relation]) { if (data[relation]) {
const relConf = resourceConfig[relation + "s"] || resourceConfig[relation]; const relConf = getRelationConfig(relation)
if (!relConf) continue
const relTable = relConf.table const relTable = relConf.table
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation])) const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
data[relation] = relData[0] || null data[relation] = relData[0] || null
@@ -600,6 +756,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
try { try {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" }); if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
const { resource } = req.params as { resource: string }; const { resource } = req.params as { resource: string };
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId) {
return reply.code(403).send({ error: "Forbidden" })
}
if (resource === "accounts") { if (resource === "accounts") {
return reply.code(403).send({ error: "Accounts are read-only" }) return reply.code(403).send({ error: "Accounts are read-only" })
} }
@@ -623,6 +783,19 @@ export default async function resourceRoutes(server: FastifyInstance) {
createData = prepared.data! createData = prepared.data!
} }
if (resource === "costcentres") {
const validationError = await validateCostCentreParent(
server,
req.user.tenant_id,
null,
createData.parentCostcentre || null
)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (config.numberRangeHolder && !body[config.numberRangeHolder]) { if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
const numberRangeResource = resource === "members" ? "customers" : resource const numberRangeResource = resource === "members" ? "customers" : resource
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource) const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
@@ -679,8 +852,12 @@ export default async function resourceRoutes(server: FastifyInstance) {
const body = req.body as Record<string, any> const body = req.body as Record<string, any>
const tenantId = req.user?.tenant_id const tenantId = req.user?.tenant_id
const userId = req.user?.user_id const userId = req.user?.user_id
const portalCustomerId = await getPortalCustomerId(server, req)
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" }) if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
if (portalCustomerId && resource !== "customers") {
return reply.code(403).send({ error: "Forbidden" })
}
const table = resourceConfig[resource].table const table = resourceConfig[resource].table
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; } const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
@@ -688,13 +865,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
const [oldRecord] = await server.db const [oldRecord] = await server.db
.select() .select()
.from(table) .from(table)
.where(and(eq(table.id, id), eq(table.tenant, tenantId))) .where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId))
.limit(1) .limit(1)
if (!oldRecord) {
return reply.code(404).send({ error: "Resource not found" })
}
let data: Record<string, any> = { ...body, updated_at: new Date().toISOString(), updated_by: userId } let data: Record<string, any> = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
//@ts-ignore //@ts-ignore
delete data.updatedBy; delete data.updatedAt; delete data.updatedBy; delete data.updatedAt;
if (portalCustomerId) {
data = {
...sanitizePortalCustomerUpdate(data),
updated_at: data.updated_at,
updated_by: data.updated_by,
}
}
if (resource === "members") { if (resource === "members") {
data = normalizeMemberPayload(data) data = normalizeMemberPayload(data)
const validationError = validateMemberPayload(data) const validationError = validateMemberPayload(data)
@@ -713,6 +902,21 @@ export default async function resourceRoutes(server: FastifyInstance) {
} }
} }
if (resource === "costcentres") {
const validationError = await validateCostCentreParent(
server,
tenantId,
oldRecord.id,
Object.prototype.hasOwnProperty.call(data, "parentCostcentre")
? data.parentCostcentre || null
: oldRecord.parentCostcentre || null
)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
Object.keys(data).forEach((key) => { Object.keys(data).forEach((key) => {
const value = data[key] const value = data[key]
const shouldNormalize = const shouldNormalize =

View File

@@ -11,7 +11,7 @@ import {
desc desc
} from "drizzle-orm" } from "drizzle-orm"
import {stafftimeevents} from "../../../db/schema/staff_time_events"; import {stafftimeevents} from "../../../db/schema/staff_time_events";
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service"; import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service"; import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service"; import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
import {z} from "zod"; import {z} from "zod";
@@ -354,7 +354,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
const combinedEvents = [ const combinedEvents = [
...factualEvents, ...factualEvents,
...relatedAdminEvents, ...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime()); ].sort(compareTimeEvents);
// SCHRITT 5: Spans ableiten // SCHRITT 5: Spans ableiten
const derivedSpans = deriveTimeSpans(combinedEvents); const derivedSpans = deriveTimeSpans(combinedEvents);
@@ -424,7 +424,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
const combinedEvents = [ const combinedEvents = [
...factualEvents, ...factualEvents,
...relatedAdminEvents, ...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime()); ].sort(compareTimeEvents);
// SCHRITT 4: Ableiten und Anreichern // SCHRITT 4: Ableiten und Anreichern
const derivedSpans = deriveTimeSpans(combinedEvents); const derivedSpans = deriveTimeSpans(combinedEvents);
@@ -453,4 +453,4 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
} }
}); });
} }

View File

@@ -12,6 +12,8 @@ import {
} from "../../db/schema" } from "../../db/schema"
import {and, desc, eq, inArray} from "drizzle-orm" import {and, desc, eq, inArray} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../utils/profileBranches"
import { enrichProfilesWithTeams } from "../utils/profileTeams"
export default async function tenantRoutes(server: FastifyInstance) { export default async function tenantRoutes(server: FastifyInstance) {
@@ -123,7 +125,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
.where(inArray(authUsers.id, userIds)) .where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden // 3) auth_profiles pro Tenant laden
const profiles = await server.db const profileRows = await server.db
.select() .select()
.from(authProfiles) .from(authProfiles)
.where( .where(
@@ -131,6 +133,8 @@ export default async function tenantRoutes(server: FastifyInstance) {
eq(authProfiles.tenant_id, tenantId), eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds) inArray(authProfiles.user_id, userIds)
)) ))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
const combined = users.map(u => { const combined = users.map(u => {
const profile = profiles.find(p => p.user_id === u.id) const profile = profiles.find(p => p.user_id === u.id)
@@ -160,11 +164,13 @@ export default async function tenantRoutes(server: FastifyInstance) {
const tenantId = req.user?.tenant_id const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const data = await server.db const profileRows = await server.db
.select() .select()
.from(authProfiles) .from(authProfiles)
.where(eq(authProfiles.tenant_id, tenantId)) .where(eq(authProfiles.tenant_id, tenantId))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const data = await enrichProfilesWithTeams(server, profilesWithBranches)
return { data } return { data }
} catch (err) { } catch (err) {

View File

@@ -8,7 +8,7 @@ import { s3 } from "../s3";
import { secrets } from "../secrets"; import { secrets } from "../secrets";
// Drizzle Core Imports // Drizzle Core Imports
import { eq, and, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm"; import { eq, and, or, isNull, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm";
// Tabellen Imports (keine Relations nötig!) // Tabellen Imports (keine Relations nötig!)
import { import {
@@ -136,6 +136,10 @@ export async function buildExportZip(
const CdCustomer = aliasedTable(customers, "cd_customer"); const CdCustomer = aliasedTable(customers, "cd_customer");
const IiVendor = aliasedTable(vendors, "ii_vendor"); const IiVendor = aliasedTable(vendors, "ii_vendor");
const ContraAccount = aliasedTable(accounts, "contra_account");
const ContraVendor = aliasedTable(vendors, "contra_vendor");
const ContraCustomer = aliasedTable(customers, "contra_customer");
const ContraOwnaccount = aliasedTable(ownaccounts, "contra_ownaccount");
const allocRaw = await server.db.select({ const allocRaw = await server.db.select({
allocation: statementallocations, allocation: statementallocations,
@@ -148,11 +152,15 @@ export async function buildExportZip(
acc: accounts, acc: accounts,
direct_vend: vendors, // Direkte Zuordnung an Kreditor direct_vend: vendors, // Direkte Zuordnung an Kreditor
direct_cust: customers, // Direkte Zuordnung an Debitor direct_cust: customers, // Direkte Zuordnung an Debitor
own: ownaccounts own: ownaccounts,
contra_acc: ContraAccount,
contra_vend: ContraVendor,
contra_cust: ContraCustomer,
contra_own: ContraOwnaccount
}) })
.from(statementallocations) .from(statementallocations)
// JOIN 1: Bankstatement (Pflicht, für Datum Filter) // JOIN 1: Bankstatement (optional, manuelle Buchungen haben keinen Bankumsatz)
.innerJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id)) .leftJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
// JOIN 2: Bankaccount (für DATEV Nummer) // JOIN 2: Bankaccount (für DATEV Nummer)
.leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id)) .leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
@@ -169,13 +177,25 @@ export async function buildExportZip(
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id)) .leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id)) .leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id)) .leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
.leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id))
.leftJoin(ContraOwnaccount, eq(statementallocations.contraOwnaccount, ContraOwnaccount.id))
.where(and( .where(and(
eq(statementallocations.tenant, tenantId), eq(statementallocations.tenant, tenantId),
eq(statementallocations.archived, false), eq(statementallocations.archived, false),
// Datum Filter direkt auf dem Bankstatement or(
gte(bankstatements.date, startDate), and(
lte(bankstatements.date, endDate) gte(bankstatements.date, startDate),
lte(bankstatements.date, endDate)
),
and(
isNull(statementallocations.bankstatement),
gte(statementallocations.manualBookingDate, startDate),
lte(statementallocations.manualBookingDate, endDate)
)
)
)); ));
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet // Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
@@ -196,7 +216,11 @@ export async function buildExportZip(
account: r.acc, account: r.acc,
vendor: r.direct_vend, vendor: r.direct_vend,
customer: r.direct_cust, customer: r.direct_cust,
ownaccount: r.own ownaccount: r.own,
contraAccount: r.contra_acc,
contraVendor: r.contra_vend,
contraCustomer: r.contra_cust,
contraOwnaccount: r.contra_own
})); }));
// --- D) Stammdaten Accounts --- // --- D) Stammdaten Accounts ---
@@ -311,8 +335,42 @@ export async function buildExportZip(
}); });
// Bank // Bank
const getManualBookingSide = (alloc: any, side: "debit" | "credit") => {
const prefix = side === "credit" ? "contra" : "";
const account = side === "credit" ? alloc.contraAccount : alloc.account;
const vendor = side === "credit" ? alloc.contraVendor : alloc.vendor;
const customer = side === "credit" ? alloc.contraCustomer : alloc.customer;
const ownaccount = side === "credit" ? alloc.contraOwnaccount : alloc.ownaccount;
const incominginvoice = alloc.manualInvoiceSide === side ? alloc.incominginvoice : null;
if (account) return { number: account.number, name: account.label, type: "Sachkonto" };
if (vendor) return { number: vendor.vendorNumber, name: vendor.name, type: "Kreditor" };
if (customer) return { number: customer.customerNumber, name: customer.name, type: "Debitor" };
if (ownaccount) return { number: ownaccount.number, name: ownaccount.name, type: "Eigenes Konto" };
if (incominginvoice) {
return {
number: incominginvoice.vendor?.vendorNumber || "",
name: `${incominginvoice.reference || "Eingangsbeleg"} ${incominginvoice.vendor?.name || ""}`.trim(),
type: "Eingangsbeleg",
reference: incominginvoice.reference || "",
};
}
return { number: "", name: "", type: prefix };
};
statementallocationsList.forEach(alloc => { statementallocationsList.forEach(alloc => {
const bs = alloc.bankstatement; // durch Mapping verfügbar const bs = alloc.bankstatement; // durch Mapping verfügbar
if(!bs && alloc.manualBookingDate) {
const debit = getManualBookingSide(alloc, "debit");
const credit = getManualBookingSide(alloc, "credit");
const dateManual = dayjs(alloc.manualBookingDate).format("DDMM");
const dateManualFull = dayjs(alloc.manualBookingDate).format("DD.MM.YYYY");
const belegnummer = debit.reference || credit.reference || "";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"S";;;;;${debit.number};${credit.number};"${alloc.datevTaxKey || ""}";${dateManual};"${belegnummer}";;;"${`MB ${debit.number} an ${credit.number} ${escapeString(alloc.description)}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${escapeString(debit.name || credit.name)}";"Kundennummer";"${debit.number}";"Belegnummer";"${belegnummer}";"Leistungsdatum";"${dateManualFull}";"Belegdatum";"${dateManualFull}";;;;;;;;;;"";;;;;;;;Manuelle-Buchung;${alloc.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
return;
}
if(!bs) return; if(!bs) return;
let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S"; let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S";
@@ -425,4 +483,4 @@ export async function buildExportZip(
console.error("DATEV Export Error:", error); console.error("DATEV Export Error:", error);
throw error; throw error;
} }
} }

View File

@@ -12,6 +12,13 @@ export const useNextNumberRangeNumber = async (
tenantId: number, tenantId: number,
numberRange: string numberRange: string
) => { ) => {
const numberRangeFallbacks: Record<string, string> = {
costEstimates: "quotes",
packingSlips: "deliveryNotes",
advanceInvoices: "invoices",
cancellationInvoices: "invoices",
}
const [tenant] = await server.db const [tenant] = await server.db
.select() .select()
.from(tenants) .from(tenants)
@@ -23,11 +30,15 @@ export const useNextNumberRangeNumber = async (
const numberRanges = tenant.numberRanges || {} const numberRanges = tenant.numberRanges || {}
if (!numberRanges[numberRange]) { const resolvedNumberRange = numberRanges[numberRange]
? numberRange
: numberRangeFallbacks[numberRange]
if (!resolvedNumberRange || !numberRanges[resolvedNumberRange]) {
throw new Error(`Number range '${numberRange}' not found`) throw new Error(`Number range '${numberRange}' not found`)
} }
const current = numberRanges[numberRange] const current = numberRanges[resolvedNumberRange]
const usedNumber = const usedNumber =
(current.prefix || "") + (current.prefix || "") +
@@ -37,7 +48,7 @@ export const useNextNumberRangeNumber = async (
const updatedRanges = { const updatedRanges = {
// @ts-ignore // @ts-ignore
...numberRanges, ...numberRanges,
[numberRange]: { [resolvedNumberRange]: {
...current, ...current,
nextNumber: current.nextNumber + 1, nextNumber: current.nextNumber + 1,
}, },

View File

@@ -3,6 +3,7 @@ import { historyitems } from "../../db/schema";
const HISTORY_ENTITY_LABELS: Record<string, string> = { const HISTORY_ENTITY_LABELS: Record<string, string> = {
customers: "Kunden", customers: "Kunden",
contracts: "Verträge",
members: "Mitglieder", members: "Mitglieder",
vendors: "Lieferanten", vendors: "Lieferanten",
projects: "Projekte", projects: "Projekte",
@@ -32,6 +33,7 @@ const HISTORY_ENTITY_LABELS: Record<string, string> = {
incominginvoices: "Eingangsrechnungen", incominginvoices: "Eingangsrechnungen",
files: "Dateien", files: "Dateien",
memberrelations: "Mitgliedsverhältnisse", memberrelations: "Mitgliedsverhältnisse",
teams: "Teams",
} }
export function getHistoryEntityLabel(entity: string) { export function getHistoryEntityLabel(entity: string) {
@@ -62,6 +64,7 @@ export async function insertHistoryItem(
const columnMap: Record<string, string> = { const columnMap: Record<string, string> = {
customers: "customer", customers: "customer",
contracts: "contract",
members: "customer", members: "customer",
vendors: "vendor", vendors: "vendor",
projects: "project", projects: "project",

View File

@@ -0,0 +1,839 @@
import dayjs from "dayjs";
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
import { and, desc, eq, gte } from "drizzle-orm";
import { FastifyInstance } from "fastify";
import { createHash } from "node:crypto";
import {
bankaccounts,
bankstatements,
createddocuments,
incominginvoices,
statementallocations,
tenants,
} from "../../db/schema";
import { secrets } from "./secrets";
type ForecastEventSource = "open_createddocument" | "open_incominginvoice" | "recurring_bankstatement" | "draft_createddocument" | "tax_settlement" | "serial_template";
type TaxEvaluationPeriod = "monthly" | "quarterly" | "yearly";
type ForecastEvent = {
date: string;
amount: number;
label: string;
source: ForecastEventSource;
sourceId?: number | string | null;
recurringKey?: string;
confidence?: number;
};
type TaxBreakdown = {
net19: number;
tax19: number;
net7: number;
tax7: number;
net0: number;
};
type TaxForecastPeriod = {
key: string;
label: string;
range: string;
dueDate: string;
outputTax: number;
inputTax: number;
balance: number;
outputCount: number;
inputCount: number;
};
type RecurringCandidate = {
key?: string;
label: string;
amount: number;
interval: "weekly" | "monthly" | "quarterly" | "yearly";
nextDate: string;
confidence: number;
evidence: string;
};
const AiRecurringFormat = z.object({
candidates: z.array(z.object({
label: z.string(),
amount: z.number(),
interval: z.enum(["weekly", "monthly", "quarterly", "yearly"]),
nextDate: z.string(),
confidence: z.number().min(0).max(1),
evidence: z.string(),
})),
});
const FORECAST_DAYS = 90;
const HISTORY_MONTHS = 12;
const roundMoney = (value: number) => Number(Number(value || 0).toFixed(2));
const createZeroTaxBreakdown = (): TaxBreakdown => ({
net19: 0,
tax19: 0,
net7: 0,
tax7: 0,
net0: 0,
});
const normalizeText = (value: unknown) => String(value || "")
.toLowerCase()
.replace(/[^a-z0-9äöüß]+/gi, " ")
.replace(/\s+/g, " ")
.trim();
const normalizeTaxEvaluationPeriod = (value?: string | null): TaxEvaluationPeriod => {
if (value === "quarterly" || value === "yearly") return value;
return "monthly";
};
const isTaxFreeDocument = (taxType?: string | null) => {
return ["13b UStG", "19 UStG", "12.3 UStG"].includes(String(taxType || ""));
};
const getTaxEvaluationPeriodBounds = (
referenceDate: dayjs.ConfigType,
period: TaxEvaluationPeriod
) => {
const base = dayjs(referenceDate);
if (period === "yearly") {
return {
start: base.startOf("year"),
end: base.endOf("year"),
};
}
if (period === "quarterly") {
const quarterStartMonth = Math.floor(base.month() / 3) * 3;
const start = base.month(quarterStartMonth).startOf("month");
return {
start,
end: start.add(2, "month").endOf("month"),
};
}
return {
start: base.startOf("month"),
end: base.endOf("month"),
};
};
const shiftTaxEvaluationPeriodStart = (
periodStart: dayjs.ConfigType,
period: TaxEvaluationPeriod,
offset: number
) => {
const base = dayjs(periodStart);
if (period === "yearly") return base.add(offset, "year").startOf("year");
if (period === "quarterly") return base.add(offset * 3, "month").startOf("month");
return base.add(offset, "month").startOf("month");
};
const formatTaxEvaluationPeriodLabel = (
periodStart: dayjs.ConfigType,
period: TaxEvaluationPeriod
) => {
const { start } = getTaxEvaluationPeriodBounds(periodStart, period);
if (period === "yearly") {
return start.format("YYYY");
}
if (period === "quarterly") {
return `Q${Math.floor(start.month() / 3) + 1} ${start.format("YYYY")}`;
}
return start.format("MMMM YYYY");
};
const formatTaxEvaluationPeriodRange = (
periodStart: dayjs.ConfigType,
period: TaxEvaluationPeriod
) => {
const { start, end } = getTaxEvaluationPeriodBounds(periodStart, period);
return `${start.format("DD.MM.YYYY")} - ${end.format("DD.MM.YYYY")}`;
};
const getTaxSettlementDate = (periodEnd: dayjs.Dayjs) => {
return periodEnd.add(1, "month").date(10).startOf("day");
};
const getRecurringKey = (candidate: RecurringCandidate) => {
const rawKey = [
Math.sign(candidate.amount),
Math.round(Math.abs(candidate.amount) * 100),
normalizeText(candidate.label),
candidate.interval,
].join("|");
return createHash("sha1").update(rawKey).digest("hex").slice(0, 16);
};
const addInterval = (date: dayjs.Dayjs, interval: RecurringCandidate["interval"]) => {
if (interval === "weekly") return date.add(1, "week");
if (interval === "quarterly") return date.add(3, "month");
if (interval === "yearly") return date.add(1, "year");
return date.add(1, "month");
};
const addSerialInterval = (date: dayjs.Dayjs, interval: string) => {
if (interval === "wöchentlich") return date.add(1, "week");
if (interval === "2 - wöchentlich") return date.add(2, "week");
if (interval === "vierteljährlich") return date.add(3, "month");
if (interval === "halbjährlich") return date.add(6, "month");
if (interval === "jährlich") return date.add(1, "year");
return date.add(1, "month");
};
const getStatementPartner = (statement: any) => {
return statement.amount < 0
? statement.credName || statement.debName || statement.text || "Regelmäßige Ausgabe"
: statement.debName || statement.credName || statement.text || "Regelmäßige Einnahme";
};
const getCreatedDocumentGrossAmount = (document: any, allDocuments: any[] = []) => {
let totalNet = 0;
let totalTax = 0;
(document.rows || []).forEach((row: any) => {
if (["pagebreak", "title", "text"].includes(row.mode)) return;
const rowNet = Number(
(Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3)
);
const taxPercent = Number(row.taxPercent);
totalNet += rowNet;
totalTax += rowNet * (Number.isFinite(taxPercent) ? taxPercent : 0) / 100;
});
let advancePayments = 0;
(document.usedAdvanceInvoices || []).forEach((advanceInvoiceId: number) => {
const advanceInvoice = allDocuments.find((item) => item.id === advanceInvoiceId);
const advanceRow = advanceInvoice?.rows?.find((row: any) => row.advanceInvoiceData);
if (!advanceRow) return;
advancePayments += Number(advanceRow.price || 0) * ((100 + Number(advanceRow.taxPercent || 0)) / 100);
});
return roundMoney(Number(totalNet.toFixed(2)) + Number(totalTax.toFixed(2)) - advancePayments);
};
const getCreatedDocumentTaxBreakdown = (document: any): TaxBreakdown => {
const breakdown = createZeroTaxBreakdown();
if (!document || isTaxFreeDocument(document.taxType)) {
return breakdown;
}
(document.rows || []).forEach((row: any) => {
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) return;
const quantity = Number(row.quantity || 0);
const price = Number(row.price || 0);
const discountPercent = Number(row.discountPercent || 0);
const taxPercent = Number(row.taxPercent || 0);
const net = Number((quantity * price * (1 - discountPercent / 100)).toFixed(2));
if (!Number.isFinite(net) || net === 0) return;
if (taxPercent === 19) {
breakdown.net19 += net;
breakdown.tax19 += Number((net * 0.19).toFixed(2));
} else if (taxPercent === 7) {
breakdown.net7 += net;
breakdown.tax7 += Number((net * 0.07).toFixed(2));
} else {
breakdown.net0 += net;
}
});
return {
net19: roundMoney(breakdown.net19),
tax19: roundMoney(breakdown.tax19),
net7: roundMoney(breakdown.net7),
tax7: roundMoney(breakdown.tax7),
net0: roundMoney(breakdown.net0),
};
};
const getIncomingInvoiceTaxBreakdown = (invoice: any): TaxBreakdown => {
const breakdown = createZeroTaxBreakdown();
(invoice?.accounts || []).forEach((account: any) => {
const taxType = String(account?.taxType || "");
const amountNet = Number(account?.amountNet || 0);
const amountTax = Number(account?.amountTax || 0);
if (taxType === "19") {
breakdown.net19 += amountNet;
breakdown.tax19 += amountTax;
} else if (taxType === "7") {
breakdown.net7 += amountNet;
breakdown.tax7 += amountTax;
} else {
breakdown.net0 += amountNet;
}
});
return {
net19: roundMoney(breakdown.net19),
tax19: roundMoney(breakdown.tax19),
net7: roundMoney(breakdown.net7),
tax7: roundMoney(breakdown.tax7),
net0: roundMoney(breakdown.net0),
};
};
const getIncomingInvoiceSignedAmount = (invoice: any) => {
const amount = (invoice.accounts || []).reduce((sum: number, account: any) => {
return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0);
}, 0);
return roundMoney(invoice.expense === false ? amount : amount * -1);
};
const getRemainingSignedAmount = (signedAmount: number, allocatedAmount: number) => {
const remainingAbsolute = Math.max(0, Math.abs(Number(signedAmount || 0)) - Math.abs(Number(allocatedAmount || 0)));
if (remainingAbsolute <= 0.01) return 0;
return roundMoney(Math.sign(Number(signedAmount || 0)) * remainingAbsolute);
};
const findCancellationDocumentIds = (documents: any[]) => {
return new Set(
documents
.filter((document) => document.type === "cancellationInvoices" && document.state !== "Entwurf" && !document.archived)
.map((document) => typeof document.createddocument === "object" ? document.createddocument?.id : document.createddocument)
.filter(Boolean)
);
};
const inferInterval = (dates: dayjs.Dayjs[]): RecurringCandidate["interval"] | null => {
if (dates.length < 3) return null;
const gaps = dates
.slice(1)
.map((date, index) => date.diff(dates[index], "day"))
.filter((gap) => gap > 0);
const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / Math.max(gaps.length, 1);
if (averageGap >= 6 && averageGap <= 8) return "weekly";
if (averageGap >= 25 && averageGap <= 35) return "monthly";
if (averageGap >= 80 && averageGap <= 100) return "quarterly";
if (averageGap >= 340 && averageGap <= 390) return "yearly";
return null;
};
const detectRecurringHeuristically = (statements: any[]): RecurringCandidate[] => {
const groups = new Map<string, any[]>();
statements.forEach((statement) => {
const parsedDate = dayjs(statement.valueDate || statement.date);
if (!parsedDate.isValid() || Number(statement.amount || 0) >= 0) return;
const partner = normalizeText(getStatementPartner(statement));
const purpose = normalizeText(statement.text).split(" ").slice(0, 5).join(" ");
const amountBucket = Math.round(Number(statement.amount || 0) * 100);
const key = [Math.sign(amountBucket), Math.abs(amountBucket), partner || purpose].join("|");
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(statement);
});
return [...groups.values()]
.map((group) => {
const sorted = group
.map((statement) => ({ ...statement, parsedDate: dayjs(statement.valueDate || statement.date) }))
.sort((a, b) => a.parsedDate.valueOf() - b.parsedDate.valueOf());
const interval = inferInterval(sorted.map((statement) => statement.parsedDate));
if (!interval) return null;
let next = addInterval(sorted[sorted.length - 1].parsedDate, interval);
while (next.isBefore(dayjs(), "day")) {
next = addInterval(next, interval);
}
const amount = roundMoney(sorted.reduce((sum, statement) => sum + Number(statement.amount || 0), 0) / sorted.length);
const partner = getStatementPartner(sorted[sorted.length - 1]);
return {
label: partner,
amount,
interval,
nextDate: next.format("YYYY-MM-DD"),
confidence: Math.min(0.9, 0.55 + sorted.length * 0.08),
evidence: `${sorted.length} ähnliche Bankbewegungen erkannt`,
};
})
.filter(Boolean) as RecurringCandidate[];
};
const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise<RecurringCandidate[]> => {
if (!secrets.OPENAI_API_KEY || statements.length < 6) return [];
const openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY });
const compactStatements = statements.slice(0, 220).map((statement) => ({
date: statement.valueDate || statement.date,
amount: roundMoney(Number(statement.amount || 0)),
partner: getStatementPartner(statement),
text: String(statement.text || "").slice(0, 160),
}));
try {
const completion = await openai.chat.completions.parse({
model: "gpt-4o",
store: true,
response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"),
messages: [
{
role: "system",
content: "Du erkennst wiederkehrende Bankbewegungen für eine Liquiditätsprognose. Gib nur Muster zurück, die durch die gelieferten Umsätze plausibel belegt sind.",
},
{
role: "user",
content: JSON.stringify({
today: dayjs().format("YYYY-MM-DD"),
horizonDays: FORECAST_DAYS,
bankStatements: compactStatements,
rules: [
"amount ist aus Sicht des Bankkontos: negative Werte sind Auszahlungen, positive Werte Einzahlungen.",
"nextDate muss in der Zukunft liegen.",
"Nutze keine einmaligen oder unsicheren Bewegungen.",
],
}),
},
],
});
return (completion.choices[0].message.parsed?.candidates || [])
.filter((candidate) => dayjs(candidate.nextDate).isValid())
.filter((candidate) => Number(candidate.amount || 0) < 0)
.map((candidate) => ({
...candidate,
amount: roundMoney(candidate.amount),
confidence: Math.max(0, Math.min(1, candidate.confidence)),
}));
} catch (error) {
server.log.warn("KI-Erkennung für regelmäßige Bankbewegungen konnte nicht ausgeführt werden.");
server.log.warn(error);
return [];
}
};
const mergeRecurringCandidates = (heuristic: RecurringCandidate[], ai: RecurringCandidate[]) => {
const merged = new Map<string, RecurringCandidate>();
[...heuristic, ...ai].forEach((candidate) => {
const key = [
Math.sign(candidate.amount),
Math.round(Math.abs(candidate.amount) * 100),
normalizeText(candidate.label).slice(0, 32),
candidate.interval,
].join("|");
const existing = merged.get(key);
if (!existing || candidate.confidence > existing.confidence) {
merged.set(key, candidate);
}
});
return [...merged.values()]
.map((candidate) => ({
...candidate,
key: getRecurringKey(candidate),
}))
.filter((candidate) => Number(candidate.amount || 0) < 0)
.filter((candidate) => Math.abs(candidate.amount) >= 1)
.sort((a, b) => a.nextDate.localeCompare(b.nextDate));
};
const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.Dayjs): ForecastEvent[] => {
const events: ForecastEvent[] = [];
recurring.forEach((candidate) => {
let date = dayjs(candidate.nextDate);
let guard = 0;
while (date.isValid() && !date.isAfter(endDate, "day") && guard < 40) {
events.push({
date: date.format("YYYY-MM-DD"),
amount: roundMoney(candidate.amount),
label: candidate.label,
source: "recurring_bankstatement",
recurringKey: candidate.key,
confidence: candidate.confidence,
});
date = addInterval(date, candidate.interval);
guard += 1;
}
});
return events;
};
const buildTaxForecastPeriods = (
documents: any[],
incomingInvoices: any[],
periodType: TaxEvaluationPeriod,
today: dayjs.Dayjs,
endDate: dayjs.Dayjs
) => {
const currentBounds = getTaxEvaluationPeriodBounds(today, periodType);
const periods: TaxForecastPeriod[] = [];
let offset = 0;
while (offset < 12) {
const periodStart = shiftTaxEvaluationPeriodStart(currentBounds.start, periodType, offset);
const bounds = getTaxEvaluationPeriodBounds(periodStart, periodType);
const dueDate = getTaxSettlementDate(bounds.end);
if (dueDate.isAfter(endDate, "day")) break;
const outputDocs = documents.filter((document) => {
if (document?.state !== "Gebucht") return false;
if (!["invoices", "advanceInvoices", "cancellationInvoices"].includes(document?.type)) return false;
const date = dayjs(document.documentDate);
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
});
const inputDocs = incomingInvoices.filter((invoice) => {
if (invoice?.state !== "Gebucht" || !invoice?.date) return false;
const date = dayjs(invoice.date);
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
});
const outputTax = roundMoney(outputDocs.reduce((sum, document) => {
const breakdown = getCreatedDocumentTaxBreakdown(document);
return sum + breakdown.tax19 + breakdown.tax7;
}, 0));
const inputTax = roundMoney(inputDocs.reduce((sum, invoice) => {
const breakdown = getIncomingInvoiceTaxBreakdown(invoice);
return sum + breakdown.tax19 + breakdown.tax7;
}, 0));
const balance = roundMoney(outputTax - inputTax);
periods.push({
key: bounds.start.format("YYYY-MM-DD"),
label: formatTaxEvaluationPeriodLabel(bounds.start, periodType),
range: formatTaxEvaluationPeriodRange(bounds.start, periodType),
dueDate: dueDate.format("YYYY-MM-DD"),
outputTax,
inputTax,
balance,
outputCount: outputDocs.length,
inputCount: inputDocs.length,
});
offset += 1;
}
return periods;
};
const buildSerialTemplateEvents = (
documents: any[],
today: dayjs.Dayjs,
endDate: dayjs.Dayjs
) => {
const events: ForecastEvent[] = [];
documents
.filter((document) => document.type === "serialInvoices")
.filter((document) => document.serialConfig?.active)
.forEach((document) => {
const firstExecution = dayjs(document.serialConfig?.firstExecution);
const executionUntil = dayjs(document.serialConfig?.executionUntil);
if (!firstExecution.isValid() || !executionUntil.isValid()) return;
const amount = getCreatedDocumentGrossAmount(document, documents);
if (amount <= 0.01) return;
let executionDate = firstExecution.startOf("day");
let guard = 0;
while (executionDate.isBefore(today, "day") && guard < 240) {
executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
guard += 1;
}
while (
executionDate.isValid()
&& !executionDate.isAfter(executionUntil, "day")
&& !executionDate.isAfter(endDate, "day")
&& guard < 400
) {
const dueDate = executionDate.add(Number(document.paymentDays || 0), "day");
events.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount,
label: document.documentNumber || document.title || `Serienvorlage ${document.id}`,
source: "serial_template",
sourceId: document.id,
confidence: 0.75,
});
executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
guard += 1;
}
});
return events.sort((a, b) => a.date.localeCompare(b.date));
};
export const generateLiquidityForecast = async (
server: FastifyInstance,
tenantId: number,
ignoredRecurringKeys: string[] = []
) => {
const today = dayjs().startOf("day");
const endDate = today.add(FORECAST_DAYS, "day");
const historyStart = today.subtract(HISTORY_MONTHS, "month").format("YYYY-MM-DD");
const [accounts, statements, documents, incomingInvoices, allocations, tenantSettings] = await Promise.all([
server.db
.select()
.from(bankaccounts)
.where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
server.db
.select()
.from(bankstatements)
.where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), gte(bankstatements.valueDate, historyStart)))
.orderBy(desc(bankstatements.valueDate)),
server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.tenant, tenantId), eq(createddocuments.archived, false))),
server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.tenant, tenantId), eq(incominginvoices.archived, false))),
server.db
.select()
.from(statementallocations)
.where(and(eq(statementallocations.tenant, tenantId), eq(statementallocations.archived, false))),
server.db
.select({
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1),
]);
const activeAccounts = accounts.filter((account) => !account.archived);
const activeStatements = statements.filter((statement) => !statement.archived);
const activeDocuments = documents.filter((document) => !document.archived);
const activeIncomingInvoices = incomingInvoices.filter((invoice) => !invoice.archived);
const activeDocumentIds = new Set(activeDocuments.map((document) => document.id));
const activeIncomingInvoiceIds = new Set(activeIncomingInvoices.map((invoice) => invoice.id));
const taxPeriodType = normalizeTaxEvaluationPeriod(tenantSettings[0]?.taxEvaluationPeriod);
const activeAllocations = allocations.filter((allocation) => {
if (allocation.archived) return false;
if (allocation.createddocument && !activeDocumentIds.has(allocation.createddocument)) return false;
if (allocation.incominginvoice && !activeIncomingInvoiceIds.has(allocation.incominginvoice)) return false;
return true;
});
const startingBalance = roundMoney(
activeAccounts
.filter((account) => !account.expired)
.reduce((sum, account) => sum + Number(account.balance || 0), 0)
);
const allocationByDocument = new Map<number, number>();
const allocationByIncomingInvoice = new Map<number, number>();
activeAllocations.forEach((allocation) => {
if (allocation.createddocument) {
allocationByDocument.set(
allocation.createddocument,
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0))
);
}
if (allocation.incominginvoice) {
allocationByIncomingInvoice.set(
allocation.incominginvoice,
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
);
}
});
const cancelledDocumentIds = findCancellationDocumentIds(activeDocuments);
const openEvents: ForecastEvent[] = [];
const draftEvents: ForecastEvent[] = [];
activeDocuments
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
.filter((document) => document.state === "Gebucht" && !cancelledDocumentIds.has(document.id))
.forEach((document) => {
const total = getCreatedDocumentGrossAmount(document, activeDocuments);
const openAmount = roundMoney(total - (allocationByDocument.get(document.id) || 0));
if (openAmount <= 0.01) return;
const dueDate = dayjs(document.documentDate).isValid()
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
: today;
openEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: openAmount,
label: document.documentNumber || document.title || `Ausgangsbeleg ${document.id}`,
source: "open_createddocument",
sourceId: document.id,
confidence: 1,
});
});
activeDocuments
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
.filter((document) => document.state === "Entwurf" && !cancelledDocumentIds.has(document.id))
.forEach((document) => {
const total = getCreatedDocumentGrossAmount(document, activeDocuments);
if (total <= 0.01) return;
const dueDate = dayjs(document.documentDate).isValid()
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
: today;
draftEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: total,
label: document.documentNumber || document.title || `Rechnungsentwurf ${document.id}`,
source: "draft_createddocument",
sourceId: document.id,
confidence: 0.35,
});
});
activeIncomingInvoices
.filter((invoice) => invoice.state === "Gebucht" || invoice.state === "Vorbereitet")
.filter((invoice) => !invoice.paid)
.forEach((invoice) => {
const signedAmount = getIncomingInvoiceSignedAmount(invoice);
const openAmount = getRemainingSignedAmount(signedAmount, allocationByIncomingInvoice.get(invoice.id) || 0);
if (Math.abs(openAmount) <= 0.01) return;
const dueDate = dayjs(invoice.dueDate || invoice.date).isValid()
? dayjs(invoice.dueDate || invoice.date)
: today;
openEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: openAmount,
label: invoice.reference || invoice.description || `Eingangsbeleg ${invoice.id}`,
source: "open_incominginvoice",
sourceId: invoice.id,
confidence: invoice.state === "Gebucht" ? 1 : 0.8,
});
});
const heuristicRecurring = detectRecurringHeuristically(activeStatements);
const aiRecurring = await detectRecurringWithAi(server, activeStatements);
const ignoredRecurringKeySet = new Set(ignoredRecurringKeys.filter(Boolean));
const recurring = mergeRecurringCandidates(heuristicRecurring, aiRecurring)
.filter((candidate) => !candidate.key || !ignoredRecurringKeySet.has(candidate.key));
const taxPeriods = buildTaxForecastPeriods(activeDocuments, activeIncomingInvoices, taxPeriodType, today, endDate);
const taxEvents: ForecastEvent[] = taxPeriods
.filter((period) => Math.abs(period.balance) > 0.01)
.map((period) => ({
date: period.dueDate,
amount: roundMoney(period.balance * -1),
label: `USt ${period.label}`,
source: "tax_settlement" as const,
sourceId: period.key,
confidence: 0.95,
}));
const serialTemplateEvents = buildSerialTemplateEvents(activeDocuments, today, endDate);
const events = [
...openEvents,
...taxEvents,
...serialTemplateEvents,
...expandRecurringEvents(recurring, endDate),
].filter((event) => {
const date = dayjs(event.date);
return date.isValid() && !date.isAfter(endDate, "day");
});
const dailyEvents = new Map<string, ForecastEvent[]>();
events.forEach((event) => {
if (!dailyEvents.has(event.date)) dailyEvents.set(event.date, []);
dailyEvents.get(event.date)!.push(event);
});
let runningBalance = startingBalance;
const points = [];
for (let offset = 0; offset <= FORECAST_DAYS; offset += 1) {
const date = today.add(offset, "day").format("YYYY-MM-DD");
const dayEvents = dailyEvents.get(date) || [];
const income = roundMoney(dayEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
const expense = roundMoney(dayEvents.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
runningBalance = roundMoney(runningBalance + income + expense);
points.push({
date,
balance: runningBalance,
income,
expense,
events: dayEvents.sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)),
});
}
const lowestPoint = points.reduce((lowest, point) => point.balance < lowest.balance ? point : lowest, points[0]);
const totalIncome = roundMoney(events.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
const totalExpense = roundMoney(events.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
const draftIncome = roundMoney(draftEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
return {
generatedAt: new Date().toISOString(),
horizonDays: FORECAST_DAYS,
startingBalance,
endingBalance: points[points.length - 1]?.balance || startingBalance,
lowestBalance: lowestPoint?.balance || startingBalance,
lowestBalanceDate: lowestPoint?.date || today.format("YYYY-MM-DD"),
totalIncome,
totalExpense,
accounts: activeAccounts.map((account) => ({
id: account.id,
name: account.name,
iban: account.iban,
balance: roundMoney(Number(account.balance || 0)),
expired: account.expired,
syncedAt: account.syncedAt,
})),
recurring,
events: events.sort((a, b) => a.date.localeCompare(b.date)),
draftEvents: draftEvents.sort((a, b) => a.date.localeCompare(b.date)),
draftIncome,
tax: {
periodType: taxPeriodType,
periods: taxPeriods,
totalBalance: roundMoney(taxPeriods.reduce((sum, period) => sum + period.balance, 0)),
},
points,
ai: {
enabled: Boolean(secrets.OPENAI_API_KEY),
candidates: aiRecurring.length,
},
};
};

View File

@@ -58,6 +58,8 @@ const getDuration = (time) => {
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => { export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => {
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
const isPackingSlip = invoiceData?.type === "packingSlips"
const genPDF = async (invoiceData, backgroundSourceBuffer) => { const genPDF = async (invoiceData, backgroundSourceBuffer) => {
const pdfDoc = await PDFDocument.create() const pdfDoc = await PDFDocument.create()
@@ -347,7 +349,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold font: fontBold
}) })
if (invoiceData.type !== "deliveryNotes") { if (isPackingSlip) {
pages[pageCounter - 1].drawText("Check", {
...getCoordinatesForPDFLib(180, 137, page1),
size: 12,
color: rgb(0, 0, 0),
lineHeight: 12,
opacity: 1,
maxWidth: 240,
font: fontBold
})
}
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawText("Steuer", { pages[pageCounter - 1].drawText("Steuer", {
...getCoordinatesForPDFLib(135, 137, page1), ...getCoordinatesForPDFLib(135, 137, page1),
size: 12, size: 12,
@@ -414,9 +428,21 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
maxWidth: 240 maxWidth: 240
}) })
if (isPackingSlip) {
pages[pageCounter - 1].drawRectangle({
...getCoordinatesForPDFLib(182, rowHeight + 1, page1),
width: 12,
height: 12,
borderColor: rgb(0, 0, 0),
borderWidth: 0.8,
opacity: 1,
borderOpacity: 1,
})
}
let rowTextLines = 0 let rowTextLines = 0
if (invoiceData.type !== "deliveryNotes") { if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 35).join("\n"), { pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 35).join("\n"), {
...getCoordinatesForPDFLib(52, rowHeight, page1), ...getCoordinatesForPDFLib(52, rowHeight, page1),
size: 10, size: 10,
@@ -428,7 +454,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
rowTextLines = splitStringBySpace(row.text, 35).length rowTextLines = splitStringBySpace(row.text, 35).length
} else { } else {
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 80).join("\n"), { pages[pageCounter - 1].drawText(splitStringBySpace(row.text, isPackingSlip ? 68 : 80).join("\n"), {
...getCoordinatesForPDFLib(52, rowHeight, page1), ...getCoordinatesForPDFLib(52, rowHeight, page1),
size: 10, size: 10,
color: rgb(0, 0, 0), color: rgb(0, 0, 0),
@@ -437,13 +463,13 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold font: fontBold
}) })
rowTextLines = splitStringBySpace(row.text, 80).length rowTextLines = splitStringBySpace(row.text, isPackingSlip ? 68 : 80).length
} }
let rowDescriptionLines = 0 let rowDescriptionLines = 0
if (row.descriptionText) { if (row.descriptionText) {
if (invoiceData.type !== "deliveryNotes") { if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
rowDescriptionLines = splitStringBySpace(row.descriptionText, 60).length rowDescriptionLines = splitStringBySpace(row.descriptionText, 60).length
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 60).join("\n"), { pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 60).join("\n"), {
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1), ...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
@@ -454,8 +480,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
}) })
} else { } else {
rowDescriptionLines = splitStringBySpace(row.descriptionText, 80).length rowDescriptionLines = splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).length
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 80).join("\n"), { pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).join("\n"), {
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1), ...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
size: 10, size: 10,
color: rgb(0, 0, 0), color: rgb(0, 0, 0),
@@ -466,7 +492,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
} }
if (invoiceData.type !== "deliveryNotes") { if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawText(`${row.taxPercent} %`, { pages[pageCounter - 1].drawText(`${row.taxPercent} %`, {
...getCoordinatesForPDFLib(135, rowHeight, page1), ...getCoordinatesForPDFLib(135, rowHeight, page1),
size: 10, size: 10,
@@ -632,7 +658,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold font: fontBold
}) })
if (invoiceData.type !== "deliveryNotes") { if (isPackingSlip) {
page.drawText("Check", {
...getCoordinatesForPDFLib(180, 22, page1),
size: 12,
color: rgb(0, 0, 0),
lineHeight: 12,
opacity: 1,
maxWidth: 240,
font: fontBold
})
}
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
page.drawText("Steuer", { page.drawText("Steuer", {
...getCoordinatesForPDFLib(135, 22, page1), ...getCoordinatesForPDFLib(135, 22, page1),
size: 12, size: 12,
@@ -742,7 +780,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
let endTextDiff = 35 let endTextDiff = 35
if (invoiceData.type !== "deliveryNotes") { if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawLine({ pages[pageCounter - 1].drawLine({
start: getCoordinatesForPDFLib(20, rowHeight, page1), start: getCoordinatesForPDFLib(20, rowHeight, page1),
end: getCoordinatesForPDFLib(198, rowHeight, page1), end: getCoordinatesForPDFLib(198, rowHeight, page1),
@@ -864,10 +902,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
opacity: 1, opacity: 1,
maxWidth: 500 maxWidth: 500
}) })
return await pdfDoc.saveAsBase64()
} }
return await pdfDoc.saveAsBase64()
} }
const pdfBytes = await genPDF(invoiceData, await getBackgroundSourceBuffer(server,backgroundPath)) const pdfBytes = await genPDF(invoiceData, await getBackgroundSourceBuffer(server,backgroundPath))
@@ -1138,4 +1176,4 @@ export const createTimeSheetPDF = async (server: FastifyInstance, returnMode, da
console.log(error) console.log(error)
throw error; // Fehler weiterwerfen, damit er oben ankommt throw error; // Fehler weiterwerfen, damit er oben ankommt
} }
} }

View File

@@ -0,0 +1,142 @@
import { and, eq, inArray } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { authProfileBranches, authProfiles, branches } from "../../db/schema"
function normalizeBranchIds(values: any[]): number[] {
return [...new Set(
values
.map((value) => {
if (typeof value === "number") return value
if (typeof value === "string" && value.trim()) return Number(value)
if (value && typeof value === "object" && "id" in value) return Number(value.id)
return NaN
})
.filter((value) => Number.isFinite(value))
)]
}
export async function enrichProfilesWithBranches(server: FastifyInstance, profiles: any[]) {
if (!profiles.length) return profiles
const profileIds = profiles.map((profile) => profile.id).filter(Boolean)
if (!profileIds.length) return profiles
const profileBranchRows = await server.db
.select()
.from(authProfileBranches)
.where(inArray(authProfileBranches.profile_id, profileIds))
const branchIds = [...new Set(profileBranchRows.map((row) => row.branch_id).filter(Boolean))]
const branchRows = branchIds.length
? await server.db.select().from(branches).where(inArray(branches.id, branchIds))
: []
const branchMap = new Map(branchRows.map((branch) => [branch.id, branch]))
const branchIdsByProfile = new Map<string, number[]>()
for (const row of profileBranchRows) {
const current = branchIdsByProfile.get(row.profile_id) || []
current.push(row.branch_id)
branchIdsByProfile.set(row.profile_id, current)
}
return profiles.map((profile) => {
const assignedBranchIds = [...new Set(branchIdsByProfile.get(profile.id) || [])]
return {
...profile,
branch: profile.branch_id ? branchMap.get(profile.branch_id) || null : null,
branches: assignedBranchIds
.map((branchId) => branchMap.get(branchId))
.filter(Boolean),
branch_ids: assignedBranchIds,
}
})
}
export async function loadProfileWithBranches(server: FastifyInstance, profileId: string, tenantId: number) {
const rows = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.id, profileId),
eq(authProfiles.tenant_id, tenantId)
)
)
.limit(1)
if (!rows.length) return null
const [profile] = await enrichProfilesWithBranches(server, rows)
return profile
}
export async function resolveTenantBranchIds(
server: FastifyInstance,
tenantId: number,
values: any[],
primaryBranchId?: any
) {
const normalizedPrimaryBranchId = primaryBranchId == null || primaryBranchId === ""
? null
: Number(primaryBranchId)
const requestedBranchIds = normalizeBranchIds([
...values,
normalizedPrimaryBranchId,
])
if (!requestedBranchIds.length) {
return {
primaryBranchId: normalizedPrimaryBranchId,
branchIds: [],
}
}
const validBranches = await server.db
.select({ id: branches.id })
.from(branches)
.where(
and(
eq(branches.tenant, tenantId),
inArray(branches.id, requestedBranchIds)
)
)
const validBranchIds = validBranches.map((branch) => branch.id)
if (validBranchIds.length !== requestedBranchIds.length) {
throw new Error("INVALID_BRANCH_SELECTION")
}
if (normalizedPrimaryBranchId != null && !validBranchIds.includes(normalizedPrimaryBranchId)) {
throw new Error("INVALID_PRIMARY_BRANCH")
}
return {
primaryBranchId: normalizedPrimaryBranchId,
branchIds: validBranchIds,
}
}
export async function syncProfileBranches(
server: FastifyInstance,
profileId: string,
branchIds: number[],
userId?: string | null
) {
await server.db
.delete(authProfileBranches)
.where(eq(authProfileBranches.profile_id, profileId))
if (!branchIds.length) return
await server.db
.insert(authProfileBranches)
.values(branchIds.map((branchId) => ({
profile_id: profileId,
branch_id: branchId,
created_by: userId || null,
})))
}

View File

@@ -0,0 +1,117 @@
import { and, eq, inArray } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { authProfileTeams, branches, teams } from "../../db/schema"
function normalizeTeamIds(values: any[]): number[] {
return [...new Set(
values
.map((value) => {
if (typeof value === "number") return value
if (typeof value === "string" && value.trim()) return Number(value)
if (value && typeof value === "object" && "id" in value) return Number(value.id)
return NaN
})
.filter((value) => Number.isFinite(value))
)]
}
export async function enrichProfilesWithTeams(server: FastifyInstance, profiles: any[]) {
if (!profiles.length) return profiles
const profileIds = profiles.map((profile) => profile.id).filter(Boolean)
if (!profileIds.length) return profiles
const profileTeamRows = await server.db
.select()
.from(authProfileTeams)
.where(inArray(authProfileTeams.profile_id, profileIds))
const teamIds = [...new Set(profileTeamRows.map((row) => row.team_id).filter(Boolean))]
const teamRows = teamIds.length
? await server.db.select().from(teams).where(inArray(teams.id, teamIds))
: []
const branchIds = [...new Set(teamRows.map((team) => team.branch).filter(Boolean))]
const branchRows = branchIds.length
? await server.db.select().from(branches).where(inArray(branches.id, branchIds))
: []
const branchMap = new Map(branchRows.map((branch) => [branch.id, branch]))
const teamMap = new Map(teamRows.map((team) => [
team.id,
{
...team,
branch: team.branch ? branchMap.get(team.branch) || null : null,
},
]))
const teamIdsByProfile = new Map<string, number[]>()
for (const row of profileTeamRows) {
const current = teamIdsByProfile.get(row.profile_id) || []
current.push(row.team_id)
teamIdsByProfile.set(row.profile_id, current)
}
return profiles.map((profile) => {
const assignedTeamIds = [...new Set(teamIdsByProfile.get(profile.id) || [])]
return {
...profile,
teams: assignedTeamIds
.map((teamId) => teamMap.get(teamId))
.filter(Boolean),
team_ids: assignedTeamIds,
}
})
}
export async function resolveTenantTeamIds(
server: FastifyInstance,
tenantId: number,
values: any[],
) {
const requestedTeamIds = normalizeTeamIds(values)
if (!requestedTeamIds.length) {
return []
}
const validTeams = await server.db
.select({ id: teams.id })
.from(teams)
.where(
and(
eq(teams.tenant, tenantId),
inArray(teams.id, requestedTeamIds)
)
)
const validTeamIds = validTeams.map((team) => team.id)
if (validTeamIds.length !== requestedTeamIds.length) {
throw new Error("INVALID_TEAM_SELECTION")
}
return validTeamIds
}
export async function syncProfileTeams(
server: FastifyInstance,
profileId: string,
teamIds: number[],
userId?: string | null
) {
await server.db
.delete(authProfileTeams)
.where(eq(authProfileTeams.profile_id, profileId))
if (!teamIds.length) return
await server.db
.insert(authProfileTeams)
.values(teamIds.map((teamId) => ({
profile_id: profileId,
team_id: teamId,
created_by: userId || null,
})))
}

View File

@@ -4,6 +4,7 @@ import {
bankaccounts, bankaccounts,
bankrequisitions, bankrequisitions,
bankstatements, bankstatements,
branches,
entitybankaccounts, entitybankaccounts,
events, events,
contacts, contacts,
@@ -35,6 +36,7 @@ import {
spaces, spaces,
statementallocations, statementallocations,
tasks, tasks,
teams,
texttemplates, texttemplates,
units, units,
vehicles, vehicles,
@@ -162,9 +164,23 @@ export const resourceConfig = {
costcentres: { costcentres: {
table: costcentres, table: costcentres,
searchColumns: ["name","number","description"], searchColumns: ["name","number","description"],
mtoLoad: ["vehicle","project","inventoryitem"], mtoLoad: ["parentCostcentre","vehicle","project","inventoryitem","branch"],
numberRangeHolder: "number", numberRangeHolder: "number",
}, },
parentCostcentre: {
table: costcentres,
searchColumns: ["name", "number", "description"],
},
branches: {
table: branches,
searchColumns: ["name","number","description"],
numberRangeHolder: "number",
},
teams: {
table: teams,
searchColumns: ["name", "description"],
mtoLoad: ["branch"],
},
tasks: { tasks: {
table: tasks, table: tasks,
}, },
@@ -186,7 +202,7 @@ export const resourceConfig = {
table: createddocuments, table: createddocuments,
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument"], mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument"],
mtmLoad: ["statementallocations","files","createddocuments"], mtmLoad: ["statementallocations","files","createddocuments"],
mtmListLoad: ["statementallocations"], mtmListLoad: ["statementallocations", "files"],
}, },
texttemplates: { texttemplates: {
table: texttemplates table: texttemplates

9
docker-compose.docs.yml Normal file
View File

@@ -0,0 +1,9 @@
services:
docs:
build:
context: .
dockerfile: docs-site/Dockerfile
container_name: fedeo-docs
restart: unless-stopped
ports:
- "3205:3000"

View File

@@ -17,10 +17,35 @@ services:
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure" - "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
- "traefik.http.routers.fedeo-frontend.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)" - "traefik.http.routers.fedeo-frontend.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend.entrypoints=web" - "traefik.http.routers.fedeo-frontend.entrypoints=web"
- "traefik.http.routers.fedeo-frontend.priority=1"
# Web Secure Entrypoint # Web Secure Entrypoint
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)" - "traefik.http.routers.fedeo-frontend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" # - "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge" - "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-frontend-secure.priority=1"
docs:
image: git.federspiel.tech/flfeders/fedeo/docs:dev
restart: always
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=3000"
# Middlewares
- "traefik.http.middlewares.fedeo-docs-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.middlewares.fedeo-docs-strip.stripprefix.prefixes=/docs"
# Web Entrypoint
- "traefik.http.routers.fedeo-docs.middlewares=fedeo-docs-redirect-web-secure"
- "traefik.http.routers.fedeo-docs.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
- "traefik.http.routers.fedeo-docs.entrypoints=web"
- "traefik.http.routers.fedeo-docs.priority=120"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-docs-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
- "traefik.http.routers.fedeo-docs-secure.entrypoints=web-secured"
- "traefik.http.routers.fedeo-docs-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-docs-secure.middlewares=fedeo-docs-strip"
- "traefik.http.routers.fedeo-docs-secure.priority=120"
backend: backend:
image: git.federspiel.tech/flfeders/fedeo/backend:dev image: git.federspiel.tech/flfeders/fedeo/backend:dev
restart: always restart: always
@@ -90,4 +115,4 @@ services:
- traefik - traefik
networks: networks:
traefik: traefik:
external: false external: false

3
docs-site/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.nuxt
.output

3
docs-site/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.nuxt
.output

19
docs-site/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:20-alpine AS builder
WORKDIR /app/docs-site
COPY docs-site/package.json docs-site/package-lock.json ./
RUN npm ci
COPY docs-site ./
COPY docs /app/docs
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app/docs-site
ENV NODE_ENV=production
COPY --from=builder /app/docs-site/.output ./.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

35
docs-site/README.md Normal file
View File

@@ -0,0 +1,35 @@
# FEDEO Docs Site (Nuxt UI + Nuxt Content)
Diese Docs-App nutzt den Standardstil des offiziellen Nuxt-UI-Docs-Templates und rendert Inhalte aus `docs/`.
## Lokale Entwicklung
Im Ordner `docs-site` ausführen:
```bash
npm install
npm run dev
```
Danach ist die App unter `http://localhost:3005` erreichbar.
## Build
```bash
npm run build
npm run preview
```
## Production-Deploy
Das Docker-Image startet einen Nuxt Node-Server auf Port `3000`.
In der Haupt-`docker-compose.yml` ist der Service hinter Traefik unter `/docs` veröffentlicht.
## Content-Synchronisierung
Vor `dev` und `build` wird automatisch synchronisiert:
- Quelle: `../docs/**/*.md`
- Ziel: `docs-site/content`
Dabei wird `docs/README.md` zu `content/index.md` gemappt.

View File

@@ -0,0 +1,56 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'green',
neutral: 'slate'
},
footer: {
slots: {
root: 'border-t border-default',
left: 'text-sm text-muted'
}
}
},
seo: {
siteName: 'FEDEO Bedienung'
},
header: {
title: 'FEDEO Bedienung',
to: '/bedienung',
search: true,
colorMode: true,
links: [
{
icon: 'i-simple-icons-github',
to: 'https://git.federspiel.tech/flfeders/FEDEO',
target: '_blank',
'aria-label': 'Repository'
}
]
},
footer: {
credits: `Built with Nuxt UI • © ${new Date().getFullYear()} FEDEO`,
colorMode: false,
links: [
{
icon: 'i-simple-icons-github',
to: 'https://git.federspiel.tech/flfeders/FEDEO',
target: '_blank',
'aria-label': 'Repository'
}
]
},
toc: {
title: 'Inhaltsverzeichnis',
bottom: {
title: 'Links',
links: [
{
icon: 'i-lucide-book-open',
label: 'Bedienung',
to: '/bedienung'
}
]
}
}
})

44
docs-site/app/app.vue Normal file
View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
const { seo } = useAppConfig()
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
server: false
})
useHead({
meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }],
htmlAttrs: { lang: 'de' }
})
useSeoMeta({
titleTemplate: `%s - ${seo?.siteName}`,
ogSiteName: seo?.siteName,
twitterCard: 'summary_large_image'
})
provide('navigation', navigation)
</script>
<template>
<UApp>
<NuxtLoadingIndicator />
<AppHeader />
<UMain>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UMain>
<AppFooter />
<ClientOnly>
<LazyUContentSearch
:files="files"
:navigation="navigation"
/>
</ClientOnly>
</UApp>
</template>

View File

@@ -0,0 +1,25 @@
@import "tailwindcss";
@import "@nuxt/ui";
@source "../../../content/**/*";
@theme static {
--container-8xl: 90rem;
--font-sans: 'Public Sans', sans-serif;
--color-green-50: #EFFDF5;
--color-green-100: #D9FBE8;
--color-green-200: #B3F5D1;
--color-green-300: #75EDAE;
--color-green-400: #00DC82;
--color-green-500: #00C16A;
--color-green-600: #00A155;
--color-green-700: #007F45;
--color-green-800: #016538;
--color-green-900: #0A5331;
--color-green-950: #052E16;
}
:root {
--ui-container: var(--container-8xl);
}

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
const { footer } = useAppConfig()
</script>
<template>
<UFooter>
<template #left>
{{ footer.credits }}
</template>
<template #right>
<UColorModeButton v-if="footer?.colorMode" />
<template v-if="footer?.links">
<UButton
v-for="(link, index) of footer?.links"
:key="index"
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
/>
</template>
</template>
</UFooter>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const { header } = useAppConfig()
</script>
<template>
<UHeader
:ui="{ center: 'flex-1' }"
:to="header?.to || '/'"
>
<UContentSearchButton
v-if="header?.search"
:collapsed="false"
class="w-full"
/>
<template #left>
<NuxtLink :to="header?.to || '/'">
<AppLogo class="w-auto h-6 shrink-0" />
</NuxtLink>
</template>
<template #right>
<UContentSearchButton
v-if="header?.search"
class="lg:hidden"
/>
<UColorModeButton v-if="header?.colorMode" />
<template v-if="header?.links">
<UButton
v-for="(link, index) of header.links"
:key="index"
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
/>
</template>
</template>
<template #body>
<UContentNavigation
highlight
:navigation="navigation"
/>
</template>
</UHeader>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<span class="font-semibold text-primary">FEDEO Docs</span>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
const route = useRoute()
const repoBase = 'https://git.federspiel.tech/flfeders/FEDEO/src/branch/dev/docs'
const sourceUrl = computed(() => {
if (route.path === '/') {
return `${repoBase}/README.md`
}
return `${repoBase}${route.path}.md`
})
</script>
<template>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-file-text"
label="Quellseite"
:to="sourceUrl"
target="_blank"
/>
</template>

31
docs-site/app/error.vue Normal file
View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
defineProps<{
error: NuxtError
}>()
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
server: false
})
provide('navigation', navigation)
</script>
<template>
<UApp>
<AppHeader />
<UError :error="error" />
<AppFooter />
<ClientOnly>
<LazyUContentSearch
:files="files"
:navigation="navigation"
/>
</ClientOnly>
</UApp>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
</script>
<template>
<UContainer>
<UPage>
<template #left>
<UPageAside>
<UContentNavigation
highlight
:navigation="navigation"
/>
</UPageAside>
</template>
<slot />
</UPage>
</UContainer>
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
import { findPageHeadline } from '@nuxt/content/utils'
definePageMeta({
layout: 'docs'
})
const route = useRoute()
const { toc } = useAppConfig()
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const { data: page } = await useAsyncData(route.path, () => queryCollection('docs').path(route.path).first())
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Seite nicht gefunden', fatal: true })
}
const { data: surround } = await useAsyncData(`${route.path}-surround`, () => {
return queryCollectionItemSurroundings('docs', route.path, {
fields: ['description']
})
})
const title = page.value.seo?.title || page.value.title
const description = page.value.seo?.description || page.value.description
useSeoMeta({
title,
ogTitle: title,
description,
ogDescription: description
})
const headline = computed(() => findPageHeadline(navigation?.value, page.value?.path))
const links = computed(() => [...(toc?.bottom?.links || [])].filter(Boolean))
</script>
<template>
<UPage v-if="page">
<UPageHeader
:title="page.title"
:description="page.description"
:headline="headline"
>
<template #links>
<UButton
v-for="(link, index) in page.links"
:key="index"
v-bind="link"
/>
<PageHeaderLinks />
</template>
</UPageHeader>
<UPageBody>
<ContentRenderer
v-if="page"
:value="page"
/>
<USeparator v-if="surround?.length" />
<UContentSurround :surround="surround" />
</UPageBody>
<template
v-if="page?.body?.toc?.links?.length"
#right
>
<UContentToc
:title="toc?.title"
:links="page.body?.toc?.links"
>
<template
v-if="toc?.bottom"
#bottom
>
<div
class="hidden lg:block space-y-6"
:class="{ 'mt-6!': page.body?.toc?.links?.length }"
>
<USeparator
v-if="page.body?.toc?.links?.length"
type="dashed"
/>
<UPageLinks
:title="toc.bottom.title"
:links="links"
/>
</div>
</template>
</UContentToc>
</template>
</UPage>
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
await navigateTo('/bedienung', { redirectCode: 302 })
</script>
<template>
<div />
</template>

View File

@@ -0,0 +1,18 @@
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
export default defineContentConfig({
collections: {
docs: defineCollection({
type: 'page',
source: '**',
schema: z.object({
links: z.array(z.object({
label: z.string(),
icon: z.string(),
to: z.string(),
target: z.string().optional()
})).optional()
})
})
}
})

View File

@@ -0,0 +1,42 @@
# Ausgangsbelege erstellen und bearbeiten
Diese Anleitung hilft dir beim Erstellen von Rechnungen, Angeboten, Lieferscheinen und ähnlichen Belegen.
## Typischer Ablauf
1. Neuen Beleg anlegen.
2. Belegart wählen (z. B. Rechnung oder Angebot).
3. Kunden- und Adressdaten prüfen.
4. Positionen eintragen.
5. Daten wie Belegdatum und Zahlungsziel prüfen.
6. Beleg speichern und bei Bedarf buchen.
## Wichtige Eingaben einfach erklärt
- `Belegart`: Legt fest, welche Art von Dokument erstellt wird.
- `Kunde`: Empfänger des Belegs.
- `Ansprechpartner`: Person beim Kunden für Rückfragen.
- `Adresse`: Zieladresse auf dem Dokument.
- `Belegnummer`: Eindeutige Nummer zur Wiedererkennung.
- `Belegdatum`: Offizielles Ausstellungsdatum.
- `Liefer-/Leistungsdatum`: Zeitraum oder Datum der Leistung.
- `Zahlungsziel`: Frist für den Zahlungseingang.
- `Zahlungsart`: Überweisung oder Lastschrift.
- `Positionen`: Leistungen oder Artikel mit Menge, Preis und Steuersatz.
## Empfehlungen für fehlerfreie Belege
- Vor dem Buchen immer Kunde, Datum und Belegnummer prüfen.
- Bei Rechnungen Zahlungsziel und Zahlungsart kontrollieren.
- Bei Zeiträumen Start- und Enddatum vollständig setzen.
- Vorschau prüfen, bevor der Beleg verschickt wird.
## Häufige Fragen
### Warum kann ich nicht buchen?
Meist fehlt eine Pflichtangabe wie Kunde, Briefpapier, Datum oder eine gültige Position.
### Wann ist ein Beleg im Kundenportal sichtbar?
Nur wenn die Freigabe aktiv ist und der Beleg nicht mehr im Entwurfsstatus steht.

View File

@@ -0,0 +1,41 @@
# Bankportal nutzen
Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordnung zu Belegen.
## Ziele im Bankportal
- Kontobewegungen aktuell halten
- Offene Zahlungsein- und -ausgänge schneller zuordnen
- Buchhaltungsprozesse vorbereiten
## Typischer Arbeitsablauf
1. Kontoverbindung prüfen oder aktualisieren.
2. Neue Umsätze abrufen.
3. Offene Bewegungen sichten.
4. Vorschläge zur Zuordnung prüfen.
5. Passende Belege oder Konten zuweisen.
6. Ergebnis kontrollieren.
## Wichtige Bereiche
- `Umsatzliste`: Zeigt alle importierten Bankbewegungen.
- `Filter/Suche`: Hilft beim schnellen Finden einzelner Vorgänge.
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
- Unklare Buchungen zeitnah klären.
- Bei wiederkehrenden Zahlungen auf konsistente Bezeichnung achten.
## Häufige Probleme
### Ein Umsatz wird nicht automatisch zugeordnet
Prüfe Betrag, Datum, Verwendungszweck und ob ein passender Beleg im System vorhanden ist.
### Es erscheinen doppelte oder fehlende Umsätze
Kontoverbindung aktualisieren und den Zeitraum der Synchronisation prüfen.

View File

@@ -0,0 +1,13 @@
# Bedienung
Diese Anleitung erklärt die wichtigsten Arbeitsabläufe in FEDEO in verständlicher, praxisnaher Form.
## Bereiche
- [Ausgangsbelege erstellen und bearbeiten](./ausgangsbelege.md)
- [Serienrechnungen planen und ausführen](./serienrechnungen.md)
- [Bankportal nutzen](./bankportal.md)
## Für wen ist diese Anleitung?
Für Anwenderinnen und Anwender, die mit FEDEO im Tagesgeschäft arbeiten und klare Schritt-für-Schritt-Hinweise benötigen.

View File

@@ -0,0 +1,31 @@
# Serienrechnungen planen und ausführen
Mit Serienrechnungen kannst du wiederkehrende Abrechnungen automatisieren.
## Wofür nutzt man Serienrechnungen?
- Regelmäßige Leistungen (z. B. monatliche Betreuung)
- Verträge mit festen Abständen
- Einheitliche Abrechnung ohne manuelles Neuerstellen jedes Belegs
## So legst du eine Serienrechnung an
1. Neue Serienrechnung öffnen.
2. Kunde, Leistungen und Preise eintragen.
3. Intervall festlegen (z. B. monatlich, quartalsweise).
4. Start- und Endzeitraum definieren.
5. Vorlage aktivieren und speichern.
## Manuelle Ausführung eines Laufs
1. Serienrechnungsübersicht öffnen.
2. Ausführungsdatum setzen.
3. Gewünschte Vorlagen auswählen.
4. Lauf starten.
5. Ergebnis prüfen und ggf. abschließen.
## Wichtige Hinweise
- Das Ausführungsdatum wirkt sich auf den Leistungszeitraum aus.
- Vor dem Lauf prüfen, ob alle Vorlagen aktiv und vollständig sind.
- Bei ungewöhnlichen Ergebnissen zuerst Beträge und Intervalle der Vorlage prüfen.

View File

@@ -0,0 +1,7 @@
# Bedienungsanleitung
Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
## Einstieg
- [Bedienung](./bedienung/README.md)

27
docs-site/nuxt.config.ts Normal file
View File

@@ -0,0 +1,27 @@
export default defineNuxtConfig({
modules: [
'@nuxt/image',
'@nuxt/ui',
'@nuxt/content'
],
css: ['~/assets/css/main.css'],
content: {
build: {
markdown: {
toc: {
searchDepth: 1
}
}
}
},
experimental: {
asyncContext: true
},
compatibilityDate: '2024-07-11',
nitro: {
preset: 'node-server'
},
icon: {
provider: 'iconify'
}
})

16365
docs-site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
docs-site/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "fedeo-docs-site",
"private": true,
"type": "module",
"scripts": {
"build": "node ./scripts/sync-content.mjs && nuxt build",
"dev": "node ./scripts/sync-content.mjs && nuxt dev --host 0.0.0.0 --port 3005",
"preview": "nuxt preview --host 0.0.0.0 --port 3005",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.102",
"@iconify-json/simple-icons": "^1.2.78",
"@nuxt/content": "^3.12.0",
"@nuxt/image": "^2.0.0",
"@nuxt/ui": "^4.6.1",
"better-sqlite3": "^12.9.0",
"nuxt": "^4.4.2"
},
"devDependencies": {
"typescript": "^6.0.2"
},
"engines": {
"node": ">=20.0"
}
}

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env node
import { promises as fs } from 'node:fs'
import path from 'node:path'
const ROOT = process.cwd()
const DOCS_SOURCE = path.resolve(ROOT, '../docs')
const CONTENT_TARGET = path.resolve(ROOT, 'content')
async function ensureDir(dirPath) {
await fs.mkdir(dirPath, { recursive: true })
}
async function clearDir(dirPath) {
await fs.rm(dirPath, { recursive: true, force: true })
await ensureDir(dirPath)
}
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true })
const out = []
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
out.push(...(await walk(full)))
} else if (entry.isFile() && entry.name.endsWith('.md')) {
out.push(full)
}
}
return out
}
function toPosix(p) {
return p.split(path.sep).join('/')
}
async function main() {
await clearDir(CONTENT_TARGET)
const files = await walk(DOCS_SOURCE)
for (const file of files) {
const rel = toPosix(path.relative(DOCS_SOURCE, file))
let targetRel = rel
if (rel === 'README.md') {
targetRel = 'index.md'
} else if (rel.endsWith('/README.md')) {
targetRel = `${rel.slice(0, -'/README.md'.length)}/index.md`
}
const target = path.join(CONTENT_TARGET, targetRel)
await ensureDir(path.dirname(target))
await fs.copyFile(file, target)
}
console.log(`Nuxt-Content Synchronisierung abgeschlossen: ${files.length} Dateien`)
}
main().catch((err) => {
console.error('Fehler bei der Content-Synchronisierung', err)
process.exit(1)
})

3
docs-site/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

7
docs/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Bedienungsanleitung
Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
## Einstieg
- [Bedienung](./bedienung/README.md)

13
docs/bedienung/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Bedienung
Diese Anleitung erklärt die wichtigsten Arbeitsabläufe in FEDEO in verständlicher, praxisnaher Form.
## Bereiche
- [Ausgangsbelege erstellen und bearbeiten](./ausgangsbelege.md)
- [Serienrechnungen planen und ausführen](./serienrechnungen.md)
- [Bankportal nutzen](./bankportal.md)
## Für wen ist diese Anleitung?
Für Anwenderinnen und Anwender, die mit FEDEO im Tagesgeschäft arbeiten und klare Schritt-für-Schritt-Hinweise benötigen.

View File

@@ -0,0 +1,42 @@
# Ausgangsbelege erstellen und bearbeiten
Diese Anleitung hilft dir beim Erstellen von Rechnungen, Angeboten, Lieferscheinen und ähnlichen Belegen.
## Typischer Ablauf
1. Neuen Beleg anlegen.
2. Belegart wählen (z. B. Rechnung oder Angebot).
3. Kunden- und Adressdaten prüfen.
4. Positionen eintragen.
5. Daten wie Belegdatum und Zahlungsziel prüfen.
6. Beleg speichern und bei Bedarf buchen.
## Wichtige Eingaben einfach erklärt
- `Belegart`: Legt fest, welche Art von Dokument erstellt wird.
- `Kunde`: Empfänger des Belegs.
- `Ansprechpartner`: Person beim Kunden für Rückfragen.
- `Adresse`: Zieladresse auf dem Dokument.
- `Belegnummer`: Eindeutige Nummer zur Wiedererkennung.
- `Belegdatum`: Offizielles Ausstellungsdatum.
- `Liefer-/Leistungsdatum`: Zeitraum oder Datum der Leistung.
- `Zahlungsziel`: Frist für den Zahlungseingang.
- `Zahlungsart`: Überweisung oder Lastschrift.
- `Positionen`: Leistungen oder Artikel mit Menge, Preis und Steuersatz.
## Empfehlungen für fehlerfreie Belege
- Vor dem Buchen immer Kunde, Datum und Belegnummer prüfen.
- Bei Rechnungen Zahlungsziel und Zahlungsart kontrollieren.
- Bei Zeiträumen Start- und Enddatum vollständig setzen.
- Vorschau prüfen, bevor der Beleg verschickt wird.
## Häufige Fragen
### Warum kann ich nicht buchen?
Meist fehlt eine Pflichtangabe wie Kunde, Briefpapier, Datum oder eine gültige Position.
### Wann ist ein Beleg im Kundenportal sichtbar?
Nur wenn die Freigabe aktiv ist und der Beleg nicht mehr im Entwurfsstatus steht.

View File

@@ -0,0 +1,41 @@
# Bankportal nutzen
Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordnung zu Belegen.
## Ziele im Bankportal
- Kontobewegungen aktuell halten
- Offene Zahlungsein- und -ausgänge schneller zuordnen
- Buchhaltungsprozesse vorbereiten
## Typischer Arbeitsablauf
1. Kontoverbindung prüfen oder aktualisieren.
2. Neue Umsätze abrufen.
3. Offene Bewegungen sichten.
4. Vorschläge zur Zuordnung prüfen.
5. Passende Belege oder Konten zuweisen.
6. Ergebnis kontrollieren.
## Wichtige Bereiche
- `Umsatzliste`: Zeigt alle importierten Bankbewegungen.
- `Filter/Suche`: Hilft beim schnellen Finden einzelner Vorgänge.
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
- Unklare Buchungen zeitnah klären.
- Bei wiederkehrenden Zahlungen auf konsistente Bezeichnung achten.
## Häufige Probleme
### Ein Umsatz wird nicht automatisch zugeordnet
Prüfe Betrag, Datum, Verwendungszweck und ob ein passender Beleg im System vorhanden ist.
### Es erscheinen doppelte oder fehlende Umsätze
Kontoverbindung aktualisieren und den Zeitraum der Synchronisation prüfen.

View File

@@ -0,0 +1,31 @@
# Serienrechnungen planen und ausführen
Mit Serienrechnungen kannst du wiederkehrende Abrechnungen automatisieren.
## Wofür nutzt man Serienrechnungen?
- Regelmäßige Leistungen (z. B. monatliche Betreuung)
- Verträge mit festen Abständen
- Einheitliche Abrechnung ohne manuelles Neuerstellen jedes Belegs
## So legst du eine Serienrechnung an
1. Neue Serienrechnung öffnen.
2. Kunde, Leistungen und Preise eintragen.
3. Intervall festlegen (z. B. monatlich, quartalsweise).
4. Start- und Endzeitraum definieren.
5. Vorlage aktivieren und speichern.
## Manuelle Ausführung eines Laufs
1. Serienrechnungsübersicht öffnen.
2. Ausführungsdatum setzen.
3. Gewünschte Vorlagen auswählen.
4. Lauf starten.
5. Ergebnis prüfen und ggf. abschließen.
## Wichtige Hinweise
- Das Ausführungsdatum wirkt sich auf den Leistungszeitraum aus.
- Vor dem Lauf prüfen, ob alle Vorlagen aktiv und vollständig sind.
- Bei ungewöhnlichen Ergebnissen zuerst Beträge und Intervalle der Vorlage prüfen.

View File

@@ -0,0 +1,231 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const DOCS_DIR = path.join(ROOT, "docs");
const BACKEND_ROUTES_DIR = path.join(ROOT, "backend", "src", "routes");
const FRONTEND_PAGES_DIR = path.join(ROOT, "frontend", "pages");
const MOBILE_APP_DIR = path.join(ROOT, "mobile", "app");
const OUT_BACKEND = path.join(DOCS_DIR, "funktionen", "backend-api.md");
const OUT_FRONTEND = path.join(DOCS_DIR, "funktionen", "frontend-web.md");
const OUT_MOBILE = path.join(DOCS_DIR, "funktionen", "mobile-app.md");
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head"];
async function walkFiles(dir, extension) {
const result = [];
async function walk(current) {
const entries = await fs.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
await walk(full);
} else if (entry.isFile() && full.endsWith(extension)) {
result.push(full);
}
}
}
await walk(dir);
return result.sort();
}
function normalizePosix(p) {
return p.split(path.sep).join("/");
}
function toRelative(filePath) {
return normalizePosix(path.relative(ROOT, filePath));
}
function extractBackendEndpoints(sourceText) {
const endpoints = [];
const regex = /\b(?:server|subApp|m2mApp|devicesApp)\.(get|post|put|patch|delete|options|head)\(\s*["'`](.+?)["'`]/g;
let match;
while ((match = regex.exec(sourceText)) !== null) {
const method = String(match[1] || "").toUpperCase();
const routePath = String(match[2] || "");
if (!HTTP_METHODS.includes(method.toLowerCase())) {
continue;
}
endpoints.push({ method, path: routePath });
}
return endpoints.sort((a, b) => {
if (a.path === b.path) {
return a.method.localeCompare(b.method);
}
return a.path.localeCompare(b.path);
});
}
function sortAndUnique(items) {
return [...new Set(items)].sort((a, b) => a.localeCompare(b));
}
function filePathToNuxtRoute(filePath, baseDir) {
const relative = normalizePosix(path.relative(baseDir, filePath));
let route = relative.replace(/\.vue$/, "");
route = route.replace(/\.client$/, "").replace(/\.server$/, "");
route = route
.replace(/\[\[\.\.\.(.+?)\]\]/g, ":$1*?")
.replace(/\[\.\.\.(.+?)\]/g, ":$1*")
.replace(/\[\[(.+?)\]\]/g, ":$1?")
.replace(/\[(.+?)\]/g, ":$1");
route = route.replace(/\/index$/g, "");
if (route === "index") {
route = "";
}
if (!route.startsWith("/")) {
route = `/${route}`;
}
if (route === "") {
return "/";
}
return route || "/";
}
function filePathToExpoRoute(filePath, baseDir) {
const relative = normalizePosix(path.relative(baseDir, filePath));
const baseName = path.basename(relative);
if (baseName.startsWith("_")) {
return null;
}
let route = relative.replace(/\.tsx$/, "");
route = route
.replace(/\[\[(.+?)\]\]/g, ":$1?")
.replace(/\[(.+?)\]/g, ":$1");
route = route.replace(/\/index$/g, "");
if (route === "index") {
route = "";
}
if (!route.startsWith("/")) {
route = `/${route}`;
}
if (route === "") {
return "/";
}
return route;
}
async function generateBackendDoc() {
const files = await walkFiles(BACKEND_ROUTES_DIR, ".ts");
let output = "# Backend API Funktionskatalog\n\n";
output += "Automatisch generiert (deterministisch, ohne Zeitstempel).\n\n";
output += "Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.\n\n";
const allEndpoints = [];
for (const file of files) {
const source = await fs.readFile(file, "utf-8");
const endpoints = extractBackendEndpoints(source);
const relativeFile = toRelative(file);
if (endpoints.length === 0) {
continue;
}
output += `## ${relativeFile}\n\n`;
output += "| Methode | Pfad |\n";
output += "|---|---|\n";
for (const endpoint of endpoints) {
output += `| ${endpoint.method} | \`${endpoint.path}\` |\n`;
allEndpoints.push(`${endpoint.method} ${endpoint.path}`);
}
output += "\n";
}
const uniqueCount = sortAndUnique(allEndpoints).length;
output += `Gesamtzahl erkannter Endpunkte: **${uniqueCount}**\n`;
await fs.writeFile(OUT_BACKEND, output, "utf-8");
}
async function generateFrontendDoc() {
const files = await walkFiles(FRONTEND_PAGES_DIR, ".vue");
const rows = files.map((file) => {
const route = filePathToNuxtRoute(file, FRONTEND_PAGES_DIR);
return { route, file: toRelative(file) };
});
rows.sort((a, b) => a.route.localeCompare(b.route));
let output = "# Frontend Web Funktionskatalog\n\n";
output += "Automatisch generiert (deterministisch, ohne Zeitstempel).\n\n";
output += "Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.\n\n";
output += "| Route (Nuxt) | Datei |\n";
output += "|---|---|\n";
for (const row of rows) {
output += `| \`${row.route}\` | \`${row.file}\` |\n`;
}
output += `\nGesamtzahl erkannter Web-Routen: **${rows.length}**\n`;
await fs.writeFile(OUT_FRONTEND, output, "utf-8");
}
async function generateMobileDoc() {
const files = await walkFiles(MOBILE_APP_DIR, ".tsx");
const rows = files
.map((file) => {
const route = filePathToExpoRoute(file, MOBILE_APP_DIR);
if (!route) {
return null;
}
return { route, file: toRelative(file) };
})
.filter(Boolean);
rows.sort((a, b) => a.route.localeCompare(b.route));
let output = "# Mobile App Funktionskatalog\n\n";
output += "Automatisch generiert (deterministisch, ohne Zeitstempel).\n\n";
output += "Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.\n\n";
output += "| Route (Expo Router) | Datei |\n";
output += "|---|---|\n";
for (const row of rows) {
output += `| \`${row.route}\` | \`${row.file}\` |\n`;
}
output += `\nGesamtzahl erkannter Mobile-Screens: **${rows.length}**\n`;
await fs.writeFile(OUT_MOBILE, output, "utf-8");
}
async function main() {
await generateBackendDoc();
await generateFrontendDoc();
await generateMobileDoc();
console.log("Funktionsdokumentation erfolgreich synchronisiert.");
}
main().catch((error) => {
console.error("Fehler bei der Doku-Synchronisierung:", error);
process.exit(1);
});

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import * as Sentry from "@sentry/browser" import * as Sentry from "@sentry/browser"
import { de as germanLocale } from "@nuxt/ui/locale"
@@ -47,7 +48,7 @@ useSeoMeta({
</script> </script>
<template> <template>
<UApp> <UApp :locale="germanLocale">
<div class="safearea"> <div class="safearea">
<NuxtLayout> <NuxtLayout>
<NuxtPage/> <NuxtPage/>

View File

@@ -1,23 +1,22 @@
<script setup> <script setup>
// Falls useDropZone nicht auto-importiert wird:
// import { useDropZone } from '@vueuse/core'
const props = defineProps({ const props = defineProps({
fileData: { fileData: {
type: Object, type: Object,
default: { default: () => ({
type: null type: null
} })
} }
}) })
const emit = defineEmits(["uploadFinished"]) const emit = defineEmits(["uploadFinished"])
const modal = useModal() const modal = useModal()
// const profileStore = useProfileStore() // Wird im Snippet nicht genutzt, aber ich lasse es drin
const uploadInProgress = ref(false) const uploadInProgress = ref(false)
const availableFiletypes = ref([]) const availableFiletypes = ref([])
const localFileData = reactive({
...props.fileData
})
// 1. State für die Dateien und die Dropzone Referenz // 1. State für die Dateien und die Dropzone Referenz
const selectedFiles = ref([]) const selectedFiles = ref([])
@@ -58,10 +57,8 @@ const uploadFiles = async () => {
uploadInProgress.value = true; uploadInProgress.value = true;
let fileData = props.fileData const { typeEnabled, ...fileData } = localFileData
delete fileData.typeEnabled
// 4. Hier nutzen wir nun selectedFiles.value statt document.getElementById
await useFiles().uploadFiles(fileData, selectedFiles.value, [], true) await useFiles().uploadFiles(fileData, selectedFiles.value, [], true)
uploadInProgress.value = false; uploadInProgress.value = false;
@@ -80,12 +77,11 @@ const fileNames = computed(() => {
<UModal> <UModal>
<template #content> <template #content>
<div ref="dropZoneRef" class="relative h-full flex flex-col"> <div ref="dropZoneRef" class="relative h-full flex flex-col">
<div <div
v-if="isOverDropZone" v-if="isOverDropZone"
class="absolute inset-0 z-50 flex items-center justify-center bg-primary-500/10 border-2 border-primary-500 border-dashed rounded-lg backdrop-blur-sm transition-all" class="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-dashed border-primary-500 bg-primary-500/10 backdrop-blur-sm transition-all"
> >
<span class="text-xl font-bold text-primary-600 bg-white/80 px-4 py-2 rounded shadow-sm"> <span class="rounded bg-white/80 px-4 py-2 text-xl font-bold text-primary-600 shadow-sm">
Dateien hier ablegen Dateien hier ablegen
</span> </span>
</div> </div>
@@ -130,16 +126,17 @@ const fileNames = computed(() => {
class="mt-3" class="mt-3"
> >
<USelectMenu <USelectMenu
option-attribute="name" :items="availableFiletypes"
value-attribute="id" v-model="localFileData.type"
searchable value-key="id"
searchable-placeholder="Suchen..." label-key="name"
:options="availableFiletypes" :search-input="{ placeholder: 'Suchen...' }"
v-model="props.fileData.type" :filter-fields="['name']"
:disabled="!props.fileData.typeEnabled" :disabled="!localFileData.typeEnabled"
class="w-full"
> >
<template #label> <template #default>
<span v-if="availableFiletypes.find(x => x.id === props.fileData.type)">{{availableFiletypes.find(x => x.id === props.fileData.type).name}}</span> <span v-if="availableFiletypes.find(x => x.id === localFileData.type)">{{ availableFiletypes.find(x => x.id === localFileData.type).name }}</span>
<span v-else>Kein Typ ausgewählt</span> <span v-else>Kein Typ ausgewählt</span>
</template> </template>
</USelectMenu> </USelectMenu>
@@ -159,5 +156,4 @@ const fileNames = computed(() => {
</template> </template>
<style scoped> <style scoped>
/* Optional: Animationen für das Overlay */
</style> </style>

View File

@@ -63,6 +63,20 @@ const generateOldItemData = () => {
} }
generateOldItemData() generateOldItemData()
const inputColumnCount = computed(() => {
return Array.isArray(dataType.inputColumns) && dataType.inputColumns.length > 0
? dataType.inputColumns.length
: 1
})
const getInputColumnStyle = () => {
if (props.platform === 'mobile') return undefined
return {
width: `${100 / inputColumnCount.value}%`
}
}
// --- ÄNDERUNG START: Computed Property statt Watcher/Function --- // --- ÄNDERUNG START: Computed Property statt Watcher/Function ---
// Dies berechnet den Status automatisch neu, egal woher die Daten kommen (Init oder User-Eingabe) // Dies berechnet den Status automatisch neu, egal woher die Daten kommen (Init oder User-Eingabe)
@@ -174,6 +188,49 @@ const setupQuery = () => {
setupQuery() setupQuery()
const loadedOptions = ref({}) const loadedOptions = ref({})
const normalizeSelectFieldValue = (value, isMultiple = false) => {
if (isMultiple) {
if (!Array.isArray(value)) return []
return value.map((entry) => {
if (entry && typeof entry === "object" && "id" in entry) {
return entry.id
}
return entry
})
}
if (value && typeof value === "object" && "id" in value) {
return value.id
}
return value
}
const normalizeLoadedSelectValues = () => {
dataType.templateColumns.forEach((datapoint) => {
if (datapoint.inputType !== "select") return
if (datapoint.key.includes(".")) {
const [parentKey, childKey] = datapoint.key.split(".")
if (!item.value[parentKey]) return
item.value[parentKey][childKey] = normalizeSelectFieldValue(
item.value[parentKey][childKey],
datapoint.selectMultiple
)
return
}
item.value[datapoint.key] = normalizeSelectFieldValue(
item.value[datapoint.key],
datapoint.selectMultiple
)
})
}
const loadOptions = async () => { const loadOptions = async () => {
let optionsToLoad = dataType.templateColumns.filter(i => i.selectDataType).map(i => { let optionsToLoad = dataType.templateColumns.filter(i => i.selectDataType).map(i => {
return { return {
@@ -184,9 +241,9 @@ const loadOptions = async () => {
for await(const option of optionsToLoad) { for await(const option of optionsToLoad) {
if (option.option === "countrys") { if (option.option === "countrys") {
loadedOptions.value[option.option] = useEntities("countrys").selectSpecial() loadedOptions.value[option.option] = await useEntities("countrys").selectSpecial()
} else if (option.option === "units") { } else if (option.option === "units") {
loadedOptions.value[option.option] = useEntities("units").selectSpecial() loadedOptions.value[option.option] = await useEntities("units").selectSpecial()
} else { } else {
loadedOptions.value[option.option] = (await useEntities(option.option).select()) loadedOptions.value[option.option] = (await useEntities(option.option).select())
@@ -197,7 +254,41 @@ const loadOptions = async () => {
} }
} }
loadOptions() normalizeLoadedSelectValues()
const initialProjecttype = props.type === "projects" ? item.value.projecttype : null
const lastAppliedProjecttype = ref(null)
const syncProjectPhasesForProjecttype = () => {
if (props.type !== "projects") return
if (!item.value?.projecttype) return
if (!Array.isArray(loadedOptions.value.projecttypes) || !loadedOptions.value.projecttypes.length) return
const projecttypeColumn = dataType.templateColumns.find((column) => column.key === "projecttype")
if (!projecttypeColumn?.inputChangeFunction) return
const shouldSyncOnCreate = props.mode === "create" && lastAppliedProjecttype.value !== item.value.projecttype
const shouldSyncOnEdit = props.mode === "edit"
&& item.value.projecttype !== initialProjecttype
&& lastAppliedProjecttype.value !== item.value.projecttype
if (!shouldSyncOnCreate && !shouldSyncOnEdit) return
projecttypeColumn.inputChangeFunction(item.value, loadedOptions.value)
lastAppliedProjecttype.value = item.value.projecttype
}
loadOptions().then(() => {
syncProjectPhasesForProjecttype()
})
watch(
() => [item.value?.projecttype, loadedOptions.value.projecttypes?.length || 0],
() => {
syncProjectPhasesForProjecttype()
},
{ immediate: true }
)
const contentChanged = (content, datapoint) => { const contentChanged = (content, datapoint) => {
if (datapoint.key.includes(".")) { if (datapoint.key.includes(".")) {
@@ -227,6 +318,15 @@ const getSelectSearchInput = (datapoint) => {
return datapoint.selectSearchAttributes ? { placeholder: 'Suche...' } : false return datapoint.selectSearchAttributes ? { placeholder: 'Suche...' } : false
} }
const triggerInputChange = (datapoint) => {
if (datapoint.inputChangeFunction) {
datapoint.inputChangeFunction(item.value, loadedOptions.value)
if (datapoint.key === "projecttype") {
lastAppliedProjecttype.value = item.value.projecttype
}
}
}
const createItem = async () => { const createItem = async () => {
let ret = null let ret = null
@@ -350,7 +450,8 @@ const updateItem = async () => {
<div :class="platform === 'mobile' ?['flex','flex-col'] : ['flex','flex-row']"> <div :class="platform === 'mobile' ?['flex','flex-col'] : ['flex','flex-row']">
<div <div
v-for="(columnName,index) in dataType.inputColumns" v-for="(columnName,index) in dataType.inputColumns"
:class="platform === 'mobile' ? ['w-full'] : [`w-1/${dataType.inputColumns.length}`, ... index < dataType.inputColumns.length -1 ? ['mr-5'] : []]" :class="platform === 'mobile' ? ['w-full'] : [... index < inputColumnCount - 1 ? ['mr-5'] : []]"
:style="getInputColumnStyle()"
> >
<USeparator :label="columnName"/> <USeparator :label="columnName"/>
@@ -393,7 +494,7 @@ const updateItem = async () => {
<USelectMenu <USelectMenu
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @update:model-value="triggerInputChange(datapoint)"
v-else-if="datapoint.inputType === 'select'" v-else-if="datapoint.inputType === 'select'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
@@ -498,7 +599,7 @@ const updateItem = async () => {
<USelectMenu <USelectMenu
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @update:model-value="triggerInputChange(datapoint)"
v-else-if="datapoint.inputType === 'select'" v-else-if="datapoint.inputType === 'select'"
v-model="item[datapoint.key]" v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
@@ -626,7 +727,7 @@ const updateItem = async () => {
<USelectMenu <USelectMenu
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @update:model-value="triggerInputChange(datapoint)"
v-else-if="datapoint.inputType === 'select'" v-else-if="datapoint.inputType === 'select'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
@@ -731,7 +832,7 @@ const updateItem = async () => {
<USelectMenu <USelectMenu
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @update:model-value="triggerInputChange(datapoint)"
v-else-if="datapoint.inputType === 'select'" v-else-if="datapoint.inputType === 'select'"
v-model="item[datapoint.key]" v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"

View File

@@ -39,7 +39,9 @@ const modal = useModal()
<template> <template>
<UButton <UButton
variant="outline" variant="outline"
class="w-25 ml-2" size="sm"
square
class="ml-2 shrink-0"
v-if="props.id && props.buttonShow" v-if="props.id && props.buttonShow"
icon="i-heroicons-eye" icon="i-heroicons-eye"
@click="modal.open(StandardEntityModal, { @click="modal.open(StandardEntityModal, {
@@ -50,7 +52,9 @@ const modal = useModal()
/> />
<UButton <UButton
variant="outline" variant="outline"
class="w-25 ml-2" size="sm"
square
class="ml-2 shrink-0"
v-if="props.id && props.buttonEdit" v-if="props.id && props.buttonEdit"
icon="i-heroicons-pencil-solid" icon="i-heroicons-pencil-solid"
@click="modal.open(StandardEntityModal, { @click="modal.open(StandardEntityModal, {
@@ -64,7 +68,9 @@ const modal = useModal()
/> />
<UButton <UButton
variant="outline" variant="outline"
class="w-25 ml-2" size="sm"
square
class="ml-2 shrink-0"
v-if="!props.id && props.buttonCreate" v-if="!props.id && props.buttonCreate"
icon="i-heroicons-plus" icon="i-heroicons-plus"
@click="modal.open(StandardEntityModal, { @click="modal.open(StandardEntityModal, {
@@ -80,4 +86,4 @@ const modal = useModal()
<style scoped> <style scoped>
</style> </style>

View File

@@ -50,10 +50,12 @@ const route = useRoute()
const dataStore = useDataStore() const dataStore = useDataStore()
const modal = useModal() const modal = useModal()
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast()
const dataType = dataStore.dataTypes[type] const dataType = dataStore.dataTypes[type]
const openTab = ref(String(route.query.tabIndex || 0)) const openTab = ref(String(route.query.tabIndex || 0))
const portalInviteLoading = ref(false)
@@ -152,6 +154,31 @@ const openCustomerInventoryLabelPrint = () => {
}) })
} }
const invitePortalUser = async () => {
if (type !== "customers" || !props.item?.id) return
portalInviteLoading.value = true
try {
const response = await useAdmin().invitePortalUser(Number(props.item.id))
toast.add({
title: "Portal-Einladung versendet",
description: `E-Mail: ${response.email}${response.initialPassword ? ` | Initialpasswort: ${response.initialPassword}` : ""}`,
timeout: 9000
})
emit("updateNeeded")
} catch (err) {
toast.add({
title: "Portal-Einladung fehlgeschlagen",
description: err?.data?.error || "Die Einladung konnte nicht erstellt werden.",
color: "error",
timeout: 7000
})
} finally {
portalInviteLoading.value = false
}
}
</script> </script>
<template> <template>
@@ -217,6 +244,15 @@ const openCustomerInventoryLabelPrint = () => {
> >
Label Label
</UButton> </UButton>
<UButton
v-if="type === 'customers' && auth.user?.is_admin"
icon="i-heroicons-envelope"
variant="outline"
:loading="portalInviteLoading"
@click="invitePortalUser"
>
Portal einladen
</UButton>
<UButton <UButton
@click="router.push(`/standardEntity/${type}/edit/${item.id}`)" @click="router.push(`/standardEntity/${type}/edit/${item.id}`)"
> >
@@ -246,6 +282,15 @@ const openCustomerInventoryLabelPrint = () => {
> >
Label Label
</UButton> </UButton>
<UButton
v-if="type === 'customers' && auth.user?.is_admin"
icon="i-heroicons-envelope"
variant="outline"
:loading="portalInviteLoading"
@click="invitePortalUser"
>
Portal einladen
</UButton>
<UButton <UButton
@click="router.push(`/standardEntity/${type}/edit/${item.id}`)" @click="router.push(`/standardEntity/${type}/edit/${item.id}`)"
> >

View File

@@ -56,6 +56,7 @@ const dataStore = useDataStore()
const tempStore = useTempStore() const tempStore = useTempStore()
const router = useRouter() const router = useRouter()
const deliveryNoteLikeDocumentTypes = ['deliveryNotes', 'packingSlips']
const createddocuments = ref([]) const createddocuments = ref([])
@@ -117,7 +118,7 @@ const getAvailableQueryStringData = (keys) => {
} }
const invoiceDeliveryNotes = () => { const invoiceDeliveryNotes = () => {
router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => i.type === "deliveryNotes").map(i => i.id)}]`) router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => deliveryNoteLikeDocumentTypes.includes(i.type)).map(i => i.id)}]`)
} }
const showFinalInvoiceConfig = ref(false) const showFinalInvoiceConfig = ref(false)
@@ -150,13 +151,18 @@ const selectItem = (item) => {
@click="invoiceDeliveryNotes" @click="invoiceDeliveryNotes"
v-if="props.topLevelType === 'projects'" v-if="props.topLevelType === 'projects'"
> >
Lieferscheine abrechnen Lieferscheine/Packscheine abrechnen
</UButton> </UButton>
<UButton <UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'quotes'})}`)" @click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'quotes'})}`)"
> >
+ Angebot + Angebot
</UButton> </UButton>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'costEstimates'})}`)"
>
+ Kostenschätzung
</UButton>
<UButton <UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'confirmationOrders'})}`)" @click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'confirmationOrders'})}`)"
> >
@@ -167,6 +173,11 @@ const selectItem = (item) => {
> >
+ Lieferschein + Lieferschein
</UButton> </UButton>
<UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'packingSlips'})}`)"
>
+ Packschein
</UButton>
<UButton <UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'advanceInvoices'})}`)" @click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'advanceInvoices'})}`)"
> >
@@ -198,7 +209,7 @@ const selectItem = (item) => {
label="Rechnungsvorlage" label="Rechnungsvorlage"
> >
<USelectMenu <USelectMenu
:items="props.item.createddocuments.filter(i => ['confirmationOrders','quotes'].includes(i.type))" :items="props.item.createddocuments.filter(i => ['confirmationOrders','quotes','costEstimates'].includes(i.type))"
value-key="id" value-key="id"
label-key="documentNumber" label-key="documentNumber"
v-model="referenceDocument" v-model="referenceDocument"
@@ -255,8 +266,8 @@ const selectItem = (item) => {
class="w-full" class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }" :ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="(row) => selectItem(row.original)" :on-select="(row) => selectItem(row.original)"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
style="height: 70vh" style="height: 70vh"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
> >
<template #type-cell="{ row }"> <template #type-cell="{ row }">
{{ dataStore.documentTypesForCreation[row.original.type].labelSingle }} {{ dataStore.documentTypesForCreation[row.original.type].labelSingle }}
@@ -299,7 +310,7 @@ const selectItem = (item) => {
<span v-if="row.original.paymentDays && ['invoices','advanceInvoices'].includes(row.original.type)">{{ row.original.documentDate ? dayjs(row.original.documentDate).add(row.original.paymentDays,'day').format("DD.MM.YY") : '' }}</span> <span v-if="row.original.paymentDays && ['invoices','advanceInvoices'].includes(row.original.type)">{{ row.original.documentDate ? dayjs(row.original.documentDate).add(row.original.paymentDays,'day').format("DD.MM.YY") : '' }}</span>
</template> </template>
<template #amount-cell="{ row }"> <template #amount-cell="{ row }">
<span v-if="row.original.type !== 'deliveryNotes'">{{ useCurrency(useSum().getCreatedDocumentSum(row.original, createddocuments)) }}</span> <span v-if="!deliveryNoteLikeDocumentTypes.includes(row.original.type)">{{ useCurrency(useSum().getCreatedDocumentSum(row.original, createddocuments)) }}</span>
</template> </template>
</UTable> </UTable>

View File

@@ -1,7 +1,7 @@
<script setup> <script setup>
import dayjs from "dayjs"; import dayjs from "dayjs"
const router = useRouter()
const router = useRouter()
const props = defineProps({ const props = defineProps({
queryStringData: { queryStringData: {
@@ -21,83 +21,264 @@ const props = defineProps({
} }
}) })
const statementallocations = ref([]) const loading = ref(true)
const incominginvoices = ref([]) const incomingInvoices = ref([])
const statementAllocations = ref([])
const selectedYear = ref(String(dayjs().year()))
const selectedMonth = ref("all")
const currency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} EUR`
const currentAccountId = computed(() => String(props.item?.id ?? ""))
const sameAccount = (value) => String(value ?? "") === currentAccountId.value
const getStatementId = (allocation) => allocation?.bankstatement?.id || allocation?.bs_id?.id || allocation?.bs_id || null
const getStatementLike = (allocation) => allocation?.bankstatement || (typeof allocation?.bs_id === "object" ? allocation.bs_id : null)
const getAllocationDate = (allocation) => {
const statement = getStatementLike(allocation)
return statement?.date || statement?.valueDate || allocation?.bs_id?.date || allocation?.bs_id?.valueDate || allocation?.manualBookingDate || null
}
const getAllocationPartner = (allocation) => {
const statement = getStatementLike(allocation)
if (!statement && allocation?.manualBookingDate) return "Manuelle Buchung"
return statement?.debName || statement?.credName || statement?.partner || allocation?.bs_id?.debName || allocation?.bs_id?.credName || ""
}
const getAllocationDescription = (allocation) => {
const statement = getStatementLike(allocation)
return allocation?.description || statement?.purpose || statement?.text || statement?.description || allocation?.bs_id?.purpose || allocation?.bs_id?.text || ""
}
const hasContent = (value) => value !== null && value !== undefined && String(value).trim() !== ""
const isContraAllocation = (allocation) =>
sameAccount(allocation.contraAccount?.id || allocation.contraAccount)
|| sameAccount(allocation.contraOwnaccount?.id || allocation.contraOwnaccount)
const touchesCurrentAccount = (allocation) =>
sameAccount(allocation.account?.id || allocation.account)
|| sameAccount(allocation.ownaccount?.id || allocation.ownaccount)
|| isContraAllocation(allocation)
const monthItems = [
{ label: "Ganzes Jahr", value: "all" },
{ label: "Januar", value: "1" },
{ label: "Februar", value: "2" },
{ label: "März", value: "3" },
{ label: "April", value: "4" },
{ label: "Mai", value: "5" },
{ label: "Juni", value: "6" },
{ label: "Juli", value: "7" },
{ label: "August", value: "8" },
{ label: "September", value: "9" },
{ label: "Oktober", value: "10" },
{ label: "November", value: "11" },
{ label: "Dezember", value: "12" }
]
const allAllocations = computed(() => {
const statementRows = statementAllocations.value.map((allocation) => ({
...allocation,
type: "statementallocation",
bankstatement: allocation.bankstatement || getStatementLike(allocation),
date: getAllocationDate(allocation),
partner: getAllocationPartner(allocation),
description: getAllocationDescription(allocation),
amount: Number(allocation.amount || 0) * (isContraAllocation(allocation) ? -1 : 1)
}))
const incomingInvoiceRows = incomingInvoices.value.flatMap((invoice) => {
return (invoice.accounts || [])
.filter((account) => sameAccount(account.account?.id || account.account))
.map((account, index) => ({
id: `${invoice.id}-${index}`,
incominginvoiceid: invoice.id,
type: "incominginvoice",
amount: Number(account.amountGross || account.amountNet || 0),
date: invoice.date,
partner: invoice.vendor?.name || "",
description: account.description || invoice.description || "",
color: invoice.expense ? "red" : "green",
expense: invoice.expense,
reference: invoice.reference || "-"
}))
})
return [...statementRows, ...incomingInvoiceRows]
})
const yearItems = computed(() => {
const years = [...new Set(
allAllocations.value
.map((allocation) => allocation.bankstatement?.date || allocation.date)
.filter(Boolean)
.map((date) => String(dayjs(date).year()))
)].sort((a, b) => Number(b) - Number(a))
return years.length > 0
? years.map((year) => ({ label: year, value: year }))
: [{ label: String(dayjs().year()), value: String(dayjs().year()) }]
})
const renderedAllocations = computed(() => {
return allAllocations.value.filter((allocation) => {
const allocationDateValue = allocation.bankstatement?.date || allocation.date
const allocationDate = allocationDateValue ? dayjs(allocationDateValue) : null
if (allocationDate && allocationDate.year().toString() !== selectedYear.value) {
return false
}
if (allocationDate && selectedMonth.value !== "all" && allocationDate.month() + 1 !== Number(selectedMonth.value)) {
return false
}
return true
})
})
const totals = computed(() => {
return renderedAllocations.value.reduce((acc, allocation) => {
const amount = Number(allocation.amount || 0)
if (allocation.incominginvoiceid) {
if (allocation.expense) {
acc.expenses += amount
acc.balance -= amount
} else {
acc.income += amount
acc.balance += amount
}
} else {
if (amount < 0) {
acc.expenses += Math.abs(amount)
} else {
acc.income += amount
}
acc.balance += amount
}
return acc
}, { income: 0, expenses: 0, balance: 0 })
})
const columns = [
{ accessorKey: "amount", header: "Betrag" },
{ accessorKey: "date", header: "Datum" },
{ accessorKey: "partner", header: "Partner" },
{ accessorKey: "description", header: "Beschreibung" }
]
const setup = async () => { const setup = async () => {
loading.value = true
statementAllocations.value = (await useEntities("statementallocations").select("*, bankstatement(*), createddocument(*), incominginvoice(*)"))
.filter((allocation) => touchesCurrentAccount(allocation))
incomingInvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)"))
.filter((invoice) => (invoice.accounts || []).some((account) => sameAccount(account.account?.id || account.account)))
const firstYear = yearItems.value[0]?.value
if (firstYear && !yearItems.value.some((item) => item.value === selectedYear.value)) {
selectedYear.value = firstYear
}
loading.value = false
} }
setup() setup()
const selectAllocation = (allocation) => { const unwrapAllocationRow = (allocationLike) => allocationLike?.original || allocationLike
if(allocation.type === "statementallocation") {
router.push(`/banking/statements/edit/${allocation.bs_id.id}`) const selectAllocation = (allocationLike) => {
} else if(allocation.type === "incominginvoice") { const allocation = unwrapAllocationRow(allocationLike)
router.push(`/incominginvoices/show/${allocation.incominginvoiceid}`)
if (!allocation) {
return
}
const statementId = getStatementId(allocation)
if (allocation.type === "statementallocation" && statementId) {
router.push(`/banking/statements/edit/${statementId}`)
} else if (allocation.type === "incominginvoice" && allocation.incominginvoiceid) {
router.push(`/incomingInvoices/show/${allocation.incominginvoiceid}`)
} }
} }
const renderedAllocations = computed(() => {
let tempstatementallocations = props.item.statementallocations.map(i => {
return {
...i,
type: "statementallocation",
date: i.bs_id.date,
partner: i.bs_id ? (i.bs_id.debName ? i.bs_id.debName : (i.bs_id.credName ? i.bs_id.credName : '')) : ''
}
})
/*let incominginvoicesallocations = []
incominginvoices.value.forEach(i => {
incominginvoicesallocations.push(...i.accounts.filter(x => x.account == route.params.id).map(x => {
return {
...x,
incominginvoiceid: i.id,
type: "incominginvoice",
amount: x.amountGross ? x.amountGross : x.amountNet,
date: i.date,
partner: i.vendor.name,
description: i.description,
color: i.expense ? "red" : "green"
}
}))
})*/
return [...tempstatementallocations/*, ... incominginvoicesallocations*/]
})
</script> </script>
<template> <template>
<UCard class="mt-5"> <UCard class="mt-5">
<UTable <div class="space-y-4">
v-if="props.item.statementallocations" <div class="flex flex-col gap-3 md:flex-row md:items-end">
:data="renderedAllocations" <UFormField label="Jahr" class="w-full md:w-48">
:columns="normalizeTableColumns([{key:'amount', label:'Betrag'},{key:'date', label:'Datum'},{key:'partner', label:'Partner'},{key:'description', label:'Beschreibung'}])" <USelectMenu
:on-select="(i) => selectAllocation(i)" v-model="selectedYear"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }" :items="yearItems"
> value-key="value"
<template #amount-cell="{row}"> label-key="label"
<span class="text-right text-rose-600" v-if="row.original.amount < 0 || row.original.color === 'red'">{{useCurrency(row.original.amount)}}</span> class="w-full"
<span class="text-right text-primary-500" v-else-if="row.original.amount > 0 || row.original.color === 'green'">{{useCurrency(row.original.amount)}}</span> />
<span v-else>{{useCurrency(row.original.amount)}}</span> </UFormField>
</template>
<template #date-cell="{row}"> <UFormField label="Monat" class="w-full md:w-56">
{{row.original.date ? dayjs(row.original.date).format('DD.MM.YYYY') : ''}} <USelectMenu
</template> v-model="selectedMonth"
<template #description-cell="{row}"> :items="monthItems"
{{row.original.description ? row.original.description : ''}} value-key="value"
</template> label-key="label"
</UTable> class="w-full"
/>
</UFormField>
</div>
<div class="grid gap-3 md:grid-cols-3">
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Einnahmen</div>
<div class="mt-1 text-xl font-semibold">{{ currency(totals.income) }}</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Ausgaben</div>
<div class="mt-1 text-xl font-semibold">{{ currency(totals.expenses) }}</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Saldo</div>
<div class="mt-1 text-xl font-semibold">{{ currency(totals.balance) }}</div>
</UCard>
</div>
<div v-if="!loading" class="overflow-auto max-h-[60vh]">
<UTable
:data="renderedAllocations"
:columns="normalizeTableColumns(columns)"
:on-select="selectAllocation"
class="w-full"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen im ausgewählten Zeitraum' }"
>
<template #amount-cell="{ row }">
<span class="text-right text-error" v-if="row.original.amount < 0 || row.original.color === 'red'">{{ useCurrency(row.original.amount) }}</span>
<span class="text-right text-primary-500" v-else-if="row.original.amount > 0 || row.original.color === 'green'">{{ useCurrency(row.original.amount) }}</span>
<span v-else>{{ useCurrency(row.original.amount) }}</span>
</template>
<template #date-cell="{ row }">
{{ row.original.date && dayjs(row.original.date).isValid() ? dayjs(row.original.date).format('DD.MM.YYYY') : '-' }}
</template>
<template #partner-cell="{ row }">
<div class="truncate">{{ hasContent(row.original.partner) ? row.original.partner : '-' }}</div>
</template>
<template #description-cell="{ row }">
<UTooltip :text="hasContent(row.original.description) ? row.original.description : '-'">
<div class="max-w-[22rem] truncate">{{ hasContent(row.original.description) ? row.original.description : '-' }}</div>
</UTooltip>
</template>
</UTable>
</div>
<UProgress v-else animation="carousel" class="w-3/4 mx-auto" />
</div>
</UCard> </UCard>
</template> </template>
<style scoped>
</style>

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