Compare commits

...

36 Commits

Author SHA1 Message Date
03bcc1a939 2. Zwischenstand
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 2m43s
2026-03-21 22:56:56 +01:00
68b2cbb0ee Zwischenstand 2026-03-21 22:13:19 +01:00
b009ac845f Start UI Change 2026-03-21 21:13:22 +01:00
cfd84b773f Revert "Added missing files"
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 53s
This reverts commit 6c3c318f86.
2026-03-21 17:57:26 +01:00
8038f03406 Added missing files 2026-03-21 17:57:23 +01:00
6c3c318f86 Added missing files 2026-03-21 17:56:39 +01:00
8dfcffc92b Added Repository Changelog
Some checks failed
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Failing after 28s
2026-03-21 17:52:01 +01:00
9ecacdab50 Handlebars Util 2026-03-21 17:44:44 +01:00
44fb50b11e Removed non available Entries 2026-03-21 17:44:37 +01:00
23c4d21f44 Added UST Auswertung 2026-03-21 17:44:25 +01:00
6f77bccd85 DB Changes 2026-03-21 17:42:59 +01:00
be336a51ab Changes on Admin Interface 2026-03-21 17:10:03 +01:00
ac2e2fcfe9 Fix for no Files present in tenant 2026-03-21 17:09:38 +01:00
9dbb194c8a Fix False Open State for cancelled Invoices 2026-03-21 17:08:57 +01:00
0aacb18aaa Fix False Showing Card 2026-03-21 17:07:47 +01:00
e3a1636018 Fix #44 with Handlebars Templates 2026-03-21 17:05:04 +01:00
55bb2589a4 Fix Darkmode Dashboard
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 52s
2026-03-18 18:47:02 +01:00
05d99e9e7d #133
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 20s
Build and Push Docker Images / build-frontend (push) Successful in 13s
2026-03-18 18:36:38 +01:00
7e0a2f5e4f New Admin Dashboard
Some checks failed
Build and Push Docker Images / build-backend (push) Failing after 25s
Build and Push Docker Images / build-frontend (push) Successful in 1m22s
2026-03-18 18:34:02 +01:00
84c174ca09 Rendering Fix 2026-03-18 18:33:47 +01:00
a9d3d0038f Card Changes for New Dashboard 2026-03-18 18:27:30 +01:00
003d88587a Fixed Dokubox and Sanitizing for File Uploads Fix #133 2026-03-18 18:27:14 +01:00
69ff646689 Selfhosting Readme 2026-03-18 18:26:39 +01:00
1511340f00 Neues Dashboard mit selbstwählbaren und verschiebbaren Cards 2026-03-18 18:26:30 +01:00
62accb5a86 Vorschläge System in Bankbuchungen 2026-03-17 18:14:09 +01:00
8c935c6101 Plantafel Reste 2026-03-17 18:12:42 +01:00
f6bdf2906f fix in invoiceprep 2026-03-17 18:12:20 +01:00
dff3a23c04 #131 2026-03-17 18:11:52 +01:00
966c121cbf #131
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 51s
2026-03-17 18:11:45 +01:00
da50782ffc Fix #138
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 52s
2026-03-17 18:10:32 +01:00
6919de096a Fix #136
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m45s
Build and Push Docker Images / build-frontend (push) Successful in 57s
2026-03-17 15:34:06 +01:00
8892b36ae5 Fixes
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m55s
Build and Push Docker Images / build-frontend (push) Successful in 1m25s
2026-03-16 20:53:41 +01:00
8a08147265 Fixes 2026-03-16 20:46:26 +01:00
52c182cb5f Fixes
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 3m8s
Build and Push Docker Images / build-frontend (push) Successful in 1m15s
2026-03-04 20:44:19 +01:00
9cef3964e9 Serienrechnungen ausführung sowie Anwahl und liste 2026-03-04 19:54:12 +01:00
cf0fb724a2 Fix #126 2026-02-22 19:33:56 +01:00
172 changed files with 61087 additions and 3346 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

12
.idea/FEDEO.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/FEDEO.iml" filepath="$PROJECT_DIR$/.idea/FEDEO.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

532
README.md
View File

@@ -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: traefik:
image: git.federspiel.tech/flfeders/fedeo/frontend:main image: traefik:v2.11
restart: always container_name: fedeo-traefik
environment: restart: unless-stopped
- NUXT_PUBLIC_API_BASE=https://${DOMAIN}/backend command:
- NUXT_PUBLIC_PDF_LICENSE=${PDF_LICENSE} - --api.insecure=false
networks: - --api.dashboard=false
- traefik - --providers.docker=true
labels: - --providers.docker.exposedbydefault=false
- "traefik.enable=true" - --entrypoints.web.address=:80
- "traefik.docker.network=traefik" - --entrypoints.websecure.address=:443
- "traefik.port=3000" - --entrypoints.web.http.redirections.entrypoint.to=websecure
# Middlewares - --entrypoints.web.http.redirections.entrypoint.scheme=https
- "traefik.http.middlewares.fedeo-frontend-redirect-web-secure.redirectscheme.scheme=https" - --certificatesresolvers.letsencrypt.acme.tlschallenge=true
# Web Entrypoint - --certificatesresolvers.letsencrypt.acme.email=${CONTACT_EMAIL}
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure" - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- "traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)" - --accesslog=true
- "traefik.http.routers.fedeo-frontend.entrypoints=web" - --accesslog.filepath=/logs/access.log
# Web Secure Entrypoint ports:
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)" - "80:80"
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" # - "443:443"
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge" volumes:
backend: - ./traefik/letsencrypt:/letsencrypt
image: git.federspiel.tech/flfeders/fedeo/backend:main - ./traefik/logs:/logs
restart: always - /var/run/docker.sock:/var/run/docker.sock:ro
environment: networks:
- INFISICAL_CLIENT_ID= - web
- INFISICAL_CLIENT_SECRET=
- NODE_ENV=production db:
networks: image: postgres:16
- traefik container_name: fedeo-db
labels: restart: unless-stopped
- "traefik.enable=true" environment:
- "traefik.docker.network=traefik" POSTGRES_DB: ${DB_NAME}
- "traefik.port=3100" POSTGRES_USER: ${DB_USER}
# Middlewares POSTGRES_PASSWORD: ${DB_PASSWORD}
- "traefik.http.middlewares.fedeo-backend-redirect-web-secure.redirectscheme.scheme=https" volumes:
- "traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend" - ./postgres:/var/lib/postgresql/data
# Web Entrypoint healthcheck:
- "traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-redirect-web-secure" test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
- "traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)" interval: 10s
- "traefik.http.routers.fedeo-backend.entrypoints=web" timeout: 5s
# Web Secure Entrypoint retries: 10
- "traefik.http.routers.fedeo-backend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)" networks:
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" # - internal
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip" minio:
# db: image: minio/minio:latest
# image: postgres container_name: fedeo-minio
# restart: always restart: unless-stopped
# shm_size: 128mb command: server /data --console-address ":9001"
# environment: environment:
# POSTGRES_PASSWORD: MINIO_ROOT_USER: ${MINIO_ROOT_USER}
# POSTGRES_USER: MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
# POSTGRES_DB: volumes:
# volumes: - ./minio:/data
# - ./pg-data:/var/lib/postgresql/data healthcheck:
# ports: test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
# - "5432:5432" interval: 10s
traefik: timeout: 5s
image: traefik:v2.11 retries: 10
restart: unless-stopped networks:
container_name: traefik - internal
command:
- "--api.insecure=false" createbuckets:
- "--api.dashboard=false" image: minio/mc:latest
- "--api.debug=false" container_name: fedeo-minio-init
- "--providers.docker=true" depends_on:
- "--providers.docker.exposedbydefault=false" minio:
- "--providers.docker.network=traefik" condition: service_healthy
- "--entrypoints.web.address=:80" entrypoint: >
- "--entrypoints.web-secured.address=:443" /bin/sh -c "
- "--accesslog=true" mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
- "--accesslog.filepath=/logs/access.log" mc mb --ignore-existing local/${MINIO_BUCKET};
- "--accesslog.bufferingsize=5000" mc anonymous set private local/${MINIO_BUCKET};
- "--accesslog.fields.defaultMode=keep" exit 0;
- "--accesslog.fields.headers.defaultMode=keep" "
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" # restart: "no"
- "--certificatesresolvers.mytlschallenge.acme.email=${CONTACT_EMAIL}" networks:
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json" - internal
ports:
- 80:80 backend:
- 443:443 build:
volumes: context: ./backend
- "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS) container_name: fedeo-backend
- "/var/run/docker.sock:/var/run/docker.sock:ro" restart: unless-stopped
- "./traefik/logs:/logs" depends_on:
networks: db:
- traefik 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: networks:
traefik: web:
external: false 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.

1
backend/.gitignore vendored
View File

@@ -3,3 +3,4 @@ node_modules
.env .env
/src/generated/prisma /src/generated/prisma
/dist/

View File

@@ -1,6 +1,14 @@
FROM node:20-alpine FROM node:20-bookworm-slim
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
poppler-utils \
tesseract-ocr \
tesseract-ocr-deu \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/*
# Package-Dateien # Package-Dateien
COPY package*.json ./ COPY package*.json ./

View File

@@ -0,0 +1,3 @@
ALTER TABLE "accounts" ADD COLUMN "accountChart" text DEFAULT 'skr03' NOT NULL;
--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "accountChart" text DEFAULT 'skr03' NOT NULL;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "createddocuments"
ALTER COLUMN "customSurchargePercentage" TYPE double precision
USING "customSurchargePercentage"::double precision;

View File

@@ -0,0 +1 @@
ALTER TABLE "files" ADD COLUMN "extracted_text" text;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth_users"
ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;

View File

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE "tenants"
ADD COLUMN "taxEvaluationPeriod" text DEFAULT 'monthly' NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -134,6 +134,34 @@
"when": 1773000900000, "when": 1773000900000,
"tag": "0018_account_chart", "tag": "0018_account_chart",
"breakpoints": true "breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1773572400000,
"tag": "0020_file_extracted_text",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1773835200000,
"tag": "0021_admin_user_flag",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1773925200000,
"tag": "0022_task_dependencies",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1774080000000,
"tag": "0023_tax_evaluation_period",
"breakpoints": true
} }
] ]
} }

View File

@@ -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 }),

View File

@@ -6,6 +6,7 @@ import {
jsonb, jsonb,
boolean, boolean,
smallint, smallint,
doublePrecision,
uuid, uuid,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
@@ -96,7 +97,7 @@ export const createddocuments = pgTable("createddocuments", {
taxType: text("taxType"), taxType: text("taxType"),
customSurchargePercentage: smallint("customSurchargePercentage") customSurchargePercentage: doublePrecision("customSurchargePercentage")
.notNull() .notNull()
.default(0), .default(0),

View File

@@ -66,6 +66,7 @@ export const files = pgTable("files", {
documentbox: uuid("documentbox").references(() => documentboxes.id), documentbox: uuid("documentbox").references(() => documentboxes.id),
name: text("name"), name: text("name"),
extractedText: text("extracted_text"),
updatedAt: timestamp("updated_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id), updatedBy: uuid("updated_by").references(() => authUsers.id),

View File

@@ -74,6 +74,48 @@ export const tenants = pgTable(
timeTracking: true, timeTracking: true,
planningBoard: true, planningBoard: true,
workingTimeTracking: true, workingTimeTracking: true,
dashboard: true,
historyitems: true,
tasks: true,
wiki: true,
files: true,
createdletters: true,
documentboxes: true,
helpdesk: true,
email: true,
members: true,
customers: true,
vendors: true,
contactsList: true,
staffTime: true,
createDocument: true,
serialInvoice: true,
incomingInvoices: true,
costcentres: true,
accounts: true,
ownaccounts: true,
banking: true,
spaces: true,
customerspaces: true,
customerinventoryitems: true,
inventoryitems: true,
inventoryitemgroups: true,
products: true,
productcategories: true,
services: true,
servicecategories: true,
memberrelations: true,
staffProfiles: true,
hourrates: true,
projecttypes: true,
contracttypes: true,
plants: true,
settingsNumberRanges: true,
settingsEmailAccounts: true,
settingsBanking: true,
settingsTexttemplates: true,
settingsTenant: true,
export: true,
}), }),
ownFields: jsonb("ownFields"), ownFields: jsonb("ownFields"),
@@ -119,6 +161,10 @@ export const tenants = pgTable(
.notNull() .notNull()
.default(14), .default(14),
taxEvaluationPeriod: text("taxEvaluationPeriod")
.notNull()
.default("monthly"),
dokuboxEmailAddresses: jsonb("dokuboxEmailAddresses").default([]), dokuboxEmailAddresses: jsonb("dokuboxEmailAddresses").default([]),
dokuboxkey: uuid("dokuboxkey").notNull().defaultRandom(), dokuboxkey: uuid("dokuboxkey").notNull().defaultRandom(),

View File

@@ -11,7 +11,8 @@
"start": "node dist/src/index.js", "start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts", "schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts", "bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
"members:import:csv": "tsx scripts/import-members-csv.ts" "members:import:csv": "tsx scripts/import-members-csv.ts",
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -0,0 +1,270 @@
import fs from "node:fs"
import path from "node:path"
import { and, eq } from "drizzle-orm"
import { db, pool } from "../db"
import { customers, entitybankaccounts } from "../db/schema"
import { decrypt, encrypt } from "../src/utils/crypt"
import { loadSecrets, secrets } from "../src/utils/secrets"
type CsvMemberRow = {
number: string
lastname: string
firstname: string
street: string
zip: string
city: string
birthdate: string
mobile: string
email: string
bankInstitute: string
iban: string
bic: string
date: string
memberStatus: string
}
const TENANT_ID = 38
const DEFAULT_CSV_PATH = "/Users/florianfederspiel/Downloads/Mitglieder Übersicht 2026_1.csv"
const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run")
const csvArg = args.find((arg) => !arg.startsWith("--"))
const csvPath = csvArg || DEFAULT_CSV_PATH
function normalizeIban(value: string) {
return String(value || "").replace(/\s+/g, "").toUpperCase()
}
function parseGermanDate(value: string): string | null {
const v = String(value || "").trim()
if (!v) return null
const m = v.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/)
if (!m) return null
const day = m[1].padStart(2, "0")
const month = m[2].padStart(2, "0")
const yy = m[3]
const year = yy.length === 4 ? yy : (Number(yy) >= 70 ? `19${yy}` : `20${yy}`)
return `${year}-${month}-${day}`
}
function parseBoolFromStatus(value: string) {
const normalized = String(value || "").trim().toLowerCase()
return normalized !== "inaktiv"
}
function parseCsv(content: string): CsvMemberRow[] {
const lines = content
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0)
if (!lines.length) return []
// Header:
// Nr;Name;Vorname;Straße, Hausnr.;PLZ;Wohnort;Geburtsdatum;Mobilfunknummer;Private Mail-Adresse;Kreditinstitut;IBAN;BIC;Datum;Mitgliedsstatus
const rows: CsvMemberRow[] = []
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(";").map((v) => v.trim())
if (cols.length < 14) continue
const number = cols[0]
const lastname = cols[1]
const firstname = cols[2]
if (!number || !lastname || !firstname) continue
rows.push({
number,
lastname,
firstname,
street: cols[3] || "",
zip: cols[4] || "",
city: cols[5] || "",
birthdate: cols[6] || "",
mobile: cols[7] || "",
email: cols[8] || "",
bankInstitute: cols[9] || "",
iban: cols[10] || "",
bic: cols[11] || "",
date: cols[12] || "",
memberStatus: cols[13] || "",
})
}
return rows
}
async function loadBankAccountByIban(tenantId: number) {
const rows = await db
.select({
id: entitybankaccounts.id,
ibanEncrypted: entitybankaccounts.ibanEncrypted,
})
.from(entitybankaccounts)
.where(eq(entitybankaccounts.tenant, tenantId))
const map = new Map<string, number>()
for (const row of rows) {
try {
const iban = normalizeIban(decrypt(row.ibanEncrypted as any))
if (iban) map.set(iban, Number(row.id))
} catch {
// skip broken ciphertext rows
}
}
return map
}
async function main() {
if (!secrets.ENCRYPTION_KEY && process.env.ENCRYPTION_KEY) {
secrets.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY
}
if (!secrets.ENCRYPTION_KEY && process.env.INFISICAL_CLIENT_ID && process.env.INFISICAL_CLIENT_SECRET) {
await loadSecrets()
}
if (!secrets.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY fehlt. Bitte ENCRYPTION_KEY setzen oder Infisical-Zugang (INFISICAL_CLIENT_ID/INFISICAL_CLIENT_SECRET) bereitstellen.")
}
const absoluteCsvPath = path.resolve(csvPath)
if (!fs.existsSync(absoluteCsvPath)) {
throw new Error(`CSV nicht gefunden: ${absoluteCsvPath}`)
}
const raw = fs.readFileSync(absoluteCsvPath, "utf8")
const csvRows = parseCsv(raw)
if (!csvRows.length) {
throw new Error("Keine importierbaren Zeilen gefunden.")
}
const existingMembers = await db
.select()
.from(customers)
.where(and(eq(customers.tenant, TENANT_ID), eq(customers.type, "Mitglied")))
const memberByNumber = new Map(existingMembers.map((m) => [String(m.customerNumber), m]))
const bankAccountByIban = await loadBankAccountByIban(TENANT_ID)
let createdMembers = 0
let updatedMembers = 0
let createdBankAccounts = 0
let skippedNoIban = 0
for (const row of csvRows) {
const iban = normalizeIban(row.iban)
if (!iban) {
skippedNoIban += 1
continue
}
const fullName = `${row.firstname} ${row.lastname}`.trim()
const birthdate = parseGermanDate(row.birthdate)
const sepaSignedAt = parseGermanDate(row.date)
const active = parseBoolFromStatus(row.memberStatus)
let bankAccountId = bankAccountByIban.get(iban) || null
if (!bankAccountId) {
if (!dryRun) {
const [created] = await db
.insert(entitybankaccounts)
.values({
tenant: TENANT_ID,
ibanEncrypted: encrypt(iban),
bicEncrypted: encrypt(row.bic || "UNBEKANNT"),
bankNameEncrypted: encrypt(row.bankInstitute || "Unbekannt"),
description: "Import Mitglieder Uebersicht 2026_1",
})
.returning({ id: entitybankaccounts.id })
bankAccountId = created?.id || null
} else {
bankAccountId = -1
}
if (bankAccountId) {
bankAccountByIban.set(iban, bankAccountId)
createdBankAccounts += 1
}
}
const existing = memberByNumber.get(String(row.number))
const existingInfo = (existing?.infoData && typeof existing.infoData === "object")
? { ...(existing.infoData as Record<string, any>) }
: {}
const existingIds = Array.isArray(existingInfo.bankAccountIds) ? existingInfo.bankAccountIds : []
const mergedBankAccountIds = bankAccountId && !existingIds.includes(bankAccountId)
? [...existingIds, bankAccountId]
: existingIds
const infoData = {
...existingInfo,
street: row.street || existingInfo.street || "",
zip: row.zip || existingInfo.zip || "",
city: row.city || existingInfo.city || "",
phone: row.mobile || existingInfo.phone || "",
email: row.email || existingInfo.email || "",
birthdate: birthdate || existingInfo.birthdate || null,
hasSEPA: Boolean(sepaSignedAt || existingInfo.sepaSignedAt || existingInfo.hasSEPA),
sepaSignedAt: sepaSignedAt || existingInfo.sepaSignedAt || null,
bankAccountIds: mergedBankAccountIds,
}
const payload = {
tenant: TENANT_ID,
customerNumber: String(row.number),
type: "Mitglied",
isCompany: false,
firstname: row.firstname,
lastname: row.lastname,
name: fullName,
active,
infoData,
archived: false,
}
if (!existing) {
if (!dryRun) {
const [created] = await db.insert(customers).values(payload).returning()
if (created) memberByNumber.set(String(row.number), created)
}
createdMembers += 1
} else {
if (!dryRun) {
await db
.update(customers)
.set({
...payload,
updatedAt: new Date(),
})
.where(and(eq(customers.id, existing.id), eq(customers.tenant, TENANT_ID)))
}
updatedMembers += 1
}
}
console.log("")
console.log(`[IMPORT MEMBERS] Tenant: ${TENANT_ID}`)
console.log(`[IMPORT MEMBERS] CSV: ${absoluteCsvPath}`)
console.log(`[IMPORT MEMBERS] Dry-Run: ${dryRun ? "JA" : "NEIN"}`)
console.log(`[IMPORT MEMBERS] Zeilen: ${csvRows.length}`)
console.log(`[IMPORT MEMBERS] Mitglieder erstellt: ${createdMembers}`)
console.log(`[IMPORT MEMBERS] Mitglieder aktualisiert: ${updatedMembers}`)
console.log(`[IMPORT MEMBERS] Bankkonten erstellt: ${createdBankAccounts}`)
console.log(`[IMPORT MEMBERS] Ohne IBAN übersprungen: ${skippedNoIban}`)
console.log("")
}
main()
.catch((err) => {
console.error("[IMPORT MEMBERS] Fehler:", err)
process.exitCode = 1
})
.finally(async () => {
await pool.end()
})

View File

@@ -0,0 +1,265 @@
import fs from "node:fs"
import path from "node:path"
import zlib from "node:zlib"
type ParsedAccount = {
number: string
label: string
}
const DEFAULT_PDF_PATH = "/Users/florianfederspiel/Downloads/12901_DATEV-Kontenrahmen SKR 42 Vereine, Stiftungen, gGmbH (Bilanz).pdf"
const ACCOUNT_CHART = "skr42"
const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run")
const parseOnly = args.includes("--parse-only")
const pdfArg = args.find((arg) => !arg.startsWith("--"))
const pdfPath = path.resolve(pdfArg || DEFAULT_PDF_PATH)
function decodePdfString(raw: string) {
let out = ""
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i]
if (ch !== "\\") {
out += ch
continue
}
const next = raw[i + 1]
if (!next) break
if (next === "n") {
out += "\n"
i += 1
continue
}
if (next === "r") {
out += "\r"
i += 1
continue
}
if (next === "t") {
out += "\t"
i += 1
continue
}
if (next === "b") {
out += "\b"
i += 1
continue
}
if (next === "f") {
out += "\f"
i += 1
continue
}
if (next === "(" || next === ")" || next === "\\") {
out += next
i += 1
continue
}
if (/[0-7]/.test(next)) {
let oct = next
let advance = 1
for (let j = 2; j <= 3; j += 1) {
const c = raw[i + j]
if (!c || !/[0-7]/.test(c)) break
oct += c
advance += 1
}
out += String.fromCharCode(parseInt(oct, 8))
i += advance
continue
}
out += next
i += 1
}
return out
}
function extractTextFromTjOperator(segment: string) {
const parts = segment.match(/\((?:\\.|[^\\)])*\)/g)
if (!parts) return ""
return parts
.map((p) => decodePdfString(p.slice(1, -1)))
.join("")
}
function extractPdfTextStreams(pdfBuffer: Buffer) {
const pdfLatin = pdfBuffer.toString("latin1")
const texts: string[] = []
let cursor = 0
while (true) {
const streamPos = pdfLatin.indexOf("stream", cursor)
if (streamPos < 0) break
let dataStart = streamPos + 6
if (pdfLatin[dataStart] === "\r" && pdfLatin[dataStart + 1] === "\n") {
dataStart += 2
} else if (pdfLatin[dataStart] === "\n") {
dataStart += 1
}
const streamEnd = pdfLatin.indexOf("endstream", dataStart)
if (streamEnd < 0) break
const sliceEnd = streamEnd > dataStart && pdfBuffer[streamEnd - 1] === 0x0d
? streamEnd - 1
: streamEnd
const compressed = pdfBuffer.subarray(dataStart, sliceEnd)
try {
const inflated = zlib.inflateSync(compressed).toString("latin1")
texts.push(inflated)
} catch {
// ignore non-flate streams
}
cursor = streamEnd + 9
}
return texts
}
function normalizeLabel(value: string) {
return value
.replace(/\s+/g, " ")
.replace(/\s+-\s+/g, "-")
.trim()
}
function looksLikeAccountLabel(value: string) {
const letters = (value.match(/[A-Za-zÄÖÜäöüß]/g) || []).length
return letters >= 3
}
function parseAccountsFromPdf(pdfBuffer: Buffer): ParsedAccount[] {
const streams = extractPdfTextStreams(pdfBuffer)
const found = new Map<string, string>()
const accountPattern = /^\s*([A-Z])?\s*(\d{3,5})\s+0\s+(.+)$/
for (const stream of streams) {
const operators = stream.match(/\[(?:.|\r|\n)*?\]TJ|\((?:\\.|[^\\)])*\)Tj/g)
if (!operators) continue
for (const op of operators) {
const text = normalizeLabel(extractTextFromTjOperator(op))
if (!text) continue
const m = text.match(accountPattern)
if (m) {
const number = m[2]
const label = normalizeLabel(m[3])
if (!looksLikeAccountLabel(label)) continue
const existing = found.get(number)
if (!existing || label.length > existing.length) {
found.set(number, label)
}
}
}
}
return [...found.entries()]
.map(([number, label]) => ({ number, label }))
.sort((a, b) => Number(a.number) - Number(b.number))
}
async function main() {
if (!fs.existsSync(pdfPath)) {
throw new Error(`PDF nicht gefunden: ${pdfPath}`)
}
const pdfBuffer = fs.readFileSync(pdfPath)
const parsed = parseAccountsFromPdf(pdfBuffer)
if (!parsed.length) {
throw new Error("Keine Konten aus PDF extrahiert.")
}
if (parseOnly) {
console.log("")
console.log(`[SKR42 IMPORT] PDF: ${pdfPath}`)
console.log(`[SKR42 IMPORT] Gefundene Konten: ${parsed.length}`)
console.log(`[SKR42 IMPORT] Parse-Only: JA`)
console.log("")
console.log("[SKR42 IMPORT] Beispiel (erste 15):")
for (const item of parsed.slice(0, 15)) {
console.log(` ${item.number} ${item.label}`)
}
console.log("")
return
}
const { eq } = await import("drizzle-orm")
const { db, pool } = await import("../db")
const { accounts } = await import("../db/schema")
const existing = await db
.select({ number: accounts.number })
.from(accounts)
.where(eq(accounts.accountChart, ACCOUNT_CHART))
const existingSet = new Set(existing.map((r) => String(r.number)))
const toInsert = parsed
.filter((a) => !existingSet.has(a.number))
.map((a) => ({
number: a.number,
label: a.label,
accountChart: ACCOUNT_CHART,
description: "DATEV SKR42 Import",
}))
if (!dryRun && toInsert.length > 0) {
const batchSize = 500
for (let i = 0; i < toInsert.length; i += batchSize) {
const batch = toInsert.slice(i, i + batchSize)
await db.insert(accounts).values(batch)
}
}
console.log("")
console.log(`[SKR42 IMPORT] PDF: ${pdfPath}`)
console.log(`[SKR42 IMPORT] Gefundene Konten: ${parsed.length}`)
console.log(`[SKR42 IMPORT] Bereits vorhanden (skr42): ${existing.length}`)
console.log(`[SKR42 IMPORT] Neu einzufuegen: ${toInsert.length}`)
console.log(`[SKR42 IMPORT] Dry-Run: ${dryRun ? "JA" : "NEIN"}`)
console.log("")
if (parsed.length > 0) {
console.log("[SKR42 IMPORT] Beispiel (erste 15):")
for (const item of parsed.slice(0, 15)) {
console.log(` ${item.number} ${item.label}`)
}
console.log("")
}
}
main()
.catch((err) => {
console.error("[SKR42 IMPORT] Fehler:", err)
process.exitCode = 1
})
.finally(async () => {
if (!parseOnly) {
const { pool } = await import("../db")
await pool.end()
}
})

BIN
backend/scripts/skr42.pdf Normal file

Binary file not shown.

View File

@@ -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")
} }

View File

@@ -8,107 +8,20 @@ import {
files, files,
filetags, filetags,
incominginvoices, incominginvoices,
vendors,
} from "../../../db/schema" } from "../../../db/schema"
import { eq, and, isNull, not, desc } from "drizzle-orm" import { eq, and, isNull, not } from "drizzle-orm"
type InvoiceAccount = { const formatInvoiceItemDescription = (item: any) => {
account?: number | null const parts = [
description?: string | null typeof item.description === "string" ? item.description.trim() : "",
taxType?: string | number | null 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)
const normalizeAccounts = (accounts: unknown): InvoiceAccount[] => { return parts.join(" - ")
if (!Array.isArray(accounts)) return []
return accounts
.map((entry: any) => ({
account: typeof entry?.account === "number" ? entry.account : null,
description: typeof entry?.description === "string" ? entry.description : null,
taxType: entry?.taxType ?? null,
}))
.filter((entry) => entry.account !== null || entry.description || entry.taxType !== null)
}
const buildLearningContext = (historicalInvoices: any[]) => {
if (!historicalInvoices.length) return null
const vendorProfiles = new Map<number, {
vendorName: string
paymentTypes: Map<string, number>
accountUsage: Map<number, number>
sampleDescriptions: string[]
}>()
const recentExamples: any[] = []
for (const invoice of historicalInvoices) {
const accounts = normalizeAccounts(invoice.accounts)
const vendorId = typeof invoice.vendorId === "number" ? invoice.vendorId : null
const vendorName = typeof invoice.vendorName === "string" ? invoice.vendorName : "Unknown"
if (vendorId) {
if (!vendorProfiles.has(vendorId)) {
vendorProfiles.set(vendorId, {
vendorName,
paymentTypes: new Map(),
accountUsage: new Map(),
sampleDescriptions: [],
})
}
const profile = vendorProfiles.get(vendorId)!
if (invoice.paymentType) {
const key = String(invoice.paymentType)
profile.paymentTypes.set(key, (profile.paymentTypes.get(key) ?? 0) + 1)
}
for (const account of accounts) {
if (typeof account.account === "number") {
profile.accountUsage.set(account.account, (profile.accountUsage.get(account.account) ?? 0) + 1)
}
}
if (invoice.description && profile.sampleDescriptions.length < 3) {
profile.sampleDescriptions.push(String(invoice.description).slice(0, 120))
}
}
if (recentExamples.length < 20) {
recentExamples.push({
vendorId,
vendorName,
paymentType: invoice.paymentType ?? null,
accounts: accounts.map((entry) => ({
account: entry.account,
description: entry.description ?? null,
taxType: entry.taxType ?? null,
})),
})
}
}
const vendorPatterns = Array.from(vendorProfiles.entries())
.map(([vendorId, profile]) => {
const commonPaymentType = Array.from(profile.paymentTypes.entries())
.sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
const topAccounts = Array.from(profile.accountUsage.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
.map(([accountId, count]) => ({ accountId, count }))
return {
vendorId,
vendorName: profile.vendorName,
commonPaymentType,
topAccounts,
sampleDescriptions: profile.sampleDescriptions,
}
})
.slice(0, 50)
return JSON.stringify({
vendorPatterns,
recentExamples,
})
} }
export function prepareIncomingInvoices(server: FastifyInstance) { export function prepareIncomingInvoices(server: FastifyInstance) {
@@ -171,34 +84,13 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
continue continue
} }
const historicalInvoices = await server.db
.select({
vendorId: incominginvoices.vendor,
vendorName: vendors.name,
paymentType: incominginvoices.paymentType,
description: incominginvoices.description,
accounts: incominginvoices.accounts,
})
.from(incominginvoices)
.leftJoin(vendors, eq(incominginvoices.vendor, vendors.id))
.where(
and(
eq(incominginvoices.tenant, tenantId),
eq(incominginvoices.archived, false)
)
)
.orderBy(desc(incominginvoices.createdAt))
.limit(120)
const learningContext = buildLearningContext(historicalInvoices)
// ------------------------------------------------------------- // -------------------------------------------------------------
// 3⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen // 3⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen
// ------------------------------------------------------------- // -------------------------------------------------------------
for (const file of filesRes) { for (const file of filesRes) {
console.log(`Processing file ${file.id} for tenant ${tenantId}`) console.log(`Processing file ${file.id} for tenant ${tenantId}`)
const data = await getInvoiceDataFromGPT(server,file, tenantId, learningContext ?? undefined) const data = await getInvoiceDataFromGPT(server,file, tenantId)
if (!data) { if (!data) {
server.log.warn(`GPT returned no data for file ${file.id}`) server.log.warn(`GPT returned no data for file ${file.id}`)
@@ -214,9 +106,9 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
} }
if (data.invoice_number) itemInfo.reference = data.invoice_number if (data.invoice_number) itemInfo.reference = data.invoice_number
if (data.invoice_date) itemInfo.date = dayjs(data.invoice_date).toISOString() if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
if (data.issuer?.id) itemInfo.vendor = data.issuer.id if (data.issuer?.id) itemInfo.vendor = data.issuer.id
if (data.invoice_duedate) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString() if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
// Payment terms mapping // Payment terms mapping
const mapPayment: any = { const mapPayment: any = {
@@ -229,16 +121,26 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
// 3.2 Positionszeilen konvertieren // 3.2 Positionszeilen konvertieren
if (data.invoice_items?.length > 0) { if (data.invoice_items?.length > 0) {
itemInfo.accounts = data.invoice_items.map(item => ({ itemInfo.accounts = data.invoice_items
account: item.account_id, .filter(item => item.description || item.total !== null || item.total_without_tax !== null)
description: item.description, .map(item => {
amountNet: item.total_without_tax, const total = typeof item.total === "number" ? item.total : null
amountTax: Number((item.total - item.total_without_tax).toFixed(2)), const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
taxType: String(item.tax_rate), const amountTax = total !== null && totalWithoutTax !== null
amountGross: item.total, ? Number((total - totalWithoutTax).toFixed(2))
costCentre: null, : null
quantity: item.quantity,
})) return {
account: item.account_id,
description: item.description,
amountNet: totalWithoutTax,
amountTax,
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
amountGross: total,
costCentre: null,
quantity: item.quantity,
}
})
} }
// 3.3 Beschreibung generieren // 3.3 Beschreibung generieren
@@ -247,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()

View File

@@ -1,6 +1,5 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import quarterOfYear from "dayjs/plugin/quarterOfYear"; import quarterOfYear from "dayjs/plugin/quarterOfYear";
import Handlebars from "handlebars";
import axios from "axios"; import axios from "axios";
import { eq, inArray, and } from "drizzle-orm"; // Drizzle Operatoren import { eq, inArray, and } from "drizzle-orm"; // Drizzle Operatoren
@@ -10,6 +9,7 @@ import { saveFile } from "../utils/files";
import {FastifyInstance} from "fastify"; import {FastifyInstance} from "fastify";
import {useNextNumberRangeNumber} from "../utils/functions"; import {useNextNumberRangeNumber} from "../utils/functions";
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen! import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
import { documentTemplateHandlebars } from "../utils/handlebars";
dayjs.extend(quarterOfYear); dayjs.extend(quarterOfYear);
@@ -609,8 +609,8 @@ export function getDocumentDataBackend(
}; };
}; };
const templateStartText = Handlebars.compile(itemInfo.startText || ""); const templateStartText = documentTemplateHandlebars.compile(itemInfo.startText || "");
const templateEndText = Handlebars.compile(itemInfo.endText || ""); const templateEndText = documentTemplateHandlebars.compile(itemInfo.endText || "");
// --- 6. Title Sums Formatting --- // --- 6. Title Sums Formatting ---
let returnTitleSums: Record<string, string> = {}; let returnTitleSums: Record<string, string> = {};

View File

@@ -38,6 +38,11 @@ function normalizeUuid(value: unknown): string | null {
return trimmed.length ? trimmed : null; return trimmed.length ? trimmed : null;
} }
function sanitizeCompositionRows(value: unknown): CompositionRow[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is CompositionRow => !!entry && typeof entry === "object");
}
export async function recalculateServicePricesForTenant(server: FastifyInstance, tenantId: number, updatedBy?: string | null) { export async function recalculateServicePricesForTenant(server: FastifyInstance, tenantId: number, updatedBy?: string | null) {
const [services, products, hourrates] = await Promise.all([ const [services, products, hourrates] = await Promise.all([
server.db.select().from(schema.services).where(eq(schema.services.tenant, tenantId)), server.db.select().from(schema.services).where(eq(schema.services.tenant, tenantId)),
@@ -88,94 +93,111 @@ export async function recalculateServicePricesForTenant(server: FastifyInstance,
materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"), materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"), workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"), workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
materialComposition: Array.isArray(service.materialComposition) ? service.materialComposition as CompositionRow[] : [], materialComposition: sanitizeCompositionRows(service.materialComposition),
personalComposition: Array.isArray(service.personalComposition) ? service.personalComposition as CompositionRow[] : [], personalComposition: sanitizeCompositionRows(service.personalComposition),
}; };
memo.set(serviceId, lockedResult); memo.set(serviceId, lockedResult);
return lockedResult; return lockedResult;
} }
stack.add(serviceId); stack.add(serviceId);
try {
const materialComposition = sanitizeCompositionRows(service.materialComposition);
const personalComposition = sanitizeCompositionRows(service.personalComposition);
const hasMaterialComposition = materialComposition.length > 0;
const hasPersonalComposition = personalComposition.length > 0;
const materialComposition: CompositionRow[] = Array.isArray(service.materialComposition) // Ohne Zusammensetzung keine automatische Überschreibung:
? (service.materialComposition as CompositionRow[]) // manuell gepflegte Preise sollen erhalten bleiben.
: []; if (!hasMaterialComposition && !hasPersonalComposition) {
const personalComposition: CompositionRow[] = Array.isArray(service.personalComposition) const manualResult = {
? (service.personalComposition as CompositionRow[]) sellingTotal: getJsonNumber(service.sellingPriceComposed, "total") || toNumber(service.sellingPrice),
: []; purchaseTotal: getJsonNumber(service.purchasePriceComposed, "total"),
materialTotal: getJsonNumber(service.sellingPriceComposed, "material"),
let materialTotal = 0; materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
let materialPurchaseTotal = 0; workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
const normalizedMaterialComposition = materialComposition.map((entry) => { materialComposition,
const quantity = toNumber(entry.quantity); personalComposition,
const productId = normalizeId(entry.product); };
const childServiceId = normalizeId(entry.service); memo.set(serviceId, manualResult);
return manualResult;
let sellingPrice = toNumber(entry.price);
let purchasePrice = toNumber(entry.purchasePrice);
if (productId) {
const product = productMap.get(productId);
sellingPrice = toNumber(product?.selling_price);
purchasePrice = toNumber(product?.purchase_price);
} else if (childServiceId) {
const child = calculateService(childServiceId);
sellingPrice = toNumber(child.sellingTotal);
purchasePrice = toNumber(child.purchaseTotal);
} }
materialTotal += quantity * sellingPrice; let materialTotal = 0;
materialPurchaseTotal += quantity * purchasePrice; let materialPurchaseTotal = 0;
return { const normalizedMaterialComposition = materialComposition.map((entry) => {
...entry, const quantity = toNumber(entry.quantity);
price: round2(sellingPrice), const productId = normalizeId(entry.product);
purchasePrice: round2(purchasePrice), const childServiceId = normalizeId(entry.service);
};
});
let workerTotal = 0; let sellingPrice = toNumber(entry.price);
let workerPurchaseTotal = 0; let purchasePrice = toNumber(entry.purchasePrice);
const normalizedPersonalComposition = personalComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const hourrateId = normalizeUuid(entry.hourrate);
let sellingPrice = toNumber(entry.price); if (productId) {
let purchasePrice = toNumber(entry.purchasePrice); const product = productMap.get(productId);
sellingPrice = toNumber(product?.selling_price);
if (hourrateId) { purchasePrice = toNumber(product?.purchase_price);
const hourrate = hourrateMap.get(hourrateId); } else if (childServiceId) {
if (hourrate) { const child = calculateService(childServiceId);
sellingPrice = toNumber(hourrate.sellingPrice); sellingPrice = toNumber(child.sellingTotal);
purchasePrice = toNumber(hourrate.purchase_price); purchasePrice = toNumber(child.purchaseTotal);
} }
}
workerTotal += quantity * sellingPrice; materialTotal += quantity * sellingPrice;
workerPurchaseTotal += quantity * purchasePrice; materialPurchaseTotal += quantity * purchasePrice;
return { return {
...entry, ...entry,
price: round2(sellingPrice), price: round2(sellingPrice),
purchasePrice: round2(purchasePrice), purchasePrice: round2(purchasePrice),
};
});
let workerTotal = 0;
let workerPurchaseTotal = 0;
const normalizedPersonalComposition = personalComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const hourrateId = normalizeUuid(entry.hourrate);
let sellingPrice = toNumber(entry.price);
let purchasePrice = toNumber(entry.purchasePrice);
if (hourrateId) {
const hourrate = hourrateMap.get(hourrateId);
if (hourrate) {
sellingPrice = toNumber(hourrate.sellingPrice);
purchasePrice = toNumber(hourrate.purchase_price);
}
}
workerTotal += quantity * sellingPrice;
workerPurchaseTotal += quantity * purchasePrice;
return {
...entry,
price: round2(sellingPrice),
purchasePrice: round2(purchasePrice),
};
});
const result = {
sellingTotal: round2(materialTotal + workerTotal),
purchaseTotal: round2(materialPurchaseTotal + workerPurchaseTotal),
materialTotal: round2(materialTotal),
materialPurchaseTotal: round2(materialPurchaseTotal),
workerTotal: round2(workerTotal),
workerPurchaseTotal: round2(workerPurchaseTotal),
materialComposition: normalizedMaterialComposition,
personalComposition: normalizedPersonalComposition,
}; };
});
const result = { memo.set(serviceId, result);
sellingTotal: round2(materialTotal + workerTotal), return result;
purchaseTotal: round2(materialPurchaseTotal + workerPurchaseTotal), } finally {
materialTotal: round2(materialTotal), stack.delete(serviceId);
materialPurchaseTotal: round2(materialPurchaseTotal), }
workerTotal: round2(workerTotal),
workerPurchaseTotal: round2(workerPurchaseTotal),
materialComposition: normalizedMaterialComposition,
personalComposition: normalizedPersonalComposition,
};
memo.set(serviceId, result);
stack.delete(serviceId);
return result;
}; };
for (const service of services) { for (const service of services) {

View File

@@ -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[]

View File

@@ -58,8 +58,6 @@ const queryConfigPlugin: FastifyPluginAsync<QueryConfigPluginOptions> = async (
const query = req.query as Record<string, any> const query = req.query as Record<string, any>
console.log(query)
// Pagination deaktivieren? // Pagination deaktivieren?
const disablePagination = const disablePagination =
query.noPagination === 'true' || query.noPagination === 'true' ||

View File

@@ -1,19 +1,761 @@
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 timestamp = new Date();
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");
const insertedFolders = await server.db
.insert(folders)
.values([
{
tenant: tenantId,
name: "Ausgangsrechnungen",
function: "yearSubCategory",
icon: "i-heroicons-document-text",
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Angebote",
function: "yearSubCategory",
icon: "i-heroicons-document-duplicate",
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Auftragsbestätigungen",
function: "yearSubCategory",
icon: "i-heroicons-clipboard-document-check",
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Lieferscheine",
function: "yearSubCategory",
icon: "i-heroicons-truck",
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Eingangsrechnungen",
function: "yearSubCategory",
icon: "i-heroicons-inbox-arrow-down",
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Belege Bankeinzahlung",
function: "yearSubCategory",
icon: "i-heroicons-banknotes",
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
])
.returning({
id: folders.id,
name: folders.name,
});
const folderByName = new Map(insertedFolders.map((folder) => [folder.name, folder.id]));
await server.db
.insert(folders)
.values([
{
tenant: tenantId,
name: String(currentYear),
parent: folderByName.get("Ausgangsrechnungen"),
function: "invoices",
year: currentYear,
icon: "i-heroicons-document-text",
standardFiletype: invoiceTag?.id,
standardFiletypeIsOptional: false,
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: String(currentYear),
parent: folderByName.get("Angebote"),
function: "quotes",
year: currentYear,
icon: "i-heroicons-document-duplicate",
standardFiletype: quoteTag?.id,
standardFiletypeIsOptional: false,
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: String(currentYear),
parent: folderByName.get("Auftragsbestätigungen"),
function: "confirmationOrders",
year: currentYear,
icon: "i-heroicons-clipboard-document-check",
standardFiletype: confirmationTag?.id,
standardFiletypeIsOptional: false,
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: String(currentYear),
parent: folderByName.get("Lieferscheine"),
function: "deliveryNotes",
year: currentYear,
icon: "i-heroicons-truck",
standardFiletype: deliveryTag?.id,
standardFiletypeIsOptional: false,
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: String(currentYear),
parent: folderByName.get("Eingangsrechnungen"),
function: "incomingInvoices",
year: currentYear,
icon: "i-heroicons-inbox-arrow-down",
standardFiletype: incomingInvoiceTag?.id,
standardFiletypeIsOptional: false,
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: String(currentYear),
parent: folderByName.get("Belege Bankeinzahlung"),
function: "deposit",
year: currentYear,
icon: "i-heroicons-banknotes",
isSystemUsed: true,
updatedAt: timestamp,
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 +786,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 +806,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) {

View File

@@ -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))
@@ -51,10 +52,12 @@ export default async function meRoutes(server: FastifyInstance) {
name: tenants.name, name: tenants.name,
short: tenants.short, short: tenants.short,
locked: tenants.locked, locked: tenants.locked,
features: tenants.features,
extraModules: tenants.extraModules, extraModules: tenants.extraModules,
businessInfo: tenants.businessInfo, businessInfo: tenants.businessInfo,
numberRanges: tenants.numberRanges, numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart, accountChart: tenants.accountChart,
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
dokuboxkey: tenants.dokuboxkey, dokuboxkey: tenants.dokuboxkey,
standardEmailForInvoices: tenants.standardEmailForInvoices, standardEmailForInvoices: tenants.standardEmailForInvoices,
standardPaymentDays: tenants.standardPaymentDays, standardPaymentDays: tenants.standardPaymentDays,

View File

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

View File

@@ -2,12 +2,12 @@ import { FastifyInstance } from "fastify"
import multipart from "@fastify/multipart" import multipart from "@fastify/multipart"
import { s3 } from "../utils/s3" import { s3 } from "../utils/s3"
import { import {
GetObjectCommand, GetObjectCommand
PutObjectCommand
} from "@aws-sdk/client-s3" } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner" import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import archiver from "archiver" import archiver from "archiver"
import { secrets } from "../utils/secrets" import { secrets } from "../utils/secrets"
import { saveFile } from "../utils/files"
import { eq, inArray } from "drizzle-orm" import { eq, inArray } from "drizzle-orm"
import { import {
@@ -40,39 +40,28 @@ export default async function fileRoutes(server: FastifyInstance) {
const fileBuffer = await data.toBuffer() const fileBuffer = await data.toBuffer()
const meta = data.fields?.meta?.value ? JSON.parse(data.fields.meta.value) : {} const meta = data.fields?.meta?.value ? JSON.parse(data.fields.meta.value) : {}
const { folder = null, type = null, ...otherMeta } = meta
// 1⃣ DB-Eintrag erzeugen const created = await saveFile(
const inserted = await server.db server,
.insert(files) tenantId,
.values({ tenant: tenantId }) null,
.returning() {
filename: data.filename,
content: fileBuffer,
contentType: data.mimetype
},
folder,
type,
otherMeta
)
const created = inserted[0]
if (!created) throw new Error("Could not create DB entry") if (!created) throw new Error("Could not create DB entry")
// 2⃣ Datei in S3 speichern
const fileKey = `${tenantId}/filesbyid/${created.id}/${data.filename}`
await s3.send(new PutObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: fileKey,
Body: fileBuffer,
ContentType: data.mimetype
}))
// 3⃣ DB updaten: meta + path
await server.db
.update(files)
.set({
...meta,
path: fileKey
})
.where(eq(files.id, created.id))
return { return {
id: created.id, id: created.id,
filename: data.filename, filename: created.filename,
path: fileKey path: created.key
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -248,7 +237,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// MULTIPLE PRESIGNED URLs // MULTIPLE PRESIGNED URLs
// ------------------------------------------------- // -------------------------------------------------
if (!ids || !Array.isArray(ids) || ids.length === 0) { if (!ids || !Array.isArray(ids) || ids.length === 0) {
return reply.code(400).send({ error: "No ids provided" }) return { files: [] }
} }
const rows = await server.db const rows = await server.db

View File

@@ -1,6 +1,11 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf"; import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions"; import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import dayjs from "dayjs"; import dayjs from "dayjs";
//import { ready as zplReady } from 'zpl-renderer-js' //import { ready as zplReady } from 'zpl-renderer-js'
//import { renderZPL } from "zpl-image"; //import { renderZPL } from "zpl-image";
@@ -13,9 +18,12 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
import duration from "dayjs/plugin/duration.js"; import duration from "dayjs/plugin/duration.js";
import timezone from "dayjs/plugin/timezone.js"; import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service"; import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys} from "../../db/schema"; import {citys, files} from "../../db/schema";
import {eq} from "drizzle-orm"; import {and, eq, isNull, not} from "drizzle-orm";
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service"; import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText";
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(isoWeek) dayjs.extend(isoWeek)
dayjs.extend(isBetween) dayjs.extend(isBetween)
@@ -24,7 +32,40 @@ dayjs.extend(isSameOrBefore)
dayjs.extend(duration) dayjs.extend(duration)
dayjs.extend(timezone) dayjs.extend(timezone)
const execFileAsync = promisify(execFile)
function resolveGitRoot() {
const searchRoots = [
process.cwd(),
path.resolve(process.cwd(), ".."),
path.resolve(__dirname, "../../.."),
path.resolve(__dirname, "../../../.."),
]
for (const startDir of searchRoots) {
let currentDir = startDir
while (currentDir && currentDir !== path.dirname(currentDir)) {
if (existsSync(path.join(currentDir, ".git"))) {
return currentDir
}
currentDir = path.dirname(currentDir)
}
}
return null
}
export default async function functionRoutes(server: FastifyInstance) { export default async function functionRoutes(server: FastifyInstance) {
const streamToBuffer = async (stream: any): Promise<Buffer> =>
new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(Buffer.concat(chunks)));
});
server.post("/functions/pdf/:type", async (req, reply) => { server.post("/functions/pdf/:type", async (req, reply) => {
const body = req.body as { const body = req.body as {
data: any data: any
@@ -150,6 +191,55 @@ export default async function functionRoutes(server: FastifyInstance) {
} }
}) })
server.get('/functions/changelog', async (req, reply) => {
const { limit } = req.query as { limit?: string | number }
const parsedLimit = Number(limit)
const safeLimit = Number.isFinite(parsedLimit)
? Math.min(Math.max(parsedLimit, 1), 50)
: 15
const gitRoot = resolveGitRoot()
if (!gitRoot) {
return reply.code(500).send({ error: 'Git repository not found' })
}
try {
const { stdout } = await execFileAsync('git', [
'-C',
gitRoot,
'log',
`--max-count=${safeLimit}`,
'--date=iso-strict',
'--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1e'
])
const entries = stdout
.split('\x1e')
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => {
const [hash, shortHash, subject, authorName, committedAt] = entry.split('\x1f')
return {
hash,
shortHash,
subject,
authorName,
committedAt
}
})
return reply.send({
repositoryRoot: gitRoot,
entries
})
} catch (err) {
req.log.error(err)
return reply.code(500).send({ error: 'Failed to load changelog' })
}
})
server.post('/functions/serial/start', async (req, reply) => { server.post('/functions/serial/start', async (req, reply) => {
console.log(req.body) console.log(req.body)
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number} const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number}
@@ -171,6 +261,58 @@ export default async function functionRoutes(server: FastifyInstance) {
await server.services.prepareIncomingInvoices.run(req.user.tenant_id) await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
}) })
server.post('/functions/services/backfillfiletext', async (req, reply) => {
const tenantId = req.user.tenant_id
const pendingFiles = await server.db
.select()
.from(files)
.where(
and(
eq(files.tenant, tenantId),
eq(files.archived, false),
not(isNull(files.path)),
isNull(files.extractedText)
)
)
let processed = 0
let withText = 0
let errors = 0
for (const file of pendingFiles) {
try {
const response: any = await s3.send(new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: file.path!
}))
const fileBuffer = await streamToBuffer(response.Body)
const result = await storeExtractedTextForFile(
server,
file.id,
fileBuffer,
file.mimeType,
file.name || file.path?.split("/").pop()
)
processed += 1
if (result.text) withText += 1
} catch (err) {
errors += 1
server.log.error(`Failed to backfill extracted text for file ${file.id}`)
server.log.error(err)
}
}
return {
pending: pendingFiles.length,
processed,
withText,
errors
}
})
server.post('/functions/services/syncdokubox', async (req, reply) => { server.post('/functions/services/syncdokubox', async (req, reply) => {
await server.services.dokuboxSync.run() await server.services.dokuboxSync.run()

View File

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

View File

@@ -217,6 +217,7 @@ export const diffTranslations: Record<
web: { label: "Webseite" }, web: { label: "Webseite" },
email: { label: "E-Mail" }, email: { label: "E-Mail" },
tel: { label: "Telefon" }, tel: { label: "Telefon" },
mobileTel: { label: "Mobilnummer" },
ustid: { label: "USt-ID" }, ustid: { label: "USt-ID" },
role: { label: "Rolle" }, role: { label: "Rolle" },
phoneHome: { label: "Festnetz" }, phoneHome: { label: "Festnetz" },

View File

@@ -0,0 +1,315 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import zlib from "node:zlib";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import { files } from "../../db/schema";
const execFileAsync = promisify(execFile);
type ExtractionMethod = "text" | "ocr" | "none";
type ExtractedDocumentText = {
text: string | null;
method: ExtractionMethod;
};
function normalizeExtractedText(text: string) {
return text
.replace(/\u0000/g, "")
.replace(/\r/g, "\n")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function decodePdfString(raw: string) {
let out = "";
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (ch !== "\\") {
out += ch;
continue;
}
const next = raw[i + 1];
if (!next) break;
if (next === "n") {
out += "\n";
i += 1;
continue;
}
if (next === "r") {
out += "\r";
i += 1;
continue;
}
if (next === "t") {
out += "\t";
i += 1;
continue;
}
if (next === "b") {
out += "\b";
i += 1;
continue;
}
if (next === "f") {
out += "\f";
i += 1;
continue;
}
if (next === "(" || next === ")" || next === "\\") {
out += next;
i += 1;
continue;
}
if (/[0-7]/.test(next)) {
let oct = next;
let advance = 1;
for (let j = 2; j <= 3; j += 1) {
const c = raw[i + j];
if (!c || !/[0-7]/.test(c)) break;
oct += c;
advance += 1;
}
out += String.fromCharCode(parseInt(oct, 8));
i += advance;
continue;
}
out += next;
i += 1;
}
return out;
}
function extractTextFromTjOperator(segment: string) {
const parts = segment.match(/\((?:\\.|[^\\)])*\)/g);
if (!parts) return "";
return parts
.map((part) => decodePdfString(part.slice(1, -1)))
.join("");
}
function extractTextStreamsFromPdf(pdfBuffer: Buffer) {
const pdfLatin = pdfBuffer.toString("latin1");
const texts: string[] = [];
let cursor = 0;
while (true) {
const streamPos = pdfLatin.indexOf("stream", cursor);
if (streamPos < 0) break;
let dataStart = streamPos + 6;
if (pdfLatin[dataStart] === "\r" && pdfLatin[dataStart + 1] === "\n") {
dataStart += 2;
} else if (pdfLatin[dataStart] === "\n") {
dataStart += 1;
}
const streamEnd = pdfLatin.indexOf("endstream", dataStart);
if (streamEnd < 0) break;
const sliceEnd = streamEnd > dataStart && pdfBuffer[streamEnd - 1] === 0x0d
? streamEnd - 1
: streamEnd;
const compressed = pdfBuffer.subarray(dataStart, sliceEnd);
try {
texts.push(zlib.inflateSync(compressed).toString("latin1"));
} catch {
// Ignore non-Flate streams.
}
cursor = streamEnd + 9;
}
return texts;
}
function extractTextFromPdfBufferFallback(pdfBuffer: Buffer) {
const streams = extractTextStreamsFromPdf(pdfBuffer);
const extracted: string[] = [];
for (const stream of streams) {
const operators = stream.match(/\[(?:.|\r|\n)*?\]TJ|\((?:\\.|[^\\)])*\)Tj/g);
if (!operators) continue;
for (const operator of operators) {
const text = extractTextFromTjOperator(operator)
.replace(/[ \t]+/g, " ")
.trim();
if (text) {
extracted.push(text);
}
}
}
return normalizeExtractedText(extracted.join("\n"));
}
async function runCommand(command: string, args: string[]) {
try {
return await execFileAsync(command, args, { maxBuffer: 50 * 1024 * 1024 });
} catch (err: any) {
if (err?.code === "ENOENT") {
return null;
}
throw err;
}
}
async function extractPdfTextWithPoppler(pdfPath: string) {
const result = await runCommand("pdftotext", ["-layout", "-enc", "UTF-8", pdfPath, "-"]);
if (!result) return null;
return normalizeExtractedText(result.stdout);
}
async function renderPdfPagesToPng(pdfPath: string, outputDir: string) {
const pdftoppmResult = await runCommand("pdftoppm", ["-png", "-r", "200", pdfPath, path.join(outputDir, "page")]);
if (pdftoppmResult) {
return (await fs.readdir(outputDir))
.filter((file) => /^page-\d+\.png$/.test(file))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
.map((file) => path.join(outputDir, file));
}
const qlmanageResult = await runCommand("qlmanage", ["-t", "-s", "2000", "-o", outputDir, pdfPath]);
if (!qlmanageResult) return null;
const quickLookFile = path.join(outputDir, `${path.basename(pdfPath)}.png`);
try {
await fs.access(quickLookFile);
return [quickLookFile];
} catch {
return null;
}
}
async function getAvailableTesseractLanguages() {
const result = await runCommand("tesseract", ["--list-langs"]);
if (!result) return [];
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("List of available languages"));
}
async function runOcrForPdf(pdfPath: string) {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-ocr-"));
try {
const pagePaths = await renderPdfPagesToPng(pdfPath, tmpDir);
if (!pagePaths?.length) return null;
const texts: string[] = [];
const configuredLanguages = (process.env.TESSERACT_LANGS || "deu+eng")
.split("+")
.map((lang) => lang.trim())
.filter(Boolean);
const availableLanguages = await getAvailableTesseractLanguages();
const selectedLanguages = configuredLanguages.filter((lang) => availableLanguages.includes(lang));
const languages = selectedLanguages.length ? selectedLanguages.join("+") : "eng";
for (const pagePath of pagePaths) {
const result = await runCommand("tesseract", [
pagePath,
"stdout",
"-l",
languages,
]);
if (!result) return null;
const pageText = normalizeExtractedText(result.stdout);
if (pageText) texts.push(pageText);
}
return normalizeExtractedText(texts.join("\n\n"));
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
export async function extractDocumentText(
fileBuffer: Buffer,
mimeType?: string | null,
fileName?: string | null
): Promise<ExtractedDocumentText> {
const normalizedMimeType = mimeType?.toLowerCase() || "";
const normalizedFileName = fileName?.toLowerCase() || "";
const isPdf = normalizedMimeType === "application/pdf" || normalizedFileName.endsWith(".pdf");
if (normalizedMimeType.startsWith("text/")) {
const text = normalizeExtractedText(fileBuffer.toString("utf-8"));
return { text: text || null, method: text ? "text" : "none" };
}
if (!isPdf) {
return { text: null, method: "none" };
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-pdf-"));
const pdfPath = path.join(tmpDir, fileName || "document.pdf");
try {
await fs.writeFile(pdfPath, fileBuffer);
const cliText = await extractPdfTextWithPoppler(pdfPath);
if (cliText) {
return { text: cliText, method: "text" };
}
const ocrText = await runOcrForPdf(pdfPath);
if (ocrText) {
return { text: ocrText, method: "ocr" };
}
const fallbackText = extractTextFromPdfBufferFallback(fileBuffer);
if (fallbackText) {
return { text: fallbackText, method: "text" };
}
return { text: null, method: "none" };
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
export async function storeExtractedTextForFile(
server: FastifyInstance,
fileId: string,
fileBuffer: Buffer,
mimeType?: string | null,
fileName?: string | null
) {
const result = await extractDocumentText(fileBuffer, mimeType, fileName);
await server.db
.update(files)
.set({ extractedText: result.text })
.where(eq(files.id, fileId));
return result;
}

View File

@@ -0,0 +1,53 @@
const UMLAUT_REPLACEMENTS: Record<string, string> = {
Ae: "Ae",
Oe: "Oe",
Ue: "Ue",
ae: "ae",
oe: "oe",
ue: "ue",
ss: "ss",
Ä: "Ae",
Ö: "Oe",
Ü: "Ue",
ä: "ae",
ö: "oe",
ü: "ue",
ß: "ss"
}
function replaceGermanCharacters(value: string) {
return value.replace(/[ÄÖÜäöüß]/g, (char) => UMLAUT_REPLACEMENTS[char] || char)
}
function sanitizeFileNamePart(value: string) {
return value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/[._-]{2,}/g, (match) => match[0])
.replace(/^[._-]+|[._-]+$/g, "")
}
export function sanitizeFilename(filename?: string | null, fallback = "file") {
const rawName = (filename || "").trim()
if (!rawName) {
return fallback
}
const normalized = replaceGermanCharacters(rawName)
.replace(/[\u0000-\u001f\u007f]/g, "")
.replace(/[\\/]/g, "-")
const lastDotIndex = normalized.lastIndexOf(".")
const hasExtension = lastDotIndex > 0 && lastDotIndex < normalized.length - 1
const basename = hasExtension ? normalized.slice(0, lastDotIndex) : normalized
const extension = hasExtension ? normalized.slice(lastDotIndex + 1) : ""
const safeBasename = sanitizeFileNamePart(basename) || fallback
const safeExtension = extension ? sanitizeFileNamePart(extension).toLowerCase() : ""
return safeExtension ? `${safeBasename}.${safeExtension}` : safeBasename
}

View File

@@ -6,6 +6,8 @@ import { secrets } from "./secrets"
import { files } from "../../db/schema" 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 { sanitizeFilename } from "./filename"
export const saveFile = async ( export const saveFile = async (
server: FastifyInstance, server: FastifyInstance,
@@ -17,6 +19,13 @@ export const saveFile = async (
other: Record<string, any> = {} other: Record<string, any> = {}
) => { ) => {
try { try {
const {
filename: providedFilename,
filesize: _providedFilesize,
mimeType: providedMimeType,
...dbFields
} = other
// --------------------------------------------------- // ---------------------------------------------------
// 1⃣ FILE ENTRY ANLEGEN // 1⃣ FILE ENTRY ANLEGEN
// --------------------------------------------------- // ---------------------------------------------------
@@ -26,7 +35,7 @@ export const saveFile = async (
tenant, tenant,
folder, folder,
type, type,
...other ...dbFields
}) })
.returning() .returning()
@@ -38,13 +47,16 @@ 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 || other.filename || `${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
// --------------------------------------------------- // ---------------------------------------------------
let body: Buffer | Uint8Array | string let body: Buffer | Uint8Array | string
let contentType = type || "application/octet-stream" let contentType = providedMimeType || "application/octet-stream"
if (Buffer.isBuffer(attachment)) { if (Buffer.isBuffer(attachment)) {
// FALL 1: RAW BUFFER (von finishManualGeneration) // FALL 1: RAW BUFFER (von finishManualGeneration)
@@ -83,13 +95,26 @@ export const saveFile = async (
// --------------------------------------------------- // ---------------------------------------------------
await server.db await server.db
.update(files) .update(files)
.set({ path: key }) .set({
path: key,
mimeType: contentType,
name: filename,
size: body.length
})
.where(eq(files.id, created.id)) .where(eq(files.id, created.id))
await storeExtractedTextForFile(
server,
created.id,
Buffer.isBuffer(body) ? body : Buffer.from(body),
contentType,
filename
)
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
} }
} }

View File

@@ -1,14 +1,13 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import axios from "axios";
import OpenAI from "openai"; import OpenAI from "openai";
import { z } from "zod"; import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod"; import { zodResponseFormat } from "openai/helpers/zod";
import { GetObjectCommand } from "@aws-sdk/client-s3"; import { GetObjectCommand } from "@aws-sdk/client-s3";
import { Blob } from "buffer";
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { s3 } from "./s3"; import { s3 } from "./s3";
import { secrets } from "./secrets"; import { secrets } from "./secrets";
import { storeExtractedTextForFile } from "./documentText";
// Drizzle schema // Drizzle schema
import { vendors, accounts, tenants } from "../../db/schema"; import { vendors, accounts, tenants } from "../../db/schema";
@@ -16,6 +15,9 @@ import {eq} from "drizzle-orm";
let openai: OpenAI | null = null; let openai: OpenAI | null = null;
const nullableString = z.string().trim().nullable();
const nullableNumber = z.number().nullable();
// --------------------------------------------------------- // ---------------------------------------------------------
// INITIALIZE OPENAI // INITIALIZE OPENAI
// --------------------------------------------------------- // ---------------------------------------------------------
@@ -41,48 +43,48 @@ async function streamToBuffer(stream: any): Promise<Buffer> {
// GPT RESPONSE FORMAT (Zod Schema) // GPT RESPONSE FORMAT (Zod Schema)
// --------------------------------------------------------- // ---------------------------------------------------------
const InstructionFormat = z.object({ const InstructionFormat = z.object({
invoice_number: z.string(), invoice_number: nullableString,
invoice_date: z.string(), invoice_date: nullableString,
invoice_duedate: z.string(), invoice_duedate: nullableString,
invoice_type: z.string(), invoice_type: nullableString,
delivery_type: z.string(), delivery_type: nullableString,
delivery_note_number: z.string(), delivery_note_number: nullableString,
reference: z.string(), reference: nullableString,
issuer: z.object({ issuer: z.object({
id: z.number().nullable().optional(), id: nullableNumber.optional(),
name: z.string(), name: nullableString,
address: z.string(), address: nullableString,
phone: z.string(), phone: nullableString,
email: z.string(), email: nullableString,
bank: z.string(), bank: nullableString,
bic: z.string(), bic: nullableString,
iban: z.string(), iban: nullableString,
}), }),
recipient: z.object({ recipient: z.object({
name: z.string(), name: nullableString,
address: z.string(), address: nullableString,
phone: z.string(), phone: nullableString,
email: z.string(), email: nullableString,
}), }),
invoice_items: z.array( invoice_items: z.array(
z.object({ z.object({
description: z.string(), description: nullableString,
unit: z.string(), unit: nullableString,
quantity: z.number(), quantity: nullableNumber,
total: z.number(), total: nullableNumber,
total_without_tax: z.number(), total_without_tax: nullableNumber,
tax_rate: z.number(), tax_rate: nullableNumber,
ean: z.number().nullable().optional(), ean: nullableNumber.optional(),
article_number: z.number().nullable().optional(), article_number: nullableNumber.optional(),
account_number: z.number().nullable().optional(), account_number: nullableNumber.optional(),
account_id: z.number().nullable().optional(), account_id: nullableNumber.optional(),
}) })
), ),
subtotal: z.number(), subtotal: nullableNumber,
tax_rate: z.number(), tax_rate: nullableNumber,
tax: z.number(), tax: nullableNumber,
total: z.number(), total: nullableNumber,
terms: z.string(), terms: nullableString,
}); });
// --------------------------------------------------------- // ---------------------------------------------------------
@@ -91,8 +93,7 @@ const InstructionFormat = z.object({
export const getInvoiceDataFromGPT = async function ( export const getInvoiceDataFromGPT = async function (
server: FastifyInstance, server: FastifyInstance,
file: any, file: any,
tenantId: number, tenantId: number
learningContext?: string
) { ) {
await initOpenAi(); await initOpenAi();
@@ -126,32 +127,27 @@ export const getInvoiceDataFromGPT = async function (
return null; return null;
} }
const fileBlob = new Blob([fileData], { type: "application/pdf" }); let extractedText = file.extractedText;
// --------------------------------------------------------- if (!extractedText?.trim()) {
// 2) SEND FILE TO PDF → TEXT API try {
// --------------------------------------------------------- const result = await storeExtractedTextForFile(
const form = new FormData(); server,
form.append("fileInput", fileBlob, file.path.split("/").pop()); file.id,
form.append("outputFormat", "txt"); fileData,
file.mimeType,
file.name || file.path?.split("/").pop()
);
extractedText = result.text;
server.log.info(`Invoice text extraction for file ${file.id} used method: ${result.method}`)
} catch (err) {
console.log("❌ Local PDF text extraction failed", err);
return null;
}
}
let extractedText: string; if (!extractedText?.trim()) {
server.log.warn(`No extractable PDF text found for file ${file.id}. Scanned PDFs require OCR.`);
try {
const res = await axios.post(
"http://23.88.52.85:8080/api/v1/convert/pdf/text",
form,
{
headers: {
"Content-Type": "multipart/form-data",
Authorization: `Bearer ${secrets.STIRLING_API_KEY}`,
},
}
);
extractedText = res.data;
} catch (err) {
console.log("❌ PDF OCR API failed", err);
return null; return null;
} }
@@ -198,13 +194,16 @@ export const getInvoiceDataFromGPT = async function (
"You extract structured invoice data.\n\n" + "You extract structured invoice data.\n\n" +
`VENDORS: ${JSON.stringify(vendorList)}\n` + `VENDORS: ${JSON.stringify(vendorList)}\n` +
`ACCOUNTS: ${JSON.stringify(accountList)}\n\n` + `ACCOUNTS: ${JSON.stringify(accountList)}\n\n` +
(learningContext "Use only values that are explicitly present in the invoice text.\n" +
? `HISTORICAL_PATTERNS: ${learningContext}\n\n` "If a field is missing or unclear, return null. If line items are missing or unclear, return an empty array.\n" +
: "") + "Do not guess invoice numbers, dates, totals, payment terms, bank data, or references.\n" +
"Do not derive values from vendor defaults or likely patterns.\n" +
"Only set issuer.id when the issuer name clearly matches a vendor name from VENDORS.\n" +
"Only set account_id when the invoice line clearly matches an account label or number from ACCOUNTS.\n" +
"If multiple accounts are plausible, set account_id to null.\n" +
"Do not merge summary totals into fabricated invoice_items.\n" +
"Match issuer by name to vendor.id.\n" + "Match issuer by name to vendor.id.\n" +
"Match invoice items to account id based on label/number.\n" + "Match invoice items to account id based on label/number.\n" +
"Use historical patterns as soft hints for vendor/account/payment mapping.\n" +
"Do not invent values when the invoice text contradicts the hints.\n" +
"Convert dates to YYYY-MM-DD.\n" + "Convert dates to YYYY-MM-DD.\n" +
"Keep invoice items in original order.\n", "Keep invoice items in original order.\n",
}, },

View File

@@ -0,0 +1,26 @@
import Handlebars from "handlebars";
const createDocumentTemplateHandlebars = () => {
const instance = Handlebars.create();
instance.registerHelper("eq", (left, right) => left === right);
instance.registerHelper("ne", (left, right) => left !== right);
instance.registerHelper("gt", (left, right) => left > right);
instance.registerHelper("gte", (left, right) => left >= right);
instance.registerHelper("lt", (left, right) => left < right);
instance.registerHelper("lte", (left, right) => left <= right);
instance.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
instance.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
instance.registerHelper("not", (value) => !value);
instance.registerHelper("includes", (collection, value) => {
if (Array.isArray(collection) || typeof collection === "string") {
return collection.includes(value);
}
return false;
});
return instance;
};
export const documentTemplateHandlebars = createDocumentTemplateHandlebars();

View File

@@ -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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

View File

@@ -1,7 +1,9 @@
export default defineAppConfig({ export default defineAppConfig({
ui: { ui: {
primary: 'green', colors: {
gray: 'slate', primary: 'green',
neutral: 'slate'
},
tooltip: { tooltip: {
background: '!bg-background' background: '!bg-background'
}, },
@@ -35,4 +37,4 @@ export default defineAppConfig({
} }
} }
} }
}) })

View File

@@ -47,14 +47,16 @@ useSeoMeta({
</script> </script>
<template> <template>
<div class="safearea"> <UApp>
<NuxtLayout> <div class="safearea">
<NuxtPage/> <NuxtLayout>
</NuxtLayout> <NuxtPage/>
<UNotifications/> </NuxtLayout>
<USlideovers /> <UNotifications/>
<UModals/> <USlideovers />
</div> <UModals/>
</div>
</UApp>
@@ -136,4 +138,4 @@ useSeoMeta({
.scroll { .scroll {
overflow-y: scroll; overflow-y: scroll;
} }
</style> </style>

View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
@import "@nuxt/ui-pro";
@theme static {
--font-sans: "SF Pro Text", "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;
--color-green-50: #f4fbf2;
--color-green-100: #e7f7e1;
--color-green-200: #cdeec4;
--color-green-300: #a6e095;
--color-green-400: #69c350;
--color-green-500: #53ad3a;
--color-green-600: #418e2b;
--color-green-700: #357025;
--color-green-800: #2d5922;
--color-green-900: #254a1d;
--color-green-950: #10280b;
}
:root {
--ui-container: 90rem;
}
body {
font-family: var(--font-sans);
}

View File

@@ -38,37 +38,39 @@ const emitConfirm = () => {
> >
Archivieren Archivieren
</UButton> </UButton>
<UModal v-model="showModal"> <UModal v-model:open="showModal">
<UCard> <template #content>
<template #header> <UCard>
<span class="text-md font-bold">Archivieren bestätigen</span> <template #header>
</template> <span class="text-md font-bold">Archivieren bestätigen</span>
Möchten Sie diese/-s/-n {{dataType.labelSingle}} wirklich archivieren? </template>
Möchten Sie diese/-s/-n {{dataType.labelSingle}} wirklich archivieren?
<template #footer> <template #footer>
<div class="text-right"> <div class="text-right">
<UButtonGroup> <UButtonGroup>
<UButton <UButton
variant="outline" variant="outline"
@click="showModal = false" @click="showModal = false"
> >
Abbrechen Abbrechen
</UButton> </UButton>
<UButton <UButton
@click="emitConfirm" @click="emitConfirm"
class="ml-2" class="ml-2"
color="rose" color="error"
> >
Archivieren Archivieren
</UButton> </UButton>
</UButtonGroup> </UButtonGroup>
</div> </div>
</template> </template>
</UCard> </UCard>
</template>
</UModal> </UModal>
</template> </template>
<style scoped> <style scoped>
</style> </style>

View File

@@ -49,7 +49,7 @@ const assignByIban = async () => {
const match = accounts.value.find((a) => normalizeIban(a.iban) === search) const match = accounts.value.find((a) => normalizeIban(a.iban) === search)
if (!match) { if (!match) {
toast.add({ title: "Kein Bankkonto mit dieser IBAN gefunden.", color: "rose" }) toast.add({ title: "Kein Bankkonto mit dieser IBAN gefunden.", color: "error" })
return return
} }
@@ -68,7 +68,7 @@ const removeAssigned = (id) => {
const createAndAssign = async () => { const createAndAssign = async () => {
if (!createPayload.value.iban || !createPayload.value.bic || !createPayload.value.bankName) { if (!createPayload.value.iban || !createPayload.value.bic || !createPayload.value.bankName) {
toast.add({ title: "IBAN, BIC und Bankinstitut sind Pflichtfelder.", color: "rose" }) toast.add({ title: "IBAN, BIC und Bankinstitut sind Pflichtfelder.", color: "error" })
return return
} }
@@ -140,43 +140,45 @@ loadAccounts()
</InputGroup> </InputGroup>
</div> </div>
<UModal v-model="showCreate"> <UModal v-model:open="showCreate">
<UCard> <template #content>
<template #header>Neue Bankverbindung erstellen</template> <UCard>
<div class="space-y-3"> <template #header>Neue Bankverbindung erstellen</template>
<UFormGroup label="IBAN"> <div class="space-y-3">
<InputGroup> <UFormField label="IBAN">
<UInput <InputGroup>
v-model="createPayload.iban" <UInput
@blur="resolveCreatePayloadFromIban" v-model="createPayload.iban"
@keydown.enter.prevent="resolveCreatePayloadFromIban" @blur="resolveCreatePayloadFromIban"
/> @keydown.enter.prevent="resolveCreatePayloadFromIban"
<UButton />
color="gray" <UButton
variant="outline" color="gray"
:loading="resolvingIban" variant="outline"
@click="resolveCreatePayloadFromIban" :loading="resolvingIban"
> @click="resolveCreatePayloadFromIban"
Ermitteln >
</UButton> Ermitteln
</InputGroup> </UButton>
</UFormGroup> </InputGroup>
<UFormGroup label="BIC"> </UFormField>
<UInput v-model="createPayload.bic" /> <UFormField label="BIC">
</UFormGroup> <UInput v-model="createPayload.bic" />
<UFormGroup label="Bankinstitut"> </UFormField>
<UInput v-model="createPayload.bankName" /> <UFormField label="Bankinstitut">
</UFormGroup> <UInput v-model="createPayload.bankName" />
<UFormGroup label="Beschreibung (optional)"> </UFormField>
<UInput v-model="createPayload.description" /> <UFormField label="Beschreibung (optional)">
</UFormGroup> <UInput v-model="createPayload.description" />
</div> </UFormField>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="outline" @click="showCreate = false">Abbrechen</UButton>
<UButton @click="createAndAssign">Erstellen und zuweisen</UButton>
</div> </div>
</template> <template #footer>
</UCard> <div class="flex justify-end gap-2">
<UButton color="gray" variant="outline" @click="showCreate = false">Abbrechen</UButton>
<UButton @click="createAndAssign">Erstellen und zuweisen</UButton>
</div>
</template>
</UCard>
</template>
</UModal> </UModal>
</template> </template>

View File

@@ -31,36 +31,38 @@ const emitConfirm = () => {
> >
<slot name="button"></slot> <slot name="button"></slot>
</UButton> </UButton>
<UModal v-model="showModal"> <UModal v-model:open="showModal">
<UCard> <template #content>
<template #header> <UCard>
<slot name="header"></slot> <template #header>
</template> <slot name="header"></slot>
<slot/> </template>
<template #footer> <slot/>
<div class="text-right"> <template #footer>
<UButtonGroup> <div class="text-right">
<UButton <UButtonGroup>
variant="outline" <UButton
@click="showModal = false" variant="outline"
> @click="showModal = false"
Abbrechen >
</UButton> Abbrechen
<UButton </UButton>
@click="emitConfirm" <UButton
class="ml-2" @click="emitConfirm"
color="rose" class="ml-2"
> color="error"
Archivieren >
</UButton> Archivieren
</UButtonGroup> </UButton>
</UButtonGroup>
</div> </div>
</template> </template>
</UCard> </UCard>
</template>
</UModal> </UModal>
</template> </template>
<style scoped> <style scoped>
</style> </style>

View File

@@ -51,8 +51,8 @@
<div class="grid grid-cols-4 gap-2"> <div class="grid grid-cols-4 gap-2">
<UTooltip text="Brutto (+19%)"><UButton color="green" variant="soft" block size="xs" @click="applyTax(19)">+19%</UButton></UTooltip> <UTooltip text="Brutto (+19%)"><UButton color="green" variant="soft" block size="xs" @click="applyTax(19)">+19%</UButton></UTooltip>
<UTooltip text="Brutto (+7%)"><UButton color="green" variant="soft" block size="xs" @click="applyTax(7)">+7%</UButton></UTooltip> <UTooltip text="Brutto (+7%)"><UButton color="green" variant="soft" block size="xs" @click="applyTax(7)">+7%</UButton></UTooltip>
<UTooltip text="Netto (-19%)"><UButton color="rose" variant="soft" block size="xs" @click="removeTax(19)">-19%</UButton></UTooltip> <UTooltip text="Netto (-19%)"><UButton color="error" variant="soft" block size="xs" @click="removeTax(19)">-19%</UButton></UTooltip>
<UTooltip text="Netto (-7%)"><UButton color="rose" variant="soft" block size="xs" @click="removeTax(7)">-7%</UButton></UTooltip> <UTooltip text="Netto (-7%)"><UButton color="error" variant="soft" block size="xs" @click="removeTax(7)">-7%</UButton></UTooltip>
<UTooltip text="Löschen"><UButton color="gray" variant="ghost" block @click="clear">C</UButton></UTooltip> <UTooltip text="Löschen"><UButton color="gray" variant="ghost" block @click="clear">C</UButton></UTooltip>
<UTooltip text="Speicher +"><UButton color="gray" variant="ghost" block @click="addToSum">M+</UButton></UTooltip> <UTooltip text="Speicher +"><UButton color="gray" variant="ghost" block @click="addToSum">M+</UButton></UTooltip>
@@ -227,9 +227,14 @@ defineShortcuts({
width: 4px; width: 4px;
} }
.custom-scrollbar::-webkit-scrollbar-track { .custom-scrollbar::-webkit-scrollbar-track {
@apply bg-transparent; background: transparent;
} }
.custom-scrollbar::-webkit-scrollbar-thumb { .custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-200 dark:bg-gray-700 rounded-full; background: #e5e7eb;
border-radius: 9999px;
} }
</style>
:global(.dark) .custom-scrollbar::-webkit-scrollbar-thumb {
background: #374151;
}
</style>

View File

@@ -27,6 +27,11 @@ const date = computed({
} }
}) })
const selectToday = () => {
emit('update:model-value', new Date())
emit('close')
}
const attrs = [{ const attrs = [{
key: 'today', key: 'today',
highlight: { highlight: {
@@ -37,18 +42,31 @@ const attrs = [{
</script> </script>
<template> <template>
<VCalendarDatePicker <div class="space-y-3">
show-weeknumbers <VCalendarDatePicker
v-model="date" show-weeknumbers
:mode="props.mode" v-model="date"
is24hr :mode="props.mode"
transparent is24hr
borderless transparent
color="green" borderless
:attributes="attrs" color="green"
:is-dark="isDark" :attributes="attrs"
title-position="left" :is-dark="isDark"
trim-weeks title-position="left"
:first-day-of-week="2" trim-weeks
/> :first-day-of-week="2"
</template> />
<div class="flex justify-end px-2 pb-2">
<UButton
size="xs"
color="gray"
variant="soft"
icon="i-heroicons-calendar-days"
label="Heute"
@click="selectToday"
/>
</div>
</div>
</template>

View File

@@ -156,7 +156,8 @@ const moveFile = async () => {
<template> <template>
<UModal fullscreen > <UModal fullscreen >
<UCard :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }" class="h-full"> <template #content>
<UCard :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }" class="h-full">
<template #header> <template #header>
<div class="flex flex-row justify-between"> <div class="flex flex-row justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -186,7 +187,7 @@ const moveFile = async () => {
<div class="w-2/3 p-5" v-if="!false"> <div class="w-2/3 p-5" v-if="!false">
<UButtonGroup> <UButtonGroup>
<ArchiveButton <ArchiveButton
color="rose" color="error"
variant="outline" variant="outline"
type="files" type="files"
@confirmed="archiveDocument" @confirmed="archiveDocument"
@@ -202,7 +203,7 @@ const moveFile = async () => {
</UButton> </UButton>
</UButtonGroup> </UButtonGroup>
<UDivider>Zuweisungen</UDivider> <USeparator label="Zuweisungen"/>
<table class="w-full"> <table class="w-full">
<tr v-if="props.documentData.project"> <tr v-if="props.documentData.project">
<td>Projekt</td> <td>Projekt</td>
@@ -278,44 +279,44 @@ const moveFile = async () => {
</tr> </tr>
</table> </table>
<UDivider class="my-3">Datei zuweisen</UDivider> <USeparator class="my-3" label="Datei zuweisen"/>
<UFormGroup <UFormField
label="Resource auswählen" label="Resource auswählen"
> >
<USelectMenu <USelectMenu
:options="resourceOptions" :items="resourceOptions"
v-model="resourceToAssign" v-model="resourceToAssign"
value-attribute="value" value-key="value"
option-attribute="label" label-key="label"
@change="getItemsBySelectedResource" @change="getItemsBySelectedResource"
> >
</USelectMenu> </USelectMenu>
</UFormGroup> </UFormField>
<UFormGroup <UFormField
label="Eintrag auswählen:" label="Eintrag auswählen:"
> >
<USelectMenu <USelectMenu
:options="itemOptions" :items="itemOptions"
v-model="idToAssign" v-model="idToAssign"
:option-attribute="resourceOptions.find(i => i.value === resourceToAssign)? resourceOptions.find(i => i.value === resourceToAssign).optionAttr : 'name'" :label-key="resourceOptions.find(i => i.value === resourceToAssign)? resourceOptions.find(i => i.value === resourceToAssign).optionAttr : 'name'"
value-attribute="id" value-key="id"
@change="updateDocumentAssignment" @change="updateDocumentAssignment"
></USelectMenu> ></USelectMenu>
</UFormGroup> </UFormField>
<UDivider class="my-5">Datei verschieben</UDivider> <USeparator class="my-5" label="Datei verschieben"/>
<InputGroup class="w-full"> <InputGroup class="w-full">
<USelectMenu <USelectMenu
class="flex-auto" class="flex-auto"
v-model="folderToMoveTo" v-model="folderToMoveTo"
value-attribute="id" value-key="id"
option-attribute="name" label-key="name"
:options="folders" :items="folders"
/> />
<UButton <UButton
@click="moveFile" @click="moveFile"
@@ -324,34 +325,35 @@ const moveFile = async () => {
>Verschieben</UButton> >Verschieben</UButton>
</InputGroup> </InputGroup>
<UDivider class="my-5">Dateityp</UDivider> <USeparator class="my-5" label="Dateityp"/>
<InputGroup class="w-full"> <InputGroup class="w-full">
<USelectMenu <USelectMenu
class="flex-auto" class="flex-auto"
v-model="props.documentData.type" v-model="props.documentData.type"
value-attribute="id" value-key="id"
option-attribute="name" label-key="name"
:options="filetypes" :items="filetypes"
@change="updateDocument" @change="updateDocument"
/> />
</InputGroup> </InputGroup>
<UDivider class="my-5">Dokumentenbox</UDivider> <USeparator class="my-5" label="Dokumentenbox" />
<InputGroup class="w-full"> <InputGroup class="w-full">
<USelectMenu <USelectMenu
class="flex-auto" class="flex-auto"
v-model="props.documentData.documentbox" v-model="props.documentData.documentbox"
value-attribute="id" value-key="id"
option-attribute="key" label-key="key"
:options="documentboxes" :items="documentboxes"
@change="updateDocument" @change="updateDocument"
/> />
</InputGroup> </InputGroup>
</div> </div>
</div> </div>
</UCard> </UCard>
</template>
</UModal> </UModal>
</template> </template>
@@ -362,4 +364,4 @@ const moveFile = async () => {
aspect-ratio: 1/ 1.414; aspect-ratio: 1/ 1.414;
} }
</style> </style>

View File

@@ -78,84 +78,86 @@ const fileNames = computed(() => {
<template> <template>
<UModal> <UModal>
<div ref="dropZoneRef" class="relative h-full flex flex-col"> <template #content>
<div ref="dropZoneRef" class="relative h-full flex flex-col">
<div <div
v-if="isOverDropZone" v-if="isOverDropZone"
class="absolute inset-0 z-50 flex items-center justify-center bg-primary-500/10 border-2 border-primary-500 border-dashed rounded-lg backdrop-blur-sm transition-all" class="absolute inset-0 z-50 flex items-center justify-center bg-primary-500/10 border-2 border-primary-500 border-dashed rounded-lg backdrop-blur-sm transition-all"
>
<span class="text-xl font-bold text-primary-600 bg-white/80 px-4 py-2 rounded shadow-sm">
Dateien hier ablegen
</span>
</div>
<UCard :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Datei hochladen
</h3>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="modal.close()"
:disabled="uploadInProgress"
/>
</div>
</template>
<UFormGroup
label="Datei:"
:help="selectedFiles.length > 0 ? `${selectedFiles.length} Datei(en) ausgewählt` : 'Ziehen Sie Dateien hierher oder klicken Sie'"
> >
<UInput <span class="text-xl font-bold text-primary-600 bg-white/80 px-4 py-2 rounded shadow-sm">
v-if="selectedFiles.length === 0" Dateien hier ablegen
type="file" </span>
id="fileUploadInput" </div>
multiple
accept="image/jpeg, image/png, image/gif, application/pdf"
@change="onFileInputChange"
/>
<div v-if="selectedFiles.length > 0" class="mt-2 text-sm text-gray-500"> <UCard :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
Ausgewählt: <span class="font-medium text-gray-700 dark:text-gray-300">{{ fileNames }}</span> <template #header>
</div> <div class="flex items-center justify-between">
</UFormGroup> <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Datei hochladen
</h3>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="modal.close()"
:disabled="uploadInProgress"
/>
</div>
</template>
<UFormGroup <UFormField
label="Typ:" label="Datei:"
class="mt-3" :help="selectedFiles.length > 0 ? `${selectedFiles.length} Datei(en) ausgewählt` : 'Ziehen Sie Dateien hierher oder klicken Sie'"
>
<USelectMenu
option-attribute="name"
value-attribute="id"
searchable
searchable-placeholder="Suchen..."
:options="availableFiletypes"
v-model="props.fileData.type"
:disabled="!props.fileData.typeEnabled"
> >
<template #label> <UInput
<span v-if="availableFiletypes.find(x => x.id === props.fileData.type)">{{availableFiletypes.find(x => x.id === props.fileData.type).name}}</span> v-if="selectedFiles.length === 0"
<span v-else>Kein Typ ausgewählt</span> type="file"
</template> id="fileUploadInput"
</USelectMenu> multiple
</UFormGroup> accept="image/jpeg, image/png, image/gif, application/pdf"
@change="onFileInputChange"
/>
<template #footer> <div v-if="selectedFiles.length > 0" class="mt-2 text-sm text-gray-500">
<UButton Ausgewählt: <span class="font-medium text-gray-700 dark:text-gray-300">{{ fileNames }}</span>
@click="uploadFiles" </div>
:loading="uploadInProgress" </UFormField>
:disabled="uploadInProgress || selectedFiles.length === 0"
>Hochladen</UButton> <UFormField
</template> label="Typ:"
</UCard> class="mt-3"
</div> >
<USelectMenu
option-attribute="name"
value-attribute="id"
searchable
searchable-placeholder="Suchen..."
:options="availableFiletypes"
v-model="props.fileData.type"
:disabled="!props.fileData.typeEnabled"
>
<template #label>
<span v-if="availableFiletypes.find(x => x.id === props.fileData.type)">{{availableFiletypes.find(x => x.id === props.fileData.type).name}}</span>
<span v-else>Kein Typ ausgewählt</span>
</template>
</USelectMenu>
</UFormField>
<template #footer>
<UButton
@click="uploadFiles"
:loading="uploadInProgress"
:disabled="uploadInProgress || selectedFiles.length === 0"
>Hochladen</UButton>
</template>
</UCard>
</div>
</template>
</UModal> </UModal>
</template> </template>
<style scoped> <style scoped>
/* Optional: Animationen für das Overlay */ /* Optional: Animationen für das Overlay */
</style> </style>

View File

@@ -211,6 +211,22 @@ const contentChanged = (content, datapoint) => {
} }
} }
const getSelectItems = (datapoint) => {
return datapoint.selectManualOptions || loadedOptions.value[datapoint.selectDataType] || []
}
const getSelectValueKey = (datapoint) => {
return datapoint.selectValueAttribute || 'id'
}
const getSelectLabelKey = (datapoint) => {
return datapoint.selectOptionAttribute || 'label'
}
const getSelectSearchInput = (datapoint) => {
return datapoint.selectSearchAttributes ? { placeholder: 'Suche...' } : false
}
const createItem = async () => { const createItem = async () => {
let ret = null let ret = null
@@ -264,7 +280,7 @@ const updateItem = async () => {
</template> </template>
<template #right> <template #right>
<ArchiveButton <ArchiveButton
color="rose" color="error"
v-if="platform !== 'mobile'" v-if="platform !== 'mobile'"
variant="outline" variant="outline"
:type="type" :type="type"
@@ -336,12 +352,12 @@ const updateItem = async () => {
v-for="(columnName,index) in dataType.inputColumns" v-for="(columnName,index) in dataType.inputColumns"
:class="platform === 'mobile' ? ['w-full'] : [`w-1/${dataType.inputColumns.length}`, ... index < dataType.inputColumns.length -1 ? ['mr-5'] : []]" :class="platform === 'mobile' ? ['w-full'] : [`w-1/${dataType.inputColumns.length}`, ... index < dataType.inputColumns.length -1 ? ['mr-5'] : []]"
> >
<UDivider>{{ columnName }}</UDivider> <USeparator :label="columnName"/>
<div <div
v-for="datapoint in dataType.templateColumns.filter(i => i.inputType && i.inputColumn === columnName)" v-for="datapoint in dataType.templateColumns.filter(i => i.inputType && i.inputColumn === columnName)"
> >
<UFormGroup <UFormField
v-if="(datapoint.showFunction ? datapoint.showFunction(item) : true)" v-if="(datapoint.showFunction ? datapoint.showFunction(item) : true)"
:label="datapoint.label" :label="datapoint.label"
> >
@@ -354,7 +370,7 @@ const updateItem = async () => {
</template> </template>
<InputGroup class="w-full" v-if="datapoint.key.includes('.')"> <InputGroup class="w-full" v-if="datapoint.key.includes('.')">
<UInput <UInput
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-if="['text','number'].includes(datapoint.inputType)" v-if="['text','number'].includes(datapoint.inputType)"
@@ -367,25 +383,25 @@ const updateItem = async () => {
<span class="text-gray-500 dark:text-gray-400 text-xs">{{ datapoint.inputTrailing }}</span> <span class="text-gray-500 dark:text-gray-400 text-xs">{{ datapoint.inputTrailing }}</span>
</template> </template>
</UInput> </UInput>
<UToggle <USwitch
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'primary'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'primary'"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'bool'" v-else-if="datapoint.inputType === 'bool'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/> />
<USelectMenu <USelectMenu
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'select'" v-else-if="datapoint.inputType === 'select'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
:option-attribute="datapoint.selectOptionAttribute" :items="getSelectItems(datapoint)"
:value-attribute="datapoint.selectValueAttribute || 'id'" :label-key="getSelectLabelKey(datapoint)"
:options="datapoint.selectManualOptions || loadedOptions[datapoint.selectDataType]" :value-key="getSelectValueKey(datapoint)"
:searchable="datapoint.selectSearchAttributes" :search-input="getSelectSearchInput(datapoint)"
:search-attributes="datapoint.selectSearchAttributes" :filter-fields="datapoint.selectSearchAttributes"
:multiple="datapoint.selectMultiple" :multiple="datapoint.selectMultiple"
> >
<template #empty> <template #empty>
@@ -393,7 +409,7 @@ const updateItem = async () => {
</template> </template>
</USelectMenu> </USelectMenu>
<UTextarea <UTextarea
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'textarea'" v-else-if="datapoint.inputType === 'textarea'"
@@ -401,9 +417,9 @@ const updateItem = async () => {
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
rows="4" rows="4"
/> />
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'date'"> <UPopover :content="{ side: 'bottom', align: 'start' }" v-else-if="datapoint.inputType === 'date'">
<UButton <UButton
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
icon="i-heroicons-calendar-days-20-solid" icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? dayjs(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]).format('DD.MM.YYYY') : 'Datum auswählen'" :label="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? dayjs(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline" variant="outline"
@@ -411,17 +427,17 @@ const updateItem = async () => {
/> />
<template #panel="{ close }"> <template #content>
<LazyDatePicker <LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" @close="close" v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/> />
</template> </template>
</UPopover> </UPopover>
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'datetime'"> <UPopover :content="{ side: 'bottom', align: 'start' }" v-else-if="datapoint.inputType === 'datetime'">
<UButton <UButton
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
icon="i-heroicons-calendar-days-20-solid" icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? dayjs(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]).format('DD.MM.YY HH:mm') : 'Datum auswählen'" :label="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? dayjs(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]).format('DD.MM.YY HH:mm') : 'Datum auswählen'"
variant="outline" variant="outline"
@@ -429,10 +445,10 @@ const updateItem = async () => {
/> />
<template #panel="{ close }"> <template #content>
<LazyDatePicker <LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" @close="close" v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
mode="datetime" mode="datetime"
/> />
@@ -460,7 +476,7 @@ const updateItem = async () => {
<InputGroup class="w-full" v-else> <InputGroup class="w-full" v-else>
<UInput <UInput
class="flex-auto" class="flex-auto"
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-if="['text','number'].includes(datapoint.inputType)" v-if="['text','number'].includes(datapoint.inputType)"
v-model="item[datapoint.key]" v-model="item[datapoint.key]"
@@ -472,34 +488,33 @@ const updateItem = async () => {
{{ datapoint.inputTrailing }} {{ datapoint.inputTrailing }}
</template> </template>
</UInput> </UInput>
<UToggle <USwitch
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'primary'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'primary'"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'bool'" v-else-if="datapoint.inputType === 'bool'"
v-model="item[datapoint.key]" v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/> />
<USelectMenu <USelectMenu
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'select'" v-else-if="datapoint.inputType === 'select'"
v-model="item[datapoint.key]" v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
:option-attribute="datapoint.selectOptionAttribute" :items="getSelectItems(datapoint)"
:value-attribute="datapoint.selectValueAttribute || 'id'" :label-key="getSelectLabelKey(datapoint)"
:options="datapoint.selectManualOptions || loadedOptions[datapoint.selectDataType]" :value-key="getSelectValueKey(datapoint)"
:searchable="datapoint.selectSearchAttributes" :search-input="getSelectSearchInput(datapoint)"
:search-attributes="datapoint.selectSearchAttributes" :filter-fields="datapoint.selectSearchAttributes"
:multiple="datapoint.selectMultiple" :multiple="datapoint.selectMultiple"
searchable-placeholder="Suche..."
> >
<template #empty> <template #empty>
Keine Optionen verfügbar Keine Optionen verfügbar
</template> </template>
</USelectMenu> </USelectMenu>
<UTextarea <UTextarea
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'textarea'" v-else-if="datapoint.inputType === 'textarea'"
@@ -507,37 +522,36 @@ const updateItem = async () => {
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
rows="4" rows="4"
/> />
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'date'"> <UPopover :content="{ side: 'bottom', align: 'start' }" v-else-if="datapoint.inputType === 'date'">
<UButton <UButton
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
icon="i-heroicons-calendar-days-20-solid" icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YYYY') : 'Datum auswählen'" :label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline" variant="outline"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/> />
<template #panel="{ close }"> <template #content>
<LazyDatePicker <LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key]" @close="close" v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/> />
</template> </template>
</UPopover> </UPopover>
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'datetime'"> <UPopover :content="{ side: 'bottom', align: 'start' }" v-else-if="datapoint.inputType === 'datetime'">
<UButton <UButton
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
icon="i-heroicons-calendar-days-20-solid" icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YY HH:mm') : 'Datum auswählen'" :label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YY HH:mm') : 'Datum auswählen'"
variant="outline" variant="outline"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/> />
<template #panel="{ close }"> <template #content>
<LazyDatePicker <LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key]" v-model="item[datapoint.key]"
@close="close"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
mode="datetime" mode="datetime"
/> />
@@ -572,11 +586,11 @@ const updateItem = async () => {
icon="i-heroicons-x-mark" icon="i-heroicons-x-mark"
/> />
</InputGroup> </InputGroup>
</UFormGroup> </UFormField>
</div> </div>
</div> </div>
</div> </div>
<UFormGroup <UFormField
v-for="datapoint in dataType.templateColumns.filter(i => i.inputType && !i.inputColumn)" v-for="datapoint in dataType.templateColumns.filter(i => i.inputType && !i.inputColumn)"
:label="datapoint.label" :label="datapoint.label"
> >
@@ -589,7 +603,7 @@ const updateItem = async () => {
</template> </template>
<InputGroup class="w-full" v-if="datapoint.key.includes('.')"> <InputGroup class="w-full" v-if="datapoint.key.includes('.')">
<UInput <UInput
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-if="['text','number'].includes(datapoint.inputType)" v-if="['text','number'].includes(datapoint.inputType)"
@@ -602,25 +616,25 @@ const updateItem = async () => {
<span class="text-gray-500 dark:text-gray-400 text-xs">{{ datapoint.inputTrailing }}</span> <span class="text-gray-500 dark:text-gray-400 text-xs">{{ datapoint.inputTrailing }}</span>
</template> </template>
</UInput> </UInput>
<UToggle <USwitch
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'primary'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'primary'"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'bool'" v-else-if="datapoint.inputType === 'bool'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/> />
<USelectMenu <USelectMenu
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'select'" v-else-if="datapoint.inputType === 'select'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
:option-attribute="datapoint.selectOptionAttribute" :items="getSelectItems(datapoint)"
:value-attribute="datapoint.selectValueAttribute || 'id'" :label-key="getSelectLabelKey(datapoint)"
:options="datapoint.selectManualOptions || loadedOptions[datapoint.selectDataType]" :value-key="getSelectValueKey(datapoint)"
:searchable="datapoint.selectSearchAttributes" :search-input="getSelectSearchInput(datapoint)"
:search-attributes="datapoint.selectSearchAttributes" :filter-fields="datapoint.selectSearchAttributes"
:multiple="datapoint.selectMultiple" :multiple="datapoint.selectMultiple"
> >
<template #empty> <template #empty>
@@ -628,7 +642,7 @@ const updateItem = async () => {
</template> </template>
</USelectMenu> </USelectMenu>
<UTextarea <UTextarea
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'textarea'" v-else-if="datapoint.inputType === 'textarea'"
@@ -636,9 +650,9 @@ const updateItem = async () => {
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
rows="4" rows="4"
/> />
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'date'"> <UPopover :content="{ side: 'bottom', align: 'start' }" v-else-if="datapoint.inputType === 'date'">
<UButton <UButton
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
icon="i-heroicons-calendar-days-20-solid" icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? dayjs(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]).format('DD.MM.YYYY') : 'Datum auswählen'" :label="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? dayjs(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline" variant="outline"
@@ -646,17 +660,17 @@ const updateItem = async () => {
/> />
<template #panel="{ close }"> <template #content>
<LazyDatePicker <LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" @close="close" v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/> />
</template> </template>
</UPopover> </UPopover>
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'datetime'"> <UPopover :content="{ side: 'bottom', align: 'start' }" v-else-if="datapoint.inputType === 'datetime'">
<UButton <UButton
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
icon="i-heroicons-calendar-days-20-solid" icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? dayjs(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]).format('DD.MM.YY HH:mm') : 'Datum auswählen'" :label="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? dayjs(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]).format('DD.MM.YY HH:mm') : 'Datum auswählen'"
variant="outline" variant="outline"
@@ -664,10 +678,10 @@ const updateItem = async () => {
/> />
<template #panel="{ close }"> <template #content>
<LazyDatePicker <LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]" @close="close" v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
mode="datetime" mode="datetime"
/> />
@@ -695,7 +709,7 @@ const updateItem = async () => {
<InputGroup class="w-full" v-else> <InputGroup class="w-full" v-else>
<UInput <UInput
class="flex-auto" class="flex-auto"
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-if="['text','number'].includes(datapoint.inputType)" v-if="['text','number'].includes(datapoint.inputType)"
v-model="item[datapoint.key]" v-model="item[datapoint.key]"
@@ -707,34 +721,33 @@ const updateItem = async () => {
{{ datapoint.inputTrailing }} {{ datapoint.inputTrailing }}
</template> </template>
</UInput> </UInput>
<UToggle <USwitch
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'primary'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'primary'"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'bool'" v-else-if="datapoint.inputType === 'bool'"
v-model="item[datapoint.key]" v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/> />
<USelectMenu <USelectMenu
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'select'" v-else-if="datapoint.inputType === 'select'"
v-model="item[datapoint.key]" v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
:option-attribute="datapoint.selectOptionAttribute" :items="getSelectItems(datapoint)"
:value-attribute="datapoint.selectValueAttribute || 'id'" :label-key="getSelectLabelKey(datapoint)"
:options="datapoint.selectManualOptions || loadedOptions[datapoint.selectDataType]" :value-key="getSelectValueKey(datapoint)"
:searchable="datapoint.selectSearchAttributes" :search-input="getSelectSearchInput(datapoint)"
:search-attributes="datapoint.selectSearchAttributes" :filter-fields="datapoint.selectSearchAttributes"
:multiple="datapoint.selectMultiple" :multiple="datapoint.selectMultiple"
searchable-placeholder="Suche..."
> >
<template #empty> <template #empty>
Keine Optionen verfügbar Keine Optionen verfügbar
</template> </template>
</USelectMenu> </USelectMenu>
<UTextarea <UTextarea
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-else-if="datapoint.inputType === 'textarea'" v-else-if="datapoint.inputType === 'textarea'"
@@ -742,37 +755,36 @@ const updateItem = async () => {
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
rows="4" rows="4"
/> />
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'date'"> <UPopover :content="{ side: 'bottom', align: 'start' }" v-else-if="datapoint.inputType === 'date'">
<UButton <UButton
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
icon="i-heroicons-calendar-days-20-solid" icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YYYY') : 'Datum auswählen'" :label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline" variant="outline"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/> />
<template #panel="{ close }"> <template #content>
<LazyDatePicker <LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key]" @close="close" v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/> />
</template> </template>
</UPopover> </UPopover>
<UPopover :popper="{ placement: 'bottom-start' }" v-else-if="datapoint.inputType === 'datetime'"> <UPopover :content="{ side: 'bottom', align: 'start' }" v-else-if="datapoint.inputType === 'datetime'">
<UButton <UButton
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'" :color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
icon="i-heroicons-calendar-days-20-solid" icon="i-heroicons-calendar-days-20-solid"
:label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YY HH:mm') : 'Datum auswählen'" :label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YY HH:mm') : 'Datum auswählen'"
variant="outline" variant="outline"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/> />
<template #panel="{ close }"> <template #content>
<LazyDatePicker <LazyDatePicker
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null" @change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
v-model="item[datapoint.key]" v-model="item[datapoint.key]"
@close="close"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false" :disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
mode="datetime" mode="datetime"
/> />
@@ -807,7 +819,7 @@ const updateItem = async () => {
icon="i-heroicons-x-mark" icon="i-heroicons-x-mark"
/> />
</InputGroup> </InputGroup>
</UFormGroup> </UFormField>
</UForm> </UForm>
</UDashboardPanelContent> </UDashboardPanelContent>
</template> </template>

View File

@@ -110,12 +110,6 @@ const filteredRows = computed(() => {
</script> </script>
<template> <template>
<FloatingActionButton
:label="`+ ${dataType.labelSingle}`"
variant="outline"
v-if="platform === 'mobile'"
@click="router.push(`/standardEntity/${type}/create`)"
/>
<UDashboardNavbar :title="dataType.label" :badge="filteredRows.length"> <UDashboardNavbar :title="dataType.label" :badge="filteredRows.length">
<template #toggle> <template #toggle>
<div v-if="platform === 'mobile'"></div> <div v-if="platform === 'mobile'"></div>
@@ -138,7 +132,7 @@ const filteredRows = computed(() => {
<UButton <UButton
icon="i-heroicons-x-mark" icon="i-heroicons-x-mark"
variant="outline" variant="outline"
color="rose" color="error"
@click="clearSearchString()" @click="clearSearchString()"
v-if="searchString.length > 0" v-if="searchString.length > 0"
/> />
@@ -161,15 +155,15 @@ const filteredRows = computed(() => {
<USelectMenu <USelectMenu
v-model="selectedColumns" v-model="selectedColumns"
icon="i-heroicons-adjustments-horizontal-solid" icon="i-heroicons-adjustments-horizontal-solid"
:options="dataType.templateColumns.filter(i => !i.disabledInTable)" :items="dataType.templateColumns.filter(i => !i.disabledInTable)"
multiple multiple
class="hidden lg:block" class="hidden lg:block"
by="key" by="key"
:color="selectedColumns.length !== dataType.templateColumns.filter(i => !i.disabledInTable).length ? 'primary' : 'white'" :color="selectedColumns.length !== dataType.templateColumns.filter(i => !i.disabledInTable).length ? 'primary' : 'white'"
:ui-menu="{ width: 'min-w-max' }" :content="{ width: 'min-w-max' }"
@change="tempStore.modifyColumns(type,selectedColumns)" @change="tempStore.modifyColumns(type,selectedColumns)"
> >
<template #label> <template #default>
Spalten Spalten
</template> </template>
</USelectMenu> </USelectMenu>
@@ -178,11 +172,11 @@ const filteredRows = computed(() => {
icon="i-heroicons-adjustments-horizontal-solid" icon="i-heroicons-adjustments-horizontal-solid"
multiple multiple
v-model="selectedFilters" v-model="selectedFilters"
:options="selectableFilters" :items="selectableFilters"
:color="selectedFilters.length > 0 ? 'primary' : 'white'" :color="selectedFilters.length > 0 ? 'primary' : 'white'"
:ui-menu="{ width: 'min-w-max' }" :content="{ width: 'min-w-max' }"
> >
<template #label> <template #default>
Filter Filter
</template> </template>
</USelectMenu> </USelectMenu>
@@ -191,14 +185,14 @@ const filteredRows = computed(() => {
<EntityTableMobile <EntityTableMobile
v-if="platform === 'mobile'" v-if="platform === 'mobile'"
:type="props.type" :type="props.type"
:columns="columns" :columns="normalizeTableColumns(columns)"
:rows="filteredRows" :rows="filteredRows"
/> />
<EntityTable <EntityTable
v-else v-else
@sort="(i) => emit('sort',i)" @sort="(i) => emit('sort',i)"
:type="props.type" :type="props.type"
:columns="columns" :columns="normalizeTableColumns(columns)"
:rows="filteredRows" :rows="filteredRows"
:loading="props.loading" :loading="props.loading"
/> />

View File

@@ -28,14 +28,16 @@ defineShortcuts({
router.back() router.back()
}, },
'arrowleft': () => { 'arrowleft': () => {
if(openTab.value > 0){ const currentIndex = Number(openTab.value)
openTab.value -= 1 if(currentIndex > 0){
openTab.value = String(currentIndex - 1)
router.push(`${router.currentRoute.value.path}?tabIndex=${openTab.value}`) router.push(`${router.currentRoute.value.path}?tabIndex=${openTab.value}`)
} }
}, },
'arrowright': () => { 'arrowright': () => {
if(openTab.value < dataType.showTabs.length - 1) { const currentIndex = Number(openTab.value)
openTab.value += 1 if(currentIndex < dataType.showTabs.length - 1) {
openTab.value = String(currentIndex + 1)
router.push(`${router.currentRoute.value.path}?tabIndex=${openTab.value}`) router.push(`${router.currentRoute.value.path}?tabIndex=${openTab.value}`)
} }
}, },
@@ -51,7 +53,7 @@ const auth = useAuthStore()
const dataType = dataStore.dataTypes[type] const dataType = dataStore.dataTypes[type]
const openTab = ref(route.query.tabIndex || 0) const openTab = ref(String(route.query.tabIndex || 0))
@@ -97,7 +99,8 @@ const getAvailableQueryStringData = (keys) => {
} }
const onTabChange = (index) => { const onTabChange = (index) => {
router.push(`${router.currentRoute.value.path}?tabIndex=${index}`) openTab.value = String(index)
router.push(`${router.currentRoute.value.path}?tabIndex=${openTab.value}`)
} }
const changePinned = async () => { const changePinned = async () => {
@@ -255,9 +258,9 @@ const openCustomerInventoryLabelPrint = () => {
v-if="props.item.id && platform !== 'mobile'" v-if="props.item.id && platform !== 'mobile'"
class="p-5" class="p-5"
v-model="openTab" v-model="openTab"
@change="onTabChange" @update:model-value="onTabChange"
> >
<template #item="{item:tab}"> <template #content="{item:tab}">
<div v-if="tab.label === 'Informationen'" class="flex flex-row"> <div v-if="tab.label === 'Informationen'" class="flex flex-row">
<EntityShowSubInformation <EntityShowSubInformation

View File

@@ -96,15 +96,15 @@ setup()
<USelectMenu <USelectMenu
v-model="selectedColumns" v-model="selectedColumns"
icon="i-heroicons-adjustments-horizontal-solid" icon="i-heroicons-adjustments-horizontal-solid"
:options="dataType.templateColumns.filter(i => !i.disabledInTable)" :items="dataType.templateColumns.filter(i => !i.disabledInTable)"
multiple multiple
class="hidden lg:block" class="hidden lg:block"
by="key" by="key"
:color="selectedColumns.length !== dataType.templateColumns.filter(i => !i.disabledInTable).length ? 'primary' : 'white'" :color="selectedColumns.length !== dataType.templateColumns.filter(i => !i.disabledInTable).length ? 'primary' : 'white'"
:ui-menu="{ width: 'min-w-max' }" :content="{ width: 'min-w-max' }"
@change="tempStore.modifyColumns(type,selectedColumns)" @change="tempStore.modifyColumns(type,selectedColumns)"
> >
<template #label> <template #default>
Spalten Spalten
</template> </template>
</USelectMenu> </USelectMenu>
@@ -114,7 +114,7 @@ setup()
<div class="scroll" style="height: 70vh"> <div class="scroll" style="height: 70vh">
<EntityTable <EntityTable
:type="type" :type="type"
:columns="columns" :columns="normalizeTableColumns(columns)"
:rows="props.item[type]" :rows="props.item[type]"
style style
/> />

View File

@@ -181,49 +181,51 @@ const selectItem = (item) => {
</UButton> </UButton>
<UModal <UModal
prevent-close prevent-close
v-model="showFinalInvoiceConfig" v-model:open="showFinalInvoiceConfig"
> >
<UCard> <template #content>
<template #header> <UCard>
<div class="flex items-center justify-between"> <template #header>
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white"> <div class="flex items-center justify-between">
Schlussrechnung konfigurieren <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
</h3> Schlussrechnung konfigurieren
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" /> </h3>
</div> <UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="showFinalInvoiceConfig = false" />
</template> </div>
</template>
<UFormGroup <UFormField
label="Rechnungsvorlage" label="Rechnungsvorlage"
>
<USelectMenu
:options="props.item.createddocuments.filter(i => ['confirmationOrders','quotes'].includes(i.type))"
value-attribute="id"
option-attribute="documentNumber"
v-model="referenceDocument"
/>
</UFormGroup>
<UFormGroup
label="Abschlagsrechnungen"
>
<USelectMenu
:options="props.item.createddocuments.filter(i => ['advanceInvoices'].includes(i.type))"
multiple
value-attribute="id"
option-attribute="documentNumber"
v-model="advanceInvoicesToAdd"
/>
</UFormGroup>
<template #footer>
<UButton
@click="invoiceAdvanceInvoices"
> >
Weiter <USelectMenu
</UButton> :items="props.item.createddocuments.filter(i => ['confirmationOrders','quotes'].includes(i.type))"
</template> value-key="id"
</UCard> label-key="documentNumber"
v-model="referenceDocument"
/>
</UFormField>
<UFormField
label="Abschlagsrechnungen"
>
<USelectMenu
:items="props.item.createddocuments.filter(i => ['advanceInvoices'].includes(i.type))"
multiple
value-key="id"
label-key="documentNumber"
v-model="advanceInvoicesToAdd"
/>
</UFormField>
<template #footer>
<UButton
@click="invoiceAdvanceInvoices"
>
Weiter
</UButton>
</template>
</UCard>
</template>
</UModal> </UModal>
<UButton <UButton
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'invoices'})}`)" @click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'invoices'})}`)"
@@ -235,48 +237,48 @@ const selectItem = (item) => {
<USelectMenu <USelectMenu
v-model="selectedColumns" v-model="selectedColumns"
icon="i-heroicons-adjustments-horizontal-solid" icon="i-heroicons-adjustments-horizontal-solid"
:options="templateColumns" :items="templateColumns"
multiple multiple
class="hidden lg:block" class="hidden lg:block"
by="key" by="key"
@change="tempStore.modifyColumns('createddocuments',selectedColumns)" @change="tempStore.modifyColumns('createddocuments',selectedColumns)"
> >
<template #label> <template #default>
Spalten Spalten
</template> </template>
</USelectMenu> </USelectMenu>
</template> </template>
</Toolbar> </Toolbar>
<UTable <UTable
:rows="props.item.createddocuments.filter(i => !i.archived)" :data="props.item.createddocuments.filter(i => !i.archived)"
:columns="columns" :columns="normalizeTableColumns(columns)"
class="w-full" class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }" :ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="selectItem" :on-select="(row) => selectItem(row.original)"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }" :empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
style="height: 70vh" style="height: 70vh"
> >
<template #type-data="{row}"> <template #type-cell="{ row }">
{{dataStore.documentTypesForCreation[row.type].labelSingle}} {{ dataStore.documentTypesForCreation[row.original.type].labelSingle }}
</template> </template>
<template #state-data="{row}"> <template #state-cell="{ row }">
<span <span
v-if="row.state === 'Entwurf'" v-if="row.original.state === 'Entwurf'"
class="text-rose-500" class="text-error-500"
> >
{{row.state}} {{ row.original.state }}
</span> </span>
<span <span
v-if="row.state === 'Gebucht'" v-if="row.original.state === 'Gebucht'"
class="text-cyan-500" class="text-cyan-500"
> >
{{row.state}} {{ row.original.state }}
</span> </span>
<span <span
v-if="row.state === 'Abgeschlossen'" v-if="row.original.state === 'Abgeschlossen'"
class="text-primary-500" class="text-primary-500"
> >
{{row.state}} {{ row.original.state }}
</span> </span>
</template> </template>
<!-- <template #paid-data="{row}"> <!-- <template #paid-data="{row}">
@@ -285,19 +287,19 @@ const selectItem = (item) => {
<span v-else class="text-rose-600">Offen</span> <span v-else class="text-rose-600">Offen</span>
</div> </div>
</template>--> </template>-->
<template #reference-data="{row}"> <template #reference-cell="{ row }">
<span v-if="row === props.item.createddocuments[selectedItem]" class="text-primary-500 font-bold">{{row.documentNumber}}</span> <span v-if="row.original === props.item.createddocuments[selectedItem]" class="text-primary-500 font-bold">{{ row.original.documentNumber }}</span>
<span v-else>{{row.documentNumber}}</span> <span v-else>{{ row.original.documentNumber }}</span>
</template> </template>
<template #date-data="{row}"> <template #date-cell="{ row }">
<span v-if="row.date">{{row.date ? dayjs(row.date).format("DD.MM.YY") : ''}}</span> <span v-if="row.original.date">{{ row.original.date ? dayjs(row.original.date).format("DD.MM.YY") : '' }}</span>
<span v-if="row.documentDate">{{row.documentDate ? dayjs(row.documentDate).format("DD.MM.YY") : ''}}</span> <span v-if="row.original.documentDate">{{ row.original.documentDate ? dayjs(row.original.documentDate).format("DD.MM.YY") : '' }}</span>
</template> </template>
<template #dueDate-data="{row}"> <template #dueDate-cell="{ row }">
<span v-if="row.paymentDays && ['invoices','advanceInvoices'].includes(row.type)" >{{row.documentDate ? dayjs(row.documentDate).add(row.paymentDays,'day').format("DD.MM.YY") : ''}}</span> <span v-if="row.original.paymentDays && ['invoices','advanceInvoices'].includes(row.original.type)">{{ row.original.documentDate ? dayjs(row.original.documentDate).add(row.original.paymentDays,'day').format("DD.MM.YY") : '' }}</span>
</template> </template>
<template #amount-data="{row}"> <template #amount-cell="{ row }">
<span v-if="row.type !== 'deliveryNotes'">{{useCurrency(useSum().getCreatedDocumentSum(row, createddocuments))}}</span> <span v-if="row.original.type !== 'deliveryNotes'">{{ useCurrency(useSum().getCreatedDocumentSum(row.original, createddocuments)) }}</span>
</template> </template>
</UTable> </UTable>

View File

@@ -94,41 +94,43 @@ function isImage(file) {
</UCard> </UCard>
<!-- 📱 PDF / IMG Viewer Slideover --> <!-- 📱 PDF / IMG Viewer Slideover -->
<UModal v-model="showViewer" side="bottom" class="h-[100dvh]" fullscreen> <UModal v-model:open="showViewer" side="bottom" class="h-[100dvh]" fullscreen>
<!-- Header --> <template #content>
<div class="p-4 border-b flex justify-between items-center flex-shrink-0"> <!-- Header -->
<h3 class="font-bold truncate max-w-[70vw]">{{ activeFile?.path?.split("/").pop() }}</h3> <div class="p-4 border-b flex justify-between items-center flex-shrink-0">
<UButton icon="i-heroicons-x-mark" variant="ghost" @click="closeViewer" /> <h3 class="font-bold truncate max-w-[70vw]">{{ activeFile?.path?.split("/").pop() }}</h3>
</div> <UButton icon="i-heroicons-x-mark" variant="ghost" @click="closeViewer" />
<!-- Content -->
<div class="flex-1 overflow-y-auto m-2">
<!-- PDF -->
<div v-if="activeFile && isPdf(activeFile)" class="h-full">
<PDFViewer
:no-controls="true"
:file-id="activeFile.id"
location="fileviewer-mobile"
class="h-full"
/>
</div> </div>
<!-- IMAGE --> <!-- Content -->
<div <div class="flex-1 overflow-y-auto m-2">
v-else-if="activeFile && isImage(activeFile)" <!-- PDF -->
class="p-4 flex justify-center" <div v-if="activeFile && isPdf(activeFile)" class="h-full">
> <PDFViewer
<img :no-controls="true"
:src="activeFile.url" :file-id="activeFile.id"
class="max-w-full max-h-[80vh] rounded-lg shadow" location="fileviewer-mobile"
class="h-full"
/>
</div>
<!-- IMAGE -->
<div
v-else-if="activeFile && isImage(activeFile)"
class="p-4 flex justify-center"
>
<img
:src="activeFile.url"
class="max-w-full max-h-[80vh] rounded-lg shadow"
/>
</div>
<UAlert
v-else
title="Nicht unterstützter Dateityp"
icon="i-heroicons-exclamation-triangle"
/> />
</div> </div>
</template>
<UAlert
v-else
title="Nicht unterstützter Dateityp"
icon="i-heroicons-exclamation-triangle"
/>
</div>
</UModal> </UModal>
</template> </template>

View File

@@ -65,7 +65,7 @@ const renderDatapointValue = (datapoint) => {
</template> </template>
<UAlert <UAlert
v-if="props.item.archived" v-if="props.item.archived"
color="rose" color="error"
variant="outline" variant="outline"
:title="`${dataType.labelSingle} archiviert`" :title="`${dataType.labelSingle} archiviert`"
icon="i-heroicons-archive-box" icon="i-heroicons-archive-box"

View File

@@ -77,21 +77,21 @@ const renderedAllocations = computed(() => {
<UCard class="mt-5"> <UCard class="mt-5">
<UTable <UTable
v-if="props.item.statementallocations" v-if="props.item.statementallocations"
:rows="renderedAllocations" :data="renderedAllocations"
:columns="[{key:'amount', label:'Betrag'},{key:'date', label:'Datum'},{key:'partner', label:'Partner'},{key:'description', label:'Beschreibung'}]" :columns="normalizeTableColumns([{key:'amount', label:'Betrag'},{key:'date', label:'Datum'},{key:'partner', label:'Partner'},{key:'description', label:'Beschreibung'}])"
@select="(i) => selectAllocation(i)" :on-select="(i) => selectAllocation(i)"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }" :empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
> >
<template #amount-data="{row}"> <template #amount-cell="{row}">
<span class="text-right text-rose-600" v-if="row.amount < 0 || row.color === 'red'">{{useCurrency(row.amount)}}</span> <span class="text-right text-rose-600" v-if="row.original.amount < 0 || row.original.color === 'red'">{{useCurrency(row.original.amount)}}</span>
<span class="text-right text-primary-500" v-else-if="row.amount > 0 || row.color === 'green'">{{useCurrency(row.amount)}}</span> <span class="text-right text-primary-500" v-else-if="row.original.amount > 0 || row.original.color === 'green'">{{useCurrency(row.original.amount)}}</span>
<span v-else>{{useCurrency(row.amount)}}</span> <span v-else>{{useCurrency(row.original.amount)}}</span>
</template> </template>
<template #date-data="{row}"> <template #date-cell="{row}">
{{row.date ? dayjs(row.date).format('DD.MM.YYYY') : ''}} {{row.original.date ? dayjs(row.original.date).format('DD.MM.YYYY') : ''}}
</template> </template>
<template #description-data="{row}"> <template #description-cell="{row}">
{{row.description ? row.description : ''}} {{row.original.description ? row.original.description : ''}}
</template> </template>
</UTable> </UTable>
</UCard> </UCard>

View File

@@ -95,26 +95,26 @@ const changeActivePhase = async (key) => {
<UAccordion <UAccordion
:items="renderedPhases" :items="renderedPhases"
> >
<template #default="{item,index,open}"> <template #default="slotProps">
<UButton <UButton
variant="ghost" variant="ghost"
:color="item.active ? 'primary' : 'white'" :color="slotProps.item.active ? 'primary' : 'white'"
class="mb-1" class="mb-1"
:disabled="true" :disabled="true"
> >
<template #leading> <template #leading>
<div class="w-6 h-6 flex items-center justify-center -my-1"> <div class="w-6 h-6 flex items-center justify-center -my-1">
<UIcon :name="item.icon" class="w-4 h-4 " /> <UIcon :name="slotProps.item.icon" class="w-4 h-4 " />
</div> </div>
</template> </template>
<span class="truncate"> {{item.label}}</span> <span class="truncate"> {{ slotProps.item.label }}</span>
<template #trailing> <template #trailing>
<UIcon <UIcon
name="i-heroicons-chevron-right-20-solid" name="i-heroicons-chevron-right-20-solid"
class="w-5 h-5 ms-auto transform transition-transform duration-200" class="w-5 h-5 ms-auto transform transition-transform duration-200"
:class="[open && 'rotate-90']" :class="[slotProps?.open && 'rotate-90']"
/> />
</template> </template>

View File

@@ -67,40 +67,40 @@ const columns = [
<UCard class="mt-5"> <UCard class="mt-5">
<UTable <UTable
class="mt-3" class="mt-3"
:columns="columns" :columns="normalizeTableColumns(columns)"
:rows="props.item.times" :data="props.item.times"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Noch keine Einträge' }" :empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Noch keine Einträge' }"
> >
<template #state-data="{row}"> <template #state-cell="{ row }">
<span <span
v-if="row.state === 'Entwurf'" v-if="row.original.state === 'Entwurf'"
class="text-rose-500" class="text-error-500"
>{{row.state}}</span> >{{ row.original.state }}</span>
<span <span
v-if="row.state === 'Eingereicht'" v-if="row.original.state === 'Eingereicht'"
class="text-cyan-500" class="text-cyan-500"
>{{row.state}}</span> >{{ row.original.state }}</span>
<span <span
v-if="row.state === 'Bestätigt'" v-if="row.original.state === 'Bestätigt'"
class="text-primary-500" class="text-primary-500"
>{{row.state}}</span> >{{ row.original.state }}</span>
</template> </template>
<template #user-data="{row}"> <template #user-cell="{ row }">
{{row.profile ? row.profile.fullName : "" }} {{ row.original.profile ? row.original.profile.fullName : "" }}
</template> </template>
<template #startDate-data="{row}"> <template #startDate-cell="{ row }">
{{dayjs(row.startDate).format("DD.MM.YY HH:mm")}} {{ dayjs(row.original.startDate).format("DD.MM.YY HH:mm") }}
</template> </template>
<template #endDate-data="{row}"> <template #endDate-cell="{ row }">
{{dayjs(row.endDate).format("DD.MM.YY HH:mm")}} {{ dayjs(row.original.endDate).format("DD.MM.YY HH:mm") }}
</template> </template>
<template #duration-data="{row}"> <template #duration-cell="{ row }">
{{Math.floor(dayjs(row.endDate).diff(row.startDate, "minutes")/60)}}:{{String(dayjs(row.endDate).diff(row.startDate, "minutes") % 60).padStart(2,"0")}} h {{ Math.floor(dayjs(row.original.endDate).diff(row.original.startDate, "minutes") / 60) }}:{{ String(dayjs(row.original.endDate).diff(row.original.startDate, "minutes") % 60).padStart(2,"0") }} h
</template> </template>
<template #project-data="{row}"> <template #project-cell="{ row }">
{{row.project ? row.project.name : "" }} {{ row.original.project ? row.original.project.name : "" }}
</template> </template>
</UTable> </UTable>
</UCard> </UCard>

View File

@@ -58,76 +58,101 @@
const dataType = dataStore.dataTypes[props.type] const dataType = dataStore.dataTypes[props.type]
const selectedItem = ref(0) const selectedItem = ref(0)
const sort = ref({ const sorting = ref([{
column: dataType.sortColumn || "date", id: dataType.sortColumn || "date",
direction: 'desc' desc: true
}) }])
const normalizedColumns = computed(() => normalizeTableColumns(props.columns))
const truncateValue = (value, maxLength) => {
if (value === null || value === undefined || value === '') {
return '\u00A0'
}
const stringValue = String(value)
if (!maxLength || stringValue.length <= maxLength) {
return stringValue
}
return `${stringValue.substring(0, maxLength)}...`
}
const handleSortChange = (value) => {
const nextSort = Array.isArray(value) ? value[0] : undefined
if (!nextSort?.id) {
return
}
emit('sort', {
sort_column: nextSort.id,
sort_direction: nextSort.desc ? 'desc' : 'asc'
})
}
const handleSelect = (row) => {
router.push(getShowRoute(props.type, row.original.id))
}
</script> </script>
<template> <template>
<UTable <UTable
:loading="props.loading" :loading="props.loading"
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
sort-mode="manual" sort-mode="manual"
v-model:sort="sort" v-model:sorting="sorting"
@update:sort="emit('sort',{sort_column: sort.column, sort_direction: sort.direction})" @update:sorting="handleSortChange"
v-if="dataType && columns" v-if="dataType && columns"
:rows="props.rows" :data="props.rows"
:columns="props.columns" :columns="normalizedColumns"
class="w-full" class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }" :ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(i) => router.push(getShowRoute(type, i.id))" :on-select="handleSelect"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: `Keine ${dataType.label} anzuzeigen` }" :empty="`Keine ${dataType.label} anzuzeigen`"
> >
<!-- <template <template #name-cell="{ row }">
v-for="column in dataType.templateColumns.filter(i => !i.disabledInTable)"
v-slot:[`${column.key}-header`]="{row}">
<span class="text-nowrap">{{column.label}}</span>
</template>-->
<template #name-data="{row}">
<span <span
v-if="row.id === props.rows[selectedItem].id" v-if="row.original.id === props.rows[selectedItem]?.id"
class="text-primary-500 font-bold"> class="block truncate text-primary-500 font-bold"
<UTooltip >
:text="row.name" <UTooltip :text="row.original.name">
> <span class="block truncate">
{{dataType.templateColumns.find(i => i.key === "name").maxLength ? (row.name.length > dataType.templateColumns.find(i => i.key === "name").maxLength ? `${row.name.substring(0,dataType.templateColumns.find(i => i.key === "name").maxLength)}...` : row.name ) : row.name}} {{ truncateValue(row.original.name, dataType.templateColumns.find(i => i.key === "name")?.maxLength) }}
</span>
</UTooltip> </span> </UTooltip> </span>
<span v-else> <span v-else class="block truncate">
<UTooltip <UTooltip :text="row.original.name">
:text="row.name" <span class="block truncate">
> {{ truncateValue(row.original.name, dataType.templateColumns.find(i => i.key === "name")?.maxLength) }}
{{dataType.templateColumns.find(i => i.key === "name").maxLength ? (row.name.length > dataType.templateColumns.find(i => i.key === "name").maxLength ? `${row.name.substring(0,dataType.templateColumns.find(i => i.key === "name").maxLength)}...` : row.name ) : row.name}} </span>
</UTooltip> </UTooltip>
</span> </span>
</template> </template>
<template #fullName-data="{row}"> <template #fullName-cell="{ row }">
<span <span
v-if="row.id === props.rows[selectedItem].id" v-if="row.original.id === props.rows[selectedItem]?.id"
class="text-primary-500 font-bold">{{row.fullName}} class="block truncate text-primary-500 font-bold">{{ row.original.fullName }}
</span> </span>
<span v-else> <span v-else class="block truncate">
{{row.fullName}} {{ row.original.fullName }}
</span> </span>
</template> </template>
<template #licensePlate-data="{row}"> <template #licensePlate-cell="{ row }">
<span <span
v-if="row.id === props.rows[selectedItem].id" v-if="row.original.id === props.rows[selectedItem]?.id"
class="text-primary-500 font-bold">{{row.licensePlate}} class="block truncate text-primary-500 font-bold">{{ row.original.licensePlate }}
</span> </span>
<span v-else> <span v-else class="block truncate">
{{row.licensePlate}} {{ row.original.licensePlate }}
</span> </span>
</template> </template>
<template <template
v-for="column in dataType.templateColumns.filter(i => i.key !== 'name' && i.key !== 'fullName' && i.key !== 'licensePlate' && !i.disabledInTable)" v-for="column in dataType.templateColumns.filter(i => i.key !== 'name' && i.key !== 'fullName' && i.key !== 'licensePlate' && !i.disabledInTable)"
v-slot:[`${column.key}-data`]="{row}"> v-slot:[`${column.key}-cell`]="{ row }">
<component v-if="column.component" :is="column.component" :row="row"></component> <component v-if="column.component" :is="column.component" :row="row.original"></component>
<span v-else-if="row[column.key]"> <span v-else-if="row.original[column.key]" class="block truncate">
<UTooltip :text="row[column.key]"> <UTooltip :text="String(row.original[column.key])">
{{row[column.key] ? `${column.maxLength ? (row[column.key].length > column.maxLength ? `${row[column.key].substring(0,column.maxLength)}...` : row[column.key]) : row[column.key]} ${column.unit ? column.unit : ''}`: ''}} <span class="block truncate">
</UTooltip> {{ `${truncateValue(row.original[column.key], column.maxLength)}${column.unit ? ` ${column.unit}` : ''}` }}
</span>
</UTooltip>
</span> </span>
</template> </template>

View File

@@ -77,7 +77,7 @@
<!-- <UTable <!-- <UTable
v-if="dataType && columns" v-if="dataType && columns"
:rows="props.rows" :data="props.rows"
:columns="props.columns" :columns="props.columns"
class="w-full" class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }" :ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"

View File

@@ -55,17 +55,19 @@ setup()
</script> </script>
<template> <template>
<UModal v-model="showMessageModal" prevent-close> <UModal v-model:open="showMessageModal" prevent-close>
<UCard> <template #content>
<template #header> <UCard>
<span class="font-bold">{{messageToShow.title}}</span> <template #header>
</template> <span class="font-bold">{{messageToShow.title}}</span>
<p class=" my-2" v-html="messageToShow.description"></p> </template>
<UButton <p class=" my-2" v-html="messageToShow.description"></p>
variant="outline" <UButton
@click="markMessageAsRead" variant="outline"
>Gelesen</UButton> @click="markMessageAsRead"
</UCard> >Gelesen</UButton>
</UCard>
</template>
</UModal> </UModal>
<!-- <UCard <!-- <UCard
@@ -79,7 +81,7 @@ setup()
variant="ghost" variant="ghost"
@click="showMessage(globalMessages[0])" @click="showMessage(globalMessages[0])"
/> />
<UModal v-model="showMessageModal"> <UModal v-model:open="showMessageModal">
<UCard> <UCard>
<template #header> <template #header>
<span class="font-bold">{{messageToShow.title}}</span> <span class="font-bold">{{messageToShow.title}}</span>

View File

@@ -123,18 +123,20 @@ function onSelect (option) {
/> />
<UModal <UModal
v-model="showCommandPalette" v-model:open="showCommandPalette"
> >
<UCommandPalette <template #content>
v-model="selectedCommand" <UCommandPalette
:groups="groups" v-model="selectedCommand"
:autoselect="false" :groups="groups"
@update:model-value="onSelect" :autoselect="false"
ref="commandPaletteRef" @update:model-value="onSelect"
/> ref="commandPaletteRef"
/>
</template>
</UModal> </UModal>
</template> </template>
<style scoped> <style scoped>
</style> </style>

View File

@@ -1,6 +1,16 @@
<script setup> <script setup>
import dayjs from 'dayjs'
const { isHelpSlideoverOpen } = useDashboard() const { isHelpSlideoverOpen } = useDashboard()
const { metaSymbol } = useShortcuts() const { entries, pending, error, seenState, refresh, markAsSeen } = useChangelog()
const metaSymbol = computed(() => {
if (import.meta.server) {
return 'Ctrl'
}
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform) ? '⌘' : 'Ctrl'
})
const shortcuts = ref(false) const shortcuts = ref(false)
const query = ref('') const query = ref('')
@@ -122,7 +132,7 @@ const addContactRequest = async () => {
toast.add({title: "Anfrage erfolgreich erstellt"}) toast.add({title: "Anfrage erfolgreich erstellt"})
resetContactRequest() resetContactRequest()
} else { } else {
toast.add({title: "Anfrage konnte nicht erstellt werden",color:"rose"}) toast.add({title: "Anfrage konnte nicht erstellt werden",color:"error"})
} }
loadingContactRequest.value = false loadingContactRequest.value = false
} }
@@ -133,10 +143,25 @@ const resetContactRequest = () => {
title: "", title: "",
} }
} }
const lastOpenedLabel = computed(() => {
if (!seenState.value.lastOpenedAt) return 'Noch nicht geöffnet'
return dayjs(seenState.value.lastOpenedAt).format('DD.MM.YYYY HH:mm')
})
const changelogEntries = computed(() => entries.value.slice(0, 12))
watch(isHelpSlideoverOpen, async (isOpen) => {
if (!isOpen || shortcuts.value) return
await refresh(true)
markAsSeen()
})
</script> </script>
<template> <template>
<UDashboardSlideover v-model="isHelpSlideoverOpen"> <USlideover v-model:open="isHelpSlideoverOpen" side="right">
<template #title> <template #title>
<UButton <UButton
v-if="shortcuts" v-if="shortcuts"
@@ -150,30 +175,94 @@ const resetContactRequest = () => {
{{ shortcuts ? 'Shortcuts' : 'Hilfe & Information' }} {{ shortcuts ? 'Shortcuts' : 'Hilfe & Information' }}
</template> </template>
<div v-if="shortcuts" class="space-y-6"> <template #body>
<UInput v-model="query" icon="i-heroicons-magnifying-glass" placeholder="Search..." autofocus color="gray" /> <div v-if="shortcuts" class="space-y-6">
<UInput v-model="query" icon="i-heroicons-magnifying-glass" placeholder="Search..." autofocus color="gray" />
<div v-for="(category, index) in filteredCategories" :key="index"> <div v-for="(category, index) in filteredCategories" :key="index">
<p class="mb-3 text-sm text-gray-900 dark:text-white font-semibold"> <p class="mb-3 text-sm text-gray-900 dark:text-white font-semibold">
{{ category.title }} {{ category.title }}
</p> </p>
<div class="space-y-2"> <div class="space-y-2">
<div v-for="(item, i) in category.items" :key="i" class="flex items-center justify-between"> <div v-for="(item, i) in category.items" :key="i" class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">{{ item.name }}</span> <span class="text-sm text-gray-500 dark:text-gray-400">{{ item.name }}</span>
<div class="flex items-center justify-end flex-shrink-0 gap-0.5"> <div class="flex items-center justify-end flex-shrink-0 gap-0.5">
<UKbd v-for="(shortcut, j) in item.shortcuts" :key="j"> <UKbd v-for="(shortcut, j) in item.shortcuts" :key="j">
{{ shortcut }} {{ shortcut }}
</UKbd> </UKbd>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> <div v-else class="flex flex-col gap-y-6">
<div v-else class="flex flex-col gap-y-3"> <div class="flex flex-col gap-y-3">
<UButton v-for="(link, index) in links" :key="index" color="white" v-bind="link" /> <UButton v-for="(link, index) in links" :key="index" color="white" v-bind="link" />
</div> </div>
<UCard>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-base font-semibold text-gray-900 dark:text-white">
Changelog
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
Zuletzt geöffnet: {{ lastOpenedLabel }}
</p>
</div>
<UButton
icon="i-heroicons-arrow-path"
color="gray"
variant="ghost"
:loading="pending"
@click="refresh(true)"
/>
</div>
<UAlert
v-if="error"
class="mt-4"
color="red"
variant="soft"
title="Changelog konnte nicht geladen werden"
:description="error"
/>
<div v-else-if="pending && !changelogEntries.length" class="mt-4">
<UProgress animation="carousel"/>
</div>
<div v-else-if="changelogEntries.length" class="mt-4 flex flex-col gap-3">
<div
v-for="entry in changelogEntries"
:key="entry.hash"
class="rounded-lg border border-gray-200 dark:border-gray-800 p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="font-medium text-gray-900 dark:text-white break-words">
{{ entry.subject }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ entry.authorName }} · {{ dayjs(entry.committedAt).format('DD.MM.YYYY HH:mm') }}
</p>
</div>
<UBadge color="gray" variant="subtle">
{{ entry.shortHash }}
</UBadge>
</div>
</div>
</div>
<p v-else class="mt-4 text-sm text-gray-500 dark:text-gray-400">
Es sind noch keine Changelog-Einträge verfügbar.
</p>
</UCard>
</div>
<!-- <div class="mt-5" v-if="!loadingContactRequest"> <!-- <div class="mt-5" v-if="!loadingContactRequest">
<h1 class="font-semibold">Kontaktanfrage:</h1> <h1 class="font-semibold">Kontaktanfrage:</h1>
<UForm <UForm
@@ -181,29 +270,29 @@ const resetContactRequest = () => {
@submit="addContactRequest" @submit="addContactRequest"
@reset="resetContactRequest" @reset="resetContactRequest"
> >
&lt;!&ndash; <UFormGroup &lt;!&ndash; <UFormField
label="Art:" label="Art:"
> >
<USelectMenu <USelectMenu
:options="['Hilfe','Software Problem / Bug','Funktionsanfrage','Kontakt','Sonstiges']" :options="['Hilfe','Software Problem / Bug','Funktionsanfrage','Kontakt','Sonstiges']"
v-model="contactRequestData.contactType" v-model="contactRequestData.contactType"
/> />
</UFormGroup>&ndash;&gt; </UFormField>&ndash;&gt;
<UFormGroup <UFormField
label="Titel:" label="Titel:"
> >
<UInput <UInput
v-model="contactRequestData.title" v-model="contactRequestData.title"
/> />
</UFormGroup> </UFormField>
<UFormGroup <UFormField
label="Nachricht:" label="Nachricht:"
> >
<UTextarea <UTextarea
v-model="contactRequestData.message" v-model="contactRequestData.message"
rows="6" rows="6"
/> />
</UFormGroup> </UFormField>
<InputGroup class="mt-3"> <InputGroup class="mt-3">
<UButton <UButton
type="submit" type="submit"
@@ -213,7 +302,7 @@ const resetContactRequest = () => {
</UButton> </UButton>
<UButton <UButton
type="reset" type="reset"
color="rose" color="error"
variant="outline" variant="outline"
:disabled="!contactRequestData.title && !contactRequestData.message" :disabled="!contactRequestData.title && !contactRequestData.message"
> >
@@ -224,5 +313,6 @@ const resetContactRequest = () => {
</UForm> </UForm>
</div> </div>
<UProgress class="mt-5" animation="carousel" v-else/>--> <UProgress class="mt-5" animation="carousel" v-else/>-->
</UDashboardSlideover> </template>
</USlideover>
</template> </template>

View File

@@ -76,38 +76,40 @@ const renderText = (text) => {
<template> <template>
<UModal <UModal
v-model="showAddHistoryItemModal" v-model:open="showAddHistoryItemModal"
> >
<UCard class="h-full"> <template #content>
<template #header> <UCard class="h-full">
<div class="flex items-center justify-between"> <template #header>
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white"> <div class="flex items-center justify-between">
Eintrag hinzufügen <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
</h3> Eintrag hinzufügen
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="showAddHistoryItemModal = false" /> </h3>
</div> <UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="showAddHistoryItemModal = false" />
</template> </div>
</template>
<UFormGroup <UFormField
label="Text:" label="Text:"
> >
<UTextarea <UTextarea
v-model="addHistoryItemData.text" v-model="addHistoryItemData.text"
@keyup.meta.enter="addHistoryItem" @keyup.meta.enter="addHistoryItem"
/> />
<!-- TODO: Add Dropdown and Checking for Usernames --> <!-- TODO: Add Dropdown and Checking for Usernames -->
<!-- <template #help> <!-- <template #help>
<UKbd>{{metaSymbol}}</UKbd> <UKbd>Enter</UKbd> Speichern <UKbd>{{metaSymbol}}</UKbd> <UKbd>Enter</UKbd> Speichern
</template>--> </template>-->
</UFormGroup> </UFormField>
<template #footer> <template #footer>
<UButton @click="addHistoryItem">Speichern</UButton> <UButton @click="addHistoryItem">Speichern</UButton>
</template> </template>
</UCard> </UCard>
</template>
</UModal> </UModal>
<Toolbar <Toolbar
v-if="!props.renderHeadline && props.elementId && props.type" v-if="!props.renderHeadline && props.elementId && props.type"
@@ -127,7 +129,7 @@ const renderText = (text) => {
+ Eintrag + Eintrag
</UButton> </UButton>
</div> </div>
<UDivider class="my-3"/> <USeparator class="my-3"/>
</div> </div>
<!-- ITEM LIST --> <!-- ITEM LIST -->
@@ -136,7 +138,7 @@ const renderText = (text) => {
v-if="items.length > 0" v-if="items.length > 0"
v-for="(item,index) in items.slice().reverse()" v-for="(item,index) in items.slice().reverse()"
> >
<UDivider <USeparator
class="my-3" class="my-3"
v-if="index !== 0" v-if="index !== 0"
/> />

View File

@@ -86,7 +86,7 @@ defineShortcuts({
</p> </p>
</div> </div>
<UDivider /> <USeparator />
</div> </div>
</UDashboardPanelContent> </UDashboardPanelContent>
</template> </template>

View File

@@ -34,7 +34,7 @@ defineProps({
</p> </p>
</div> </div>
<UDivider class="my-5" /> <USeparator class="my-5" />
<div class="flex-1"> <div class="flex-1">
<p class="text-lg"> <p class="text-lg">
@@ -42,7 +42,7 @@ defineProps({
</p> </p>
</div> </div>
<UDivider class="my-5" /> <USeparator class="my-5" />
<form @submit.prevent> <form @submit.prevent>
<UTextarea color="gray" required size="xl" :rows="5" :placeholder="`Reply to ${mail.from.name}`"> <UTextarea color="gray" required size="xl" :rows="5" :placeholder="`Reply to ${mail.from.name}`">

View File

@@ -90,7 +90,8 @@ watch(() => labelPrinter.connected, (connected) => {
<template> <template>
<UModal :ui="{ width: 'sm:max-w-5xl' }"> <UModal :ui="{ width: 'sm:max-w-5xl' }">
<UCard class="w-[92vw] max-w-5xl"> <template #content>
<UCard class="w-[92vw] max-w-5xl">
<template #header> <template #header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -133,6 +134,7 @@ watch(() => labelPrinter.connected, (connected) => {
</div> </div>
</template> </template>
</UCard> </UCard>
</template>
</UModal> </UModal>
</template> </template>

View File

@@ -18,21 +18,23 @@ const handleClick = async () => {
<template> <template>
<!-- Printer Button --> <!-- Printer Button -->
<UModal v-model="showPrinterInfo"> <UModal v-model:open="showPrinterInfo">
<UCard> <template #content>
<template #header> <UCard>
<div class="flex items-center justify-between"> <template #header>
<h3 class="text-lg font-semibold">Drucker Informationen</h3> <div class="flex items-center justify-between">
<UButton icon="i-heroicons-x-mark" variant="ghost" @click="showPrinterInfo = false" /> <h3 class="text-lg font-semibold">Drucker Informationen</h3>
</div> <UButton icon="i-heroicons-x-mark" variant="ghost" @click="showPrinterInfo = false" />
</template> </div>
<p>Seriennummer: {{labelPrinter.info.serial}}</p> </template>
<p>MAC: {{labelPrinter.info.mac}}</p> <p>Seriennummer: {{labelPrinter.info.serial}}</p>
<p>Modell: {{labelPrinter.info.modelId}}</p> <p>MAC: {{labelPrinter.info.mac}}</p>
<p>Charge: {{labelPrinter.info.charge}}</p> <p>Modell: {{labelPrinter.info.modelId}}</p>
<p>Hardware Version: {{labelPrinter.info.hardwareVersion}}</p> <p>Charge: {{labelPrinter.info.charge}}</p>
<p>Software Version: {{labelPrinter.info.softwareVersion}}</p> <p>Hardware Version: {{labelPrinter.info.hardwareVersion}}</p>
</UCard> <p>Software Version: {{labelPrinter.info.softwareVersion}}</p>
</UCard>
</template>
</UModal> </UModal>
<UButton <UButton
@@ -50,4 +52,4 @@ const handleClick = async () => {
<style scoped> <style scoped>
</style> </style>

View File

@@ -1,10 +1,14 @@
<script setup> <script setup>
const props = defineProps({
collapsed: {
type: Boolean,
default: false
}
})
const route = useRoute() const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
const { has } = usePermission() const { has } = usePermission()
// Lokaler State für den Taschenrechner
const showCalculator = ref(false)
const tenantExtraModules = computed(() => { const tenantExtraModules = computed(() => {
const modules = auth.activeTenantData?.extraModules const modules = auth.activeTenantData?.extraModules
return Array.isArray(modules) ? modules : [] return Array.isArray(modules) ? modules : []
@@ -15,389 +19,457 @@ 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 featureEnabled = (key) => tenantFeatures.value?.[key] !== false
const visibleItems = (items) => items.filter(item => item && !item.disabled)
const isRouteActive = (to) => {
if (!to) {
return false
}
if (to === '/') {
return route.path === '/'
}
return route.path === to || route.path.startsWith(`${to}/`)
}
const links = computed(() => { const links = computed(() => {
return [ const organisationChildren = [
has("tasks") && featureEnabled("tasks") ? {
label: "Aufgaben",
to: "/tasks",
icon: "i-heroicons-rectangle-stack"
} : null,
featureEnabled("planningBoard") ? {
label: "Plantafel",
to: "/organisation/plantafel",
icon: "i-heroicons-calendar-days"
} : null,
featureEnabled("wiki") ? {
label: "Wiki",
to: "/wiki",
icon: "i-heroicons-book-open"
} : null,
]
const documentChildren = [
featureEnabled("files") ? {
label: "Dateien",
to: "/files",
icon: "i-heroicons-document"
} : null,
featureEnabled("createdletters") ? {
label: "Anschreiben",
to: "/createdletters",
icon: "i-heroicons-document",
disabled: true
} : null,
featureEnabled("documentboxes") ? {
label: "Boxen",
to: "/standardEntity/documentboxes",
icon: "i-heroicons-archive-box",
disabled: true
} : null,
]
const communicationChildren = [
featureEnabled("helpdesk") ? {
label: "Helpdesk",
to: "/helpdesk",
icon: "i-heroicons-chat-bubble-left-right",
disabled: true
} : null,
featureEnabled("email") ? {
label: "E-Mail",
to: "/email/new",
icon: "i-heroicons-envelope",
disabled: true
} : null,
]
const contactsChildren = [
showMembersNav.value && featureEnabled("members") ? {
label: "Mitglieder",
to: "/standardEntity/members",
icon: "i-heroicons-user-group"
} : null,
has("customers") && featureEnabled("customers") ? {
label: "Kunden",
to: "/standardEntity/customers",
icon: "i-heroicons-user-group"
} : null,
has("vendors") && featureEnabled("vendors") ? {
label: "Lieferanten",
to: "/standardEntity/vendors",
icon: "i-heroicons-truck"
} : null,
has("contacts") && featureEnabled("contactsList") ? {
label: "Ansprechpartner",
to: "/standardEntity/contacts",
icon: "i-heroicons-user-group"
} : null,
]
const staffChildren = [
featureEnabled("staffTime") ? {
label: "Zeiten",
to: "/staff/time",
icon: "i-heroicons-clock",
} : null,
]
const accountingChildren = [
featureEnabled("createDocument") ? {
label: "Ausgangsbelege",
to: "/createDocument",
icon: "i-heroicons-document-text"
} : null,
featureEnabled("serialInvoice") ? {
label: "Serienvorlagen",
to: "/createDocument/serialInvoice",
icon: "i-heroicons-document-text"
} : null,
featureEnabled("incomingInvoices") ? {
label: "Eingangsbelege",
to: "/incomingInvoices",
icon: "i-heroicons-document-text",
} : null,
(featureEnabled("createDocument") || featureEnabled("incomingInvoices")) ? {
label: "USt-Auswertung",
to: "/accounting/tax",
icon: "i-heroicons-calculator",
} : null,
featureEnabled("costcentres") ? {
label: "Kostenstellen",
to: "/standardEntity/costcentres",
icon: "i-heroicons-document-currency-euro"
} : null,
featureEnabled("accounts") ? {
label: "Buchungskonten",
to: "/accounts",
icon: "i-heroicons-document-text",
} : null,
featureEnabled("ownaccounts") ? {
label: "zusätzliche Buchungskonten",
to: "/standardEntity/ownaccounts",
icon: "i-heroicons-document-text"
} : null,
featureEnabled("banking") ? {
label: "Bank",
to: "/banking",
icon: "i-heroicons-document-text",
} : null,
]
const inventoryChildren = [
has("spaces") && featureEnabled("spaces") ? {
label: "Lagerplätze",
to: "/standardEntity/spaces",
icon: "i-heroicons-square-3-stack-3d"
} : null,
has("inventoryitems") && featureEnabled("customerspaces") ? {
label: "Kundenlagerplätze",
to: "/standardEntity/customerspaces",
icon: "i-heroicons-squares-plus"
} : null,
has("inventoryitems") && featureEnabled("customerinventoryitems") ? {
label: "Kundeninventar",
to: "/standardEntity/customerinventoryitems",
icon: "i-heroicons-qr-code"
} : null,
has("inventoryitems") && featureEnabled("inventoryitems") ? {
label: "Inventar",
to: "/standardEntity/inventoryitems",
icon: "i-heroicons-puzzle-piece"
} : null,
has("inventoryitems") && featureEnabled("inventoryitemgroups") ? {
label: "Inventargruppen",
to: "/standardEntity/inventoryitemgroups",
icon: "i-heroicons-puzzle-piece"
} : null,
]
const masterDataChildren = [
has("products") && featureEnabled("products") ? {
label: "Artikel",
to: "/standardEntity/products",
icon: "i-heroicons-puzzle-piece"
} : null,
has("productcategories") && featureEnabled("productcategories") ? {
label: "Artikelkategorien",
to: "/standardEntity/productcategories",
icon: "i-heroicons-puzzle-piece"
} : null,
has("services") && featureEnabled("services") ? {
label: "Leistungen",
to: "/standardEntity/services",
icon: "i-heroicons-wrench-screwdriver"
} : null,
has("servicecategories") && featureEnabled("servicecategories") ? {
label: "Leistungskategorien",
to: "/standardEntity/servicecategories",
icon: "i-heroicons-wrench-screwdriver"
} : null,
showMemberRelationsNav.value && featureEnabled("memberrelations") ? {
label: "Mitgliedsverhältnisse",
to: "/standardEntity/memberrelations",
icon: "i-heroicons-identification"
} : null,
featureEnabled("staffProfiles") ? {
label: "Mitarbeiter",
to: "/staff/profiles",
icon: "i-heroicons-user-group"
} : null,
featureEnabled("hourrates") ? {
label: "Stundensätze",
to: "/standardEntity/hourrates",
icon: "i-heroicons-user-group"
} : null,
featureEnabled("projecttypes") ? {
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("contracttypes") ? {
label: "Vertragstypen",
to: "/standardEntity/contracttypes",
icon: "i-heroicons-document-duplicate",
} : null,
has("vehicles") && featureEnabled("vehicles") ? {
label: "Fahrzeuge",
to: "/standardEntity/vehicles",
icon: "i-heroicons-truck"
} : null,
]
const settingsChildren = [
featureEnabled("settingsNumberRanges") ? {
label: "Nummernkreise",
to: "/settings/numberRanges",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("settingsEmailAccounts") ? {
label: "E-Mail Konten",
to: "/settings/emailaccounts",
icon: "i-heroicons-envelope",
} : null,
featureEnabled("settingsBanking") ? {
label: "Bankkonten",
to: "/settings/banking",
icon: "i-heroicons-currency-euro",
} : null,
featureEnabled("settingsTexttemplates") ? {
label: "Textvorlagen",
to: "/settings/texttemplates",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("settingsTenant") ? {
label: "Firmeneinstellungen",
to: "/settings/tenant",
icon: "i-heroicons-building-office",
} : null,
isAdmin.value ? {
label: "Administration",
to: "/settings/admin",
icon: "i-heroicons-shield-check",
} : null,
featureEnabled("export") ? {
label: "Export",
to: "/export",
icon: "i-heroicons-clipboard-document-list"
} : null,
]
const visibleOrganisationChildren = visibleItems(organisationChildren)
const visibleDocumentChildren = visibleItems(documentChildren)
const visibleCommunicationChildren = visibleItems(communicationChildren)
const visibleContactsChildren = visibleItems(contactsChildren)
const visibleStaffChildren = visibleItems(staffChildren)
const visibleAccountingChildren = visibleItems(accountingChildren)
const visibleInventoryChildren = visibleItems(inventoryChildren)
const visibleMasterDataChildren = visibleItems(masterDataChildren)
const visibleSettingsChildren = visibleItems(settingsChildren)
return visibleItems([
...(auth.profile?.pinned_on_navigation || []).map(pin => { ...(auth.profile?.pinned_on_navigation || []).map(pin => {
if (pin.type === "external") { if (pin.type === "external") {
return { return {
label: pin.label, label: pin.label,
to: pin.link, to: pin.link,
icon: pin.icon, icon: pin.icon,
target: "_blank", target: "_blank"
pinned: true
} }
} else if (pin.type === "standardEntity") { } else if (pin.type === "standardEntity") {
return { return {
label: pin.label, label: pin.label,
to: pin.datatype === "tasks" ? `/tasks/show/${pin.id}` : `/standardEntity/${pin.datatype}/show/${pin.id}`, to: pin.datatype === "tasks" ? `/tasks/show/${pin.id}` : `/standardEntity/${pin.datatype}/show/${pin.id}`,
icon: pin.icon, icon: pin.icon
pinned: true
} }
} }
}), }),
{ featureEnabled("dashboard") ? {
id: 'dashboard', id: 'dashboard',
label: "Dashboard", label: "Dashboard",
to: "/", to: "/",
icon: "i-heroicons-home" icon: "i-heroicons-home"
}, } : null,
{ featureEnabled("historyitems") ? {
id: 'historyitems', id: 'historyitems',
label: "Logbuch", label: "Logbuch",
to: "/historyitems", to: "/historyitems",
icon: "i-heroicons-book-open" icon: "i-heroicons-book-open"
}, } : null,
{ ...(has("projects") && featureEnabled("projects")) ? [{
label: "Organisation",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: [
...has("tasks") ? [{
label: "Aufgaben",
to: "/tasks",
icon: "i-heroicons-rectangle-stack"
}] : [],
...true ? [{
label: "Wiki",
to: "/wiki",
icon: "i-heroicons-book-open"
}] : [],
]
},
{
label: "Dokumente",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: [
{
label: "Dateien",
to: "/files",
icon: "i-heroicons-document"
}, {
label: "Anschreiben",
to: "/createdletters",
icon: "i-heroicons-document",
disabled: true
}, {
label: "Boxen",
to: "/standardEntity/documentboxes",
icon: "i-heroicons-archive-box",
disabled: true
},
]
},
{
label: "Kommunikation",
icon: "i-heroicons-megaphone",
defaultOpen: false,
children: [
{
label: "Helpdesk",
to: "/helpdesk",
icon: "i-heroicons-chat-bubble-left-right",
disabled: true
},
{
label: "E-Mail",
to: "/email/new",
icon: "i-heroicons-envelope",
disabled: true
}
]
},
...(has("customers") || has("vendors") || has("contacts") || showMembersNav.value) ? [{
label: "Kontakte",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: [
...showMembersNav.value ? [{
label: "Mitglieder",
to: "/standardEntity/members",
icon: "i-heroicons-user-group"
}] : [],
...has("customers") ? [{
label: "Kunden",
to: "/standardEntity/customers",
icon: "i-heroicons-user-group"
}] : [],
...has("vendors") ? [{
label: "Lieferanten",
to: "/standardEntity/vendors",
icon: "i-heroicons-truck"
}] : [],
...has("contacts") ? [{
label: "Ansprechpartner",
to: "/standardEntity/contacts",
icon: "i-heroicons-user-group"
}] : [],
]
}] : [],
{
label: "Mitarbeiter",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: [
...true ? [{
label: "Zeiten",
to: "/staff/time",
icon: "i-heroicons-clock",
}] : [],
]
},
...[{
label: "Buchhaltung",
defaultOpen: false,
icon: "i-heroicons-chart-bar-square",
children: [
{
label: "Ausgangsbelege",
to: "/createDocument",
icon: "i-heroicons-document-text"
}, {
label: "Serienvorlagen",
to: "/createDocument/serialInvoice",
icon: "i-heroicons-document-text"
}, {
label: "Eingangsbelege",
to: "/incomingInvoices",
icon: "i-heroicons-document-text",
}, {
label: "Kostenstellen",
to: "/standardEntity/costcentres",
icon: "i-heroicons-document-currency-euro"
}, {
label: "Buchungskonten",
to: "/accounts",
icon: "i-heroicons-document-text",
}, {
label: "zusätzliche Buchungskonten",
to: "/standardEntity/ownaccounts",
icon: "i-heroicons-document-text"
},
{
label: "Bank",
to: "/banking",
icon: "i-heroicons-document-text",
},
]
}],
...has("inventory") ? [{
label: "Lager",
icon: "i-heroicons-puzzle-piece",
defaultOpen: false,
children: [
...has("spaces") ? [{
label: "Lagerplätze",
to: "/standardEntity/spaces",
icon: "i-heroicons-square-3-stack-3d"
}] : [],
...has("inventoryitems") ? [{
label: "Kundenlagerplätze",
to: "/standardEntity/customerspaces",
icon: "i-heroicons-squares-plus"
}] : [],
...has("inventoryitems") ? [{
label: "Kundeninventar",
to: "/standardEntity/customerinventoryitems",
icon: "i-heroicons-qr-code"
}] : [],
...has("inventoryitems") ? [{
label: "Inventar",
to: "/standardEntity/inventoryitems",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("inventoryitems") ? [{
label: "Inventargruppen",
to: "/standardEntity/inventoryitemgroups",
icon: "i-heroicons-puzzle-piece"
}] : [],
]
}] : [],
{
label: "Stammdaten",
defaultOpen: false,
icon: "i-heroicons-clipboard-document",
children: [
...has("products") ? [{
label: "Artikel",
to: "/standardEntity/products",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("productcategories") ? [{
label: "Artikelkategorien",
to: "/standardEntity/productcategories",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("services") ? [{
label: "Leistungen",
to: "/standardEntity/services",
icon: "i-heroicons-wrench-screwdriver"
}] : [],
...has("servicecategories") ? [{
label: "Leistungskategorien",
to: "/standardEntity/servicecategories",
icon: "i-heroicons-wrench-screwdriver"
}] : [],
...showMemberRelationsNav.value ? [{
label: "Mitgliedsverhältnisse",
to: "/standardEntity/memberrelations",
icon: "i-heroicons-identification"
}] : [],
{
label: "Mitarbeiter",
to: "/staff/profiles",
icon: "i-heroicons-user-group"
},
{
label: "Stundensätze",
to: "/standardEntity/hourrates",
icon: "i-heroicons-user-group"
},
{
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list",
},
{
label: "Vertragstypen",
to: "/standardEntity/contracttypes",
icon: "i-heroicons-document-duplicate",
},
...has("vehicles") ? [{
label: "Fahrzeuge",
to: "/standardEntity/vehicles",
icon: "i-heroicons-truck"
}] : [],
]
},
...has("projects") ? [{
label: "Projekte", label: "Projekte",
to: "/standardEntity/projects", to: "/standardEntity/projects",
icon: "i-heroicons-clipboard-document-check" icon: "i-heroicons-clipboard-document-check"
}] : [], }] : [],
...has("contracts") ? [{ ...(has("contracts") && featureEnabled("contracts")) ? [{
label: "Verträge", label: "Verträge",
to: "/standardEntity/contracts", to: "/standardEntity/contracts",
icon: "i-heroicons-clipboard-document" icon: "i-heroicons-clipboard-document"
}] : [], }] : [],
...has("plants") ? [{ ...(has("plants") && featureEnabled("plants")) ? [{
label: "Objekte", label: "Objekte",
to: "/standardEntity/plants", to: "/standardEntity/plants",
icon: "i-heroicons-clipboard-document" icon: "i-heroicons-clipboard-document"
}] : [], }] : [],
{ ...(visibleOrganisationChildren.length > 0 ? [{
label: "Organisation",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: visibleOrganisationChildren
}] : []),
...(visibleDocumentChildren.length > 0 ? [{
label: "Dokumente",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: visibleDocumentChildren
}] : []),
...(visibleCommunicationChildren.length > 0 ? [{
label: "Kommunikation",
icon: "i-heroicons-megaphone",
defaultOpen: false,
children: visibleCommunicationChildren
}] : []),
...(visibleContactsChildren.length > 0 ? [{
label: "Kontakte",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: visibleContactsChildren
}] : []),
...(visibleStaffChildren.length > 0 ? [{
label: "Mitarbeiter",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: visibleStaffChildren
}] : []),
...(visibleAccountingChildren.length > 0 ? [{
label: "Buchhaltung",
defaultOpen: false,
icon: "i-heroicons-chart-bar-square",
children: visibleAccountingChildren
}] : []),
...(visibleInventoryChildren.length > 0 ? [{
label: "Lager",
icon: "i-heroicons-puzzle-piece",
defaultOpen: false,
children: visibleInventoryChildren
}] : []),
...(visibleMasterDataChildren.length > 0 ? [{
label: "Stammdaten",
defaultOpen: false,
icon: "i-heroicons-clipboard-document",
children: visibleMasterDataChildren
}] : []),
...(visibleSettingsChildren.length > 0 ? [{
label: "Einstellungen", label: "Einstellungen",
defaultOpen: false, defaultOpen: false,
icon: "i-heroicons-cog-8-tooth", icon: "i-heroicons-cog-8-tooth",
children: [ children: visibleSettingsChildren
{ }] : []),
label: "Nummernkreise", ])
to: "/settings/numberRanges",
icon: "i-heroicons-clipboard-document-list",
}, {
label: "E-Mail Konten",
to: "/settings/emailaccounts",
icon: "i-heroicons-envelope",
}, {
label: "Bankkonten",
to: "/settings/banking",
icon: "i-heroicons-currency-euro",
}, {
label: "Textvorlagen",
to: "/settings/texttemplates",
icon: "i-heroicons-clipboard-document-list",
}, {
label: "Firmeneinstellungen",
to: "/settings/tenant",
icon: "i-heroicons-building-office",
}, {
label: "Export",
to: "/export",
icon: "i-heroicons-clipboard-document-list"
}
]
},
]
}) })
const accordionItems = computed(() => const navItems = computed(() =>
links.value.filter(item => Array.isArray(item.children) && item.children.length > 0) links.value
) .filter(Boolean)
.map((item, index) => {
const children = Array.isArray(item.children)
? item.children.map((child, childIndex) => ({
...child,
value: child.id || child.label || `${index}-${childIndex}`,
active: isRouteActive(child.to)
}))
: undefined
const buttonItems = computed(() => const active = item.active || isRouteActive(item.to) || Boolean(children?.some(child => child.active))
links.value.filter(item => !item.children || item.children.length === 0)
return {
...item,
children,
value: item.id || item.label || String(index),
defaultOpen: item.defaultOpen || active,
active,
tooltip: true,
popover: true,
trailingIcon: children?.length ? undefined : ''
}
})
) )
</script> </script>
<template> <template>
<div class="flex flex-col gap-1"> <UNavigationMenu
<UButton :items="navItems"
v-for="item in buttonItems" orientation="vertical"
:key="item.label" :collapsed="props.collapsed"
variant="ghost" tooltip
:color="(item.to && route.path === item.to) ? 'primary' : (item.pinned ? 'amber' : 'gray')" popover
:icon="item.pinned ? 'i-heroicons-star' : item.icon" color="neutral"
class="w-full" highlight
:to="item.to" highlight-color="primary"
:target="item.target" class="w-full"
@click="item.click ? item.click() : null" :ui="{
> root: 'w-full',
<UIcon list: 'space-y-1',
v-if="item.pinned" link: 'min-w-0 rounded-lg px-2.5 py-2',
:name="item.icon" linkLeadingIcon: 'size-5 shrink-0',
class="w-5 h-5 me-2" linkLabel: 'truncate',
/> childList: 'ms-0 space-y-1 border-l border-default ps-3',
{{ item.label }} childLink: 'min-w-0 rounded-lg px-2 py-1.5',
</UButton> childLinkLabel: 'truncate'
</div> }"
<UDivider class="my-2"/>
<UAccordion
:items="accordionItems"
:multiple="false"
class="mt-2"
> >
<template #default="{ item, open }"> <template #item-leading="{ item, active }">
<UButton <UIcon
variant="ghost" v-if="item.icon"
:color="(item.children?.some(c => route.path.includes(c.to))) ? 'primary' : 'gray'" :name="item.icon"
:icon="item.icon" class="size-5 shrink-0"
class="w-full" :class="active ? 'text-primary' : 'text-muted'"
/>
</template>
<template #item-trailing="{ item, active }">
<UBadge
v-if="item.badge && !props.collapsed"
color="primary"
variant="soft"
size="xs"
> >
{{ item.label }} {{ item.badge }}
<template #trailing> </UBadge>
<UIcon <UIcon
name="i-heroicons-chevron-right-20-solid" v-else-if="item.children?.length"
class="w-5 h-5 ms-auto transform transition-transform duration-200" name="i-heroicons-chevron-down-20-solid"
:class="[open && 'rotate-90']" class="size-4 shrink-0 transition-transform"
/> :class="active ? 'text-primary' : 'text-muted'"
</template> />
</UButton>
</template> </template>
</UNavigationMenu>
<template #item="{ item }">
<div class="flex flex-col">
<UButton
v-for="child in item.children"
:key="child.label"
variant="ghost"
:color="child.to === route.path ? 'primary' : 'gray'"
:icon="child.icon"
class="ml-4"
:to="child.to"
:target="child.target"
:disabled="child.disabled"
@click="child.click ? child.click() : null"
>
{{ child.label }}
</UButton>
</div>
</template>
</UAccordion>
<Calculator v-if="showCalculator" v-model="showCalculator"/>
</template> </template>

View File

@@ -36,28 +36,30 @@ const setNotificationAsRead = async (notification) => {
</script> </script>
<template> <template>
<UDashboardSlideover v-model="isNotificationsSlideoverOpen" title="Benachrichtigungen"> <USlideover v-model:open="isNotificationsSlideoverOpen" title="Benachrichtigungen" side="right">
<NuxtLink <template #body>
v-for="notification in notifications" <NuxtLink
:key="notification.id" v-for="notification in notifications"
:to="notification.link" :key="notification.id"
class="p-3 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer flex items-center gap-3 relative" :to="notification.link"
@click="setNotificationAsRead(notification)" class="p-3 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer flex items-center gap-3 relative"
> @click="setNotificationAsRead(notification)"
<UChip color="primary" :show="!notification.read && !notification.readAt" inset> >
<UAvatar alt="FEDEO" size="md" /> <UChip color="primary" :show="!notification.read && !notification.readAt" inset>
</UChip> <UAvatar alt="FEDEO" size="md" />
</UChip>
<div class="text-sm flex-1"> <div class="text-sm flex-1">
<p class="flex items-center justify-between"> <p class="flex items-center justify-between">
<span class="text-gray-900 dark:text-white font-medium">{{notification.title}}</span> <span class="text-gray-900 dark:text-white font-medium">{{notification.title}}</span>
<time :datetime="notification.date || notification.createdAt || notification.created_at" class="text-gray-500 dark:text-gray-400 text-xs" v-text="formatTimeAgo(new Date(notification.createdAt || notification.created_at))" /> <time :datetime="notification.date || notification.createdAt || notification.created_at" class="text-gray-500 dark:text-gray-400 text-xs" v-text="formatTimeAgo(new Date(notification.createdAt || notification.created_at))" />
</p> </p>
<p class="text-gray-500 dark:text-gray-400"> <p class="text-gray-500 dark:text-gray-400">
{{ notification.message }} {{ notification.message }}
</p> </p>
</div> </div>
</NuxtLink> </NuxtLink>
</UDashboardSlideover> </template>
</USlideover>
</template> </template>

View File

@@ -102,6 +102,10 @@ const currentUnit = computed(() => {
const selectedService = data.value.services?.find(s => s.id === form.value.service) const selectedService = data.value.services?.find(s => s.id === form.value.service)
return selectedService?.unitSymbol || data.value?.units?.[0]?.symbol || 'h' return selectedService?.unitSymbol || data.value?.units?.[0]?.symbol || 'h'
}) })
const setDeliveryDateToToday = () => {
form.value.deliveryDate = dayjs().format('YYYY-MM-DD')
}
</script> </script>
<template> <template>
@@ -115,20 +119,24 @@ const currentUnit = computed(() => {
<div class="space-y-5"> <div class="space-y-5">
<UFormGroup <UFormField
label="Datum der Ausführung" label="Datum der Ausführung"
:error="errors.deliveryDate" :error="errors.deliveryDate"
required required
> >
<UInput <div class="flex items-center gap-2">
v-model="form.deliveryDate" <UInput
type="date" v-model="form.deliveryDate"
size="lg" type="date"
icon="i-heroicons-calendar-days" size="lg"
/> icon="i-heroicons-calendar-days"
</UFormGroup> class="flex-1"
/>
<UButton color="gray" variant="soft" size="lg" label="Heute" @click="setDeliveryDateToToday" />
</div>
</UFormField>
<UFormGroup <UFormField
v-if="!context?.meta?.defaultProfileId && data?.profiles?.length > 0" v-if="!context?.meta?.defaultProfileId && data?.profiles?.length > 0"
label="Mitarbeiter" label="Mitarbeiter"
:error="errors.profile" :error="errors.profile"
@@ -136,16 +144,16 @@ const currentUnit = computed(() => {
> >
<USelectMenu <USelectMenu
v-model="form.profile" v-model="form.profile"
:options="data.profiles" :items="data.profiles"
option-attribute="fullName" label-key="fullName"
value-attribute="id" value-key="id"
placeholder="Name auswählen..." placeholder="Name auswählen..."
searchable searchable
size="lg" size="lg"
/> />
</UFormGroup> </UFormField>
<UFormGroup <UFormField
v-if="data?.projects?.length > 0" v-if="data?.projects?.length > 0"
:label="config.ui?.labels?.project || 'Projekt / Auftrag'" :label="config.ui?.labels?.project || 'Projekt / Auftrag'"
:error="errors.project" :error="errors.project"
@@ -153,16 +161,16 @@ const currentUnit = computed(() => {
> >
<USelectMenu <USelectMenu
v-model="form.project" v-model="form.project"
:options="data.projects" :items="data.projects"
option-attribute="name" label-key="name"
value-attribute="id" value-key="id"
placeholder="Wählen..." placeholder="Wählen..."
searchable searchable
size="lg" size="lg"
/> />
</UFormGroup> </UFormField>
<UFormGroup <UFormField
v-if="data?.services?.length > 0" v-if="data?.services?.length > 0"
:label="config?.ui?.labels?.service || 'Tätigkeit'" :label="config?.ui?.labels?.service || 'Tätigkeit'"
:error="errors.service" :error="errors.service"
@@ -170,16 +178,16 @@ const currentUnit = computed(() => {
> >
<USelectMenu <USelectMenu
v-model="form.service" v-model="form.service"
:options="data.services" :items="data.services"
option-attribute="name" label-key="name"
value-attribute="id" value-key="id"
placeholder="Wählen..." placeholder="Wählen..."
searchable searchable
size="lg" size="lg"
/> />
</UFormGroup> </UFormField>
<UFormGroup <UFormField
label="Menge / Dauer" label="Menge / Dauer"
:error="errors.quantity" :error="errors.quantity"
required required
@@ -195,9 +203,9 @@ const currentUnit = computed(() => {
<span class="text-gray-500 text-sm pr-2">{{ currentUnit }}</span> <span class="text-gray-500 text-sm pr-2">{{ currentUnit }}</span>
</template> </template>
</UInput> </UInput>
</UFormGroup> </UFormField>
<UFormGroup <UFormField
v-if="config?.features?.agriculture?.showDieselUsage" v-if="config?.features?.agriculture?.showDieselUsage"
label="Dieselverbrauch" label="Dieselverbrauch"
:error="errors.diesel" :error="errors.diesel"
@@ -208,11 +216,11 @@ const currentUnit = computed(() => {
<span class="text-gray-500 text-xs">Liter</span> <span class="text-gray-500 text-xs">Liter</span>
</template> </template>
</UInput> </UInput>
</UFormGroup> </UFormField>
<UFormGroup :label="config?.ui?.labels?.description || 'Notiz / Vorkommnisse'"> <UFormField :label="config?.ui?.labels?.description || 'Notiz / Vorkommnisse'">
<UTextarea v-model="form.description" :rows="3" placeholder="Optional..." /> <UTextarea v-model="form.description" :rows="3" placeholder="Optional..." />
</UFormGroup> </UFormField>
</div> </div>
@@ -226,4 +234,4 @@ const currentUnit = computed(() => {
/> />
</template> </template>
</UCard> </UCard>
</template> </template>

View File

@@ -16,28 +16,30 @@ const onLogout = async () => {
</script> </script>
<template> <template>
<UModal v-model="auth.sessionWarningVisible" prevent-close> <UModal v-model:open="auth.sessionWarningVisible" prevent-close>
<UCard> <template #content>
<template #header> <UCard>
<h3 class="text-lg font-semibold">Sitzung läuft bald ab</h3> <template #header>
</template> <h3 class="text-lg font-semibold">Sitzung läuft bald ab</h3>
</template>
<p class="text-sm text-gray-600 dark:text-gray-300"> <p class="text-sm text-gray-600 dark:text-gray-300">
Deine Sitzung endet in Deine Sitzung endet in
<span class="font-semibold">{{ remainingTimeLabel }}</span>. <span class="font-semibold">{{ remainingTimeLabel }}</span>.
Bitte bestätige, um eingeloggt zu bleiben. Bitte bestätige, um eingeloggt zu bleiben.
</p> </p>
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<UButton variant="outline" color="gray" @click="onLogout"> <UButton variant="outline" color="gray" @click="onLogout">
Abmelden Abmelden
</UButton> </UButton>
<UButton color="primary" @click="onRefresh"> <UButton color="primary" @click="onRefresh">
Eingeloggt bleiben Eingeloggt bleiben
</UButton> </UButton>
</div> </div>
</template> </template>
</UCard> </UCard>
</template>
</UModal> </UModal>
</template> </template>

View File

@@ -50,6 +50,9 @@ const isOpen = computed({
const toDateStr = (dateStr: string) => dateStr ? $dayjs(dateStr).format('YYYY-MM-DD') : '' const toDateStr = (dateStr: string) => dateStr ? $dayjs(dateStr).format('YYYY-MM-DD') : ''
const toTimeStr = (dateStr: string) => dateStr ? $dayjs(dateStr).format('HH:mm') : '' const toTimeStr = (dateStr: string) => dateStr ? $dayjs(dateStr).format('HH:mm') : ''
const setDateFieldToToday = (field: 'start_date' | 'end_date') => {
state[field] = $dayjs().format('YYYY-MM-DD')
}
watch(() => props.entry, (newVal) => { watch(() => props.entry, (newVal) => {
if (newVal) { if (newVal) {
@@ -113,7 +116,7 @@ async function onSubmit(event: FormSubmitEvent<any>) {
emit('saved') emit('saved')
isOpen.value = false isOpen.value = false
} catch (error: any) { } catch (error: any) {
toast.add({ title: 'Fehler', description: error.message, color: 'red' }) toast.add({ title: 'Fehler', description: error.message, color: 'error' })
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -121,51 +124,63 @@ async function onSubmit(event: FormSubmitEvent<any>) {
</script> </script>
<template> <template>
<UModal v-model="isOpen"> <UModal v-model:open="isOpen">
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }"> <template #content>
<template #header> <UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
<div class="flex items-center justify-between"> <template #header>
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white"> <div class="flex items-center justify-between">
{{ entry ? 'Eintrag bearbeiten' : 'Neue Zeit erfassen' }} <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
</h3> {{ entry ? 'Eintrag bearbeiten' : 'Neue Zeit erfassen' }}
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" /> </h3>
</div> <UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" />
</template> </div>
</template>
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit"> <UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<UFormField label="Typ" name="type">
<USelectMenu
v-model="state.type"
:items="types"
value-key="value"
label-key="label"
/>
</UFormField>
<UFormGroup label="Typ" name="type"> <div class="grid grid-cols-2 gap-4">
<USelectMenu v-model="state.type" :options="types" value-attribute="value" option-attribute="label" /> <UFormField label="Start Datum" name="start_date">
</UFormGroup> <div class="flex items-center gap-2">
<UInput type="date" v-model="state.start_date" class="flex-1" />
<UButton color="gray" variant="soft" label="Heute" @click="setDateFieldToToday('start_date')" />
</div>
</UFormField>
<UFormField label="Start Zeit" name="start_time">
<UInput type="time" v-model="state.start_time" />
</UFormField>
</div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<UFormGroup label="Start Datum" name="start_date"> <UFormField label="Ende Datum" name="end_date">
<UInput type="date" v-model="state.start_date" /> <div class="flex items-center gap-2">
</UFormGroup> <UInput type="date" v-model="state.end_date" class="flex-1" />
<UFormGroup label="Start Zeit" name="start_time"> <UButton color="gray" variant="soft" label="Heute" @click="setDateFieldToToday('end_date')" />
<UInput type="time" v-model="state.start_time" /> </div>
</UFormGroup> </UFormField>
</div> <UFormField label="Ende Zeit" name="end_time">
<UInput type="time" v-model="state.end_time" />
</UFormField>
</div>
<p class="text-xs text-gray-500 -mt-2">Leer lassen, wenn die Zeit noch läuft.</p>
<div class="grid grid-cols-2 gap-4"> <UFormField label="Beschreibung / Notiz" name="description">
<UFormGroup label="Ende Datum" name="end_date"> <UTextarea v-model="state.description" placeholder="Was wurde gemacht?" />
<UInput type="date" v-model="state.end_date" /> </UFormField>
</UFormGroup>
<UFormGroup label="Ende Zeit" name="end_time">
<UInput type="time" v-model="state.end_time" />
</UFormGroup>
</div>
<p class="text-xs text-gray-500 -mt-2">Leer lassen, wenn die Zeit noch läuft.</p>
<UFormGroup label="Beschreibung / Notiz" name="description"> <div class="flex justify-end gap-2 pt-4">
<UTextarea v-model="state.description" placeholder="Was wurde gemacht?" /> <UButton label="Abbrechen" color="gray" variant="ghost" @click="isOpen = false" />
</UFormGroup> <UButton type="submit" label="Speichern" color="primary" :loading="loading" />
</div>
<div class="flex justify-end gap-2 pt-4"> </UForm>
<UButton label="Abbrechen" color="gray" variant="ghost" @click="isOpen = false" /> </UCard>
<UButton type="submit" label="Speichern" color="primary" :loading="loading" /> </template>
</div>
</UForm>
</UCard>
</UModal> </UModal>
</template> </template>

View File

@@ -61,33 +61,35 @@ setupPage()
<template> <template>
<UModal :fullscreen="props.mode === 'show'"> <UModal :fullscreen="props.mode === 'show'">
<EntityShow <template #content>
v-if="loaded && props.mode === 'show'" <EntityShow
:type="props.type" v-if="loaded && props.mode === 'show'"
:item="item" :type="props.type"
@updateNeeded="setupPage" :item="item"
:key="item" @updateNeeded="setupPage"
:in-modal="true" :key="item"
/> :in-modal="true"
<EntityEdit />
v-else-if="loaded && (props.mode === 'edit' || props.mode === 'create')" <EntityEdit
:type="props.type" v-else-if="loaded && (props.mode === 'edit' || props.mode === 'create')"
:item="item" :type="props.type"
:inModal="true" :item="item"
@return-data="(data) => emit('return-data',data)" :inModal="true"
:createQuery="props.createQuery" @return-data="(data) => emit('return-data',data)"
:mode="props.mode" :createQuery="props.createQuery"
/> :mode="props.mode"
<!-- <EntityList />
v-else-if="loaded && props.mode === 'list'" <!-- <EntityList
:type="props.type" v-else-if="loaded && props.mode === 'list'"
:items="items" :type="props.type"
/>--> :items="items"
<UProgress />-->
v-else <UProgress
animation="carousel" v-else
class="p-5 mt-10" animation="carousel"
/> class="p-5 mt-10"
/>
</template>
</UModal> </UModal>
</template> </template>

View File

@@ -1,27 +1,59 @@
<script setup> <script setup>
const auth = useAuthStore() const auth = useAuthStore()
const selectedTenant = ref(auth.user.tenant_id) const activeTenantName = computed(() => {
return auth.activeTenantData?.name || auth.tenants?.find((tenant) => tenant.id === auth.activeTenant)?.name || 'Mandant waehlen'
})
const tenantInitials = computed(() => {
return activeTenantName.value
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() || '')
.join('') || 'M'
})
const tenantItems = computed(() => [
auth.tenants.map((tenant) => ({
label: tenant.name,
icon: tenant.id === auth.activeTenant ? 'i-heroicons-check' : undefined,
disabled: Boolean(tenant.locked),
onSelect: async (event) => {
if (tenant.locked || tenant.id === auth.activeTenant) {
event?.preventDefault?.()
return
}
await auth.switchTenant(tenant.id)
}
}))
])
</script> </script>
<template> <template>
<USelectMenu <USelectMenu
:options="auth.tenants" :items="tenantItems"
value-attribute="id" :content="{ align: 'start', side: 'bottom', sideOffset: 6 }"
class="w-40" :ui="{ content: 'w-[var(--reka-dropdown-menu-trigger-width)] max-w-[var(--reka-dropdown-menu-trigger-width)]' }"
@change="auth.switchTenant(selectedTenant)" class="block w-40"
v-model="selectedTenant" :avatar="{
alt: activeTenantName,
text: tenantInitials,
loading: 'lazy'
}"
> >
<UButton color="gray" variant="ghost" :class="[open && 'bg-gray-50 dark:bg-gray-800']" class="w-full"> <template #default="{ open }">
<UAvatar :alt="auth.activeTenantData?.name" size="md" /> <UButton
color="gray"
<span class="truncate text-gray-900 dark:text-white font-semibold">{{auth.tenants.find(i => auth.activeTenant === i.id).name}}</span> variant="ghost"
</UButton> class="w-full min-w-0 max-w-full justify-start gap-2 rounded-lg px-2.5 py-2 text-left"
:class="[open && 'bg-gray-100 dark:bg-gray-800']"
<template #option="{option}"> >
{{option.name}} <span class="min-w-0 flex-1 truncate font-medium text-gray-900 dark:text-white">
{{ activeTenantName }}
</span>
</UButton>
</template> </template>
</USelectMenu> </USelectMenu>
</template> </template>

View File

@@ -11,7 +11,7 @@
<slot name="right"/> <slot name="right"/>
</InputGroup> </InputGroup>
</div> </div>
<UDivider class="my-3"/> <USeparator class="my-3"/>
</template> </template>
<style scoped> <style scoped>

View File

@@ -0,0 +1,27 @@
<script setup>
defineOptions({
inheritAttrs: false
})
const props = defineProps({
as: {
type: [String, Object],
default: 'div'
}
})
const attrs = useAttrs()
</script>
<template>
<component
:is="props.as"
v-bind="attrs"
:class="[
'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6 sm:py-5',
attrs.class
]"
>
<slot />
</component>
</template>

View File

@@ -1,57 +1,44 @@
<script setup> <script setup>
const { isHelpSlideoverOpen } = useDashboard()
const { isDashboardSearchModalOpen } = useUIState()
const { metaSymbol } = useShortcuts()
const auth = useAuthStore() const auth = useAuthStore()
const items = computed(() => [ const userItems = computed(() => [[
[{ {
slot: 'account', label: 'Passwort aendern',
label: '',
disabled: true
}], [/*{
label: 'Mein Profil',
icon: 'i-heroicons-user',
to: `/profiles/show/${profileStore.activeProfile.id}`
},*/{
label: 'Passwort ändern',
icon: 'i-heroicons-shield-check', icon: 'i-heroicons-shield-check',
to: `/password-change` to: '/password-change'
},{ },
{
label: 'Abmelden', label: 'Abmelden',
icon: 'i-heroicons-arrow-left-on-rectangle', icon: 'i-heroicons-arrow-left-on-rectangle',
click: async () => { onSelect: async () => {
await auth.logout() await auth.logout()
} }
}] }
]) ]])
</script> </script>
<template> <template>
<UDropdown mode="hover" :items="items" :ui="{ width: 'w-full', item: { disabled: 'cursor-text select-text' } }" :popper="{ strategy: 'absolute', placement: 'top' }" class="w-full"> <UDropdownMenu
:items="userItems"
:content="{ align: 'start', side: 'top', sideOffset: 8 }"
:ui="{ content: 'w-[var(--reka-dropdown-menu-trigger-width)] max-w-[var(--reka-dropdown-menu-trigger-width)]' }"
class="block w-full"
>
<template #default="{ open }"> <template #default="{ open }">
<UButton color="gray" variant="ghost" class="w-full" :label="auth.user.email" :class="[open && 'bg-gray-50 dark:bg-gray-800']"> <UButton
<!-- <template #leading> color="gray"
<UAvatar :alt="auth.user.email" size="xs" /> variant="ghost"
</template>--> class="w-full min-w-0 justify-start gap-2 rounded-lg px-2.5 py-2 text-left"
:class="[open && 'bg-gray-100 dark:bg-gray-800']"
>
<span class="min-w-0 flex-1 truncate font-medium text-gray-900 dark:text-white">
{{ auth.user.email }}
</span>
<template #trailing> <template #trailing>
<UIcon name="i-heroicons-ellipsis-vertical" class="w-5 h-5 ml-auto" /> <UIcon name="i-heroicons-ellipsis-vertical" class="h-5 w-5 shrink-0" />
</template> </template>
</UButton> </UButton>
</template> </template>
</UDropdownMenu>
<template #account>
<div class="text-left">
<p>
Angemeldet als
</p>
<p class="truncate font-medium text-gray-900 dark:text-white">
{{auth.user.email}}
</p>
</div>
</template>
</UDropdown>
</template> </template>

View File

@@ -6,8 +6,20 @@ const props = defineProps({
default: {} default: {}
} }
}) })
const descriptionText = computed(() => {
const description = props.row?.description
if (!description) return ""
if (typeof description === "string") return description
if (typeof description === "object") {
if (typeof description.text === "string" && description.text.trim().length) {
return description.text
}
}
return String(description)
})
</script> </script>
<template> <template>
<div v-if="props.row.description" v-html="props.row.description.html"/> <div v-if="descriptionText">{{ descriptionText }}</div>
</template> </template>

View File

@@ -67,12 +67,13 @@ const startImport = () => {
<template> <template>
<UModal :fullscreen="false"> <UModal :fullscreen="false">
<template #content>
<UCard> <UCard>
<template #header> <template #header>
Erstelltes Dokument Kopieren Erstelltes Dokument Kopieren
</template> </template>
<UFormGroup <UFormField
label="Dokumententyp:" label="Dokumententyp:"
class="mb-3" class="mb-3"
> >
@@ -84,7 +85,7 @@ const startImport = () => {
> >
</USelectMenu> </USelectMenu>
</UFormGroup> </UFormField>
<UCheckbox <UCheckbox
v-for="key in Object.keys(optionsToImport).filter(i => documentTypeToUse !== props.type ? !['startText','endText'].includes(i) : true)" v-for="key in Object.keys(optionsToImport).filter(i => documentTypeToUse !== props.type ? !['startText','endText'].includes(i) : true)"
v-model="optionsToImport[key]" v-model="optionsToImport[key]"
@@ -101,9 +102,10 @@ const startImport = () => {
</template> </template>
</UCard> </UCard>
</template>
</UModal> </UModal>
</template> </template>
<style scoped> <style scoped>
</style> </style>

View File

@@ -1,239 +1,340 @@
<script setup> <script setup>
import customParseFormat from "dayjs/plugin/customParseFormat"; import customParseFormat from "dayjs/plugin/customParseFormat";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Line } from "vue-chartjs";
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
const props = defineProps({
let incomeData = ref({}) headerTarget: {
let expenseData = ref({}) type: String,
default: ""
const setup = async () => {
let incomeRawData = (await useEntities("createddocuments").select()).filter(i => i.state === "Gebucht" && ['invoices','advanceInvoices','cancellationInvoices'].includes(i.type))
let incomeRawFilteredData = incomeRawData.filter(x => x.state === 'Gebucht' && incomeRawData.find(i => i.linkedDocument && i.linkedDocument.id === x.id && i.type === 'cancellationInvoices') && ['invoices','advanceInvoices'].includes(row.type))
let expenseRawData =(await useEntities("incominginvoices").select())
let withoutInvoiceRawData = (await useEntities("statementallocations").select()).filter(i => i.account)
let withoutInvoiceRawDataExpenses = []
let withoutInvoiceRawDataIncomes = []
withoutInvoiceRawData.forEach(i => {
if(i.amount > 0) {
withoutInvoiceRawDataIncomes.push({
id: i.id,
date: dayjs(i.created_at).format("DD-MM-YY"),
amount: Math.abs(i.amount),
bs_id: i.bs_id
})
} else if(i.amount < 0) {
withoutInvoiceRawDataExpenses.push({
id: i.id,
date: dayjs(i.created_at).format("DD-MM-YY"),
amount: Math.abs(i.amount),
bs_id: i.bs_id
})
}
})
/*withoutInvoiceRawDataExpenses.forEach(i => {
expenseData.value[i.date] ? expenseData.value[i.date] = Number((expenseData.value[i.date] + i.amount).toFixed(2)) : expenseData.value[i.date] = i.amount
})
withoutInvoiceRawDataIncomes.forEach(i => {
incomeData.value[i.date] ? incomeData.value[i.date] = Number((incomeData.value[i.date] + i.amount).toFixed(2)) : incomeData.value[i.date] = i.amount
})*/
expenseRawData = expenseRawData.filter(i => i.date).map(i => {
let amount = 0
i.accounts.forEach(a => {
amount += a.amountNet
})
amount = Number(amount.toFixed(2))
return {
id: i.id,
date: dayjs(i.date).format("DD-MM-YY"),
amount
}
})
expenseRawData.forEach(i => {
expenseData.value[i.date] ? expenseData.value[i.date] = Number((expenseData.value[i.date] + i.amount).toFixed(2)) : expenseData.value[i.date] = i.amount
})
let expenseMonths = {
"01": 0,
"02": 0,
"03": 0,
"04": 0,
"05": 0,
"06": 0,
"07": 0,
"08": 0,
"09": 0,
"10": 0,
"11": 0,
"12": 0,
} }
Object.keys(expenseMonths).forEach(month => {
let dates = Object.keys(expenseData.value).filter(i => i.split("-")[1] === month && i.split("-")[2] === dayjs().format("YY"))
dates.forEach(date => {
if(expenseMonths[month]){
expenseMonths[month] = Number((expenseMonths[month] + expenseData.value[date]).toFixed(2))
} else {
expenseMonths[month] = expenseData.value[date]
}
})
})
expenseData.value = expenseMonths
incomeRawData = incomeRawData.map(i => {
let amount = 0
i.rows.forEach(r => {
if(r.mode !== "pagebreak" && r.mode !== "title" && r.mode !== "text"){
amount += r.price * r.quantity * (1 - r.discountPercent/100)
}
})
amount = Number(amount.toFixed(2))
return {
id: i.id,
date: dayjs(i.documentDate).format("DD-MM-YY"),
amount
}
})
incomeRawData.forEach(i => {
incomeData.value[i.date] ? incomeData.value[i.date] = Number((incomeData.value[i.date] + i.amount).toFixed(2)) : incomeData.value[i.date] = i.amount
})
let incomeMonths = {
"01": 0,
"02": 0,
"03": 0,
"04": 0,
"05": 0,
"06": 0,
"07": 0,
"08": 0,
"09": 0,
"10": 0,
"11": 0,
"12": 0,
}
Object.keys(incomeMonths).forEach(month => {
let dates = Object.keys(incomeData.value).filter(i => i.split("-")[1] === month && i.split("-")[2] === dayjs().format("YY"))
dates.forEach(date => {
if(incomeMonths[month]){
incomeMonths[month] = Number((incomeMonths[month] + incomeData.value[date]).toFixed(2))
} else {
incomeMonths[month] = incomeData.value[date]
}
})
})
incomeData.value = incomeMonths
}
const days = computed(() => {
let days = []
days = Object.keys(incomeData.value)
let expenseDays = Object.keys(expenseData.value)
expenseDays.forEach(expenseDay => {
if(!days.find(i => i === expenseDay)){
days.push(expenseDay)
}
})
days = days.sort(function(a, b) {
var keyA = dayjs(a, "DD-MM-YY"),
keyB = dayjs(b, "DD-MM-YY");
// Compare the 2 dates
if (keyA.isBefore(keyB,'day')) {
return -1;
} else if(keyB.isBefore(keyA, 'day')) {
return 1
} else {
return 0;
}
});
return days
}) })
/*const chartData = computed(() => { const tempStore = useTempStore()
return { const isMounted = ref(false)
labels: days.value,
datasets: [ const amountMode = ref("net")
{ const granularity = ref("year")
label: 'Einnahmen', const selectedYear = ref(dayjs().year())
data: [2, 1, 16, 3, 2], const selectedMonth = ref(dayjs().month() + 1)
backgroundColor: 'rgba(20, 255, 0, 0.3)',
borderColor: 'red', const incomeDocuments = ref([])
borderWidth: 2, const expenseInvoices = ref([])
}
] const granularityOptions = [
{ label: "Jahr", value: "year" },
{ label: "Monat", value: "month" }
]
const monthOptions = [
{ label: "Januar", value: 1 },
{ label: "Februar", value: 2 },
{ label: "März", value: 3 },
{ label: "April", value: 4 },
{ label: "Mai", value: 5 },
{ label: "Juni", value: 6 },
{ label: "Juli", value: 7 },
{ label: "August", value: 8 },
{ label: "September", value: 9 },
{ label: "Oktober", value: 10 },
{ label: "November", value: 11 },
{ label: "Dezember", value: 12 }
]
const normalizeMode = (value) => value === "gross" ? "gross" : "net"
const normalizeGranularity = (value) => value === "month" ? "month" : "year"
watch(
() => tempStore.settings?.dashboardIncomeExpenseView,
(storedView) => {
const legacyMode = tempStore.settings?.dashboardIncomeExpenseMode
amountMode.value = normalizeMode(storedView?.amountMode || legacyMode)
granularity.value = normalizeGranularity(storedView?.granularity)
const nextYear = Number(storedView?.year)
const nextMonth = Number(storedView?.month)
selectedYear.value = Number.isFinite(nextYear) ? nextYear : dayjs().year()
selectedMonth.value = Number.isFinite(nextMonth) && nextMonth >= 1 && nextMonth <= 12
? nextMonth
: dayjs().month() + 1
},
{ immediate: true }
)
watch([amountMode, granularity, selectedYear, selectedMonth], () => {
tempStore.modifySettings("dashboardIncomeExpenseView", {
amountMode: amountMode.value,
granularity: granularity.value,
year: selectedYear.value,
month: selectedMonth.value
})
// Backward compatibility for any existing consumers.
tempStore.modifySettings("dashboardIncomeExpenseMode", amountMode.value)
})
const loadData = async () => {
const [docs, incoming] = await Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select()
])
incomeDocuments.value = (docs || []).filter((item) => item.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(item.type))
expenseInvoices.value = (incoming || []).filter((item) => item.date)
}
const yearsInData = computed(() => {
const years = new Set([dayjs().year()])
incomeDocuments.value.forEach((item) => {
const parsed = dayjs(item.documentDate)
if (parsed.isValid()) years.add(parsed.year())
})
expenseInvoices.value.forEach((item) => {
const parsed = dayjs(item.date)
if (parsed.isValid()) years.add(parsed.year())
})
return Array.from(years).sort((a, b) => b - a)
})
const yearOptions = computed(() => yearsInData.value.map((year) => ({ label: String(year), value: year })))
watch(yearsInData, (years) => {
if (!years.includes(selectedYear.value) && years.length > 0) {
selectedYear.value = years[0]
} }
})*/ }, { immediate: true })
const computeDocumentAmount = (doc) => {
let amount = 0
;(doc.rows || []).forEach((row) => {
if (["pagebreak", "title", "text"].includes(row.mode)) return
const net = Number(row.price || 0) * Number(row.quantity || 0) * (1 - Number(row.discountPercent || 0) / 100)
const taxPercent = Number(row.taxPercent)
const gross = net * (1 + (Number.isFinite(taxPercent) ? taxPercent : 0) / 100)
amount += amountMode.value === "gross" ? gross : net
})
return Number(amount.toFixed(2))
}
const computeIncomingInvoiceAmount = (invoice) => {
let amount = 0
;(invoice.accounts || []).forEach((account) => {
const net = Number(account.amountNet || 0)
const tax = Number(account.amountTax || 0)
const grossValue = Number(account.amountGross)
const gross = Number.isFinite(grossValue) ? grossValue : (net + tax)
amount += amountMode.value === "gross" ? gross : net
})
return Number(amount.toFixed(2))
}
const buckets = computed(() => {
const income = {}
const expense = {}
if (granularity.value === "year") {
for (let month = 1; month <= 12; month += 1) {
const key = String(month).padStart(2, "0")
income[key] = 0
expense[key] = 0
}
} else {
const daysInMonth = dayjs(`${selectedYear.value}-${String(selectedMonth.value).padStart(2, "0")}-01`).daysInMonth()
for (let day = 1; day <= daysInMonth; day += 1) {
const key = String(day).padStart(2, "0")
income[key] = 0
expense[key] = 0
}
}
incomeDocuments.value.forEach((doc) => {
const docDate = dayjs(doc.documentDate)
if (!docDate.isValid() || docDate.year() !== selectedYear.value) return
if (granularity.value === "month" && docDate.month() + 1 !== selectedMonth.value) return
const key = granularity.value === "year"
? String(docDate.month() + 1).padStart(2, "0")
: String(docDate.date()).padStart(2, "0")
income[key] = Number((income[key] + computeDocumentAmount(doc)).toFixed(2))
})
expenseInvoices.value.forEach((invoice) => {
const invoiceDate = dayjs(invoice.date)
if (!invoiceDate.isValid() || invoiceDate.year() !== selectedYear.value) return
if (granularity.value === "month" && invoiceDate.month() + 1 !== selectedMonth.value) return
const key = granularity.value === "year"
? String(invoiceDate.month() + 1).padStart(2, "0")
: String(invoiceDate.date()).padStart(2, "0")
expense[key] = Number((expense[key] + computeIncomingInvoiceAmount(invoice)).toFixed(2))
})
return { income, expense }
})
const chartLabels = computed(() => {
if (granularity.value === "year") {
return ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
}
return Object.keys(buckets.value.income).map((day) => `${day}.`)
})
import { Line } from 'vue-chartjs'
const chartData = computed(() => { const chartData = computed(() => {
const keys = Object.keys(buckets.value.income).sort()
return { return {
labels: ["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"], labels: chartLabels.value,
datasets: [ datasets: [
{ {
label: 'Ausgaben', label: "Ausgaben",
backgroundColor: '#f87979', backgroundColor: "#f87979",
borderColor: '#f87979', borderColor: "#f87979",
data: Object.keys(expenseData.value).sort().map(i => expenseData.value[i]), data: keys.map((key) => buckets.value.expense[key]),
tension: 0.3, tension: 0.3,
},{ },
label: 'Einnahmen', {
backgroundColor: '#69c350', label: "Einnahmen",
borderColor: '#69c350', backgroundColor: "#69c350",
data: Object.keys(incomeData.value).sort().map(i => incomeData.value[i]), borderColor: "#69c350",
data: keys.map((key) => buckets.value.income[key]),
tension: 0.3 tension: 0.3
}, },
], ],
} }
}) })
const chartOptions = ref({ const chartOptions = ref({
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
}) })
setup() const showHeaderControls = computed(() => isMounted.value && !!props.headerTarget)
const showInlineControls = computed(() => !showHeaderControls.value)
onMounted(() => {
isMounted.value = true
})
loadData()
</script> </script>
<template> <template>
<Line <div class="h-full flex flex-col gap-2">
:data="chartData" <Teleport v-if="showHeaderControls" :to="props.headerTarget">
:options="chartOptions" <div class="flex flex-wrap items-center justify-end gap-2">
/> <div class="flex flex-wrap items-center gap-2">
<USelectMenu
v-model="granularity"
:items="granularityOptions"
value-key="value"
label-key="label"
class="w-28"
/>
<USelectMenu
v-model="selectedYear"
:items="yearOptions"
value-key="value"
label-key="label"
class="w-24"
/>
<USelectMenu
v-if="granularity === 'month'"
v-model="selectedMonth"
:items="monthOptions"
value-key="value"
label-key="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">
<USelectMenu
v-model="granularity"
:items="granularityOptions"
value-key="value"
label-key="label"
class="w-28"
/>
<USelectMenu
v-model="selectedYear"
:items="yearOptions"
value-key="value"
label-key="label"
class="w-24"
/>
<USelectMenu
v-if="granularity === 'month'"
v-model="selectedMonth"
:items="monthOptions"
value-key="value"
label-key="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>
<div class="flex-1 min-h-[280px]">
<Line
:data="chartData"
:options="chartOptions"
/>
</div>
</div>
</template> </template>
<style scoped> <style scoped>

View File

@@ -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)
@@ -18,27 +16,23 @@ const setupPage = async () => {
let documents = items.filter(i => i.type === "invoices" ||i.type === "advanceInvoices") let documents = items.filter(i => i.type === "invoices" ||i.type === "advanceInvoices")
let draftDocuments = documents.filter(i => i.state === "Entwurf") let draftDocuments = documents.filter(i => i.state === "Entwurf")
let finalizedDocuments = documents.filter(i => i.state === "Gebucht") let finalizedDocuments = documents.filter(i => useSum().isOpenCreatedDocument(i, items))
finalizedDocuments = finalizedDocuments.filter(i => i.statementallocations.reduce((n,{amount}) => n + amount, 0).toFixed(2) !== useSum().getCreatedDocumentSum(i, documents).toFixed(2))
finalizedDocuments = finalizedDocuments.filter(x => (x.type === 'invoices' || x.type === 'advanceInvoices') && x.state === 'Gebucht' && !items.find(i => i.createddocument && i.createddocument.id === x.id))
finalizedDocuments.forEach(i => { finalizedDocuments.forEach(i => {
if(dayjs().subtract(i.paymentDays,"days").isAfter(i.documentDate)) { if(dayjs().subtract(i.paymentDays,"days").isAfter(i.documentDate)) {
unpaidOverdueInvoicesSum.value += useSum().getCreatedDocumentSum(i, documents) - i.statementallocations.reduce((n,{amount}) => n + amount, 0) unpaidOverdueInvoicesSum.value += useSum().getCreatedDocumentOpenAmount(i, items)
unpaidOverdueInvoicesCount.value += 1 unpaidOverdueInvoicesCount.value += 1
} else { } else {
unpaidInvoicesSum.value += useSum().getCreatedDocumentSum(i, items) - i.statementallocations.reduce((n,{amount}) => n + amount, 0) unpaidInvoicesSum.value += useSum().getCreatedDocumentOpenAmount(i, items)
unpaidInvoicesCount.value += 1 unpaidInvoicesCount.value += 1
} }
}) })
//unpaidInvoicesCount.value = finalizedDocuments.length //unpaidInvoicesCount.value = finalizedDocuments.length
draftDocuments.forEach(i => { draftDocuments.forEach(i => {
draftInvoicesSum.value += useSum().getCreatedDocumentSum(i, documents) - i.statementallocations.reduce((n,{amount}) => n + amount, 0) draftInvoicesSum.value += useSum().getCreatedDocumentOpenAmount(i, items)
}) })
draftInvoicesCount.value = draftDocuments.length draftInvoicesCount.value = draftDocuments.length
@@ -50,43 +44,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;
}
</style> .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>

View File

@@ -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
}) })
} }
@@ -19,9 +23,9 @@ setupPage()
<template> <template>
<UTable <UTable
v-if="openTasks.length > 0" v-if="openTasks.length > 0"
:rows="openTasks" :data="openTasks"
:columns="[{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}]" :columns="normalizeTableColumns([{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}])"
@select="(i) => router.push(`/tasks/show/${i.id}`)" :on-select="(i) => router.push(`/tasks/show/${i.id}`)"
/> />
<div v-else> <div v-else>
<p class="text-center font-bold">Keine offenen Aufgaben</p> <p class="text-center font-bold">Keine offenen Aufgaben</p>

View File

@@ -29,7 +29,7 @@ const startTime = async () => {
await setupPage() await setupPage()
} catch (error) { } catch (error) {
console.log(error) console.log(error)
toast.add({title: "Fehler beim starten der Projektzeit",color:"rose"}) toast.add({title: "Fehler beim starten der Projektzeit",color:"error"})
} }
} }
@@ -41,7 +41,7 @@ const stopStartedTime = async () => {
} catch (error) { } catch (error) {
console.log(error) console.log(error)
let errorId = await useError().logError(`${JSON.stringify(error)}`) let errorId = await useError().logError(`${JSON.stringify(error)}`)
toast.add({title: errorId ? `Fehler beim stoppen der Projektzeit (Fehler ID: ${errorId})` : `Fehler beim stoppen der Projektzeit`,color:"rose"}) toast.add({title: errorId ? `Fehler beim stoppen der Projektzeit (Fehler ID: ${errorId})` : `Fehler beim stoppen der Projektzeit`,color:"error"})
} }
} }
@@ -53,15 +53,15 @@ const stopStartedTime = async () => {
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p> <p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p> <p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p>
<UFormGroup <UFormField
class="mt-2" class="mt-2"
label="Notizen:" label="Notizen:"
> >
<UTextarea <UTextarea
v-model="runningTimeInfo.notes" v-model="runningTimeInfo.notes"
/> />
</UFormGroup> </UFormField>
<UFormGroup <UFormField
class="mt-2" class="mt-2"
label="Projekt:" label="Projekt:"
> >
@@ -74,7 +74,7 @@ const stopStartedTime = async () => {
value-attribute="id" value-attribute="id"
option-attribute="name" option-attribute="name"
/> />
</UFormGroup> </UFormField>
<UButton <UButton
class="mt-3" class="mt-3"
@click="stopStartedTime" @click="stopStartedTime"

View File

@@ -25,7 +25,7 @@ const startTime = async () => {
await setupPage() await setupPage()
} catch (error) { } catch (error) {
console.log(error) console.log(error)
toast.add({title: "Fehler beim starten der Zeit",color:"rose"}) toast.add({title: "Fehler beim starten der Zeit",color:"error"})
} }
} }
@@ -37,7 +37,7 @@ const stopStartedTime = async () => {
} catch (error) { } catch (error) {
console.log(error) console.log(error)
let errorId = await useError().logError(`${JSON.stringify(error)}`) let errorId = await useError().logError(`${JSON.stringify(error)}`)
toast.add({title: errorId ? `Fehler beim stoppen der Anwesenheit (Fehler ID: ${errorId})` : `Fehler beim stoppen der Anwesenheit`,color:"rose"}) toast.add({title: errorId ? `Fehler beim stoppen der Anwesenheit (Fehler ID: ${errorId})` : `Fehler beim stoppen der Anwesenheit`,color:"error"})
} }
} }
@@ -49,14 +49,14 @@ const stopStartedTime = async () => {
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p> <p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p> <p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p>
<UFormGroup <UFormField
class="mt-2" class="mt-2"
label="Notizen:" label="Notizen:"
> >
<UTextarea <UTextarea
v-model="runningTimeInfo.notes" v-model="runningTimeInfo.notes"
/> />
</UFormGroup> </UFormField>
<UButton <UButton
class="mt-3" class="mt-3"
@click="stopStartedTime" @click="stopStartedTime"

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import dayjs from "dayjs"
import customParseFormat from "dayjs/plugin/customParseFormat"
import {
formatTaxEvaluationPeriodLabel,
formatTaxEvaluationPeriodRange,
getCreatedDocumentTaxBreakdown,
getIncomingInvoiceTaxBreakdown,
getTaxEvaluationPeriodBounds,
normalizeTaxEvaluationPeriod
} from "~/composables/useTaxEvaluation"
dayjs.extend(customParseFormat)
const auth = useAuthStore()
const loading = ref(true)
const summary = ref({
label: "",
range: "",
outputTax: 0,
inputTax: 0,
balance: 0,
outputCount: 0,
inputCount: 0,
})
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR"
}).format(Number(value || 0))
}
const loadSummary = async () => {
loading.value = true
try {
const periodType = normalizeTaxEvaluationPeriod(auth.activeTenantData?.taxEvaluationPeriod)
const bounds = getTaxEvaluationPeriodBounds(dayjs(), periodType)
const [docs, incoming] = await Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select()
])
const outputDocs = (docs || []).filter((doc: any) => {
if (doc?.state !== "Gebucht") return false
if (!["invoices", "advanceInvoices", "cancellationInvoices"].includes(doc?.type)) return false
const date = dayjs(doc.documentDate)
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
})
const inputDocs = (incoming || []).filter((invoice: any) => {
if (invoice?.state !== "Gebucht" || !invoice?.date) return false
const date = dayjs(invoice.date)
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
})
const outputTax = outputDocs.reduce((sum: number, doc: any) => {
const breakdown = getCreatedDocumentTaxBreakdown(doc)
return sum + breakdown.tax19 + breakdown.tax7
}, 0)
const inputTax = inputDocs.reduce((sum: number, invoice: any) => {
const breakdown = getIncomingInvoiceTaxBreakdown(invoice)
return sum + breakdown.tax19 + breakdown.tax7
}, 0)
summary.value = {
label: formatTaxEvaluationPeriodLabel(bounds.start, periodType),
range: formatTaxEvaluationPeriodRange(bounds.start, periodType),
outputTax: Number(outputTax.toFixed(2)),
inputTax: Number(inputTax.toFixed(2)),
balance: Number((outputTax - inputTax).toFixed(2)),
outputCount: outputDocs.length,
inputCount: inputDocs.length,
}
} finally {
loading.value = false
}
}
onMounted(loadSummary)
</script>
<template>
<div class="space-y-3">
<div class="tax-summary-top">
<div>
<p class="tax-summary-period">{{ summary.label }}</p>
<p class="tax-summary-range">{{ summary.range }}</p>
</div>
<UButton
size="xs"
variant="soft"
color="gray"
icon="i-heroicons-arrow-top-right-on-square"
@click="navigateTo('/accounting/tax')"
>
Details
</UButton>
</div>
<div class="tax-summary-row">
<span class="tax-summary-label">USt Rechnungen</span>
<span class="tax-summary-value text-amber-600 dark:text-amber-400">
{{ loading ? "..." : formatCurrency(summary.outputTax) }}
</span>
</div>
<div class="tax-summary-row">
<span class="tax-summary-label">Vorsteuer</span>
<span class="tax-summary-value text-sky-600 dark:text-sky-400">
{{ loading ? "..." : formatCurrency(summary.inputTax) }}
</span>
</div>
<div class="tax-summary-row">
<span class="tax-summary-label">Ergebnis</span>
<span
class="tax-summary-value"
:class="summary.balance >= 0 ? 'text-rose-600 dark:text-rose-400' : 'text-emerald-600 dark:text-emerald-400'"
>
{{ loading ? "..." : formatCurrency(summary.balance) }}
</span>
</div>
<div class="tax-summary-meta">
{{ summary.outputCount }} Ausgangsbelege | {{ summary.inputCount }} Eingangsbelege
</div>
</div>
</template>
<style scoped>
.tax-summary-top {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
}
.tax-summary-period {
margin: 0;
font-weight: 700;
color: rgb(17 24 39);
}
.tax-summary-range,
.tax-summary-meta {
margin: 0;
font-size: 0.875rem;
color: rgb(107 114 128);
}
.tax-summary-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
.tax-summary-label {
color: rgb(55 65 81);
}
.tax-summary-value {
font-weight: 700;
text-align: right;
}
:deep(.dark) .tax-summary-period {
color: rgb(243 244 246);
}
:deep(.dark) .tax-summary-range,
:deep(.dark) .tax-summary-meta,
:deep(.dark) .tax-summary-label {
color: rgb(156 163 175);
}
</style>

View File

@@ -12,6 +12,10 @@ const props = defineProps({
const products = ref([]) const products = ref([])
const units = ref([]) const units = ref([])
const productSearchInput = {
placeholder: 'Artikel suchen...'
}
const setup = async () => { const setup = async () => {
products.value = await useEntities("products").select() products.value = await useEntities("products").select()
units.value = await useEntities("units").selectSpecial() units.value = await useEntities("units").selectSpecial()
@@ -80,16 +84,16 @@ const setRowData = (row) => {
> >
<td> <td>
<USelectMenu <USelectMenu
searchable :items="products"
:search-attributes="['name']" label-key="name"
:options="products" value-key="id"
value-attribute="id" :search-input="productSearchInput"
option-attribute="name" :filter-fields="['name']"
v-model="product.product" v-model="product.product"
:color="product.product ? 'primary' : 'rose'" :color="product.product ? 'primary' : 'error'"
@change="setRowData(product)" @change="setRowData(product)"
> >
<template #label> <template #default>
{{products.find(i => i.id === product.product) ? products.find(i => i.id === product.product).name : 'Kein Artikel ausgewählt'}} {{products.find(i => i.id === product.product) ? products.find(i => i.id === product.product).name : 'Kein Artikel ausgewählt'}}
</template> </template>
</USelectMenu> </USelectMenu>
@@ -104,9 +108,9 @@ const setRowData = (row) => {
</td> </td>
<td> <td>
<USelectMenu <USelectMenu
:options="units" :items="units"
value-attribute="id" label-key="name"
option-attribute="name" value-key="id"
v-model="product.unit" v-model="product.unit"
></USelectMenu> ></USelectMenu>
</td> </td>
@@ -123,7 +127,7 @@ const setRowData = (row) => {
icon="i-heroicons-x-mark" icon="i-heroicons-x-mark"
@click="removeProductFromMaterialComposition(product.id)" @click="removeProductFromMaterialComposition(product.id)"
variant="outline" variant="outline"
color="rose" color="error"
/> />
</td> </td>
</tr> </tr>
@@ -135,4 +139,4 @@ const setRowData = (row) => {
td { td {
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
</style> </style>

View File

@@ -53,7 +53,10 @@ const emit = defineEmits(["click"])
<style scoped> <style scoped>
/* FAB Basis */ /* FAB Basis */
.fab-base { .fab-base {
@apply rounded-full px-5 py-4 text-lg font-semibold; border-radius: 9999px;
padding: 1rem 1.25rem;
font-size: 1.125rem;
font-weight: 600;
/* Wenn nur ein Icon vorhanden ist → runder Kreis */ /* Wenn nur ein Icon vorhanden ist → runder Kreis */
/* Wenn Label + Icon → Extended FAB */ /* Wenn Label + Icon → Extended FAB */
@@ -61,6 +64,12 @@ const emit = defineEmits(["click"])
/* Optional: Auto-Kreisen wenn kein Label */ /* Optional: Auto-Kreisen wenn kein Label */
#fab:not([label]) { #fab:not([label]) {
@apply w-14 h-14 p-0 flex items-center justify-center text-2xl; width: 3.5rem;
height: 3.5rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
} }
</style> </style>

View File

@@ -47,14 +47,14 @@ async function handlePrint() {
{{labelPrinter.printProgress}} {{labelPrinter.printProgress}}
<UFormGroup label="Breite"> <UFormField label="Breite">
<UInput v-model="labelWidth"><template #trailing>mm</template></UInput> <UInput v-model="labelWidth"><template #trailing>mm</template></UInput>
</UFormGroup> </UFormField>
<UFormGroup label="Höhe"> <UFormField label="Höhe">
<UInput v-model="labelHeight"><template #trailing>mm</template></UInput> <UInput v-model="labelHeight"><template #trailing>mm</template></UInput>
</UFormGroup> </UFormField>
<UFormGroup label="ZPL"> <UFormField label="ZPL">
<UTextarea v-model="zpl" rows="6" /> <UTextarea v-model="zpl" rows="6" />
</UFormGroup> </UFormField>
</UCard> </UCard>
</template> </template>

View File

@@ -12,6 +12,10 @@ const props = defineProps({
const hourrates = ref([]) const hourrates = ref([])
const units = ref([]) const units = ref([])
const hourrateSearchInput = {
placeholder: 'Stundensatz suchen...'
}
const setup = async () => { const setup = async () => {
hourrates.value = await useEntities("hourrates").select() hourrates.value = await useEntities("hourrates").select()
units.value = await useEntities("units").selectSpecial() units.value = await useEntities("units").selectSpecial()
@@ -82,14 +86,14 @@ const setRowData = (row) => {
> >
<td> <td>
<USelectMenu <USelectMenu
searchable :items="hourrates"
:search-attributes="['name']" label-key="name"
:options="hourrates" value-key="id"
value-attribute="id" :search-input="hourrateSearchInput"
option-attribute="name" :filter-fields="['name']"
v-model="row.hourrate" v-model="row.hourrate"
:color="row.hourrate ? 'primary' : 'rose'" :color="row.hourrate ? 'primary' : 'error'"
@change="setRowData(row)" @change="setRowData(row)"
> >
<!-- <template #label> <!-- <template #label>
{{products.find(i => i.id === product.product) ? products.find(i => i.id === product.product).name : 'Kein Artikel ausgewählt'}} {{products.find(i => i.id === product.product) ? products.find(i => i.id === product.product).name : 'Kein Artikel ausgewählt'}}
@@ -106,10 +110,10 @@ const setRowData = (row) => {
</td> </td>
<td> <td>
<USelectMenu <USelectMenu
:options="units" :items="units"
disabled disabled
value-attribute="id" label-key="name"
option-attribute="name" value-key="id"
v-model="row.unit" v-model="row.unit"
></USelectMenu> ></USelectMenu>
</td> </td>
@@ -134,7 +138,7 @@ const setRowData = (row) => {
icon="i-heroicons-x-mark" icon="i-heroicons-x-mark"
@click="removeRowFromPersonalComposition(row.id)" @click="removeRowFromPersonalComposition(row.id)"
variant="outline" variant="outline"
color="rose" color="error"
/> />
</td> </td>
</tr> </tr>
@@ -146,4 +150,4 @@ const setRowData = (row) => {
td { td {
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
</style> </style>

View File

@@ -206,16 +206,62 @@ const addVideo = () => {
<style scoped> <style scoped>
/* Toolbar & Buttons */ /* Toolbar & Buttons */
.toolbar-btn { .toolbar-btn {
@apply p-1.5 rounded text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm font-medium min-w-[28px] h-[28px] flex items-center justify-center; padding: 0.375rem;
border-radius: 0.25rem;
color: #4b5563;
transition: background-color 0.2s ease, color 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
min-width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
} }
.toolbar-btn.is-active { .toolbar-btn.is-active {
@apply bg-gray-200 dark:bg-gray-600 text-black dark:text-white shadow-inner; background: #e5e7eb;
color: #000;
box-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
} }
.bubble-btn { .bubble-btn {
@apply px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors text-sm min-w-[24px] h-[24px] flex items-center justify-center text-gray-700 dark:text-gray-200; padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: background-color 0.2s ease, color 0.2s ease;
font-size: 0.875rem;
min-width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #374151;
} }
.bubble-btn.is-active { .bubble-btn.is-active {
@apply bg-gray-200 dark:bg-gray-600 text-black dark:text-white; background: #e5e7eb;
color: #000;
}
.toolbar-btn:hover,
.bubble-btn:hover {
background: #f3f4f6;
}
:global(.dark) .toolbar-btn {
color: #d1d5db;
}
:global(.dark) .toolbar-btn:hover,
:global(.dark) .bubble-btn:hover {
background: #374151;
}
:global(.dark) .toolbar-btn.is-active,
:global(.dark) .bubble-btn.is-active {
background: #4b5563;
color: #fff;
}
:global(.dark) .bubble-btn {
color: #e5e7eb;
} }
/* GLOBAL EDITOR STYLES */ /* GLOBAL EDITOR STYLES */
@@ -235,20 +281,48 @@ const addVideo = () => {
/* MENTION */ /* MENTION */
.wiki-mention { .wiki-mention {
/* Pill-Shape, grau/neutral statt knallig blau */ /* Pill-Shape, grau/neutral statt knallig blau */
@apply bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 px-1.5 py-0.5 rounded-md text-sm font-medium no-underline inline-block mx-0.5 align-middle border border-gray-200 dark:border-gray-700; background: #f3f4f6;
color: #374151;
padding: 0.125rem 0.375rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
display: inline-block;
margin-inline: 0.125rem;
vertical-align: middle;
border: 1px solid #e5e7eb;
box-decoration-break: clone; box-decoration-break: clone;
} }
.wiki-mention::before { .wiki-mention::before {
@apply text-gray-400 dark:text-gray-500 mr-0.5; color: #9ca3af;
margin-right: 0.125rem;
} }
.wiki-mention:hover { .wiki-mention:hover {
@apply bg-primary-50 dark:bg-primary-900/30 border-primary-200 dark:border-primary-800 text-primary-700 dark:text-primary-400; background: #eefbf0;
border-color: #bbf7d0;
color: #15803d;
cursor: pointer; cursor: pointer;
} }
:global(.dark) .wiki-mention {
background: #1f2937;
color: #e5e7eb;
border-color: #374151;
}
:global(.dark) .wiki-mention::before {
color: #6b7280;
}
:global(.dark) .wiki-mention:hover {
background: rgb(20 83 45 / 0.3);
border-color: #166534;
color: #4ade80;
}
/* TABLE */ /* TABLE */
table { width: 100% !important; border-collapse: collapse; table-layout: fixed; margin: 1rem 0; } table { width: 100% !important; border-collapse: collapse; table-layout: fixed; margin: 1rem 0; }
th, td { border: 1px solid #d1d5db; padding: 0.5rem; vertical-align: top; box-sizing: border-box; min-width: 1em; } th, td { border: 1px solid #d1d5db; padding: 0.5rem; vertical-align: top; box-sizing: border-box; min-width: 1em; }
@@ -258,7 +332,7 @@ const addVideo = () => {
.column-resize-handle { background-color: #3b82f6; width: 4px; } .column-resize-handle { background-color: #3b82f6; width: 4px; }
/* CODE */ /* CODE */
pre { background: #0d1117; color: #c9d1d9; font-family: 'JetBrains Mono', monospace; padding: 0.75rem 1rem; border-radius: 0.5rem; margin: 1rem 0; overflow-x: auto; } pre { background: #0d1117; color: #c9d1d9; font-family: var(--font-mono); padding: 0.75rem 1rem; border-radius: 0.5rem; margin: 1rem 0; overflow-x: auto; }
code { color: inherit; padding: 0; background: none; font-size: 0.9rem; } code { color: inherit; padding: 0; background: none; font-size: 0.9rem; }
/* IMG */ /* IMG */
@@ -269,4 +343,4 @@ const addVideo = () => {
mark { background-color: #fef08a; padding: 0.1rem 0.2rem; border-radius: 0.2rem; } mark { background-color: #fef08a; padding: 0.1rem 0.2rem; border-radius: 0.2rem; }
.ProseMirror-selectednode { outline: 2px solid #3b82f6; } .ProseMirror-selectednode { outline: 2px solid #3b82f6; }
} }
</style> </style>

View File

@@ -98,17 +98,19 @@
</div> </div>
</div> </div>
<UModal v-model="isCreateModalOpen"> <UModal v-model:open="isCreateModalOpen">
<div class="p-5"> <template #content>
<h3 class="font-bold mb-4">Neue Seite</h3> <div class="p-5">
<form @submit.prevent="createPage"> <h3 class="font-bold mb-4">Neue Seite</h3>
<UInput v-model="newTitle" placeholder="Titel..." autofocus /> <form @submit.prevent="createPage">
<div class="mt-4 flex justify-end gap-2"> <UInput v-model="newTitle" placeholder="Titel..." autofocus />
<UButton color="gray" variant="ghost" @click="isCreateModalOpen = false">Abbrechen</UButton> <div class="mt-4 flex justify-end gap-2">
<UButton type="submit" color="primary" :loading="isCreating">Erstellen</UButton> <UButton color="gray" variant="ghost" @click="isCreateModalOpen = false">Abbrechen</UButton>
</div> <UButton type="submit" color="primary" :loading="isCreating">Erstellen</UButton>
</form> </div>
</div> </form>
</div>
</template>
</UModal> </UModal>
</div> </div>
@@ -163,7 +165,7 @@ async function selectPage(id: string) {
const data = await $api(`/api/wiki/${id}`, { method: 'GET' }) const data = await $api(`/api/wiki/${id}`, { method: 'GET' })
selectedPage.value = data selectedPage.value = data
} catch (e) { } catch (e) {
toast.add({ title: 'Fehler beim Laden', color: 'red' }) toast.add({ title: 'Fehler beim Laden', color: 'error' })
} finally { } finally {
loadingContent.value = false loadingContent.value = false
} }
@@ -233,4 +235,4 @@ watch(() => [props.entityId, props.entityUuid], fetchList)
<style scoped> <style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 4px; } .custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background-color: #ddd; border-radius: 4px; } .custom-scrollbar::-webkit-scrollbar-thumb { background-color: #ddd; border-radius: 4px; }
</style> </style>

View File

@@ -0,0 +1,155 @@
import { createSharedComposable } from '@vueuse/core'
type ChangelogEntry = {
hash: string
shortHash: string
subject: string
authorName: string
committedAt: string
}
type ChangelogSeenState = {
lastOpenedAt: string | null
latestSeenHash: string | null
}
const defaultSeenState = (): ChangelogSeenState => ({
lastOpenedAt: null,
latestSeenHash: null
})
let changelogRequest: Promise<void> | null = null
const _useChangelog = () => {
const auth = useAuthStore()
const entries = useState<ChangelogEntry[]>('changelog:entries', () => [])
const pending = useState<boolean>('changelog:pending', () => false)
const error = useState<string | null>('changelog:error', () => null)
const loadedKey = useState<string | null>('changelog:loaded-key', () => null)
const seenState = useState<ChangelogSeenState>('changelog:seen-state', defaultSeenState)
const scopeKey = computed(() => {
const userId = auth.user?.id
const tenantId = auth.activeTenant
if (!userId || !tenantId) return null
return `${userId}:${tenantId}`
})
const storageKey = computed(() => {
if (!scopeKey.value) return null
return `fedeo:changelog:last-opened:${scopeKey.value}`
})
const latestEntry = computed(() => entries.value[0] || null)
const hasUnread = computed(() => {
if (!latestEntry.value?.hash) return false
return latestEntry.value.hash !== seenState.value.latestSeenHash
})
function loadSeenState() {
if (!process.client || !storageKey.value) {
seenState.value = defaultSeenState()
return
}
try {
const raw = localStorage.getItem(storageKey.value)
if (!raw) {
seenState.value = defaultSeenState()
return
}
const parsed = JSON.parse(raw)
seenState.value = {
lastOpenedAt: parsed?.lastOpenedAt || null,
latestSeenHash: parsed?.latestSeenHash || null
}
} catch (err) {
console.error('Could not parse changelog seen state', err)
seenState.value = defaultSeenState()
}
}
async function refresh(force = false) {
if (!process.client || !scopeKey.value) return
if (!force && loadedKey.value === scopeKey.value && entries.value.length) return
if (changelogRequest) return changelogRequest
changelogRequest = (async () => {
pending.value = true
error.value = null
try {
const response = await useNuxtApp().$api('/api/functions/changelog', {
query: { limit: 20 }
})
entries.value = Array.isArray(response?.entries) ? response.entries : []
loadedKey.value = scopeKey.value
} catch (err: any) {
error.value = err?.data?.error || err?.message || 'Changelog konnte nicht geladen werden.'
} finally {
pending.value = false
}
})()
try {
await changelogRequest
} finally {
changelogRequest = null
}
}
function markAsSeen() {
if (!process.client || !storageKey.value) return
const nextState = {
lastOpenedAt: new Date().toISOString(),
latestSeenHash: latestEntry.value?.hash || null
}
seenState.value = nextState
try {
localStorage.setItem(storageKey.value, JSON.stringify(nextState))
} catch (err) {
console.error('Could not persist changelog seen state', err)
}
}
watch(storageKey, () => {
loadSeenState()
}, { immediate: true })
watch(scopeKey, (nextScopeKey, previousScopeKey) => {
if (!process.client || !nextScopeKey) return
if (nextScopeKey !== previousScopeKey) {
entries.value = []
loadedKey.value = null
}
void refresh(true)
}, { immediate: true })
return {
entries,
pending,
error,
latestEntry,
hasUnread,
seenState,
refresh,
markAsSeen
}
}
export const useChangelog = createSharedComposable(_useChangelog)

View File

@@ -36,6 +36,9 @@ export const useFiles = () => {
let data = [] let data = []
data = await useEntities("files").select("*, incominginvoice(*), project(*), vendor(*), customer(*), contract(*), plant(*), createddocument(*), vehicle(*), product(*), profile(*), check(*), inventoryitem(*)") data = await useEntities("files").select("*, incominginvoice(*), project(*), vendor(*), customer(*), contract(*), plant(*), createddocument(*), vehicle(*), product(*), profile(*), check(*), inventoryitem(*)")
if (!Array.isArray(data) || data.length === 0) {
return []
}
const res = await useNuxtApp().$api("/api/files/presigned",{ const res = await useNuxtApp().$api("/api/files/presigned",{
@@ -138,4 +141,4 @@ export const useFiles = () => {
return {uploadFiles, selectDocuments, selectSomeDocuments, selectDocument, downloadFile, dataURLtoFile} return {uploadFiles, selectDocuments, selectSomeDocuments, selectDocument, downloadFile, dataURLtoFile}
} }

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