Compare commits
10 Commits
966c121cbf
...
7e0a2f5e4f
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e0a2f5e4f | |||
| 84c174ca09 | |||
| a9d3d0038f | |||
| 003d88587a | |||
| 69ff646689 | |||
| 1511340f00 | |||
| 62accb5a86 | |||
| 8c935c6101 | |||
| f6bdf2906f | |||
| dff3a23c04 |
518
README.md
518
README.md
@@ -1,109 +1,439 @@
|
|||||||
|
# FEDEO Hosting Guide
|
||||||
|
|
||||||
|
Diese Anleitung beschreibt ein produktionsnahes Self-Hosting von FEDEO mit Docker Compose, Traefik, PostgreSQL und optionalem S3-kompatiblem Objektspeicher via MinIO.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
# Docker Compose Setup
|
Der Stack besteht aus:
|
||||||
|
|
||||||
## ENV Vars
|
- `frontend`: Nuxt-Frontend auf Port `3000`
|
||||||
|
- `backend`: Node/Fastify-API auf Port `3100`
|
||||||
|
- `db`: PostgreSQL
|
||||||
|
- `traefik`: Reverse Proxy mit automatischen Let's-Encrypt-Zertifikaten
|
||||||
|
- optional `minio`: S3-kompatibler Objektspeicher fur Dateiuploads
|
||||||
|
|
||||||
- DOMAIN
|
Die Konfiguration erfolgt uber Umgebungsvariablen beziehungsweise eine `.env`-Datei im Deploy-Verzeichnis.
|
||||||
- PDF_LICENSE
|
|
||||||
- DB_PASS
|
|
||||||
- DB_USER
|
|
||||||
- CONTACT_EMAIL
|
|
||||||
|
|
||||||
## Docker Compose File
|
## Voraussetzungen
|
||||||
~~~
|
|
||||||
|
Vor dem Deployment sollten folgende Punkte erfullt sein:
|
||||||
|
|
||||||
|
- Ein Linux-Server oder VPS mit offentlichen Ports `80` und `443`
|
||||||
|
- Docker Engine inkl. Compose Plugin
|
||||||
|
- Eine Domain, die auf den Server zeigt, z. B. `app.example.com`
|
||||||
|
- Optional: SMTP-Zugang fur E-Mails
|
||||||
|
- Optional: S3-Bucket oder MinIO fur Dateispeicher
|
||||||
|
|
||||||
|
Empfohlen:
|
||||||
|
|
||||||
|
- mindestens 2 vCPU
|
||||||
|
- mindestens 4 GB RAM
|
||||||
|
- SSD-Speicher fur PostgreSQL und Dateiuploads
|
||||||
|
|
||||||
|
## DNS und Netzwerk
|
||||||
|
|
||||||
|
Lege mindestens einen A- oder AAAA-Record an:
|
||||||
|
|
||||||
|
- `app.example.com -> <SERVER-IP>`
|
||||||
|
|
||||||
|
Traefik terminiert TLS direkt im Compose-Stack. Es ist kein zusatzlicher Reverse Proxy davor erforderlich.
|
||||||
|
|
||||||
|
## Benotigte Backend-Umgebungsvariablen
|
||||||
|
|
||||||
|
Das Backend erwartet mindestens diese Umgebungsvariablen:
|
||||||
|
|
||||||
|
- `COOKIE_SECRET`
|
||||||
|
- `JWT_SECRET`
|
||||||
|
- `PORT`
|
||||||
|
- `HOST`
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `S3_BUCKET`
|
||||||
|
- `ENCRYPTION_KEY`
|
||||||
|
- `MAILER_SMTP_HOST`
|
||||||
|
- `MAILER_SMTP_PORT`
|
||||||
|
- `MAILER_SMTP_SSL`
|
||||||
|
- `MAILER_SMTP_USER`
|
||||||
|
- `MAILER_SMTP_PASS`
|
||||||
|
- `MAILER_FROM`
|
||||||
|
- `S3_ENDPOINT`
|
||||||
|
- `S3_REGION`
|
||||||
|
- `S3_ACCESS_KEY`
|
||||||
|
- `S3_SECRET_KEY`
|
||||||
|
- `M2M_API_KEY`
|
||||||
|
- `API_BASE_URL`
|
||||||
|
- `GOCARDLESS_BASE_URL`
|
||||||
|
- `GOCARDLESS_SECRET_ID`
|
||||||
|
- `GOCARDLESS_SECRET_KEY`
|
||||||
|
- `DOKUBOX_IMAP_HOST`
|
||||||
|
- `DOKUBOX_IMAP_PORT`
|
||||||
|
- `DOKUBOX_IMAP_SECURE`
|
||||||
|
- `DOKUBOX_IMAP_USER`
|
||||||
|
- `DOKUBOX_IMAP_PASSWORD`
|
||||||
|
- `OPENAI_API_KEY`
|
||||||
|
- `STIRLING_API_KEY`
|
||||||
|
|
||||||
|
Minimal wichtige Werte fur den ersten Start:
|
||||||
|
|
||||||
|
- `HOST=0.0.0.0`
|
||||||
|
- `PORT=3100`
|
||||||
|
- `DATABASE_URL=postgres://fedeo:<starkes-passwort>@db:5432/fedeo`
|
||||||
|
- `API_BASE_URL=https://app.example.com/backend`
|
||||||
|
|
||||||
|
Wenn du MinIO verwendest, setze zusatzlich:
|
||||||
|
|
||||||
|
- `S3_ENDPOINT=http://minio:9000`
|
||||||
|
- `S3_REGION=eu-central-1`
|
||||||
|
- `S3_ACCESS_KEY=<MINIO_ROOT_USER>`
|
||||||
|
- `S3_SECRET_KEY=<MINIO_ROOT_PASSWORD>`
|
||||||
|
- `S3_BUCKET=fedeo`
|
||||||
|
|
||||||
|
## Deploy-Struktur
|
||||||
|
|
||||||
|
Deploye den Stack direkt aus einem geklonten Checkout dieses Repositories, weil die Compose-Datei die lokalen Build-Kontexte `./frontend` und `./backend` verwendet.
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <DEIN-REPO-URL> /opt/fedeo
|
||||||
|
cd /opt/fedeo
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Verzeichnisstruktur sollte dann mindestens so aussehen:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/fedeo/
|
||||||
|
docker-compose.yml
|
||||||
|
.env
|
||||||
|
backend/
|
||||||
|
frontend/
|
||||||
|
traefik/
|
||||||
|
letsencrypt/
|
||||||
|
logs/
|
||||||
|
postgres/
|
||||||
|
minio/
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/fedeo/traefik/letsencrypt
|
||||||
|
mkdir -p /opt/fedeo/traefik/logs
|
||||||
|
mkdir -p /opt/fedeo/postgres
|
||||||
|
mkdir -p /opt/fedeo/minio
|
||||||
|
touch /opt/fedeo/traefik/letsencrypt/acme.json
|
||||||
|
chmod 600 /opt/fedeo/traefik/letsencrypt/acme.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel `.env`
|
||||||
|
|
||||||
|
Diese Datei liegt neben der `docker-compose.yml`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DOMAIN=app.example.com
|
||||||
|
CONTACT_EMAIL=admin@example.com
|
||||||
|
|
||||||
|
DB_NAME=fedeo
|
||||||
|
DB_USER=fedeo
|
||||||
|
DB_PASSWORD=change-this-db-password
|
||||||
|
DATABASE_URL=postgres://fedeo:change-this-db-password@db:5432/fedeo
|
||||||
|
|
||||||
|
MINIO_ROOT_USER=fedeo-minio
|
||||||
|
MINIO_ROOT_PASSWORD=change-this-minio-password
|
||||||
|
MINIO_BUCKET=fedeo
|
||||||
|
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3100
|
||||||
|
COOKIE_SECRET=change-this-cookie-secret
|
||||||
|
JWT_SECRET=change-this-jwt-secret
|
||||||
|
ENCRYPTION_KEY=change-this-encryption-key
|
||||||
|
|
||||||
|
MAILER_SMTP_HOST=smtp.example.com
|
||||||
|
MAILER_SMTP_PORT=587
|
||||||
|
MAILER_SMTP_SSL=false
|
||||||
|
MAILER_SMTP_USER=mailer@example.com
|
||||||
|
MAILER_SMTP_PASS=change-this-mail-password
|
||||||
|
MAILER_FROM=FEDEO <no-reply@example.com>
|
||||||
|
|
||||||
|
S3_ENDPOINT=http://minio:9000
|
||||||
|
S3_REGION=eu-central-1
|
||||||
|
S3_ACCESS_KEY=fedeo-minio
|
||||||
|
S3_SECRET_KEY=change-this-minio-password
|
||||||
|
S3_BUCKET=fedeo
|
||||||
|
|
||||||
|
M2M_API_KEY=change-this-m2m-key
|
||||||
|
API_BASE_URL=https://app.example.com/backend
|
||||||
|
GOCARDLESS_BASE_URL=https://api.gocardless.com
|
||||||
|
GOCARDLESS_SECRET_ID=replace-this
|
||||||
|
GOCARDLESS_SECRET_KEY=replace-this
|
||||||
|
|
||||||
|
DOKUBOX_IMAP_HOST=imap.example.com
|
||||||
|
DOKUBOX_IMAP_PORT=993
|
||||||
|
DOKUBOX_IMAP_SECURE=true
|
||||||
|
DOKUBOX_IMAP_USER=dokubox@example.com
|
||||||
|
DOKUBOX_IMAP_PASSWORD=change-this-imap-password
|
||||||
|
|
||||||
|
OPENAI_API_KEY=replace-this
|
||||||
|
STIRLING_API_KEY=replace-this
|
||||||
|
|
||||||
|
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vollstandiges Docker Compose mit optionaler S3-MinIO-Option
|
||||||
|
|
||||||
|
Hinweis: Der Stack unten startet MinIO standardmassig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
|
||||||
|
|
||||||
|
```yaml
|
||||||
services:
|
services:
|
||||||
frontend:
|
|
||||||
image: git.federspiel.tech/flfeders/fedeo/frontend:main
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
- NUXT_PUBLIC_API_BASE=https://${DOMAIN}/backend
|
|
||||||
- NUXT_PUBLIC_PDF_LICENSE=${PDF_LICENSE}
|
|
||||||
networks:
|
|
||||||
- traefik
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.docker.network=traefik"
|
|
||||||
- "traefik.port=3000"
|
|
||||||
# Middlewares
|
|
||||||
- "traefik.http.middlewares.fedeo-frontend-redirect-web-secure.redirectscheme.scheme=https"
|
|
||||||
# Web Entrypoint
|
|
||||||
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
|
|
||||||
- "traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)"
|
|
||||||
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
|
|
||||||
# Web Secure Entrypoint
|
|
||||||
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)"
|
|
||||||
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
|
|
||||||
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
|
|
||||||
backend:
|
|
||||||
image: git.federspiel.tech/flfeders/fedeo/backend:main
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
- INFISICAL_CLIENT_ID=
|
|
||||||
- INFISICAL_CLIENT_SECRET=
|
|
||||||
- NODE_ENV=production
|
|
||||||
networks:
|
|
||||||
- traefik
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.docker.network=traefik"
|
|
||||||
- "traefik.port=3100"
|
|
||||||
# Middlewares
|
|
||||||
- "traefik.http.middlewares.fedeo-backend-redirect-web-secure.redirectscheme.scheme=https"
|
|
||||||
- "traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend"
|
|
||||||
# Web Entrypoint
|
|
||||||
- "traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-redirect-web-secure"
|
|
||||||
- "traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)"
|
|
||||||
- "traefik.http.routers.fedeo-backend.entrypoints=web"
|
|
||||||
# Web Secure Entrypoint
|
|
||||||
- "traefik.http.routers.fedeo-backend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)"
|
|
||||||
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
|
|
||||||
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
|
|
||||||
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
|
|
||||||
# db:
|
|
||||||
# image: postgres
|
|
||||||
# restart: always
|
|
||||||
# shm_size: 128mb
|
|
||||||
# environment:
|
|
||||||
# POSTGRES_PASSWORD:
|
|
||||||
# POSTGRES_USER:
|
|
||||||
# POSTGRES_DB:
|
|
||||||
# volumes:
|
|
||||||
# - ./pg-data:/var/lib/postgresql/data
|
|
||||||
# ports:
|
|
||||||
# - "5432:5432"
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v2.11
|
image: traefik:v2.11
|
||||||
|
container_name: fedeo-traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
container_name: traefik
|
|
||||||
command:
|
command:
|
||||||
- "--api.insecure=false"
|
- --api.insecure=false
|
||||||
- "--api.dashboard=false"
|
- --api.dashboard=false
|
||||||
- "--api.debug=false"
|
- --providers.docker=true
|
||||||
- "--providers.docker=true"
|
- --providers.docker.exposedbydefault=false
|
||||||
- "--providers.docker.exposedbydefault=false"
|
- --entrypoints.web.address=:80
|
||||||
- "--providers.docker.network=traefik"
|
- --entrypoints.websecure.address=:443
|
||||||
- "--entrypoints.web.address=:80"
|
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||||
- "--entrypoints.web-secured.address=:443"
|
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||||
- "--accesslog=true"
|
- --certificatesresolvers.letsencrypt.acme.tlschallenge=true
|
||||||
- "--accesslog.filepath=/logs/access.log"
|
- --certificatesresolvers.letsencrypt.acme.email=${CONTACT_EMAIL}
|
||||||
- "--accesslog.bufferingsize=5000"
|
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||||
- "--accesslog.fields.defaultMode=keep"
|
- --accesslog=true
|
||||||
- "--accesslog.fields.headers.defaultMode=keep"
|
- --accesslog.filepath=/logs/access.log
|
||||||
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" #
|
|
||||||
- "--certificatesresolvers.mytlschallenge.acme.email=${CONTACT_EMAIL}"
|
|
||||||
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
|
|
||||||
ports:
|
ports:
|
||||||
- 80:80
|
- "80:80"
|
||||||
- 443:443
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS)
|
- ./traefik/letsencrypt:/letsencrypt
|
||||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
- ./traefik/logs:/logs
|
||||||
- "./traefik/logs:/logs"
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
networks:
|
networks:
|
||||||
- traefik
|
- web
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: fedeo-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_NAME}
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ./postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
networks:
|
networks:
|
||||||
traefik:
|
- internal
|
||||||
external: false
|
|
||||||
~~~
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: fedeo-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ./minio:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
createbuckets:
|
||||||
|
image: minio/mc:latest
|
||||||
|
container_name: fedeo-minio-init
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
|
||||||
|
mc mb --ignore-existing local/${MINIO_BUCKET};
|
||||||
|
mc anonymous set private local/${MINIO_BUCKET};
|
||||||
|
exit 0;
|
||||||
|
"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
container_name: fedeo-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
createbuckets:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
HOST: ${HOST}
|
||||||
|
PORT: ${PORT}
|
||||||
|
COOKIE_SECRET: ${COOKIE_SECRET}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
MAILER_SMTP_HOST: ${MAILER_SMTP_HOST}
|
||||||
|
MAILER_SMTP_PORT: ${MAILER_SMTP_PORT}
|
||||||
|
MAILER_SMTP_SSL: ${MAILER_SMTP_SSL}
|
||||||
|
MAILER_SMTP_USER: ${MAILER_SMTP_USER}
|
||||||
|
MAILER_SMTP_PASS: ${MAILER_SMTP_PASS}
|
||||||
|
MAILER_FROM: ${MAILER_FROM}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||||
|
S3_REGION: ${S3_REGION}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
S3_BUCKET: ${S3_BUCKET}
|
||||||
|
M2M_API_KEY: ${M2M_API_KEY}
|
||||||
|
API_BASE_URL: ${API_BASE_URL}
|
||||||
|
GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL}
|
||||||
|
GOCARDLESS_SECRET_ID: ${GOCARDLESS_SECRET_ID}
|
||||||
|
GOCARDLESS_SECRET_KEY: ${GOCARDLESS_SECRET_KEY}
|
||||||
|
DOKUBOX_IMAP_HOST: ${DOKUBOX_IMAP_HOST}
|
||||||
|
DOKUBOX_IMAP_PORT: ${DOKUBOX_IMAP_PORT}
|
||||||
|
DOKUBOX_IMAP_SECURE: ${DOKUBOX_IMAP_SECURE}
|
||||||
|
DOKUBOX_IMAP_USER: ${DOKUBOX_IMAP_USER}
|
||||||
|
DOKUBOX_IMAP_PASSWORD: ${DOKUBOX_IMAP_PASSWORD}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
STIRLING_API_KEY: ${STIRLING_API_KEY}
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
|
||||||
|
- traefik.http.routers.fedeo-backend.entrypoints=websecure
|
||||||
|
- traefik.http.routers.fedeo-backend.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
|
||||||
|
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
|
||||||
|
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
- internal
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
container_name: fedeo-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend
|
||||||
|
NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE}
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
|
||||||
|
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
|
||||||
|
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
|
driver: bridge
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
## Externe S3-Provider statt MinIO
|
||||||
|
|
||||||
|
Wenn du keinen lokalen MinIO-Container betreiben willst:
|
||||||
|
|
||||||
|
1. Entferne die Services `minio` und `createbuckets` aus der Compose-Datei.
|
||||||
|
2. Entferne im Backend `depends_on` fur `minio` und `createbuckets`.
|
||||||
|
3. Trage in `.env` die Zugangsdaten des externen S3-Dienstes ein.
|
||||||
|
|
||||||
|
Beispiel fur die relevanten Werte:
|
||||||
|
|
||||||
|
```env
|
||||||
|
S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
|
||||||
|
S3_REGION=eu-central-1
|
||||||
|
S3_ACCESS_KEY=...
|
||||||
|
S3_SECRET_KEY=...
|
||||||
|
S3_BUCKET=fedeo
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweis: Das Backend nutzt `forcePathStyle: true`. Das funktioniert sauber mit MinIO und vielen S3-kompatiblen Providern. Bei reinem AWS S3 kann je nach Endpoint-Setup ein abweichendes Verhalten sinnvoll sein. Falls du AWS S3 einsetzen willst, sollte die S3-Initialisierung im Backend gegen den konkreten Zielprovider getestet werden.
|
||||||
|
|
||||||
|
## Start des Stacks
|
||||||
|
|
||||||
|
Im Deploy-Verzeichnis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach Status prufen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f traefik
|
||||||
|
docker compose logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Funktionsprufung
|
||||||
|
|
||||||
|
Nach dem ersten Start sollten mindestens diese Checks erfolgreich sein:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://app.example.com
|
||||||
|
curl https://app.example.com/backend/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartung:
|
||||||
|
|
||||||
|
- Frontend liefert `200` oder `302`
|
||||||
|
- Backend liefert JSON wie `{"status":"ok"}`
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
Bei neuen Versionen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls du statt lokaler Builds vorgebaute Images verwenden willst, kannst du in der Compose-Datei `build:` durch passende `image:`-Eintrage ersetzen. Erst dann ist ein vorgelagertes `docker compose pull` sinnvoll.
|
||||||
|
|
||||||
|
## Backup-Empfehlung
|
||||||
|
|
||||||
|
Regelmassig sichern:
|
||||||
|
|
||||||
|
- `./postgres`
|
||||||
|
- `./minio` falls MinIO lokal genutzt wird
|
||||||
|
- `./traefik/letsencrypt/acme.json`
|
||||||
|
- deine `.env`
|
||||||
|
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management
|
||||||
|
|
||||||
|
## Bekannte Betriebsbesonderheiten
|
||||||
|
|
||||||
|
- Das Backend startet nur sauber, wenn alle Pflichtvariablen gesetzt sind.
|
||||||
|
- Ohne korrekt gesetzte S3-Secrets funktionieren Dateiuploads und dateibasierte Funktionen nicht.
|
||||||
|
- Fur die Frontend-PDF-Funktion wird eine gueltige `NUXT_PUBLIC_PDF_LICENSE` benotigt.
|
||||||
|
- PostgreSQL ist im Projekt vorgesehen; andere SQL-Datenbanken sind in dieser Compose-Datei nicht berucksichtigt.
|
||||||
|
|
||||||
|
## Optional: Nur mit bestehender externer Infrastruktur
|
||||||
|
|
||||||
|
Wenn bereits vorhanden:
|
||||||
|
|
||||||
|
- externer Reverse Proxy
|
||||||
|
- externer PostgreSQL-Server
|
||||||
|
- externer S3-Speicher
|
||||||
|
- externe Zertifikatsverwaltung
|
||||||
|
|
||||||
|
dann konnen `traefik`, `db` und `minio` aus dem Stack entfernt werden. In diesem Fall mussen die zugehorigen Hostnamen und Zugangsdaten in der `.env` beziehungsweise im Frontend-Environment auf die externe Infrastruktur zeigen.
|
||||||
|
|||||||
@@ -141,6 +141,13 @@
|
|||||||
"when": 1773572400000,
|
"when": 1773572400000,
|
||||||
"tag": "0020_file_extracted_text",
|
"tag": "0020_file_extracted_text",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773835200000,
|
||||||
|
"tag": "0021_admin_user_flag",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const authUsers = pgTable("auth_users", {
|
|||||||
|
|
||||||
multiTenant: boolean("multi_tenant").notNull().default(true),
|
multiTenant: boolean("multi_tenant").notNull().default(true),
|
||||||
must_change_password: boolean("must_change_password").notNull().default(false),
|
must_change_password: boolean("must_change_password").notNull().default(false),
|
||||||
|
is_admin: boolean("is_admin").notNull().default(false),
|
||||||
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export function syncDokuboxService (server: FastifyInstance) {
|
|||||||
let client: ImapFlow | null = null
|
let client: ImapFlow | null = null
|
||||||
|
|
||||||
async function initDokuboxClient() {
|
async function initDokuboxClient() {
|
||||||
|
if (client?.usable) {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
client = new ImapFlow({
|
client = new ImapFlow({
|
||||||
host: secrets.DOKUBOX_IMAP_HOST,
|
host: secrets.DOKUBOX_IMAP_HOST,
|
||||||
port: secrets.DOKUBOX_IMAP_PORT,
|
port: secrets.DOKUBOX_IMAP_PORT,
|
||||||
@@ -41,6 +45,7 @@ export function syncDokuboxService (server: FastifyInstance) {
|
|||||||
console.log("Dokubox E-Mail Client Initialized")
|
console.log("Dokubox E-Mail Client Initialized")
|
||||||
|
|
||||||
await client.connect()
|
await client.connect()
|
||||||
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncDokubox = async () => {
|
const syncDokubox = async () => {
|
||||||
@@ -92,7 +97,8 @@ export function syncDokuboxService (server: FastifyInstance) {
|
|||||||
if (!badMessageMessageSent) {
|
if (!badMessageMessageSent) {
|
||||||
badMessageMessageSent = true
|
badMessageMessageSent = true
|
||||||
}
|
}
|
||||||
return
|
server.log.warn({ messageId: message.id, subject: message.subject }, "Dokubox message could not be mapped to a tenant")
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.attachments.length > 0) {
|
if (message.attachments.length > 0) {
|
||||||
@@ -248,7 +254,6 @@ export function syncDokuboxService (server: FastifyInstance) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
run: async () => {
|
run: async () => {
|
||||||
await initDokuboxClient()
|
|
||||||
await syncDokubox()
|
await syncDokubox()
|
||||||
console.log("Service: Dokubox sync finished")
|
console.log("Service: Dokubox sync finished")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ import {
|
|||||||
|
|
||||||
import { eq, and, isNull, not } from "drizzle-orm"
|
import { eq, and, isNull, not } from "drizzle-orm"
|
||||||
|
|
||||||
|
const formatInvoiceItemDescription = (item: any) => {
|
||||||
|
const parts = [
|
||||||
|
typeof item.description === "string" ? item.description.trim() : "",
|
||||||
|
item.quantity !== null && item.quantity !== undefined
|
||||||
|
? [item.quantity, item.unit].filter(Boolean).join(" ")
|
||||||
|
: (typeof item.unit === "string" ? item.unit.trim() : ""),
|
||||||
|
typeof item.total === "number" ? `${item.total.toFixed(2)} EUR` : "",
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
return parts.join(" - ")
|
||||||
|
}
|
||||||
|
|
||||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||||
const processInvoices = async (tenantId:number) => {
|
const processInvoices = async (tenantId:number) => {
|
||||||
console.log("▶ Starting Incoming Invoice Preparation")
|
console.log("▶ Starting Incoming Invoice Preparation")
|
||||||
@@ -137,7 +149,8 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
if (data.reference) description += `Referenz: ${data.reference}\n`
|
if (data.reference) description += `Referenz: ${data.reference}\n`
|
||||||
if (data.invoice_items) {
|
if (data.invoice_items) {
|
||||||
for (const item of data.invoice_items) {
|
for (const item of data.invoice_items) {
|
||||||
description += `${item.description} - ${item.quantity} ${item.unit} - ${item.total}\n`
|
const line = formatInvoiceItemDescription(item)
|
||||||
|
if (line) description += `${line}\n`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemInfo.description = description.trim()
|
itemInfo.description = description.trim()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { secrets } from "../utils/secrets"
|
|||||||
import {
|
import {
|
||||||
authUserRoles,
|
authUserRoles,
|
||||||
authRolePermissions,
|
authRolePermissions,
|
||||||
|
authUsers,
|
||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
|
|
||||||
import { eq, and } from "drizzle-orm"
|
import { eq, and } from "drizzle-orm"
|
||||||
@@ -43,6 +44,16 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
// Payload an Request hängen
|
// Payload an Request hängen
|
||||||
req.user = payload
|
req.user = payload
|
||||||
|
|
||||||
|
const [currentUser] = await server.db
|
||||||
|
.select({
|
||||||
|
is_admin: authUsers.is_admin,
|
||||||
|
})
|
||||||
|
.from(authUsers)
|
||||||
|
.where(eq(authUsers.id, payload.user_id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
req.user.is_admin = Boolean(currentUser?.is_admin)
|
||||||
|
|
||||||
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
|
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
|
||||||
if (!req.user.tenant_id) {
|
if (!req.user.tenant_id) {
|
||||||
return
|
return
|
||||||
@@ -66,6 +77,13 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (roleRows.length === 0) {
|
if (roleRows.length === 0) {
|
||||||
|
if (req.user.is_admin) {
|
||||||
|
req.role = ""
|
||||||
|
req.permissions = []
|
||||||
|
req.hasPermission = () => false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return reply
|
return reply
|
||||||
.code(403)
|
.code(403)
|
||||||
.send({ error: "No role assigned for this tenant" })
|
.send({ error: "No role assigned for this tenant" })
|
||||||
@@ -107,6 +125,7 @@ declare module "fastify" {
|
|||||||
user_id: string
|
user_id: string
|
||||||
email: string
|
email: string
|
||||||
tenant_id: number | null
|
tenant_id: number | null
|
||||||
|
is_admin?: boolean
|
||||||
}
|
}
|
||||||
role: string
|
role: string
|
||||||
permissions: string[]
|
permissions: string[]
|
||||||
|
|||||||
@@ -1,19 +1,689 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
authTenantUsers,
|
authTenantUsers,
|
||||||
|
authProfiles,
|
||||||
|
authRoles,
|
||||||
|
authUserRoles,
|
||||||
authUsers,
|
authUsers,
|
||||||
|
filetags,
|
||||||
|
folders,
|
||||||
tenants,
|
tenants,
|
||||||
} from "../../db/schema";
|
} from "../../db/schema";
|
||||||
|
import { generateRandomPassword, hashPassword } from "../utils/password";
|
||||||
|
|
||||||
export default async function adminRoutes(server: FastifyInstance) {
|
export default async function adminRoutes(server: FastifyInstance) {
|
||||||
|
const deriveNameFromEmail = (email: string) => {
|
||||||
|
const localPart = email.split("@")[0] || "Benutzer";
|
||||||
|
const normalized = localPart.replace(/[._-]+/g, " ").trim();
|
||||||
|
const parts = normalized.split(/\s+/).filter(Boolean);
|
||||||
|
const firstName = parts[0]
|
||||||
|
? parts[0].charAt(0).toUpperCase() + parts[0].slice(1)
|
||||||
|
: "Neuer";
|
||||||
|
const lastName = parts.length > 1
|
||||||
|
? parts.slice(1).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ")
|
||||||
|
: "Benutzer";
|
||||||
|
|
||||||
|
return { first_name: firstName, last_name: lastName };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTenantSeeds = async (tenantId: number, createdBy: string) => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
const insertedTags = await server.db
|
||||||
|
.insert(filetags)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Rechnungen",
|
||||||
|
color: "#16a34a",
|
||||||
|
createdDocumentType: "invoices",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Angebote",
|
||||||
|
color: "#2563eb",
|
||||||
|
createdDocumentType: "quotes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Auftragsbestätigungen",
|
||||||
|
color: "#7c3aed",
|
||||||
|
createdDocumentType: "confirmationOrders",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Lieferscheine",
|
||||||
|
color: "#ea580c",
|
||||||
|
createdDocumentType: "deliveryNotes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Eingangsrechnungen",
|
||||||
|
color: "#dc2626",
|
||||||
|
incomingDocumentType: "invoices",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Mahnungen",
|
||||||
|
color: "#b91c1c",
|
||||||
|
incomingDocumentType: "reminders",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.returning({
|
||||||
|
id: filetags.id,
|
||||||
|
name: filetags.name,
|
||||||
|
createdDocumentType: filetags.createdDocumentType,
|
||||||
|
incomingDocumentType: filetags.incomingDocumentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoiceTag = insertedTags.find((tag) => tag.createdDocumentType === "invoices");
|
||||||
|
const quoteTag = insertedTags.find((tag) => tag.createdDocumentType === "quotes");
|
||||||
|
const confirmationTag = insertedTags.find((tag) => tag.createdDocumentType === "confirmationOrders");
|
||||||
|
const deliveryTag = insertedTags.find((tag) => tag.createdDocumentType === "deliveryNotes");
|
||||||
|
const incomingInvoiceTag = insertedTags.find((tag) => tag.incomingDocumentType === "invoices");
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.insert(folders)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Ausgangsrechnungen",
|
||||||
|
function: "invoices",
|
||||||
|
year: currentYear,
|
||||||
|
icon: "i-heroicons-document-text",
|
||||||
|
standardFiletype: invoiceTag?.id,
|
||||||
|
standardFiletypeIsOptional: false,
|
||||||
|
isSystemUsed: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: createdBy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Angebote",
|
||||||
|
function: "quotes",
|
||||||
|
year: currentYear,
|
||||||
|
icon: "i-heroicons-document-duplicate",
|
||||||
|
standardFiletype: quoteTag?.id,
|
||||||
|
standardFiletypeIsOptional: false,
|
||||||
|
isSystemUsed: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: createdBy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Auftragsbestätigungen",
|
||||||
|
function: "confirmationOrders",
|
||||||
|
year: currentYear,
|
||||||
|
icon: "i-heroicons-clipboard-document-check",
|
||||||
|
standardFiletype: confirmationTag?.id,
|
||||||
|
standardFiletypeIsOptional: false,
|
||||||
|
isSystemUsed: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: createdBy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Lieferscheine",
|
||||||
|
function: "deliveryNotes",
|
||||||
|
year: currentYear,
|
||||||
|
icon: "i-heroicons-truck",
|
||||||
|
standardFiletype: deliveryTag?.id,
|
||||||
|
standardFiletypeIsOptional: false,
|
||||||
|
isSystemUsed: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: createdBy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Eingangsrechnungen",
|
||||||
|
function: "incomingInvoices",
|
||||||
|
year: currentYear,
|
||||||
|
icon: "i-heroicons-inbox-arrow-down",
|
||||||
|
standardFiletype: incomingInvoiceTag?.id,
|
||||||
|
standardFiletypeIsOptional: false,
|
||||||
|
isSystemUsed: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: createdBy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Belege Bankeinzahlung",
|
||||||
|
function: "deposit",
|
||||||
|
year: currentYear,
|
||||||
|
icon: "i-heroicons-banknotes",
|
||||||
|
isSystemUsed: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: createdBy,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
if (!req.user?.user_id) {
|
||||||
|
reply.code(401).send({ error: "Unauthorized" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [currentUser] = await server.db
|
||||||
|
.select({
|
||||||
|
id: authUsers.id,
|
||||||
|
is_admin: authUsers.is_admin,
|
||||||
|
})
|
||||||
|
.from(authUsers)
|
||||||
|
.where(eq(authUsers.id, req.user.user_id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!currentUser?.is_admin) {
|
||||||
|
reply.code(403).send({ error: "Admin access required" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// GET /admin/overview
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.get("/admin/overview", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([
|
||||||
|
server.db
|
||||||
|
.select({
|
||||||
|
id: tenants.id,
|
||||||
|
name: tenants.name,
|
||||||
|
short: tenants.short,
|
||||||
|
createdAt: tenants.createdAt,
|
||||||
|
locked: tenants.locked,
|
||||||
|
})
|
||||||
|
.from(tenants),
|
||||||
|
server.db
|
||||||
|
.select({
|
||||||
|
id: authUsers.id,
|
||||||
|
email: authUsers.email,
|
||||||
|
created_at: authUsers.created_at,
|
||||||
|
multiTenant: authUsers.multiTenant,
|
||||||
|
must_change_password: authUsers.must_change_password,
|
||||||
|
is_admin: authUsers.is_admin,
|
||||||
|
})
|
||||||
|
.from(authUsers),
|
||||||
|
server.db
|
||||||
|
.select({
|
||||||
|
id: authProfiles.id,
|
||||||
|
user_id: authProfiles.user_id,
|
||||||
|
tenant_id: authProfiles.tenant_id,
|
||||||
|
first_name: authProfiles.first_name,
|
||||||
|
last_name: authProfiles.last_name,
|
||||||
|
full_name: authProfiles.full_name,
|
||||||
|
email: authProfiles.email,
|
||||||
|
active: authProfiles.active,
|
||||||
|
})
|
||||||
|
.from(authProfiles),
|
||||||
|
server.db
|
||||||
|
.select()
|
||||||
|
.from(authTenantUsers),
|
||||||
|
server.db
|
||||||
|
.select({
|
||||||
|
id: authRoles.id,
|
||||||
|
name: authRoles.name,
|
||||||
|
description: authRoles.description,
|
||||||
|
tenant_id: authRoles.tenant_id,
|
||||||
|
})
|
||||||
|
.from(authRoles),
|
||||||
|
server.db
|
||||||
|
.select({
|
||||||
|
user_id: authUserRoles.user_id,
|
||||||
|
role_id: authUserRoles.role_id,
|
||||||
|
tenant_id: authUserRoles.tenant_id,
|
||||||
|
})
|
||||||
|
.from(authUserRoles),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const users = userRows.map((user) => {
|
||||||
|
const profiles = profileRows.filter((profile) => profile.user_id === user.id);
|
||||||
|
const memberships = membershipRows.filter((membership) => membership.user_id === user.id);
|
||||||
|
const roleAssignments = roleAssignmentRows.filter((assignment) => assignment.user_id === user.id);
|
||||||
|
const preferredProfile = profiles.find((profile) => profile.active) || profiles[0];
|
||||||
|
const fallbackName = deriveNameFromEmail(user.email);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
display_name: preferredProfile?.full_name || user.email,
|
||||||
|
profile_defaults: {
|
||||||
|
first_name: preferredProfile?.first_name || fallbackName.first_name,
|
||||||
|
last_name: preferredProfile?.last_name || fallbackName.last_name,
|
||||||
|
},
|
||||||
|
profiles,
|
||||||
|
tenant_ids: memberships.map((membership) => membership.tenant_id),
|
||||||
|
role_assignments: roleAssignments,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenantsWithCounts = tenantRows.map((tenant) => ({
|
||||||
|
...tenant,
|
||||||
|
user_count: membershipRows.filter((membership) => membership.tenant_id === tenant.id).length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
users,
|
||||||
|
tenants: tenantsWithCounts,
|
||||||
|
roles: roleRows,
|
||||||
|
unassignedProfiles: profileRows.filter((profile) => !profile.user_id),
|
||||||
|
memberships: membershipRows,
|
||||||
|
roleAssignments: roleAssignmentRows,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/overview:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// POST /admin/users
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.post("/admin/users", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const body = req.body as {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
is_admin?: boolean;
|
||||||
|
multiTenant?: boolean;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const email = body.email?.trim().toLowerCase();
|
||||||
|
if (!email) {
|
||||||
|
return reply.code(400).send({ error: "email required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = body.password?.trim() || generateRandomPassword(14);
|
||||||
|
const passwordHash = await hashPassword(initialPassword);
|
||||||
|
|
||||||
|
const [createdUser] = await server.db
|
||||||
|
.insert(authUsers)
|
||||||
|
.values({
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
is_admin: Boolean(body.is_admin),
|
||||||
|
multiTenant: typeof body.multiTenant === "boolean" ? body.multiTenant : true,
|
||||||
|
must_change_password: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: authUsers.id,
|
||||||
|
email: authUsers.email,
|
||||||
|
created_at: authUsers.created_at,
|
||||||
|
multiTenant: authUsers.multiTenant,
|
||||||
|
must_change_password: authUsers.must_change_password,
|
||||||
|
is_admin: authUsers.is_admin,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: createdUser,
|
||||||
|
initialPassword,
|
||||||
|
profile_defaults: {
|
||||||
|
first_name: body.first_name?.trim() || deriveNameFromEmail(email).first_name,
|
||||||
|
last_name: body.last_name?.trim() || deriveNameFromEmail(email).last_name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/users:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// POST /admin/tenants
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.post("/admin/tenants", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const body = req.body as {
|
||||||
|
name?: string;
|
||||||
|
short?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = body.name?.trim();
|
||||||
|
const short = body.short?.trim();
|
||||||
|
|
||||||
|
if (!name || !short) {
|
||||||
|
return reply.code(400).send({ error: "name and short required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [createdTenant] = await server.db
|
||||||
|
.insert(tenants)
|
||||||
|
.values({
|
||||||
|
name,
|
||||||
|
short,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: currentUser.id,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: tenants.id,
|
||||||
|
name: tenants.name,
|
||||||
|
short: tenants.short,
|
||||||
|
createdAt: tenants.createdAt,
|
||||||
|
locked: tenants.locked,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createTenantSeeds(createdTenant.id, currentUser.id);
|
||||||
|
|
||||||
|
return { tenant: createdTenant };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/tenants:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// PUT /admin/users/:user_id
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.put("/admin/users/:user_id", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const { user_id } = req.params as { user_id: string };
|
||||||
|
const body = req.body as {
|
||||||
|
email?: string;
|
||||||
|
multiTenant?: boolean;
|
||||||
|
must_change_password?: boolean;
|
||||||
|
is_admin?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateData: Record<string, any> = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof body.email === "string") updateData.email = body.email.trim().toLowerCase();
|
||||||
|
if (typeof body.multiTenant === "boolean") updateData.multiTenant = body.multiTenant;
|
||||||
|
if (typeof body.must_change_password === "boolean") updateData.must_change_password = body.must_change_password;
|
||||||
|
if (typeof body.is_admin === "boolean") updateData.is_admin = body.is_admin;
|
||||||
|
|
||||||
|
const [updatedUser] = await server.db
|
||||||
|
.update(authUsers)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(authUsers.id, user_id))
|
||||||
|
.returning({
|
||||||
|
id: authUsers.id,
|
||||||
|
email: authUsers.email,
|
||||||
|
created_at: authUsers.created_at,
|
||||||
|
multiTenant: authUsers.multiTenant,
|
||||||
|
must_change_password: authUsers.must_change_password,
|
||||||
|
is_admin: authUsers.is_admin,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedUser) {
|
||||||
|
return reply.code(404).send({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user: updatedUser };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/users/:user_id:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// PUT /admin/tenants/:tenant_id
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.put("/admin/tenants/:tenant_id", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const { tenant_id } = req.params as { tenant_id: string };
|
||||||
|
const body = req.body as {
|
||||||
|
name?: string;
|
||||||
|
short?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateData: Record<string, any> = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: currentUser.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof body.name === "string") updateData.name = body.name.trim();
|
||||||
|
if (typeof body.short === "string") updateData.short = body.short.trim();
|
||||||
|
|
||||||
|
const [updatedTenant] = await server.db
|
||||||
|
.update(tenants)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(tenants.id, Number(tenant_id)))
|
||||||
|
.returning({
|
||||||
|
id: tenants.id,
|
||||||
|
name: tenants.name,
|
||||||
|
short: tenants.short,
|
||||||
|
createdAt: tenants.createdAt,
|
||||||
|
locked: tenants.locked,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedTenant) {
|
||||||
|
return reply.code(404).send({ error: "Tenant not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tenant: updatedTenant };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/tenants/:tenant_id:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// PUT /admin/users/:user_id/access
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.put("/admin/users/:user_id/access", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const { user_id } = req.params as { user_id: string };
|
||||||
|
const body = req.body as {
|
||||||
|
tenant_ids?: number[];
|
||||||
|
role_assignments?: { tenant_id: number; role_id: string }[];
|
||||||
|
profile_defaults?: { first_name?: string; last_name?: string };
|
||||||
|
profile_assignments?: { tenant_id: number; profile_id?: string | null }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const tenantIds = Array.from(new Set((body.tenant_ids || []).map((tenantId) => Number(tenantId)).filter(Boolean)));
|
||||||
|
const requestedAssignments = (body.role_assignments || [])
|
||||||
|
.map((assignment) => ({
|
||||||
|
tenant_id: Number(assignment.tenant_id),
|
||||||
|
role_id: assignment.role_id,
|
||||||
|
}))
|
||||||
|
.filter((assignment) => assignment.tenant_id && assignment.role_id && tenantIds.includes(assignment.tenant_id));
|
||||||
|
|
||||||
|
const [targetUser] = await server.db
|
||||||
|
.select({ id: authUsers.id, email: authUsers.email })
|
||||||
|
.from(authUsers)
|
||||||
|
.where(eq(authUsers.id, user_id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return reply.code(404).send({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableRoles = requestedAssignments.length
|
||||||
|
? await server.db
|
||||||
|
.select({
|
||||||
|
id: authRoles.id,
|
||||||
|
tenant_id: authRoles.tenant_id,
|
||||||
|
})
|
||||||
|
.from(authRoles)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
authRoles.id,
|
||||||
|
requestedAssignments.map((assignment) => assignment.role_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const validRoleIds = new Set(
|
||||||
|
availableRoles
|
||||||
|
.filter((role) =>
|
||||||
|
role.tenant_id === null ||
|
||||||
|
requestedAssignments.some((assignment) => assignment.role_id === role.id && assignment.tenant_id === role.tenant_id)
|
||||||
|
)
|
||||||
|
.map((role) => role.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const validAssignments = requestedAssignments.filter((assignment) => validRoleIds.has(assignment.role_id));
|
||||||
|
const existingMemberships = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authTenantUsers)
|
||||||
|
.where(eq(authTenantUsers.user_id, user_id));
|
||||||
|
const removedTenantIds = existingMemberships
|
||||||
|
.map((membership) => membership.tenant_id)
|
||||||
|
.filter((tenantId) => !tenantIds.includes(tenantId));
|
||||||
|
|
||||||
|
const existingUserProfiles = await server.db
|
||||||
|
.select({
|
||||||
|
id: authProfiles.id,
|
||||||
|
tenant_id: authProfiles.tenant_id,
|
||||||
|
})
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(eq(authProfiles.user_id, user_id));
|
||||||
|
|
||||||
|
const unassignedProfiles = tenantIds.length
|
||||||
|
? await server.db
|
||||||
|
.select({
|
||||||
|
id: authProfiles.id,
|
||||||
|
tenant_id: authProfiles.tenant_id,
|
||||||
|
})
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(authProfiles.tenant_id, tenantIds),
|
||||||
|
isNull(authProfiles.user_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const fallbackName = deriveNameFromEmail(targetUser.email);
|
||||||
|
const profileDefaults = {
|
||||||
|
first_name: body.profile_defaults?.first_name?.trim() || fallbackName.first_name,
|
||||||
|
last_name: body.profile_defaults?.last_name?.trim() || fallbackName.last_name,
|
||||||
|
};
|
||||||
|
const requestedProfileAssignments = new Map<number, string>(
|
||||||
|
(body.profile_assignments || [])
|
||||||
|
.filter((assignment) => assignment?.tenant_id && assignment.profile_id)
|
||||||
|
.map((assignment) => [Number(assignment.tenant_id), String(assignment.profile_id)])
|
||||||
|
);
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.delete(authUserRoles)
|
||||||
|
.where(eq(authUserRoles.user_id, user_id));
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.delete(authTenantUsers)
|
||||||
|
.where(eq(authTenantUsers.user_id, user_id));
|
||||||
|
|
||||||
|
if (tenantIds.length) {
|
||||||
|
await server.db
|
||||||
|
.insert(authTenantUsers)
|
||||||
|
.values(
|
||||||
|
tenantIds.map((tenantId) => ({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id,
|
||||||
|
created_by: currentUser.id,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validAssignments.length) {
|
||||||
|
await server.db
|
||||||
|
.insert(authUserRoles)
|
||||||
|
.values(
|
||||||
|
validAssignments.map((assignment) => ({
|
||||||
|
user_id,
|
||||||
|
tenant_id: assignment.tenant_id,
|
||||||
|
role_id: assignment.role_id,
|
||||||
|
created_by: currentUser.id,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedTenantIds.length) {
|
||||||
|
await server.db
|
||||||
|
.update(authProfiles)
|
||||||
|
.set({ user_id: null })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(authProfiles.user_id, user_id),
|
||||||
|
inArray(authProfiles.tenant_id, removedTenantIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingProfileTenantIds = new Set(existingUserProfiles.map((profile) => profile.tenant_id));
|
||||||
|
|
||||||
|
for (const tenantId of tenantIds) {
|
||||||
|
if (existingProfileTenantIds.has(tenantId)) continue;
|
||||||
|
|
||||||
|
const requestedProfileId = requestedProfileAssignments.get(tenantId);
|
||||||
|
const freeProfile = requestedProfileId
|
||||||
|
? unassignedProfiles.find((profile) => profile.id === requestedProfileId && profile.tenant_id === tenantId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (freeProfile) {
|
||||||
|
await server.db
|
||||||
|
.update(authProfiles)
|
||||||
|
.set({ user_id })
|
||||||
|
.where(eq(authProfiles.id, freeProfile.id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.insert(authProfiles)
|
||||||
|
.values({
|
||||||
|
user_id,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
first_name: profileDefaults.first_name,
|
||||||
|
last_name: profileDefaults.last_name,
|
||||||
|
email: targetUser.email,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
tenant_ids: tenantIds,
|
||||||
|
role_assignments: validAssignments,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/users/:user_id/access:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// POST /admin/add-user-to-tenant
|
// POST /admin/add-user-to-tenant
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
server.post("/admin/add-user-to-tenant", async (req, reply) => {
|
server.post("/admin/add-user-to-tenant", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
const body = req.body as {
|
const body = req.body as {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
tenant_id: number;
|
tenant_id: number;
|
||||||
@@ -44,11 +714,10 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
await server.db
|
await server.db
|
||||||
.insert(authTenantUsers)
|
.insert(authTenantUsers)
|
||||||
// @ts-ignore
|
|
||||||
.values({
|
.values({
|
||||||
user_id: body.user_id,
|
user_id: body.user_id,
|
||||||
tenantId: body.tenant_id,
|
tenant_id: body.tenant_id,
|
||||||
role: body.role ?? "member",
|
created_by: currentUser.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, mode };
|
return { success: true, mode };
|
||||||
@@ -65,6 +734,9 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
server.get("/admin/user-tenants/:user_id", async (req, reply) => {
|
server.get("/admin/user-tenants/:user_id", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
const { user_id } = req.params as { user_id: string };
|
const { user_id } = req.params as { user_id: string };
|
||||||
|
|
||||||
if (!user_id) {
|
if (!user_id) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default async function meRoutes(server: FastifyInstance) {
|
|||||||
email: authUsers.email,
|
email: authUsers.email,
|
||||||
created_at: authUsers.created_at,
|
created_at: authUsers.created_at,
|
||||||
must_change_password: authUsers.must_change_password,
|
must_change_password: authUsers.must_change_password,
|
||||||
|
is_admin: authUsers.is_admin,
|
||||||
})
|
})
|
||||||
.from(authUsers)
|
.from(authUsers)
|
||||||
.where(eq(authUsers.id, userId))
|
.where(eq(authUsers.id, userId))
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
const normalizeIban = (value?: string | null) =>
|
const normalizeIban = (value?: string | null) =>
|
||||||
String(value || "").replace(/\s+/g, "").toUpperCase()
|
String(value || "").replace(/\s+/g, "").toUpperCase()
|
||||||
|
|
||||||
|
const normalizeName = (value?: string | null) =>
|
||||||
|
String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9äöüß]+/gi, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
|
||||||
const pickPartnerBankData = (statement: any, partnerType: "customer" | "vendor") => {
|
const pickPartnerBankData = (statement: any, partnerType: "customer" | "vendor") => {
|
||||||
if (!statement) return null
|
if (!statement) return null
|
||||||
|
|
||||||
@@ -60,6 +67,26 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pickPartnerReference = (statement: any, partnerType: "customer" | "vendor") => {
|
||||||
|
if (!statement) return null
|
||||||
|
|
||||||
|
const prefersDebit = partnerType === "customer"
|
||||||
|
? Number(statement.amount) >= 0
|
||||||
|
: Number(statement.amount) > 0
|
||||||
|
|
||||||
|
const primary = prefersDebit
|
||||||
|
? { iban: statement.debIban, name: statement.debName }
|
||||||
|
: { iban: statement.credIban, name: statement.credName }
|
||||||
|
const fallback = prefersDebit
|
||||||
|
? { iban: statement.credIban, name: statement.credName }
|
||||||
|
: { iban: statement.debIban, name: statement.debName }
|
||||||
|
|
||||||
|
return {
|
||||||
|
iban: normalizeIban(primary.iban) || normalizeIban(fallback.iban) || null,
|
||||||
|
name: String(primary.name || fallback.name || "").trim() || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mergePartnerIban = (infoData: Record<string, any>, iban: string, bankAccountId?: number | null) => {
|
const mergePartnerIban = (infoData: Record<string, any>, iban: string, bankAccountId?: number | null) => {
|
||||||
if (!iban && !bankAccountId) return infoData || {}
|
if (!iban && !bankAccountId) return infoData || {}
|
||||||
const info = infoData && typeof infoData === "object" ? { ...infoData } : {}
|
const info = infoData && typeof infoData === "object" ? { ...infoData } : {}
|
||||||
@@ -239,6 +266,177 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.get("/banking/statements/:id/suggestions", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const statementId = Number(id)
|
||||||
|
if (!statementId) return reply.code(400).send({ error: "Invalid statement id" })
|
||||||
|
|
||||||
|
const [statement] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(bankstatements)
|
||||||
|
.where(and(eq(bankstatements.id, statementId), eq(bankstatements.tenant, req.user.tenant_id)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!statement) return reply.code(404).send({ error: "Statement not found" })
|
||||||
|
|
||||||
|
const partnerType: "customer" | "vendor" = Number(statement.amount) >= 0 ? "customer" : "vendor"
|
||||||
|
const partnerRef = pickPartnerReference(statement, partnerType)
|
||||||
|
|
||||||
|
const suggestions: Array<Record<string, any>> = []
|
||||||
|
let matchedBankAccountId: number | null = null
|
||||||
|
|
||||||
|
if (partnerRef?.iban) {
|
||||||
|
const allAccounts = await server.db
|
||||||
|
.select({
|
||||||
|
id: entitybankaccounts.id,
|
||||||
|
ibanEncrypted: entitybankaccounts.ibanEncrypted,
|
||||||
|
})
|
||||||
|
.from(entitybankaccounts)
|
||||||
|
.where(eq(entitybankaccounts.tenant, req.user.tenant_id))
|
||||||
|
|
||||||
|
const matchingAccount = allAccounts.find((row) => {
|
||||||
|
if (!row.ibanEncrypted) return false
|
||||||
|
try {
|
||||||
|
return normalizeIban(decrypt(row.ibanEncrypted as any)) === partnerRef.iban
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
matchedBankAccountId = matchingAccount?.id ? Number(matchingAccount.id) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partnerType === "customer") {
|
||||||
|
const customerRows = await server.db
|
||||||
|
.select({
|
||||||
|
id: customers.id,
|
||||||
|
name: customers.name,
|
||||||
|
customerNumber: customers.customerNumber,
|
||||||
|
infoData: customers.infoData,
|
||||||
|
})
|
||||||
|
.from(customers)
|
||||||
|
.where(and(eq(customers.tenant, req.user.tenant_id), eq(customers.archived, false)))
|
||||||
|
|
||||||
|
for (const row of customerRows) {
|
||||||
|
const infoData = row.infoData && typeof row.infoData === "object" ? row.infoData as Record<string, any> : {}
|
||||||
|
const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.map(Number) : []
|
||||||
|
const bankingIbans = Array.isArray(infoData.bankingIbans) ? infoData.bankingIbans.map((iban) => normalizeIban(String(iban))) : []
|
||||||
|
const normalizedEntityName = normalizeName(row.name)
|
||||||
|
const normalizedStatementName = normalizeName(partnerRef?.name)
|
||||||
|
|
||||||
|
const matchesBankAccountId = matchedBankAccountId ? bankAccountIds.includes(matchedBankAccountId) : false
|
||||||
|
const matchesIban = partnerRef?.iban ? bankingIbans.includes(partnerRef.iban) : false
|
||||||
|
const exactNameMatch = normalizedEntityName && normalizedStatementName && normalizedEntityName === normalizedStatementName
|
||||||
|
const partialNameMatch = normalizedEntityName && normalizedStatementName
|
||||||
|
? normalizedEntityName.includes(normalizedStatementName) || normalizedStatementName.includes(normalizedEntityName)
|
||||||
|
: false
|
||||||
|
|
||||||
|
let score = 0
|
||||||
|
let reason = ""
|
||||||
|
|
||||||
|
if (matchesBankAccountId && matchesIban) {
|
||||||
|
score = 100
|
||||||
|
reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein"
|
||||||
|
} else if (matchesBankAccountId) {
|
||||||
|
score = 95
|
||||||
|
reason = "Hinterlegte Bankverbindung passt zur IBAN"
|
||||||
|
} else if (matchesIban) {
|
||||||
|
score = 90
|
||||||
|
reason = "IBAN wurde bereits bei diesem Kunden verwendet"
|
||||||
|
} else if (exactNameMatch) {
|
||||||
|
score = 60
|
||||||
|
reason = "Name passt exakt zur Buchung"
|
||||||
|
} else if (partialNameMatch) {
|
||||||
|
score = 45
|
||||||
|
reason = "Name aehnelt der Buchung"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!score) continue
|
||||||
|
|
||||||
|
suggestions.push({
|
||||||
|
type: "customer",
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
number: row.customerNumber,
|
||||||
|
score,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const vendorRows = await server.db
|
||||||
|
.select({
|
||||||
|
id: vendors.id,
|
||||||
|
name: vendors.name,
|
||||||
|
vendorNumber: vendors.vendorNumber,
|
||||||
|
infoData: vendors.infoData,
|
||||||
|
})
|
||||||
|
.from(vendors)
|
||||||
|
.where(and(eq(vendors.tenant, req.user.tenant_id), eq(vendors.archived, false)))
|
||||||
|
|
||||||
|
for (const row of vendorRows) {
|
||||||
|
const infoData = row.infoData && typeof row.infoData === "object" ? row.infoData as Record<string, any> : {}
|
||||||
|
const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.map(Number) : []
|
||||||
|
const bankingIbans = Array.isArray(infoData.bankingIbans) ? infoData.bankingIbans.map((iban) => normalizeIban(String(iban))) : []
|
||||||
|
const normalizedEntityName = normalizeName(row.name)
|
||||||
|
const normalizedStatementName = normalizeName(partnerRef?.name)
|
||||||
|
|
||||||
|
const matchesBankAccountId = matchedBankAccountId ? bankAccountIds.includes(matchedBankAccountId) : false
|
||||||
|
const matchesIban = partnerRef?.iban ? bankingIbans.includes(partnerRef.iban) : false
|
||||||
|
const exactNameMatch = normalizedEntityName && normalizedStatementName && normalizedEntityName === normalizedStatementName
|
||||||
|
const partialNameMatch = normalizedEntityName && normalizedStatementName
|
||||||
|
? normalizedEntityName.includes(normalizedStatementName) || normalizedStatementName.includes(normalizedEntityName)
|
||||||
|
: false
|
||||||
|
|
||||||
|
let score = 0
|
||||||
|
let reason = ""
|
||||||
|
|
||||||
|
if (matchesBankAccountId && matchesIban) {
|
||||||
|
score = 100
|
||||||
|
reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein"
|
||||||
|
} else if (matchesBankAccountId) {
|
||||||
|
score = 95
|
||||||
|
reason = "Hinterlegte Bankverbindung passt zur IBAN"
|
||||||
|
} else if (matchesIban) {
|
||||||
|
score = 90
|
||||||
|
reason = "IBAN wurde bereits bei diesem Lieferanten verwendet"
|
||||||
|
} else if (exactNameMatch) {
|
||||||
|
score = 60
|
||||||
|
reason = "Name passt exakt zur Buchung"
|
||||||
|
} else if (partialNameMatch) {
|
||||||
|
score = 45
|
||||||
|
reason = "Name aehnelt der Buchung"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!score) continue
|
||||||
|
|
||||||
|
suggestions.push({
|
||||||
|
type: "vendor",
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
number: row.vendorNumber,
|
||||||
|
score,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions.sort((a, b) => b.score - a.score || String(a.name).localeCompare(String(b.name), "de"))
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
partnerType,
|
||||||
|
partnerName: partnerRef?.name || null,
|
||||||
|
partnerIban: partnerRef?.iban || null,
|
||||||
|
suggestions: suggestions.slice(0, 5),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
server.log.error(err)
|
||||||
|
return reply.code(500).send({ error: "Failed to load statement suggestions" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const assignIbanFromStatementToCustomer = async (tenantId: number, userId: string, statementId: number, createdDocumentId?: number) => {
|
const assignIbanFromStatementToCustomer = async (tenantId: number, userId: string, statementId: number, createdDocumentId?: number) => {
|
||||||
if (!createdDocumentId) return
|
if (!createdDocumentId) return
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: created.id,
|
id: created.id,
|
||||||
filename: data.filename,
|
filename: created.filename,
|
||||||
path: created.key
|
path: created.key
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any)
|
|||||||
return whereCond
|
return whereCond
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTenantColumn(resource: string, table: any) {
|
||||||
|
const config = resourceConfig[resource]
|
||||||
|
const tenantKey = config?.tenantKey || "tenant"
|
||||||
|
return table[tenantKey]
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -241,9 +247,13 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
const { resource } = req.params as { resource: string }
|
const { resource } = req.params as { resource: string }
|
||||||
const config = resourceConfig[resource]
|
const config = resourceConfig[resource]
|
||||||
|
if (!config) {
|
||||||
|
return reply.code(404).send({ error: "Unknown resource" })
|
||||||
|
}
|
||||||
const table = config.table
|
const table = config.table
|
||||||
|
|
||||||
let whereCond: any = eq(table.tenant, tenantId)
|
const tenantColumn = getTenantColumn(resource, table)
|
||||||
|
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
||||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||||
let q = server.db.select().from(table).$dynamic()
|
let q = server.db.select().from(table).$dynamic()
|
||||||
|
|
||||||
@@ -345,13 +355,17 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
const { resource } = req.params as { resource: string };
|
const { resource } = req.params as { resource: string };
|
||||||
const config = resourceConfig[resource];
|
const config = resourceConfig[resource];
|
||||||
|
if (!config) {
|
||||||
|
return reply.code(404).send({ error: "Unknown resource" });
|
||||||
|
}
|
||||||
const table = config.table;
|
const table = config.table;
|
||||||
|
|
||||||
const { queryConfig } = req;
|
const { queryConfig } = req;
|
||||||
const { pagination, sort, filters } = queryConfig;
|
const { pagination, sort, filters } = queryConfig;
|
||||||
const { search, distinctColumns } = req.query as { search?: string; distinctColumns?: string; };
|
const { search, distinctColumns } = req.query as { search?: string; distinctColumns?: string; };
|
||||||
|
|
||||||
let whereCond: any = eq(table.tenant, tenantId);
|
const tenantColumn = getTenantColumn(resource, table);
|
||||||
|
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
|
||||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||||
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 || [])];
|
||||||
@@ -451,7 +465,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let distinctWhereCond: any = eq(table.tenant, tenantId)
|
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
||||||
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
|
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { files } from "../../db/schema"
|
|||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { storeExtractedTextForFile } from "./documentText"
|
import { storeExtractedTextForFile } from "./documentText"
|
||||||
|
import { sanitizeFilename } from "./filename"
|
||||||
|
|
||||||
export const saveFile = async (
|
export const saveFile = async (
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
@@ -46,7 +47,10 @@ export const saveFile = async (
|
|||||||
|
|
||||||
// Name ermitteln (Fallback Logik)
|
// Name ermitteln (Fallback Logik)
|
||||||
// Wenn attachment ein Buffer ist, muss der Name in 'other' stehen oder generiert werden
|
// Wenn attachment ein Buffer ist, muss der Name in 'other' stehen oder generiert werden
|
||||||
const filename = attachment.filename || providedFilename || `${created.id}.pdf`
|
const filename = sanitizeFilename(
|
||||||
|
attachment.filename || providedFilename || `${created.id}.pdf`,
|
||||||
|
`${created.id}.pdf`
|
||||||
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------
|
// ---------------------------------------------------
|
||||||
// 2️⃣ BODY & CONTENT TYPE ERMITTELN
|
// 2️⃣ BODY & CONTENT TYPE ERMITTELN
|
||||||
@@ -108,7 +112,7 @@ export const saveFile = async (
|
|||||||
)
|
)
|
||||||
|
|
||||||
console.log(`File saved: ${key}`)
|
console.log(`File saved: ${key}`)
|
||||||
return { id: created.id, key }
|
return { id: created.id, key, filename }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("saveFile error:", err)
|
console.error("saveFile error:", err)
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
|
authProfiles,
|
||||||
bankaccounts,
|
bankaccounts,
|
||||||
bankrequisitions,
|
bankrequisitions,
|
||||||
bankstatements,
|
bankstatements,
|
||||||
entitybankaccounts,
|
entitybankaccounts,
|
||||||
|
events,
|
||||||
contacts,
|
contacts,
|
||||||
contracts,
|
contracts,
|
||||||
contracttypes,
|
contracttypes,
|
||||||
@@ -166,6 +168,16 @@ export const resourceConfig = {
|
|||||||
tasks: {
|
tasks: {
|
||||||
table: tasks,
|
table: tasks,
|
||||||
},
|
},
|
||||||
|
events: {
|
||||||
|
table: events,
|
||||||
|
mtoLoad: ["project", "customer"],
|
||||||
|
searchColumns: ["name", "notes", "link", "eventtype"],
|
||||||
|
},
|
||||||
|
profiles: {
|
||||||
|
table: authProfiles,
|
||||||
|
tenantKey: "tenant_id",
|
||||||
|
searchColumns: ["first_name", "last_name", "full_name", "email", "employee_number"],
|
||||||
|
},
|
||||||
letterheads: {
|
letterheads: {
|
||||||
table: letterheads,
|
table: letterheads,
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const showMembersNav = computed(() => {
|
|||||||
const showMemberRelationsNav = computed(() => {
|
const showMemberRelationsNav = computed(() => {
|
||||||
return tenantExtraModules.value.includes("verein") && has("members")
|
return tenantExtraModules.value.includes("verein") && has("members")
|
||||||
})
|
})
|
||||||
|
const isAdmin = computed(() => Boolean(auth.user?.is_admin))
|
||||||
const tenantFeatures = computed(() => auth.activeTenantData?.features || {})
|
const tenantFeatures = computed(() => auth.activeTenantData?.features || {})
|
||||||
const featureEnabled = (key) => tenantFeatures.value?.[key] !== false
|
const featureEnabled = (key) => tenantFeatures.value?.[key] !== false
|
||||||
|
|
||||||
@@ -25,6 +26,11 @@ const links = computed(() => {
|
|||||||
to: "/tasks",
|
to: "/tasks",
|
||||||
icon: "i-heroicons-rectangle-stack"
|
icon: "i-heroicons-rectangle-stack"
|
||||||
} : null,
|
} : null,
|
||||||
|
featureEnabled("planningBoard") ? {
|
||||||
|
label: "Plantafel",
|
||||||
|
to: "/organisation/plantafel",
|
||||||
|
icon: "i-heroicons-calendar-days"
|
||||||
|
} : null,
|
||||||
featureEnabled("wiki") ? {
|
featureEnabled("wiki") ? {
|
||||||
label: "Wiki",
|
label: "Wiki",
|
||||||
to: "/wiki",
|
to: "/wiki",
|
||||||
@@ -243,6 +249,11 @@ const links = computed(() => {
|
|||||||
to: "/settings/tenant",
|
to: "/settings/tenant",
|
||||||
icon: "i-heroicons-building-office",
|
icon: "i-heroicons-building-office",
|
||||||
} : null,
|
} : null,
|
||||||
|
isAdmin.value ? {
|
||||||
|
label: "Administration",
|
||||||
|
to: "/settings/admin",
|
||||||
|
icon: "i-heroicons-shield-check",
|
||||||
|
} : null,
|
||||||
featureEnabled("export") ? {
|
featureEnabled("export") ? {
|
||||||
label: "Export",
|
label: "Export",
|
||||||
to: "/export",
|
to: "/export",
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import { Line } from "vue-chartjs";
|
|||||||
|
|
||||||
dayjs.extend(customParseFormat)
|
dayjs.extend(customParseFormat)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
headerTarget: {
|
||||||
|
type: String,
|
||||||
|
default: ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const tempStore = useTempStore()
|
const tempStore = useTempStore()
|
||||||
|
const isMounted = ref(false)
|
||||||
|
|
||||||
const amountMode = ref("net")
|
const amountMode = ref("net")
|
||||||
const granularity = ref("year")
|
const granularity = ref("year")
|
||||||
@@ -218,12 +226,65 @@ const chartOptions = ref({
|
|||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showHeaderControls = computed(() => isMounted.value && !!props.headerTarget)
|
||||||
|
const showInlineControls = computed(() => !showHeaderControls.value)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isMounted.value = true
|
||||||
|
})
|
||||||
|
|
||||||
loadData()
|
loadData()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col gap-2">
|
<div class="h-full flex flex-col gap-2">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<Teleport v-if="showHeaderControls" :to="props.headerTarget">
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="granularity"
|
||||||
|
:options="granularityOptions"
|
||||||
|
value-attribute="value"
|
||||||
|
option-attribute="label"
|
||||||
|
class="w-28"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<USelectMenu
|
||||||
|
v-model="selectedYear"
|
||||||
|
:options="yearOptions"
|
||||||
|
value-attribute="value"
|
||||||
|
option-attribute="label"
|
||||||
|
class="w-24"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<USelectMenu
|
||||||
|
v-if="granularity === 'month'"
|
||||||
|
v-model="selectedMonth"
|
||||||
|
:options="monthOptions"
|
||||||
|
value-attribute="value"
|
||||||
|
option-attribute="label"
|
||||||
|
class="w-36"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButtonGroup size="xs">
|
||||||
|
<UButton
|
||||||
|
:variant="amountMode === 'net' ? 'solid' : 'outline'"
|
||||||
|
@click="amountMode = 'net'"
|
||||||
|
>
|
||||||
|
Netto
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
:variant="amountMode === 'gross' ? 'solid' : 'outline'"
|
||||||
|
@click="amountMode = 'gross'"
|
||||||
|
>
|
||||||
|
Brutto
|
||||||
|
</UButton>
|
||||||
|
</UButtonGroup>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<div v-if="showInlineControls" class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="granularity"
|
v-model="granularity"
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const profileStore = useProfileStore();
|
|
||||||
|
|
||||||
let unpaidInvoicesSum = ref(0)
|
let unpaidInvoicesSum = ref(0)
|
||||||
let unpaidInvoicesCount = ref(0)
|
let unpaidInvoicesCount = ref(0)
|
||||||
let unpaidOverdueInvoicesSum = ref(0)
|
let unpaidOverdueInvoicesSum = ref(0)
|
||||||
@@ -50,43 +48,91 @@ setupPage()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<table>
|
<div class="space-y-3">
|
||||||
<tr>
|
<div class="balance-row">
|
||||||
<td class="break-all">Offene Rechnungen:</td>
|
<p class="balance-label">Offene Rechnungen</p>
|
||||||
<td
|
<div
|
||||||
v-if="unpaidInvoicesSum > 0"
|
v-if="unpaidInvoicesSum > 0"
|
||||||
class="text-orange-500 font-bold text-nowrap"
|
class="balance-value text-orange-500"
|
||||||
>{{unpaidInvoicesCount}} Stk /<br> {{useCurrency(unpaidInvoicesSum)}}</td>
|
>
|
||||||
<td v-else class="text-primary-500 font-bold text-no-wrap">0 Stk / 0,00€</td>
|
<span>{{ unpaidInvoicesCount }} Stk</span>
|
||||||
</tr>
|
<span>{{ useCurrency(unpaidInvoicesSum) }}</span>
|
||||||
<tr>
|
</div>
|
||||||
<td class="break-all">Überfällige Rechnungen:</td>
|
<div v-else class="balance-value text-primary-500">
|
||||||
<td
|
<span>0 Stk</span>
|
||||||
v-if="unpaidOverdueInvoicesSum !== 0"
|
<span>0,00€</span>
|
||||||
class="text-rose-600 font-bold text-nowrap"
|
</div>
|
||||||
>{{unpaidOverdueInvoicesCount}} Stk /<br> {{useCurrency(unpaidOverdueInvoicesSum)}}</td>
|
</div>
|
||||||
<td v-else class="text-primary-500 font-bold text-no-wrap">0 Stk / 0,00€</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="break-all">Angelegte Rechnungsentwürfe:</td>
|
|
||||||
<td
|
|
||||||
v-if="draftInvoicesSum > 0"
|
|
||||||
class="text-orange-500 font-bold text-nowrap"
|
|
||||||
>{{draftInvoicesCount}} Stk /<br> {{useCurrency(draftInvoicesSum)}}</td>
|
|
||||||
<td v-else class="text-primary-500 font-bold text-no-wrap">0 Stk / 0,00€</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="break-all">Vorbereitete Eingangsrechnungen:</td>
|
|
||||||
<td
|
|
||||||
v-if="countPreparedOpenIncomingInvoices > 0"
|
|
||||||
class="text-orange-500 font-bold text-wrap"
|
|
||||||
>{{countPreparedOpenIncomingInvoices}} Stk </td>
|
|
||||||
<td v-else class="text-primary-500 font-bold text-no-wrap">0 Stk</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
|
<div class="balance-row">
|
||||||
|
<p class="balance-label">Überfällige Rechnungen</p>
|
||||||
|
<div
|
||||||
|
v-if="unpaidOverdueInvoicesSum !== 0"
|
||||||
|
class="balance-value text-rose-600"
|
||||||
|
>
|
||||||
|
<span>{{ unpaidOverdueInvoicesCount }} Stk</span>
|
||||||
|
<span>{{ useCurrency(unpaidOverdueInvoicesSum) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="balance-value text-primary-500">
|
||||||
|
<span>0 Stk</span>
|
||||||
|
<span>0,00€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="balance-row">
|
||||||
|
<p class="balance-label">Angelegte Rechnungsentwürfe</p>
|
||||||
|
<div
|
||||||
|
v-if="draftInvoicesSum > 0"
|
||||||
|
class="balance-value text-orange-500"
|
||||||
|
>
|
||||||
|
<span>{{ draftInvoicesCount }} Stk</span>
|
||||||
|
<span>{{ useCurrency(draftInvoicesSum) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="balance-value text-primary-500">
|
||||||
|
<span>0 Stk</span>
|
||||||
|
<span>0,00€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="balance-row">
|
||||||
|
<p class="balance-label">Vorbereitete Eingangsrechnungen</p>
|
||||||
|
<div
|
||||||
|
v-if="countPreparedOpenIncomingInvoices > 0"
|
||||||
|
class="balance-value text-orange-500"
|
||||||
|
>
|
||||||
|
<span>{{ countPreparedOpenIncomingInvoices }} Stk</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="balance-value text-primary-500">
|
||||||
|
<span>0 Stk</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.balance-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-label {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: rgb(55 65 81);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-value {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dark) .balance-label {
|
||||||
|
color: rgb(209 213 219);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -4,11 +4,15 @@ const openTasks = ref([])
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
function isCompletedTask(task) {
|
||||||
|
return ["Abgeschlossen", "Erledigt"].includes(String(task?.categorie || "").trim())
|
||||||
|
}
|
||||||
|
|
||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
openTasks.value = (await useEntities("tasks").select()).filter((task) => {
|
openTasks.value = (await useEntities("tasks").select()).filter((task) => {
|
||||||
const assignee = task.userId || task.user_id || task.profile
|
const assignee = task.userId || task.user_id || task.profile
|
||||||
const currentUser = auth.user?.user_id || auth.user?.id
|
const currentUser = auth.user?.user_id || auth.user?.id
|
||||||
return !task.archived && assignee === currentUser
|
return !task.archived && !isCompletedTask(task) && assignee === currentUser
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,5 +92,10 @@ export const useFunctions = () => {
|
|||||||
return await useNuxtApp().$api(`/api/banking/iban/${encodeURIComponent(normalized)}`)
|
return await useNuxtApp().$api(`/api/banking/iban/${encodeURIComponent(normalized)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useCreatePDF}
|
const useBankingStatementSuggestions = async (statementId) => {
|
||||||
|
if (!statementId) return { suggestions: [] }
|
||||||
|
return await useNuxtApp().$api(`/api/banking/statements/${statementId}/suggestions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useCreatePDF}
|
||||||
}
|
}
|
||||||
|
|||||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@@ -68,6 +68,7 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"fast-sort": "^3.4.1",
|
"fast-sort": "^3.4.1",
|
||||||
|
"gridstack": "^12.4.2",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"image-js": "^1.1.0",
|
"image-js": "^1.1.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
@@ -12114,6 +12115,22 @@
|
|||||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gridstack": {
|
||||||
|
"version": "12.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/gridstack/-/gridstack-12.4.2.tgz",
|
||||||
|
"integrity": "sha512-aXbJrQpi3LwpYXYOr4UriPM5uc/dPcjK01SdOE5PDpx2vi8tnLhU7yBg/1i4T59UhNkG/RBfabdFUObuN+gMnw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://www.paypal.me/alaind831"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "venmo",
|
||||||
|
"url": "https://www.venmo.com/adumesny"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/gzip-size": {
|
"node_modules/gzip-size": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-7.0.0.tgz",
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"fast-sort": "^3.4.1",
|
"fast-sort": "^3.4.1",
|
||||||
|
"gridstack": "^12.4.2",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"image-js": "^1.1.0",
|
"image-js": "^1.1.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
|||||||
@@ -12,9 +12,18 @@ const route = useRoute()
|
|||||||
|
|
||||||
const bankstatements = ref([])
|
const bankstatements = ref([])
|
||||||
const bankaccounts = ref([])
|
const bankaccounts = ref([])
|
||||||
|
const customers = ref([])
|
||||||
|
const vendors = ref([])
|
||||||
|
const entitybankaccounts = ref([])
|
||||||
|
const createddocuments = ref([])
|
||||||
|
const incominginvoices = ref([])
|
||||||
|
const openDocuments = ref([])
|
||||||
|
const openIncomingInvoices = ref([])
|
||||||
const filterAccount = ref([])
|
const filterAccount = ref([])
|
||||||
const isSyncing = ref(false)
|
const isSyncing = ref(false)
|
||||||
const loadingDocs = ref(true) // Startet im Ladezustand
|
const loadingDocs = ref(true) // Startet im Ladezustand
|
||||||
|
const suggestionsModalOpen = ref(false)
|
||||||
|
const selectedSuggestionRowId = ref(null)
|
||||||
|
|
||||||
// Zeitraum-Optionen
|
// Zeitraum-Optionen
|
||||||
const periodOptions = [
|
const periodOptions = [
|
||||||
@@ -32,16 +41,42 @@ const dateRange = ref({
|
|||||||
end: $dayjs().endOf('month').format('YYYY-MM-DD')
|
end: $dayjs().endOf('month').format('YYYY-MM-DD')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const setDateRangeFieldToToday = (field) => {
|
||||||
|
dateRange.value[field] = $dayjs().format('YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
|
||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
loadingDocs.value = true
|
loadingDocs.value = true
|
||||||
try {
|
try {
|
||||||
const [statements, accounts] = await Promise.all([
|
const [statements, accounts, customerItems, vendorItems, entityBankItems, documentItems, invoiceItems] = await Promise.all([
|
||||||
useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false),
|
useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false),
|
||||||
useEntities("bankaccounts").select()
|
useEntities("bankaccounts").select(),
|
||||||
|
useEntities("customers").select(),
|
||||||
|
useEntities("vendors").select(),
|
||||||
|
useEntities("entitybankaccounts").select(),
|
||||||
|
useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"),
|
||||||
|
useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")
|
||||||
])
|
])
|
||||||
|
|
||||||
bankstatements.value = statements
|
bankstatements.value = statements
|
||||||
bankaccounts.value = accounts
|
bankaccounts.value = accounts
|
||||||
|
customers.value = customerItems
|
||||||
|
vendors.value = vendorItems
|
||||||
|
entitybankaccounts.value = entityBankItems
|
||||||
|
createddocuments.value = documentItems
|
||||||
|
incominginvoices.value = invoiceItems.filter(i => i.state === "Gebucht")
|
||||||
|
|
||||||
|
const documents = createddocuments.value.filter(i => i.type === "invoices" || i.type === "advanceInvoices")
|
||||||
|
openDocuments.value = documents.filter(i => i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== useSum().getCreatedDocumentSum(i, createddocuments.value).toFixed(2))
|
||||||
|
.map(i => ({
|
||||||
|
...i,
|
||||||
|
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
||||||
|
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
|
||||||
|
openSum: (useSum().getCreatedDocumentSum(i, createddocuments.value) - Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0))).toFixed(2)
|
||||||
|
}))
|
||||||
|
|
||||||
|
openIncomingInvoices.value = invoiceItems
|
||||||
|
.filter(i => i.state === "Gebucht" && !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false))
|
||||||
|
|
||||||
if (bankaccounts.value.length > 0 && filterAccount.value.length === 0) {
|
if (bankaccounts.value.length > 0 && filterAccount.value.length === 0) {
|
||||||
filterAccount.value = bankaccounts.value
|
filterAccount.value = bankaccounts.value
|
||||||
@@ -126,6 +161,213 @@ const calculateOpenSum = (statement) => {
|
|||||||
return (statement.amount - allocated).toFixed(2);
|
return (statement.amount - allocated).toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getInvoiceSum = (invoice, onlyOpenSum) => {
|
||||||
|
let sum = 0
|
||||||
|
if (invoice.accounts) {
|
||||||
|
invoice.accounts.forEach(account => {
|
||||||
|
sum += (account.amountTax || 0)
|
||||||
|
sum += (account.amountNet || 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0))
|
||||||
|
|
||||||
|
if (invoice.expense) {
|
||||||
|
return (sum * -1).toFixed(2)
|
||||||
|
} else {
|
||||||
|
return sum.toFixed(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeIban = (value) => String(value || "").replace(/\s+/g, "").toUpperCase()
|
||||||
|
const normalizeSuggestionText = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9äöüß]+/gi, " ").replace(/\s+/g, " ").trim()
|
||||||
|
const getSuggestionTokens = (value) => [...new Set(normalizeSuggestionText(value).split(" ").filter((token) => token.length >= 4))]
|
||||||
|
const getMatchingPurposeParts = (value, candidates = []) => {
|
||||||
|
const haystack = normalizeSuggestionText(value)
|
||||||
|
if (!haystack) return []
|
||||||
|
|
||||||
|
return [...new Set(
|
||||||
|
candidates
|
||||||
|
.flatMap(candidate => getSuggestionTokens(candidate))
|
||||||
|
.filter(token => haystack.includes(token))
|
||||||
|
)].slice(0, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAmountMatchLabel = (openSum, remaining) => {
|
||||||
|
const difference = Math.abs(Math.abs(Number(openSum)) - Math.abs(Number(remaining)))
|
||||||
|
if (difference < 0.01) return "Betrag exakt"
|
||||||
|
if (difference <= 1) return "Betrag fast passend"
|
||||||
|
if (difference <= 5) return "Betrag aehnlich"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWithinAmountTolerance = (candidateSum, remaining) => {
|
||||||
|
const reference = Math.abs(Number(remaining))
|
||||||
|
const candidate = Math.abs(Number(candidateSum))
|
||||||
|
if (!reference || !candidate) return false
|
||||||
|
|
||||||
|
const difference = Math.abs(candidate - reference)
|
||||||
|
return difference / reference <= 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveMatchedBankAccountId = (statement) => {
|
||||||
|
const partnerIban = normalizeIban(statement.amount >= 0 ? (statement.debIban || statement.credIban) : (statement.credIban || statement.debIban))
|
||||||
|
if (!partnerIban) return null
|
||||||
|
return entitybankaccounts.value.find(account => normalizeIban(account.iban) === partnerIban)?.id || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTopEntitySuggestion = (statement) => {
|
||||||
|
const isCustomer = Number(statement.amount) >= 0
|
||||||
|
const partnerName = normalizeSuggestionText(isCustomer ? (statement.debName || statement.credName) : (statement.credName || statement.debName))
|
||||||
|
const partnerIban = normalizeIban(isCustomer ? (statement.debIban || statement.credIban) : (statement.credIban || statement.debIban))
|
||||||
|
const matchedBankAccountId = resolveMatchedBankAccountId(statement)
|
||||||
|
const sourceItems = isCustomer ? customers.value : vendors.value
|
||||||
|
|
||||||
|
const suggestions = sourceItems.map(item => {
|
||||||
|
const infoData = item.infoData || {}
|
||||||
|
const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.map(Number) : []
|
||||||
|
const bankingIbans = Array.isArray(infoData.bankingIbans) ? infoData.bankingIbans.map(iban => normalizeIban(iban)) : []
|
||||||
|
const normalizedEntityName = normalizeSuggestionText(item.name)
|
||||||
|
const exactNameMatch = normalizedEntityName && partnerName && normalizedEntityName === partnerName
|
||||||
|
const partialNameMatch = normalizedEntityName && partnerName
|
||||||
|
? normalizedEntityName.includes(partnerName) || partnerName.includes(normalizedEntityName)
|
||||||
|
: false
|
||||||
|
|
||||||
|
let score = 0
|
||||||
|
let reason = ""
|
||||||
|
|
||||||
|
if (matchedBankAccountId && bankAccountIds.includes(matchedBankAccountId) && partnerIban && bankingIbans.includes(partnerIban)) {
|
||||||
|
score = 100
|
||||||
|
reason = "IBAN und Bankkonto passen"
|
||||||
|
} else if (matchedBankAccountId && bankAccountIds.includes(matchedBankAccountId)) {
|
||||||
|
score = 95
|
||||||
|
reason = "Bankkonto passt"
|
||||||
|
} else if (partnerIban && bankingIbans.includes(partnerIban)) {
|
||||||
|
score = 90
|
||||||
|
reason = "IBAN bekannt"
|
||||||
|
} else if (exactNameMatch) {
|
||||||
|
score = 60
|
||||||
|
reason = "Name passt"
|
||||||
|
} else if (partialNameMatch) {
|
||||||
|
score = 45
|
||||||
|
reason = "Name aehnlich"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: isCustomer ? "customer" : "vendor",
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
number: isCustomer ? item.customerNumber : item.vendorNumber,
|
||||||
|
score,
|
||||||
|
reason
|
||||||
|
}
|
||||||
|
}).filter(item => item.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
|
||||||
|
return suggestions[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDocumentSuggestionMeta = (statement, document, suggestion) => {
|
||||||
|
let score = 0
|
||||||
|
const remaining = Math.abs(Number(calculateOpenSum(statement)))
|
||||||
|
const openSum = Math.abs(Number(document.openSum))
|
||||||
|
const purposeMatches = getMatchingPurposeParts(statement.text, [document.documentNumber, document.title, document.description])
|
||||||
|
const amountLabel = getAmountMatchLabel(openSum, remaining)
|
||||||
|
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
|
||||||
|
|
||||||
|
if (!withinTolerance) {
|
||||||
|
return { score: 0, amountLabel: null, purposeMatches: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openSum === remaining) score += 100
|
||||||
|
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
||||||
|
|
||||||
|
if (suggestion && document.customer?.id === suggestion.id) score += 80
|
||||||
|
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
|
||||||
|
|
||||||
|
return { score, amountLabel, purposeMatches }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
|
||||||
|
let score = 0
|
||||||
|
const remaining = Math.abs(Number(calculateOpenSum(statement)))
|
||||||
|
const openSum = Math.abs(Number(getInvoiceSum(invoice, true)))
|
||||||
|
const purposeMatches = getMatchingPurposeParts(statement.text, [invoice.reference, invoice.description])
|
||||||
|
const amountLabel = getAmountMatchLabel(openSum, remaining)
|
||||||
|
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
|
||||||
|
|
||||||
|
if (!withinTolerance) {
|
||||||
|
return { score: 0, amountLabel: null, purposeMatches: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openSum === remaining) score += 100
|
||||||
|
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
||||||
|
|
||||||
|
if (suggestion && invoice.vendor?.id === suggestion.id) score += 80
|
||||||
|
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
|
||||||
|
|
||||||
|
return { score, amountLabel, purposeMatches }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRowSuggestions = (statement) => {
|
||||||
|
const entitySuggestion = getTopEntitySuggestion(statement)
|
||||||
|
|
||||||
|
const topDocument = (entitySuggestion?.type === "customer" ? openDocuments.value.filter(doc => doc.customer?.id === entitySuggestion.id) : openDocuments.value)
|
||||||
|
.map(document => {
|
||||||
|
const meta = getDocumentSuggestionMeta(statement, document, entitySuggestion?.type === "customer" ? entitySuggestion : null)
|
||||||
|
return { ...document, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `document:${document.id}` }
|
||||||
|
})
|
||||||
|
.filter(document => !isSuggestionDismissed(statement.id, document.suggestionKey))
|
||||||
|
.filter(document => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0)
|
||||||
|
.sort((a, b) => b.suggestionScore - a.suggestionScore)[0] || null
|
||||||
|
|
||||||
|
const topIncomingInvoice = (entitySuggestion?.type === "vendor" ? openIncomingInvoices.value.filter(invoice => invoice.vendor?.id === entitySuggestion.id) : openIncomingInvoices.value)
|
||||||
|
.map(invoice => {
|
||||||
|
const meta = getIncomingInvoiceSuggestionMeta(statement, invoice, entitySuggestion?.type === "vendor" ? entitySuggestion : null)
|
||||||
|
return { ...invoice, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `invoice:${invoice.id}` }
|
||||||
|
})
|
||||||
|
.filter(invoice => !isSuggestionDismissed(statement.id, invoice.suggestionKey))
|
||||||
|
.filter(invoice => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0)
|
||||||
|
.sort((a, b) => b.suggestionScore - a.suggestionScore)[0] || null
|
||||||
|
|
||||||
|
return {
|
||||||
|
topDocument,
|
||||||
|
topIncomingInvoice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowSuggestions = computed(() => {
|
||||||
|
const entries = filteredRows.value.map(row => [row.id, getRowSuggestions(row)])
|
||||||
|
return Object.fromEntries(entries)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dismissedSuggestions = computed(() => tempStore.settings?.banking?.dismissedSuggestions || {})
|
||||||
|
|
||||||
|
const getDismissedSuggestionKeys = (statementId) => {
|
||||||
|
const key = String(statementId || "")
|
||||||
|
const values = dismissedSuggestions.value?.[key]
|
||||||
|
return Array.isArray(values) ? values : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuggestionDismissed = (statementId, suggestionKey) => {
|
||||||
|
return getDismissedSuggestionKeys(statementId).includes(suggestionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsWithSuggestions = computed(() => {
|
||||||
|
return filteredRows.value
|
||||||
|
.map((row) => ({
|
||||||
|
row,
|
||||||
|
suggestions: rowSuggestions.value[row.id]
|
||||||
|
}))
|
||||||
|
.filter(({ suggestions }) => suggestions?.topDocument || suggestions?.topIncomingInvoice)
|
||||||
|
})
|
||||||
|
|
||||||
|
const suggestionCount = computed(() => rowsWithSuggestions.value.length)
|
||||||
|
|
||||||
|
const selectedSuggestionRow = computed(() => {
|
||||||
|
return rowsWithSuggestions.value.find((entry) => entry.row.id === selectedSuggestionRowId.value) || rowsWithSuggestions.value[0] || null
|
||||||
|
})
|
||||||
|
|
||||||
const filteredRows = computed(() => {
|
const filteredRows = computed(() => {
|
||||||
if (!bankstatements.value.length) return []
|
if (!bankstatements.value.length) return []
|
||||||
|
|
||||||
@@ -162,6 +404,60 @@ const filteredRows = computed(() => {
|
|||||||
|
|
||||||
const displayCurrency = (value) => `${Number(value).toFixed(2).replace(".", ",")} €`
|
const displayCurrency = (value) => `${Number(value).toFixed(2).replace(".", ",")} €`
|
||||||
|
|
||||||
|
const saveAllocation = async (allocation) => {
|
||||||
|
await $api("/api/banking/statements", {
|
||||||
|
method: "POST",
|
||||||
|
body: { data: allocation }
|
||||||
|
})
|
||||||
|
await setupPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAssignDocument = async (row, document, event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
await saveAllocation({
|
||||||
|
createddocument: document.id,
|
||||||
|
bankstatement: row.id,
|
||||||
|
amount: Number(Number(document.openSum) < Number(calculateOpenSum(row)) ? document.openSum : calculateOpenSum(row)),
|
||||||
|
description: "Automatischer Vorschlag"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAssignIncomingInvoice = async (row, invoice, event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
await saveAllocation({
|
||||||
|
incominginvoice: invoice.id,
|
||||||
|
bankstatement: row.id,
|
||||||
|
amount: Number(Math.abs(getInvoiceSum(invoice, true)) > Math.abs(Number(calculateOpenSum(row))) ? calculateOpenSum(row) : getInvoiceSum(invoice, true)),
|
||||||
|
description: "Automatischer Vorschlag"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissSuggestion = (row, suggestionKey, event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
const bankingSettings = tempStore.settings?.banking || {}
|
||||||
|
const dismissed = { ...(bankingSettings.dismissedSuggestions || {}) }
|
||||||
|
const rowKey = String(row.id || "")
|
||||||
|
const existing = Array.isArray(dismissed[rowKey]) ? dismissed[rowKey] : []
|
||||||
|
if (!existing.includes(suggestionKey)) dismissed[rowKey] = [...existing, suggestionKey]
|
||||||
|
|
||||||
|
tempStore.modifySettings("banking", {
|
||||||
|
...bankingSettings,
|
||||||
|
dismissedSuggestions: dismissed
|
||||||
|
})
|
||||||
|
|
||||||
|
if (selectedSuggestionRowId.value === row.id) {
|
||||||
|
const remaining = getDismissedSuggestionKeys(row.id).concat([suggestionKey])
|
||||||
|
const current = rowSuggestions.value[row.id]
|
||||||
|
const noMoreSuggestions = (!current?.topDocument || remaining.includes(current.topDocument.suggestionKey))
|
||||||
|
&& (!current?.topIncomingInvoice || remaining.includes(current.topIncomingInvoice.suggestionKey))
|
||||||
|
|
||||||
|
if (noMoreSuggestions) {
|
||||||
|
const nextRow = rowsWithSuggestions.value.find((entry) => entry.row.id !== row.id)
|
||||||
|
selectedSuggestionRowId.value = nextRow?.row.id || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setupPage()
|
setupPage()
|
||||||
})
|
})
|
||||||
@@ -170,6 +466,15 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<UDashboardNavbar title="Bankbuchungen" :badge="filteredRows.length">
|
<UDashboardNavbar title="Bankbuchungen" :badge="filteredRows.length">
|
||||||
<template #right>
|
<template #right>
|
||||||
|
<UButton
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-sparkles"
|
||||||
|
@click="suggestionsModalOpen = true"
|
||||||
|
>
|
||||||
|
Vorschlaege
|
||||||
|
<UBadge color="primary" variant="solid" size="xs" class="ml-2">{{ suggestionCount }}</UBadge>
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
label="Bankabruf"
|
label="Bankabruf"
|
||||||
icon="i-heroicons-arrow-path"
|
icon="i-heroicons-arrow-path"
|
||||||
@@ -208,8 +513,14 @@ onMounted(() => {
|
|||||||
icon="i-heroicons-calendar-days"
|
icon="i-heroicons-calendar-days"
|
||||||
/>
|
/>
|
||||||
<div v-if="selectedPeriod.key === 'custom'" class="flex items-center gap-1">
|
<div v-if="selectedPeriod.key === 'custom'" class="flex items-center gap-1">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
<UInput type="date" v-model="dateRange.start" size="xs" class="w-32"/>
|
<UInput type="date" v-model="dateRange.start" size="xs" class="w-32"/>
|
||||||
|
<UButton size="2xs" color="gray" variant="soft" label="Heute" @click="setDateRangeFieldToToday('start')" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
<UInput type="date" v-model="dateRange.end" size="xs" class="w-32"/>
|
<UInput type="date" v-model="dateRange.end" size="xs" class="w-32"/>
|
||||||
|
<UButton size="2xs" color="gray" variant="soft" label="Heute" @click="setDateRangeFieldToToday('end')" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-gray-400 hidden sm:block italic">
|
<div v-else class="text-xs text-gray-400 hidden sm:block italic">
|
||||||
{{ $dayjs(dateRange.start).format('DD.MM.') }} - {{ $dayjs(dateRange.end).format('DD.MM.YYYY') }}
|
{{ $dayjs(dateRange.start).format('DD.MM.') }} - {{ $dayjs(dateRange.end).format('DD.MM.YYYY') }}
|
||||||
@@ -289,4 +600,111 @@ onMounted(() => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<PageLeaveGuard :when="isSyncing"/>
|
<PageLeaveGuard :when="isSyncing"/>
|
||||||
|
|
||||||
|
<UModal v-model="suggestionsModalOpen" :ui="{ width: 'sm:max-w-6xl' }">
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold">Vorschlaege fuer Bankbuchungen</div>
|
||||||
|
<div class="text-sm text-gray-500">Direkte Zuweisung von passenden Rechnungen und Eingangsbelegen</div>
|
||||||
|
</div>
|
||||||
|
<UBadge color="primary" variant="subtle">{{ suggestionCount }}</UBadge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="rowsWithSuggestions.length > 0" class="grid grid-cols-1 lg:grid-cols-3 gap-4 min-h-[520px]">
|
||||||
|
<div class="lg:col-span-1 border rounded-lg overflow-hidden dark:border-gray-800">
|
||||||
|
<div
|
||||||
|
v-for="entry in rowsWithSuggestions"
|
||||||
|
:key="entry.row.id"
|
||||||
|
class="p-3 border-b dark:border-gray-800 cursor-pointer transition-colors"
|
||||||
|
:class="selectedSuggestionRowId === entry.row.id || (!selectedSuggestionRowId && selectedSuggestionRow?.row.id === entry.row.id) ? 'bg-primary-50 dark:bg-primary-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-900/50'"
|
||||||
|
@click="selectedSuggestionRowId = entry.row.id"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium truncate">{{ entry.row.amount < 0 ? entry.row.credName : entry.row.debName }}</div>
|
||||||
|
<div class="text-xs text-gray-500 truncate">{{ entry.row.text || 'Ohne Verwendungszweck' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-mono whitespace-nowrap" :class="entry.row.amount >= 0 ? 'text-green-600' : 'text-rose-600'">
|
||||||
|
{{ displayCurrency(entry.row.amount) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
|
<UBadge v-if="entry.suggestions?.topDocument" size="xs" color="emerald" variant="subtle">Rechnung</UBadge>
|
||||||
|
<UBadge v-if="entry.suggestions?.topIncomingInvoice" size="xs" color="rose" variant="subtle">Eingangsbeleg</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-2 space-y-3" v-if="selectedSuggestionRow">
|
||||||
|
<div class="rounded-lg border dark:border-gray-800 p-4 bg-gray-50 dark:bg-gray-900/50">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold">{{ selectedSuggestionRow.row.amount < 0 ? selectedSuggestionRow.row.credName : selectedSuggestionRow.row.debName }}</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">{{ selectedSuggestionRow.row.text || 'Ohne Verwendungszweck' }}</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">{{ $dayjs(selectedSuggestionRow.row.valueDate).format("DD.MM.YYYY") }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="font-mono font-semibold" :class="selectedSuggestionRow.row.amount >= 0 ? 'text-green-600' : 'text-rose-600'">{{ displayCurrency(selectedSuggestionRow.row.amount) }}</div>
|
||||||
|
<div class="text-xs text-gray-400">Offen {{ displayCurrency(calculateOpenSum(selectedSuggestionRow.row)) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedSuggestionRow.suggestions?.topDocument" class="rounded-lg border border-emerald-200 bg-emerald-50/70 px-4 py-3">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-semibold text-emerald-700">Rechnung {{ selectedSuggestionRow.suggestions.topDocument.documentNumber }}</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1">{{ selectedSuggestionRow.suggestions.topDocument.customer?.name }} | Offen {{ displayCurrency(selectedSuggestionRow.suggestions.topDocument.openSum) }}</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1" v-if="selectedSuggestionRow.suggestions.topDocument.suggestionAmountLabel || selectedSuggestionRow.suggestions.topDocument.suggestionPurposeMatches?.length">
|
||||||
|
<UBadge v-if="selectedSuggestionRow.suggestions.topDocument.suggestionAmountLabel" size="xs" color="emerald" variant="subtle">
|
||||||
|
{{ selectedSuggestionRow.suggestions.topDocument.suggestionAmountLabel }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge v-for="match in selectedSuggestionRow.suggestions.topDocument.suggestionPurposeMatches" :key="`modal-doc-${selectedSuggestionRow.row.id}-${match}`" size="xs" color="amber" variant="subtle">
|
||||||
|
VWZ: {{ match }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UButton size="sm" color="emerald" @click="handleAssignDocument(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument, $event)">
|
||||||
|
Zuweisen
|
||||||
|
</UButton>
|
||||||
|
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument.suggestionKey, $event)">
|
||||||
|
Ablehnen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedSuggestionRow.suggestions?.topIncomingInvoice" class="rounded-lg border border-rose-200 bg-rose-50/70 px-4 py-3">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-semibold text-rose-700">Eingangsbeleg {{ selectedSuggestionRow.suggestions.topIncomingInvoice.reference || '-' }}</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1">{{ selectedSuggestionRow.suggestions.topIncomingInvoice.vendor?.name }} | Offen {{ displayCurrency(getInvoiceSum(selectedSuggestionRow.suggestions.topIncomingInvoice, true)) }}</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1" v-if="selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionAmountLabel || selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionPurposeMatches?.length">
|
||||||
|
<UBadge v-if="selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionAmountLabel" size="xs" color="emerald" variant="subtle">
|
||||||
|
{{ selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionAmountLabel }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge v-for="match in selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionPurposeMatches" :key="`modal-invoice-${selectedSuggestionRow.row.id}-${match}`" size="xs" color="amber" variant="subtle">
|
||||||
|
VWZ: {{ match }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UButton size="sm" color="rose" @click="handleAssignIncomingInvoice(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice, $event)">
|
||||||
|
Zuweisen
|
||||||
|
</UButton>
|
||||||
|
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionKey, $event)">
|
||||||
|
Ablehnen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="py-10 text-center text-gray-400">
|
||||||
|
<UIcon name="i-heroicons-sparkles" class="w-10 h-10 mx-auto mb-2 opacity-30"/>
|
||||||
|
<p>Keine Vorschlaege fuer die aktuelle Filterung vorhanden.</p>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
@@ -21,6 +21,7 @@ const openDocuments = ref([])
|
|||||||
const allocatedDocuments = ref([])
|
const allocatedDocuments = ref([])
|
||||||
const openIncomingInvoices = ref([])
|
const openIncomingInvoices = ref([])
|
||||||
const allocatedIncomingInvoices = ref([])
|
const allocatedIncomingInvoices = ref([])
|
||||||
|
const statementSuggestions = ref({ suggestions: [] })
|
||||||
|
|
||||||
const customers = ref([])
|
const customers = ref([])
|
||||||
const vendors = ref([])
|
const vendors = ref([])
|
||||||
@@ -67,6 +68,12 @@ const setup = async () => {
|
|||||||
|
|
||||||
openIncomingInvoices.value = (await useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")).filter(i => !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false))
|
openIncomingInvoices.value = (await useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")).filter(i => !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false))
|
||||||
|
|
||||||
|
if (itemInfo.value?.id) {
|
||||||
|
statementSuggestions.value = await useFunctions().useBankingStatementSuggestions(itemInfo.value.id)
|
||||||
|
} else {
|
||||||
|
statementSuggestions.value = { suggestions: [] }
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +170,225 @@ const filteredIncomingInvoices = computed(() => {
|
|||||||
return useSearch(searchString.value, openIncomingInvoices.value.filter(i => i.state === "Gebucht"))
|
return useSearch(searchString.value, openIncomingInvoices.value.filter(i => i.state === "Gebucht"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const dismissedSuggestions = computed(() => tempStore.settings?.banking?.dismissedSuggestions || {})
|
||||||
|
|
||||||
|
const getDismissedSuggestionKeys = (statementId) => {
|
||||||
|
const key = String(statementId || "")
|
||||||
|
const values = dismissedSuggestions.value?.[key]
|
||||||
|
return Array.isArray(values) ? values : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuggestionDismissed = (statementId, suggestionKey) => {
|
||||||
|
return getDismissedSuggestionKeys(statementId).includes(suggestionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissSuggestion = (statementId, suggestionKey) => {
|
||||||
|
const bankingSettings = tempStore.settings?.banking || {}
|
||||||
|
const dismissed = { ...(bankingSettings.dismissedSuggestions || {}) }
|
||||||
|
const rowKey = String(statementId || "")
|
||||||
|
const existing = Array.isArray(dismissed[rowKey]) ? dismissed[rowKey] : []
|
||||||
|
if (!existing.includes(suggestionKey)) dismissed[rowKey] = [...existing, suggestionKey]
|
||||||
|
|
||||||
|
tempStore.modifySettings("banking", {
|
||||||
|
...bankingSettings,
|
||||||
|
dismissedSuggestions: dismissed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const topEntitySuggestion = computed(() => {
|
||||||
|
const suggestions = Array.isArray(statementSuggestions.value?.suggestions) ? statementSuggestions.value.suggestions : []
|
||||||
|
return suggestions[0] || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeSuggestionText = (value) => {
|
||||||
|
return String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9äöüß]+/gi, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSuggestionTokens = (value) => {
|
||||||
|
return [...new Set(
|
||||||
|
normalizeSuggestionText(value)
|
||||||
|
.split(" ")
|
||||||
|
.filter((token) => token.length >= 4)
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMatchingPurposeParts = (value, candidates = []) => {
|
||||||
|
const haystack = normalizeSuggestionText(value)
|
||||||
|
if (!haystack) return []
|
||||||
|
|
||||||
|
return [...new Set(
|
||||||
|
candidates
|
||||||
|
.flatMap(candidate => getSuggestionTokens(candidate))
|
||||||
|
.filter(token => haystack.includes(token))
|
||||||
|
)].slice(0, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAmountMatchLabel = (openSum, remaining) => {
|
||||||
|
const difference = Math.abs(Math.abs(Number(openSum)) - Math.abs(Number(remaining)))
|
||||||
|
if (difference < 0.01) return "Betrag exakt passend"
|
||||||
|
if (difference <= 1) return `Betrag fast passend (${displayCurrency(difference)} Abweichung)`
|
||||||
|
if (difference <= 5) return `Betrag aehnlich (${displayCurrency(difference)} Abweichung)`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWithinAmountTolerance = (candidateSum, remaining) => {
|
||||||
|
const reference = Math.abs(Number(remaining))
|
||||||
|
const candidate = Math.abs(Number(candidateSum))
|
||||||
|
if (!reference || !candidate) return false
|
||||||
|
|
||||||
|
const difference = Math.abs(candidate - reference)
|
||||||
|
return difference / reference <= 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDocumentSuggestionMeta = (document, suggestion) => {
|
||||||
|
let score = 0
|
||||||
|
const remaining = Math.abs(Number(calculateOpenSum.value))
|
||||||
|
const openSum = Math.abs(Number(document.openSum))
|
||||||
|
const purposeMatches = getMatchingPurposeParts(itemInfo.value.text, [
|
||||||
|
document.documentNumber,
|
||||||
|
document.title,
|
||||||
|
document.description
|
||||||
|
])
|
||||||
|
const amountLabel = getAmountMatchLabel(openSum, remaining)
|
||||||
|
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
|
||||||
|
|
||||||
|
if (!withinTolerance) {
|
||||||
|
return {
|
||||||
|
score: 0,
|
||||||
|
purposeMatches: [],
|
||||||
|
amountLabel: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openSum === remaining) score += 100
|
||||||
|
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
||||||
|
|
||||||
|
if (suggestion && document.customer?.id === suggestion.id) score += 80
|
||||||
|
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
purposeMatches,
|
||||||
|
amountLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInvoiceSuggestionMeta = (invoice, suggestion) => {
|
||||||
|
let score = 0
|
||||||
|
const remaining = Math.abs(Number(calculateOpenSum.value))
|
||||||
|
const openSum = Math.abs(Number(getInvoiceSum(invoice, true)))
|
||||||
|
const purposeMatches = getMatchingPurposeParts(itemInfo.value.text, [
|
||||||
|
invoice.reference,
|
||||||
|
invoice.description
|
||||||
|
])
|
||||||
|
const amountLabel = getAmountMatchLabel(openSum, remaining)
|
||||||
|
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
|
||||||
|
|
||||||
|
if (!withinTolerance) {
|
||||||
|
return {
|
||||||
|
score: 0,
|
||||||
|
purposeMatches: [],
|
||||||
|
amountLabel: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openSum === remaining) score += 100
|
||||||
|
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
||||||
|
|
||||||
|
if (suggestion && invoice.vendor?.id === suggestion.id) score += 80
|
||||||
|
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
purposeMatches,
|
||||||
|
amountLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestedDocuments = computed(() => {
|
||||||
|
if (topEntitySuggestion.value?.type !== "customer") return []
|
||||||
|
|
||||||
|
return openDocuments.value
|
||||||
|
.filter((document) => document.customer?.id === topEntitySuggestion.value.id)
|
||||||
|
.map((document) => {
|
||||||
|
const meta = getDocumentSuggestionMeta(document, topEntitySuggestion.value)
|
||||||
|
return {
|
||||||
|
...document,
|
||||||
|
suggestionScore: meta.score,
|
||||||
|
suggestionPurposeMatches: meta.purposeMatches,
|
||||||
|
suggestionAmountLabel: meta.amountLabel,
|
||||||
|
suggestionKey: `document:${document.id}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((document) => !isSuggestionDismissed(itemInfo.value?.id, document.suggestionKey))
|
||||||
|
.sort((a, b) => b.suggestionScore - a.suggestionScore)
|
||||||
|
.slice(0, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
const suggestedIncomingInvoices = computed(() => {
|
||||||
|
if (topEntitySuggestion.value?.type !== "vendor") return []
|
||||||
|
|
||||||
|
return openIncomingInvoices.value
|
||||||
|
.filter((invoice) => invoice.vendor?.id === topEntitySuggestion.value.id)
|
||||||
|
.map((invoice) => {
|
||||||
|
const meta = getInvoiceSuggestionMeta(invoice, topEntitySuggestion.value)
|
||||||
|
return {
|
||||||
|
...invoice,
|
||||||
|
suggestionScore: meta.score,
|
||||||
|
suggestionPurposeMatches: meta.purposeMatches,
|
||||||
|
suggestionAmountLabel: meta.amountLabel,
|
||||||
|
suggestionKey: `invoice:${invoice.id}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((invoice) => !isSuggestionDismissed(itemInfo.value?.id, invoice.suggestionKey))
|
||||||
|
.sort((a, b) => b.suggestionScore - a.suggestionScore)
|
||||||
|
.slice(0, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fallbackSuggestedDocuments = computed(() => {
|
||||||
|
if (topEntitySuggestion.value) return []
|
||||||
|
|
||||||
|
return openDocuments.value
|
||||||
|
.map((document) => {
|
||||||
|
const meta = getDocumentSuggestionMeta(document, null)
|
||||||
|
return {
|
||||||
|
...document,
|
||||||
|
suggestionScore: meta.score,
|
||||||
|
suggestionPurposeMatches: meta.purposeMatches,
|
||||||
|
suggestionAmountLabel: meta.amountLabel,
|
||||||
|
suggestionKey: `document:${document.id}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((document) => !isSuggestionDismissed(itemInfo.value?.id, document.suggestionKey))
|
||||||
|
.filter((document) => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0)
|
||||||
|
.sort((a, b) => b.suggestionScore - a.suggestionScore)
|
||||||
|
.slice(0, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fallbackSuggestedIncomingInvoices = computed(() => {
|
||||||
|
if (topEntitySuggestion.value) return []
|
||||||
|
|
||||||
|
return openIncomingInvoices.value
|
||||||
|
.map((invoice) => {
|
||||||
|
const meta = getInvoiceSuggestionMeta(invoice, null)
|
||||||
|
return {
|
||||||
|
...invoice,
|
||||||
|
suggestionScore: meta.score,
|
||||||
|
suggestionPurposeMatches: meta.purposeMatches,
|
||||||
|
suggestionAmountLabel: meta.amountLabel,
|
||||||
|
suggestionKey: `invoice:${invoice.id}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((invoice) => !isSuggestionDismissed(itemInfo.value?.id, invoice.suggestionKey))
|
||||||
|
.filter((invoice) => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0)
|
||||||
|
.sort((a, b) => b.suggestionScore - a.suggestionScore)
|
||||||
|
.slice(0, 3)
|
||||||
|
})
|
||||||
|
|
||||||
const archiveStatement = async () => {
|
const archiveStatement = async () => {
|
||||||
let temp = {...itemInfo.value}
|
let temp = {...itemInfo.value}
|
||||||
delete temp.statementallocations
|
delete temp.statementallocations
|
||||||
@@ -442,6 +668,102 @@ setup()
|
|||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 space-y-6">
|
<div class="flex-1 overflow-y-auto p-4 space-y-6">
|
||||||
|
|
||||||
|
<div v-if="Number(calculateOpenSum) !== 0 && (topEntitySuggestion || fallbackSuggestedDocuments.length > 0 || fallbackSuggestedIncomingInvoices.length > 0)" class="rounded-xl border border-primary-200 bg-primary-50/70 dark:bg-primary-900/10 dark:border-primary-800 p-4">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div v-if="topEntitySuggestion">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wide text-primary-700 dark:text-primary-300">Automatischer Vorschlag</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ topEntitySuggestion.type === 'customer' ? 'Kunde' : 'Lieferant' }}:
|
||||||
|
{{ topEntitySuggestion.number }} - {{ topEntitySuggestion.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-300 mt-1">
|
||||||
|
{{ topEntitySuggestion.reason }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1" v-if="statementSuggestions.partnerIban || statementSuggestions.partnerName">
|
||||||
|
{{ statementSuggestions.partnerName || 'Unbekannter Partner' }}
|
||||||
|
<span v-if="statementSuggestions.partnerIban">| {{ separateIBAN(statementSuggestions.partnerIban) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!topEntitySuggestion" class="mb-3">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wide text-primary-700 dark:text-primary-300">Automatische Belegvorschlaege</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
Kein eindeutiger Kunde oder Lieferant erkannt. Vorschlaege basieren auf Betrag und Verwendungszweck.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="suggestedDocuments.length > 0 || suggestedIncomingInvoices.length > 0 || fallbackSuggestedDocuments.length > 0 || fallbackSuggestedIncomingInvoices.length > 0" class="mt-4 grid gap-2">
|
||||||
|
<div
|
||||||
|
v-for="document in (topEntitySuggestion ? suggestedDocuments : fallbackSuggestedDocuments)"
|
||||||
|
:key="`suggested-document-${document.id}`"
|
||||||
|
class="flex items-center justify-between rounded-lg border border-white/70 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ document.documentNumber }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ document.customer?.name }} | Offen {{ displayCurrency(document.openSum) }}</div>
|
||||||
|
<div class="mt-1 flex flex-wrap gap-1" v-if="document.suggestionAmountLabel || document.suggestionPurposeMatches?.length">
|
||||||
|
<UBadge v-if="document.suggestionAmountLabel" size="xs" color="emerald" variant="subtle">
|
||||||
|
{{ document.suggestionAmountLabel }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge v-for="match in document.suggestionPurposeMatches" :key="`doc-match-${document.id}-${match}`" size="xs" color="amber" variant="subtle">
|
||||||
|
VWZ: {{ match }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
color="primary"
|
||||||
|
@click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription || 'Automatischer Vorschlag'})"
|
||||||
|
>
|
||||||
|
Rechnung zuweisen
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
variant="ghost"
|
||||||
|
@click="dismissSuggestion(itemInfo.id, document.suggestionKey)"
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="invoice in (topEntitySuggestion ? suggestedIncomingInvoices : fallbackSuggestedIncomingInvoices)"
|
||||||
|
:key="`suggested-invoice-${invoice.id}`"
|
||||||
|
class="flex items-center justify-between rounded-lg border border-white/70 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ invoice.vendor?.name }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Ref: {{ invoice.reference || '-' }} | Offen {{ displayCurrency(getInvoiceSum(invoice, true)) }}</div>
|
||||||
|
<div class="mt-1 flex flex-wrap gap-1" v-if="invoice.suggestionAmountLabel || invoice.suggestionPurposeMatches?.length">
|
||||||
|
<UBadge v-if="invoice.suggestionAmountLabel" size="xs" color="emerald" variant="subtle">
|
||||||
|
{{ invoice.suggestionAmountLabel }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge v-for="match in invoice.suggestionPurposeMatches" :key="`invoice-match-${invoice.id}-${match}`" size="xs" color="amber" variant="subtle">
|
||||||
|
VWZ: {{ match }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
color="rose"
|
||||||
|
@click="saveAllocation({incominginvoice: invoice.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(invoice,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(invoice,true)), description: allocationDescription || 'Automatischer Vorschlag'})"
|
||||||
|
>
|
||||||
|
Beleg zuweisen
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
variant="ghost"
|
||||||
|
@click="dismissSuggestion(itemInfo.id, invoice.suggestionKey)"
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="filteredDocuments.length > 0">
|
<div v-if="filteredDocuments.length > 0">
|
||||||
<h3 class="text-xs font-bold text-gray-500 uppercase mb-2 pl-1 flex items-center gap-2">
|
<h3 class="text-xs font-bold text-gray-500 uppercase mb-2 pl-1 flex items-center gap-2">
|
||||||
<UIcon name="i-heroicons-document-arrow-up"/>
|
<UIcon name="i-heroicons-document-arrow-up"/>
|
||||||
|
|||||||
564
frontend/pages/index.client.vue
Normal file
564
frontend/pages/index.client.vue
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
<script setup>
|
||||||
|
import { setPageLayout } from "#app"
|
||||||
|
import "gridstack/dist/gridstack.min.css"
|
||||||
|
|
||||||
|
import DisplayIncomeAndExpenditure from "~/components/displayIncomeAndExpenditure.vue"
|
||||||
|
import DisplayOpenBalances from "~/components/displayOpenBalances.vue"
|
||||||
|
import DisplayBankaccounts from "~/components/displayBankaccounts.vue"
|
||||||
|
import DisplayProjectsInPhases from "~/components/displayProjectsInPhases.vue"
|
||||||
|
import DisplayOpenTasks from "~/components/displayOpenTasks.vue"
|
||||||
|
|
||||||
|
setPageLayout("default")
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const tempStore = useTempStore()
|
||||||
|
|
||||||
|
const gridElement = ref(null)
|
||||||
|
const grid = shallowRef(null)
|
||||||
|
const isEditMode = ref(false)
|
||||||
|
const manageCardsOpen = ref(false)
|
||||||
|
const widgets = ref([])
|
||||||
|
|
||||||
|
let gridStackClass = null
|
||||||
|
let persistTimeout = null
|
||||||
|
const isSyncingGrid = ref(false)
|
||||||
|
|
||||||
|
const DASHBOARD_WIDGETS = [
|
||||||
|
{
|
||||||
|
id: "income-expense",
|
||||||
|
title: "Einnahmen und Ausgaben",
|
||||||
|
description: "Umsatz- und Ausgabenentwicklung",
|
||||||
|
component: markRaw(DisplayIncomeAndExpenditure),
|
||||||
|
defaultLayout: { x: 0, y: 0, w: 12, h: 4 },
|
||||||
|
minW: 4,
|
||||||
|
minH: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "open-balances",
|
||||||
|
title: "Buchhaltung",
|
||||||
|
description: "Offene Rechnungen und Entwurfsstatus",
|
||||||
|
component: markRaw(DisplayOpenBalances),
|
||||||
|
defaultLayout: { x: 0, y: 4, w: 4, h: 3 },
|
||||||
|
minW: 3,
|
||||||
|
minH: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bankaccounts",
|
||||||
|
title: "Bank",
|
||||||
|
description: "Kontostand und offene Zuordnungen",
|
||||||
|
component: markRaw(DisplayBankaccounts),
|
||||||
|
defaultLayout: { x: 4, y: 4, w: 4, h: 3 },
|
||||||
|
minW: 3,
|
||||||
|
minH: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "projects",
|
||||||
|
title: "Projekte",
|
||||||
|
description: "Aktuelle Projektphasen",
|
||||||
|
component: markRaw(DisplayProjectsInPhases),
|
||||||
|
defaultLayout: { x: 8, y: 4, w: 4, h: 3 },
|
||||||
|
minW: 3,
|
||||||
|
minH: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tasks",
|
||||||
|
title: "Aufgaben",
|
||||||
|
description: "Offene Aufgaben des aktuellen Nutzers",
|
||||||
|
component: markRaw(DisplayOpenTasks),
|
||||||
|
defaultLayout: { x: 0, y: 7, w: 4, h: 3 },
|
||||||
|
minW: 3,
|
||||||
|
minH: 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const widgetDefinitions = Object.fromEntries(DASHBOARD_WIDGETS.map((widget) => [widget.id, widget]))
|
||||||
|
|
||||||
|
function normalizeNumber(value, fallback) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeWidgets(input) {
|
||||||
|
return input.map((widget) => ({
|
||||||
|
id: widget.id,
|
||||||
|
x: widget.x,
|
||||||
|
y: widget.y,
|
||||||
|
w: widget.w,
|
||||||
|
h: widget.h,
|
||||||
|
visible: widget.visible
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDashboardWidgets(storedWidgets) {
|
||||||
|
const storedById = new Map(
|
||||||
|
(Array.isArray(storedWidgets) ? storedWidgets : [])
|
||||||
|
.filter((widget) => widget?.id && widgetDefinitions[widget.id])
|
||||||
|
.map((widget) => [widget.id, widget])
|
||||||
|
)
|
||||||
|
|
||||||
|
return DASHBOARD_WIDGETS.map((definition) => {
|
||||||
|
const stored = storedById.get(definition.id) || {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: definition.id,
|
||||||
|
x: normalizeNumber(stored.x, definition.defaultLayout.x),
|
||||||
|
y: normalizeNumber(stored.y, definition.defaultLayout.y),
|
||||||
|
w: normalizeNumber(stored.w, definition.defaultLayout.w),
|
||||||
|
h: normalizeNumber(stored.h, definition.defaultLayout.h),
|
||||||
|
visible: typeof stored.visible === "boolean" ? stored.visible : true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function widgetsSignature(input) {
|
||||||
|
return JSON.stringify(serializeWidgets(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWidgetLayout(id) {
|
||||||
|
return widgets.value.find((widget) => widget.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWidget(id, patch) {
|
||||||
|
widgets.value = widgets.value.map((widget) => {
|
||||||
|
if (widget.id !== id) return widget
|
||||||
|
return { ...widget, ...patch }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistWidgets() {
|
||||||
|
tempStore.modifySettings("dashboard", {
|
||||||
|
version: 1,
|
||||||
|
widgets: serializeWidgets(widgets.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePersistWidgets() {
|
||||||
|
clearTimeout(persistTimeout)
|
||||||
|
persistTimeout = setTimeout(() => {
|
||||||
|
persistWidgets()
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGridStack() {
|
||||||
|
if (gridStackClass) return gridStackClass
|
||||||
|
const gridStackModule = await import("gridstack")
|
||||||
|
gridStackClass = gridStackModule.GridStack
|
||||||
|
return gridStackClass
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridOptionsFor(widget) {
|
||||||
|
const definition = widgetDefinitions[widget.id]
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: widget.x,
|
||||||
|
y: widget.y,
|
||||||
|
w: widget.w,
|
||||||
|
h: widget.h,
|
||||||
|
minW: definition.minW,
|
||||||
|
minH: definition.minH,
|
||||||
|
id: widget.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGridChange(_event, items = []) {
|
||||||
|
if (isSyncingGrid.value || !isEditMode.value) return
|
||||||
|
|
||||||
|
let hasChanges = false
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const widgetId = item.el?.dataset?.widgetId || item.id
|
||||||
|
if (!widgetId) return
|
||||||
|
|
||||||
|
const current = getWidgetLayout(widgetId)
|
||||||
|
if (!current) return
|
||||||
|
|
||||||
|
const nextPatch = {
|
||||||
|
x: normalizeNumber(item.x, current.x),
|
||||||
|
y: normalizeNumber(item.y, current.y),
|
||||||
|
w: normalizeNumber(item.w, current.w),
|
||||||
|
h: normalizeNumber(item.h, current.h)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.x === nextPatch.x && current.y === nextPatch.y && current.w === nextPatch.w && current.h === nextPatch.h) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWidget(widgetId, nextPatch)
|
||||||
|
hasChanges = true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasChanges) schedulePersistWidgets()
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncGridInteractivity() {
|
||||||
|
if (!grid.value) return
|
||||||
|
grid.value.enableMove(isEditMode.value)
|
||||||
|
grid.value.enableResize(isEditMode.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncGridWithDom() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
if (!gridElement.value) return
|
||||||
|
|
||||||
|
const GridStack = await ensureGridStack()
|
||||||
|
|
||||||
|
if (!grid.value) {
|
||||||
|
isSyncingGrid.value = true
|
||||||
|
grid.value = GridStack.init(
|
||||||
|
{
|
||||||
|
column: 12,
|
||||||
|
float: true,
|
||||||
|
margin: 16,
|
||||||
|
cellHeight: 96,
|
||||||
|
disableOneColumnMode: false,
|
||||||
|
handle: ".dashboard-widget-drag-handle",
|
||||||
|
resizable: {
|
||||||
|
handles: "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
gridElement.value
|
||||||
|
)
|
||||||
|
grid.value.on("change", handleGridChange)
|
||||||
|
syncGridInteractivity()
|
||||||
|
isSyncingGrid.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleIds = new Set(visibleWidgets.value.map((widget) => widget.id))
|
||||||
|
|
||||||
|
isSyncingGrid.value = true
|
||||||
|
grid.value.batchUpdate()
|
||||||
|
|
||||||
|
;[...grid.value.engine.nodes].forEach((node) => {
|
||||||
|
const widgetId = node.el?.dataset?.widgetId || node.id
|
||||||
|
if (!node.el || !node.el.isConnected || !visibleIds.has(widgetId)) {
|
||||||
|
if (node.el) grid.value.removeWidget(node.el, false, false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const domWidgets = [...gridElement.value.querySelectorAll(".grid-stack-item")]
|
||||||
|
|
||||||
|
domWidgets.forEach((element) => {
|
||||||
|
const widget = visibleWidgets.value.find((item) => item.id === element.dataset.widgetId)
|
||||||
|
if (!widget) return
|
||||||
|
|
||||||
|
if (!element.gridstackNode) {
|
||||||
|
grid.value.makeWidget(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.value.update(element, gridOptionsFor(widget))
|
||||||
|
})
|
||||||
|
|
||||||
|
grid.value.batchUpdate(false)
|
||||||
|
isSyncingGrid.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWidget(id) {
|
||||||
|
const widget = getWidgetLayout(id)
|
||||||
|
if (!widget || widget.visible) return
|
||||||
|
|
||||||
|
updateWidget(id, { visible: true })
|
||||||
|
persistWidgets()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEditMode() {
|
||||||
|
isEditMode.value = !isEditMode.value
|
||||||
|
if (!isEditMode.value) manageCardsOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWidget(id) {
|
||||||
|
if (visibleWidgets.value.length <= 1) {
|
||||||
|
toast.add({
|
||||||
|
title: "Letzte Karte",
|
||||||
|
description: "Mindestens eine Dashboard-Karte sollte sichtbar bleiben.",
|
||||||
|
color: "orange"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWidget(id, { visible: false })
|
||||||
|
persistWidgets()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDashboard() {
|
||||||
|
widgets.value = normalizeDashboardWidgets()
|
||||||
|
persistWidgets()
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleWidgets = computed(() =>
|
||||||
|
widgets.value
|
||||||
|
.filter((widget) => widget.visible)
|
||||||
|
.map((widget) => ({
|
||||||
|
...widgetDefinitions[widget.id],
|
||||||
|
...widget
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const hiddenWidgets = computed(() =>
|
||||||
|
DASHBOARD_WIDGETS.filter((definition) => !getWidgetLayout(definition.id)?.visible)
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => tempStore.settings?.dashboard,
|
||||||
|
(storedDashboard) => {
|
||||||
|
const nextWidgets = normalizeDashboardWidgets(storedDashboard?.widgets)
|
||||||
|
if (widgetsSignature(nextWidgets) === widgetsSignature(widgets.value)) return
|
||||||
|
widgets.value = nextWidgets
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
visibleWidgets,
|
||||||
|
async () => {
|
||||||
|
await syncGridWithDom()
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(isEditMode, () => {
|
||||||
|
syncGridInteractivity()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!widgets.value.length) widgets.value = normalizeDashboardWidgets()
|
||||||
|
await syncGridWithDom()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(persistTimeout)
|
||||||
|
if (grid.value) {
|
||||||
|
grid.value.off("change", handleGridChange)
|
||||||
|
grid.value.destroy(false)
|
||||||
|
grid.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<UDashboardNavbar title="Home">
|
||||||
|
<template #right>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UButton
|
||||||
|
:icon="isEditMode ? 'i-heroicons-check' : 'i-heroicons-pencil-square'"
|
||||||
|
:color="isEditMode ? 'primary' : 'gray'"
|
||||||
|
:variant="isEditMode ? 'solid' : 'ghost'"
|
||||||
|
@click="toggleEditMode"
|
||||||
|
>
|
||||||
|
{{ isEditMode ? "Bearbeitung beenden" : "Dashboard bearbeiten" }}
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="isEditMode && hiddenWidgets.length > 0"
|
||||||
|
icon="i-heroicons-plus"
|
||||||
|
color="white"
|
||||||
|
variant="soft"
|
||||||
|
@click="manageCardsOpen = true"
|
||||||
|
>
|
||||||
|
Karte hinzufügen
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="isEditMode"
|
||||||
|
icon="i-heroicons-squares-2x2"
|
||||||
|
color="gray"
|
||||||
|
variant="ghost"
|
||||||
|
@click="manageCardsOpen = true"
|
||||||
|
>
|
||||||
|
Karten verwalten
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UDashboardNavbar>
|
||||||
|
|
||||||
|
<div v-if="visibleWidgets.length > 0" ref="gridElement" class="grid-stack dashboard-grid overflow-y-auto h-80">
|
||||||
|
<div
|
||||||
|
v-for="widget in visibleWidgets"
|
||||||
|
:key="widget.id"
|
||||||
|
:class="['grid-stack-item', isEditMode ? 'dashboard-widget-editing' : '']"
|
||||||
|
:data-widget-id="widget.id"
|
||||||
|
:gs-x="widget.x"
|
||||||
|
:gs-y="widget.y"
|
||||||
|
:gs-w="widget.w"
|
||||||
|
:gs-h="widget.h"
|
||||||
|
:gs-min-w="widget.minW"
|
||||||
|
:gs-min-h="widget.minH"
|
||||||
|
>
|
||||||
|
<div class="grid-stack-item-content dashboard-grid-item">
|
||||||
|
<div class="dashboard-widget-card">
|
||||||
|
<div class="dashboard-widget-header">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div :class="['dashboard-widget-drag-handle font-semibold text-gray-900 dark:text-white', isEditMode ? 'cursor-move' : 'cursor-default']">
|
||||||
|
{{ widget.title }}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ widget.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-widget-header-actions">
|
||||||
|
<div :id="`dashboard-widget-header-actions-${widget.id}`" class="dashboard-widget-header-target" />
|
||||||
|
<UButtonGroup v-if="isEditMode" size="xs">
|
||||||
|
<UButton
|
||||||
|
color="gray"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-arrows-pointing-out"
|
||||||
|
class="dashboard-widget-drag-handle"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
color="gray"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-x-mark"
|
||||||
|
:disabled="visibleWidgets.length <= 1"
|
||||||
|
@click="removeWidget(widget.id)"
|
||||||
|
/>
|
||||||
|
</UButtonGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-widget-body">
|
||||||
|
<component
|
||||||
|
:is="widget.component"
|
||||||
|
v-bind="widget.id === 'income-expense'
|
||||||
|
? { headerTarget: `#dashboard-widget-header-actions-${widget.id}` }
|
||||||
|
: {}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rounded-xl border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Es sind aktuell keine Dashboard-Karten sichtbar.
|
||||||
|
</p>
|
||||||
|
<UButton v-if="isEditMode" class="mt-4" icon="i-heroicons-plus" @click="manageCardsOpen = true">
|
||||||
|
Karte hinzufügen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UModal v-model="manageCardsOpen">
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold">Dashboard-Karten</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Karten ein- oder ausblenden und bei Bedarf auf das Standardlayout zurücksetzen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UButton color="gray" variant="ghost" icon="i-heroicons-arrow-path" @click="resetDashboard">
|
||||||
|
Zurücksetzen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="definition in DASHBOARD_WIDGETS"
|
||||||
|
:key="definition.id"
|
||||||
|
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">{{ definition.title }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ definition.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="getWidgetLayout(definition.id)?.visible"
|
||||||
|
color="gray"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-minus"
|
||||||
|
:disabled="visibleWidgets.length <= 1"
|
||||||
|
@click="removeWidget(definition.id)"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-else
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-plus"
|
||||||
|
@click="addWidget(definition.id)"
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</UModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-grid {
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid-item {
|
||||||
|
height: 100%;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-widget-card {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid rgb(229 231 235);
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-widget-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: 1px solid rgb(229 231 235);
|
||||||
|
padding: 1rem 1rem 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-widget-header-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-widget-header-target {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-widget-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.grid-stack-item-content) {
|
||||||
|
inset: 0;
|
||||||
|
background: transparent;
|
||||||
|
overflow-y: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.grid-stack-item:not(.dashboard-widget-editing) .ui-resizable-handle) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dark .dashboard-widget-card) {
|
||||||
|
border-color: rgb(31 41 55);
|
||||||
|
background: rgb(17 24 39);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dark .dashboard-widget-header) {
|
||||||
|
border-color: rgb(31 41 55);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UDashboardNavbar title="Home">
|
|
||||||
<template #right>
|
|
||||||
<!-- <UTooltip text="Notifications" :shortcuts="['N']">
|
|
||||||
<UButton color="gray" variant="ghost" square @click="isNotificationsSlideoverOpen = true">
|
|
||||||
<UChip :show="unreadMessages" color="primary" inset>
|
|
||||||
<UIcon name="i-heroicons-bell" class="w-5 h-5" />
|
|
||||||
</UChip>
|
|
||||||
</UButton>
|
|
||||||
</UTooltip>-->
|
|
||||||
</template>
|
|
||||||
</UDashboardNavbar>
|
|
||||||
|
|
||||||
<UDashboardPanelContent>
|
|
||||||
<div class="mb-5">
|
|
||||||
<UDashboardCard
|
|
||||||
title="Einnahmen und Ausgaben"
|
|
||||||
class="mt-3"
|
|
||||||
>
|
|
||||||
<display-income-and-expenditure/>
|
|
||||||
</UDashboardCard>
|
|
||||||
</div>
|
|
||||||
<UPageGrid>
|
|
||||||
<UDashboardCard
|
|
||||||
title="Buchhaltung"
|
|
||||||
>
|
|
||||||
<display-open-balances/>
|
|
||||||
</UDashboardCard>
|
|
||||||
<UDashboardCard
|
|
||||||
title="Bank"
|
|
||||||
>
|
|
||||||
<display-bankaccounts/>
|
|
||||||
</UDashboardCard>
|
|
||||||
<UDashboardCard
|
|
||||||
title="Projekte"
|
|
||||||
>
|
|
||||||
<display-projects-in-phases/>
|
|
||||||
</UDashboardCard>
|
|
||||||
<!--<UDashboardCard
|
|
||||||
title="Anwesende"
|
|
||||||
>
|
|
||||||
<display-present-profiles/>
|
|
||||||
</UDashboardCard>
|
|
||||||
<UDashboardCard
|
|
||||||
title="Projektzeiten"
|
|
||||||
>
|
|
||||||
<display-running-time/>
|
|
||||||
</UDashboardCard>
|
|
||||||
<UDashboardCard
|
|
||||||
title="Anwesenheiten"
|
|
||||||
>
|
|
||||||
<display-running-working-time/>
|
|
||||||
</UDashboardCard>-->
|
|
||||||
<UDashboardCard
|
|
||||||
title="Aufgaben"
|
|
||||||
>
|
|
||||||
<display-open-tasks/>
|
|
||||||
</UDashboardCard>
|
|
||||||
<!-- <UDashboardCard
|
|
||||||
title="Label Test"
|
|
||||||
>
|
|
||||||
<UButton
|
|
||||||
@click="modal.open(LabelPrintModal, {
|
|
||||||
context: {
|
|
||||||
datamatrix: '1234',
|
|
||||||
text: 'FEDEO TEST'
|
|
||||||
}
|
|
||||||
})"
|
|
||||||
icon="i-heroicons-printer"
|
|
||||||
>
|
|
||||||
Label Drucken
|
|
||||||
</UButton>
|
|
||||||
</UDashboardCard>-->
|
|
||||||
</UPageGrid>
|
|
||||||
</UDashboardPanelContent>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
|
|
||||||
import Nimbot from "~/components/nimbot.vue";
|
|
||||||
import LabelPrintModal from "~/components/LabelPrintModal.vue";
|
|
||||||
|
|
||||||
|
|
||||||
const modal = useModal();
|
|
||||||
|
|
||||||
const { isNotificationsSlideoverOpen } = useDashboard()
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
@@ -60,6 +60,7 @@ const featureOptions = [
|
|||||||
{ key: "dashboard", label: "Dashboard" },
|
{ key: "dashboard", label: "Dashboard" },
|
||||||
{ key: "historyitems", label: "Logbuch" },
|
{ key: "historyitems", label: "Logbuch" },
|
||||||
{ key: "tasks", label: "Aufgaben" },
|
{ key: "tasks", label: "Aufgaben" },
|
||||||
|
{ key: "planningBoard", label: "Plantafel" },
|
||||||
{ key: "wiki", label: "Wiki" },
|
{ key: "wiki", label: "Wiki" },
|
||||||
{ key: "files", label: "Dateien" },
|
{ key: "files", label: "Dateien" },
|
||||||
{ key: "createdletters", label: "Anschreiben" },
|
{ key: "createdletters", label: "Anschreiben" },
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import {Preferences} from "@capacitor/preferences";
|
|||||||
|
|
||||||
export const useAuthStore = defineStore("auth", {
|
export const useAuthStore = defineStore("auth", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
user: null as null | { user_id: string; email: string; tenant_id?: string; role?: string },
|
user: null as null | {
|
||||||
|
id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
email: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
role?: string;
|
||||||
|
must_change_password?: boolean;
|
||||||
|
is_admin?: boolean;
|
||||||
|
},
|
||||||
profile: null as null | any,
|
profile: null as null | any,
|
||||||
tenants: [] as { tenant_id: string; role: string; tenants: { id: string; name: string } }[],
|
tenants: [] as { tenant_id: string; role: string; tenants: { id: string; name: string } }[],
|
||||||
permissions: [] as string[],
|
permissions: [] as string[],
|
||||||
|
|||||||
Reference in New Issue
Block a user