Compare commits
41 Commits
0141a243ce
...
uichange
| Author | SHA1 | Date | |
|---|---|---|---|
| 03bcc1a939 | |||
| 68b2cbb0ee | |||
| b009ac845f | |||
| cfd84b773f | |||
| 8038f03406 | |||
| 6c3c318f86 | |||
| 8dfcffc92b | |||
| 9ecacdab50 | |||
| 44fb50b11e | |||
| 23c4d21f44 | |||
| 6f77bccd85 | |||
| be336a51ab | |||
| ac2e2fcfe9 | |||
| 9dbb194c8a | |||
| 0aacb18aaa | |||
| e3a1636018 | |||
| 55bb2589a4 | |||
| 05d99e9e7d | |||
| 7e0a2f5e4f | |||
| 84c174ca09 | |||
| a9d3d0038f | |||
| 003d88587a | |||
| 69ff646689 | |||
| 1511340f00 | |||
| 62accb5a86 | |||
| 8c935c6101 | |||
| f6bdf2906f | |||
| dff3a23c04 | |||
| 966c121cbf | |||
| da50782ffc | |||
| 6919de096a | |||
| 8892b36ae5 | |||
| 8a08147265 | |||
| 52c182cb5f | |||
| 9cef3964e9 | |||
| cf0fb724a2 | |||
| bbb893dd6c | |||
| 724f152d70 | |||
| 27be8241bf | |||
| d27e437ba6 | |||
| f5253b29f4 |
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal 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
12
.idea/FEDEO.iml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
532
README.md
@@ -1,109 +1,439 @@
|
||||
# FEDEO Hosting Guide
|
||||
|
||||
Diese Anleitung beschreibt ein produktionsnahes Self-Hosting von FEDEO mit Docker Compose, Traefik, PostgreSQL und optionalem S3-kompatiblem Objektspeicher via MinIO.
|
||||
|
||||
## Architektur
|
||||
|
||||
# Docker Compose Setup
|
||||
Der Stack besteht aus:
|
||||
|
||||
## ENV Vars
|
||||
- `frontend`: Nuxt-Frontend auf Port `3000`
|
||||
- `backend`: Node/Fastify-API auf Port `3100`
|
||||
- `db`: PostgreSQL
|
||||
- `traefik`: Reverse Proxy mit automatischen Let's-Encrypt-Zertifikaten
|
||||
- optional `minio`: S3-kompatibler Objektspeicher fur Dateiuploads
|
||||
|
||||
- DOMAIN
|
||||
- PDF_LICENSE
|
||||
- DB_PASS
|
||||
- DB_USER
|
||||
- CONTACT_EMAIL
|
||||
Die Konfiguration erfolgt uber Umgebungsvariablen beziehungsweise eine `.env`-Datei im Deploy-Verzeichnis.
|
||||
|
||||
## 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:
|
||||
frontend:
|
||||
image: git.federspiel.tech/flfeders/fedeo/frontend:main
|
||||
restart: always
|
||||
environment:
|
||||
- NUXT_PUBLIC_API_BASE=https://${DOMAIN}/backend
|
||||
- NUXT_PUBLIC_PDF_LICENSE=${PDF_LICENSE}
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.port=3000"
|
||||
# Middlewares
|
||||
- "traefik.http.middlewares.fedeo-frontend-redirect-web-secure.redirectscheme.scheme=https"
|
||||
# Web Entrypoint
|
||||
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
|
||||
- "traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)"
|
||||
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
|
||||
# Web Secure Entrypoint
|
||||
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)"
|
||||
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
|
||||
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
|
||||
backend:
|
||||
image: git.federspiel.tech/flfeders/fedeo/backend:main
|
||||
restart: always
|
||||
environment:
|
||||
- INFISICAL_CLIENT_ID=
|
||||
- INFISICAL_CLIENT_SECRET=
|
||||
- NODE_ENV=production
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.port=3100"
|
||||
# Middlewares
|
||||
- "traefik.http.middlewares.fedeo-backend-redirect-web-secure.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend"
|
||||
# Web Entrypoint
|
||||
- "traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-redirect-web-secure"
|
||||
- "traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)"
|
||||
- "traefik.http.routers.fedeo-backend.entrypoints=web"
|
||||
# Web Secure Entrypoint
|
||||
- "traefik.http.routers.fedeo-backend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)"
|
||||
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
|
||||
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
|
||||
# db:
|
||||
# image: postgres
|
||||
# restart: always
|
||||
# shm_size: 128mb
|
||||
# environment:
|
||||
# POSTGRES_PASSWORD:
|
||||
# POSTGRES_USER:
|
||||
# POSTGRES_DB:
|
||||
# volumes:
|
||||
# - ./pg-data:/var/lib/postgresql/data
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
traefik:
|
||||
image: traefik:v2.11
|
||||
restart: unless-stopped
|
||||
container_name: traefik
|
||||
command:
|
||||
- "--api.insecure=false"
|
||||
- "--api.dashboard=false"
|
||||
- "--api.debug=false"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--providers.docker.network=traefik"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.web-secured.address=:443"
|
||||
- "--accesslog=true"
|
||||
- "--accesslog.filepath=/logs/access.log"
|
||||
- "--accesslog.bufferingsize=5000"
|
||||
- "--accesslog.fields.defaultMode=keep"
|
||||
- "--accesslog.fields.headers.defaultMode=keep"
|
||||
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" #
|
||||
- "--certificatesresolvers.mytlschallenge.acme.email=${CONTACT_EMAIL}"
|
||||
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS)
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
- "./traefik/logs:/logs"
|
||||
networks:
|
||||
- traefik
|
||||
traefik:
|
||||
image: traefik:v2.11
|
||||
container_name: fedeo-traefik
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --api.insecure=false
|
||||
- --api.dashboard=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --certificatesresolvers.letsencrypt.acme.tlschallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${CONTACT_EMAIL}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
- --accesslog=true
|
||||
- --accesslog.filepath=/logs/access.log
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./traefik/letsencrypt:/letsencrypt
|
||||
- ./traefik/logs:/logs
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- web
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
container_name: fedeo-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- internal
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: fedeo-minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- ./minio:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- internal
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc:latest
|
||||
container_name: fedeo-minio-init
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
|
||||
mc mb --ignore-existing local/${MINIO_BUCKET};
|
||||
mc anonymous set private local/${MINIO_BUCKET};
|
||||
exit 0;
|
||||
"
|
||||
restart: "no"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: fedeo-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
createbuckets:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOST: ${HOST}
|
||||
PORT: ${PORT}
|
||||
COOKIE_SECRET: ${COOKIE_SECRET}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
MAILER_SMTP_HOST: ${MAILER_SMTP_HOST}
|
||||
MAILER_SMTP_PORT: ${MAILER_SMTP_PORT}
|
||||
MAILER_SMTP_SSL: ${MAILER_SMTP_SSL}
|
||||
MAILER_SMTP_USER: ${MAILER_SMTP_USER}
|
||||
MAILER_SMTP_PASS: ${MAILER_SMTP_PASS}
|
||||
MAILER_FROM: ${MAILER_FROM}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
S3_REGION: ${S3_REGION}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
M2M_API_KEY: ${M2M_API_KEY}
|
||||
API_BASE_URL: ${API_BASE_URL}
|
||||
GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL}
|
||||
GOCARDLESS_SECRET_ID: ${GOCARDLESS_SECRET_ID}
|
||||
GOCARDLESS_SECRET_KEY: ${GOCARDLESS_SECRET_KEY}
|
||||
DOKUBOX_IMAP_HOST: ${DOKUBOX_IMAP_HOST}
|
||||
DOKUBOX_IMAP_PORT: ${DOKUBOX_IMAP_PORT}
|
||||
DOKUBOX_IMAP_SECURE: ${DOKUBOX_IMAP_SECURE}
|
||||
DOKUBOX_IMAP_USER: ${DOKUBOX_IMAP_USER}
|
||||
DOKUBOX_IMAP_PASSWORD: ${DOKUBOX_IMAP_PASSWORD}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
STIRLING_API_KEY: ${STIRLING_API_KEY}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
|
||||
- traefik.http.routers.fedeo-backend.entrypoints=websecure
|
||||
- traefik.http.routers.fedeo-backend.tls.certresolver=letsencrypt
|
||||
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
|
||||
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
|
||||
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
|
||||
networks:
|
||||
- web
|
||||
- internal
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
container_name: fedeo-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend
|
||||
NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
|
||||
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
|
||||
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: false
|
||||
~~~
|
||||
web:
|
||||
driver: bridge
|
||||
internal:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
## Externe S3-Provider statt MinIO
|
||||
|
||||
Wenn du keinen lokalen MinIO-Container betreiben willst:
|
||||
|
||||
1. Entferne die Services `minio` und `createbuckets` aus der Compose-Datei.
|
||||
2. Entferne im Backend `depends_on` fur `minio` und `createbuckets`.
|
||||
3. Trage in `.env` die Zugangsdaten des externen S3-Dienstes ein.
|
||||
|
||||
Beispiel fur die relevanten Werte:
|
||||
|
||||
```env
|
||||
S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
|
||||
S3_REGION=eu-central-1
|
||||
S3_ACCESS_KEY=...
|
||||
S3_SECRET_KEY=...
|
||||
S3_BUCKET=fedeo
|
||||
```
|
||||
|
||||
Hinweis: Das Backend nutzt `forcePathStyle: true`. Das funktioniert sauber mit MinIO und vielen S3-kompatiblen Providern. Bei reinem AWS S3 kann je nach Endpoint-Setup ein abweichendes Verhalten sinnvoll sein. Falls du AWS S3 einsetzen willst, sollte die S3-Initialisierung im Backend gegen den konkreten Zielprovider getestet werden.
|
||||
|
||||
## Start des Stacks
|
||||
|
||||
Im Deploy-Verzeichnis:
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Danach Status prufen:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f traefik
|
||||
docker compose logs -f backend
|
||||
```
|
||||
|
||||
## Funktionsprufung
|
||||
|
||||
Nach dem ersten Start sollten mindestens diese Checks erfolgreich sein:
|
||||
|
||||
```bash
|
||||
curl -I https://app.example.com
|
||||
curl https://app.example.com/backend/health
|
||||
```
|
||||
|
||||
Erwartung:
|
||||
|
||||
- Frontend liefert `200` oder `302`
|
||||
- Backend liefert JSON wie `{"status":"ok"}`
|
||||
|
||||
## Updates
|
||||
|
||||
Bei neuen Versionen:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Falls du statt lokaler Builds vorgebaute Images verwenden willst, kannst du in der Compose-Datei `build:` durch passende `image:`-Eintrage ersetzen. Erst dann ist ein vorgelagertes `docker compose pull` sinnvoll.
|
||||
|
||||
## Backup-Empfehlung
|
||||
|
||||
Regelmassig sichern:
|
||||
|
||||
- `./postgres`
|
||||
- `./minio` falls MinIO lokal genutzt wird
|
||||
- `./traefik/letsencrypt/acme.json`
|
||||
- deine `.env`
|
||||
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management
|
||||
|
||||
## Bekannte Betriebsbesonderheiten
|
||||
|
||||
- Das Backend startet nur sauber, wenn alle Pflichtvariablen gesetzt sind.
|
||||
- Ohne korrekt gesetzte S3-Secrets funktionieren Dateiuploads und dateibasierte Funktionen nicht.
|
||||
- Fur die Frontend-PDF-Funktion wird eine gueltige `NUXT_PUBLIC_PDF_LICENSE` benotigt.
|
||||
- PostgreSQL ist im Projekt vorgesehen; andere SQL-Datenbanken sind in dieser Compose-Datei nicht berucksichtigt.
|
||||
|
||||
## Optional: Nur mit bestehender externer Infrastruktur
|
||||
|
||||
Wenn bereits vorhanden:
|
||||
|
||||
- externer Reverse Proxy
|
||||
- externer PostgreSQL-Server
|
||||
- externer S3-Speicher
|
||||
- externe Zertifikatsverwaltung
|
||||
|
||||
dann konnen `traefik`, `db` und `minio` aus dem Stack entfernt werden. In diesem Fall mussen die zugehorigen Hostnamen und Zugangsdaten in der `.env` beziehungsweise im Frontend-Environment auf die externe Infrastruktur zeigen.
|
||||
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -3,3 +3,4 @@ node_modules
|
||||
.env
|
||||
|
||||
/src/generated/prisma
|
||||
/dist/
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:20-bookworm-slim
|
||||
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
|
||||
COPY package*.json ./
|
||||
|
||||
|
||||
3
backend/db/migrations/0018_account_chart.sql
Normal file
3
backend/db/migrations/0018_account_chart.sql
Normal 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;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "createddocuments"
|
||||
ALTER COLUMN "customSurchargePercentage" TYPE double precision
|
||||
USING "customSurchargePercentage"::double precision;
|
||||
1
backend/db/migrations/0020_file_extracted_text.sql
Normal file
1
backend/db/migrations/0020_file_extracted_text.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "files" ADD COLUMN "extracted_text" text;
|
||||
2
backend/db/migrations/0021_admin_user_flag.sql
Normal file
2
backend/db/migrations/0021_admin_user_flag.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "auth_users"
|
||||
ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;
|
||||
2
backend/db/migrations/0022_task_dependencies.sql
Normal file
2
backend/db/migrations/0022_task_dependencies.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "tasks"
|
||||
ADD COLUMN "dependency_ids" jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
2
backend/db/migrations/0023_tax_evaluation_period.sql
Normal file
2
backend/db/migrations/0023_tax_evaluation_period.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "tenants"
|
||||
ADD COLUMN "taxEvaluationPeriod" text DEFAULT 'monthly' NOT NULL;
|
||||
10685
backend/db/migrations/meta/0005_snapshot.json
Normal file
10685
backend/db/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
11525
backend/db/migrations/meta/0017_snapshot.json
Normal file
11525
backend/db/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,34 @@
|
||||
"when": 1773000900000,
|
||||
"tag": "0018_account_chart",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export const authUsers = pgTable("auth_users", {
|
||||
|
||||
multiTenant: boolean("multi_tenant").notNull().default(true),
|
||||
must_change_password: boolean("must_change_password").notNull().default(false),
|
||||
is_admin: boolean("is_admin").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
jsonb,
|
||||
boolean,
|
||||
smallint,
|
||||
doublePrecision,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
@@ -96,7 +97,7 @@ export const createddocuments = pgTable("createddocuments", {
|
||||
|
||||
taxType: text("taxType"),
|
||||
|
||||
customSurchargePercentage: smallint("customSurchargePercentage")
|
||||
customSurchargePercentage: doublePrecision("customSurchargePercentage")
|
||||
.notNull()
|
||||
.default(0),
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ export const files = pgTable("files", {
|
||||
documentbox: uuid("documentbox").references(() => documentboxes.id),
|
||||
|
||||
name: text("name"),
|
||||
extractedText: text("extracted_text"),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
|
||||
@@ -74,6 +74,48 @@ export const tenants = pgTable(
|
||||
timeTracking: true,
|
||||
planningBoard: 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"),
|
||||
@@ -119,6 +161,10 @@ export const tenants = pgTable(
|
||||
.notNull()
|
||||
.default(14),
|
||||
|
||||
taxEvaluationPeriod: text("taxEvaluationPeriod")
|
||||
.notNull()
|
||||
.default("monthly"),
|
||||
|
||||
dokuboxEmailAddresses: jsonb("dokuboxEmailAddresses").default([]),
|
||||
|
||||
dokuboxkey: uuid("dokuboxkey").notNull().defaultRandom(),
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"start": "node dist/src/index.js",
|
||||
"schema:index": "ts-node scripts/generate-schema-index.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": {
|
||||
"type": "git",
|
||||
|
||||
270
backend/scripts/import-members-csv.ts
Normal file
270
backend/scripts/import-members-csv.ts
Normal 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()
|
||||
})
|
||||
265
backend/scripts/import-skr42-accounts.ts
Normal file
265
backend/scripts/import-skr42-accounts.ts
Normal 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
BIN
backend/scripts/skr42.pdf
Normal file
Binary file not shown.
@@ -27,6 +27,10 @@ export function syncDokuboxService (server: FastifyInstance) {
|
||||
let client: ImapFlow | null = null
|
||||
|
||||
async function initDokuboxClient() {
|
||||
if (client?.usable) {
|
||||
return client
|
||||
}
|
||||
|
||||
client = new ImapFlow({
|
||||
host: secrets.DOKUBOX_IMAP_HOST,
|
||||
port: secrets.DOKUBOX_IMAP_PORT,
|
||||
@@ -41,6 +45,7 @@ export function syncDokuboxService (server: FastifyInstance) {
|
||||
console.log("Dokubox E-Mail Client Initialized")
|
||||
|
||||
await client.connect()
|
||||
return client
|
||||
}
|
||||
|
||||
const syncDokubox = async () => {
|
||||
@@ -92,7 +97,8 @@ export function syncDokuboxService (server: FastifyInstance) {
|
||||
if (!badMessageMessageSent) {
|
||||
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) {
|
||||
@@ -248,7 +254,6 @@ export function syncDokuboxService (server: FastifyInstance) {
|
||||
|
||||
return {
|
||||
run: async () => {
|
||||
await initDokuboxClient()
|
||||
await syncDokubox()
|
||||
console.log("Service: Dokubox sync finished")
|
||||
}
|
||||
|
||||
@@ -8,107 +8,20 @@ import {
|
||||
files,
|
||||
filetags,
|
||||
incominginvoices,
|
||||
vendors,
|
||||
} from "../../../db/schema"
|
||||
|
||||
import { eq, and, isNull, not, desc } from "drizzle-orm"
|
||||
import { eq, and, isNull, not } from "drizzle-orm"
|
||||
|
||||
type InvoiceAccount = {
|
||||
account?: number | null
|
||||
description?: string | null
|
||||
taxType?: string | number | null
|
||||
}
|
||||
const formatInvoiceItemDescription = (item: any) => {
|
||||
const parts = [
|
||||
typeof item.description === "string" ? item.description.trim() : "",
|
||||
item.quantity !== null && item.quantity !== undefined
|
||||
? [item.quantity, item.unit].filter(Boolean).join(" ")
|
||||
: (typeof item.unit === "string" ? item.unit.trim() : ""),
|
||||
typeof item.total === "number" ? `${item.total.toFixed(2)} EUR` : "",
|
||||
].filter(Boolean)
|
||||
|
||||
const normalizeAccounts = (accounts: unknown): InvoiceAccount[] => {
|
||||
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,
|
||||
})
|
||||
return parts.join(" - ")
|
||||
}
|
||||
|
||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
@@ -171,34 +84,13 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
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
|
||||
// -------------------------------------------------------------
|
||||
for (const file of filesRes) {
|
||||
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) {
|
||||
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_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.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
|
||||
const mapPayment: any = {
|
||||
@@ -229,16 +121,26 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
|
||||
// 3.2 Positionszeilen konvertieren
|
||||
if (data.invoice_items?.length > 0) {
|
||||
itemInfo.accounts = data.invoice_items.map(item => ({
|
||||
account: item.account_id,
|
||||
description: item.description,
|
||||
amountNet: item.total_without_tax,
|
||||
amountTax: Number((item.total - item.total_without_tax).toFixed(2)),
|
||||
taxType: String(item.tax_rate),
|
||||
amountGross: item.total,
|
||||
costCentre: null,
|
||||
quantity: item.quantity,
|
||||
}))
|
||||
itemInfo.accounts = data.invoice_items
|
||||
.filter(item => item.description || item.total !== null || item.total_without_tax !== null)
|
||||
.map(item => {
|
||||
const total = typeof item.total === "number" ? item.total : null
|
||||
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
|
||||
const amountTax = total !== null && totalWithoutTax !== null
|
||||
? Number((total - totalWithoutTax).toFixed(2))
|
||||
: null
|
||||
|
||||
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
|
||||
@@ -247,7 +149,8 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
if (data.reference) description += `Referenz: ${data.reference}\n`
|
||||
if (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()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import dayjs from "dayjs";
|
||||
import quarterOfYear from "dayjs/plugin/quarterOfYear";
|
||||
import Handlebars from "handlebars";
|
||||
import axios from "axios";
|
||||
import { eq, inArray, and } from "drizzle-orm"; // Drizzle Operatoren
|
||||
|
||||
@@ -10,6 +9,7 @@ import { saveFile } from "../utils/files";
|
||||
import {FastifyInstance} from "fastify";
|
||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
|
||||
import { documentTemplateHandlebars } from "../utils/handlebars";
|
||||
|
||||
dayjs.extend(quarterOfYear);
|
||||
|
||||
@@ -609,8 +609,8 @@ export function getDocumentDataBackend(
|
||||
};
|
||||
};
|
||||
|
||||
const templateStartText = Handlebars.compile(itemInfo.startText || "");
|
||||
const templateEndText = Handlebars.compile(itemInfo.endText || "");
|
||||
const templateStartText = documentTemplateHandlebars.compile(itemInfo.startText || "");
|
||||
const templateEndText = documentTemplateHandlebars.compile(itemInfo.endText || "");
|
||||
|
||||
// --- 6. Title Sums Formatting ---
|
||||
let returnTitleSums: Record<string, string> = {};
|
||||
|
||||
@@ -38,6 +38,11 @@ function normalizeUuid(value: unknown): string | 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) {
|
||||
const [services, products, hourrates] = await Promise.all([
|
||||
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"),
|
||||
workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
|
||||
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
|
||||
materialComposition: Array.isArray(service.materialComposition) ? service.materialComposition as CompositionRow[] : [],
|
||||
personalComposition: Array.isArray(service.personalComposition) ? service.personalComposition as CompositionRow[] : [],
|
||||
materialComposition: sanitizeCompositionRows(service.materialComposition),
|
||||
personalComposition: sanitizeCompositionRows(service.personalComposition),
|
||||
};
|
||||
memo.set(serviceId, lockedResult);
|
||||
return lockedResult;
|
||||
}
|
||||
|
||||
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)
|
||||
? (service.materialComposition as CompositionRow[])
|
||||
: [];
|
||||
const personalComposition: CompositionRow[] = Array.isArray(service.personalComposition)
|
||||
? (service.personalComposition as CompositionRow[])
|
||||
: [];
|
||||
|
||||
let materialTotal = 0;
|
||||
let materialPurchaseTotal = 0;
|
||||
|
||||
const normalizedMaterialComposition = materialComposition.map((entry) => {
|
||||
const quantity = toNumber(entry.quantity);
|
||||
const productId = normalizeId(entry.product);
|
||||
const childServiceId = normalizeId(entry.service);
|
||||
|
||||
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);
|
||||
// Ohne Zusammensetzung keine automatische Überschreibung:
|
||||
// manuell gepflegte Preise sollen erhalten bleiben.
|
||||
if (!hasMaterialComposition && !hasPersonalComposition) {
|
||||
const manualResult = {
|
||||
sellingTotal: getJsonNumber(service.sellingPriceComposed, "total") || toNumber(service.sellingPrice),
|
||||
purchaseTotal: getJsonNumber(service.purchasePriceComposed, "total"),
|
||||
materialTotal: getJsonNumber(service.sellingPriceComposed, "material"),
|
||||
materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
|
||||
workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
|
||||
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
|
||||
materialComposition,
|
||||
personalComposition,
|
||||
};
|
||||
memo.set(serviceId, manualResult);
|
||||
return manualResult;
|
||||
}
|
||||
|
||||
materialTotal += quantity * sellingPrice;
|
||||
materialPurchaseTotal += quantity * purchasePrice;
|
||||
let materialTotal = 0;
|
||||
let materialPurchaseTotal = 0;
|
||||
|
||||
return {
|
||||
...entry,
|
||||
price: round2(sellingPrice),
|
||||
purchasePrice: round2(purchasePrice),
|
||||
};
|
||||
});
|
||||
const normalizedMaterialComposition = materialComposition.map((entry) => {
|
||||
const quantity = toNumber(entry.quantity);
|
||||
const productId = normalizeId(entry.product);
|
||||
const childServiceId = normalizeId(entry.service);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
workerTotal += quantity * sellingPrice;
|
||||
workerPurchaseTotal += quantity * purchasePrice;
|
||||
materialTotal += quantity * sellingPrice;
|
||||
materialPurchaseTotal += quantity * purchasePrice;
|
||||
|
||||
return {
|
||||
...entry,
|
||||
price: round2(sellingPrice),
|
||||
purchasePrice: round2(purchasePrice),
|
||||
return {
|
||||
...entry,
|
||||
price: round2(sellingPrice),
|
||||
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 = {
|
||||
sellingTotal: round2(materialTotal + workerTotal),
|
||||
purchaseTotal: round2(materialPurchaseTotal + workerPurchaseTotal),
|
||||
materialTotal: round2(materialTotal),
|
||||
materialPurchaseTotal: round2(materialPurchaseTotal),
|
||||
workerTotal: round2(workerTotal),
|
||||
workerPurchaseTotal: round2(workerPurchaseTotal),
|
||||
materialComposition: normalizedMaterialComposition,
|
||||
personalComposition: normalizedPersonalComposition,
|
||||
};
|
||||
|
||||
memo.set(serviceId, result);
|
||||
stack.delete(serviceId);
|
||||
return result;
|
||||
memo.set(serviceId, result);
|
||||
return result;
|
||||
} finally {
|
||||
stack.delete(serviceId);
|
||||
}
|
||||
};
|
||||
|
||||
for (const service of services) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { secrets } from "../utils/secrets"
|
||||
import {
|
||||
authUserRoles,
|
||||
authRolePermissions,
|
||||
authUsers,
|
||||
} from "../../db/schema"
|
||||
|
||||
import { eq, and } from "drizzle-orm"
|
||||
@@ -43,6 +44,16 @@ export default fp(async (server: FastifyInstance) => {
|
||||
// Payload an Request hängen
|
||||
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
|
||||
if (!req.user.tenant_id) {
|
||||
return
|
||||
@@ -66,6 +77,13 @@ export default fp(async (server: FastifyInstance) => {
|
||||
.limit(1)
|
||||
|
||||
if (roleRows.length === 0) {
|
||||
if (req.user.is_admin) {
|
||||
req.role = ""
|
||||
req.permissions = []
|
||||
req.hasPermission = () => false
|
||||
return
|
||||
}
|
||||
|
||||
return reply
|
||||
.code(403)
|
||||
.send({ error: "No role assigned for this tenant" })
|
||||
@@ -107,6 +125,7 @@ declare module "fastify" {
|
||||
user_id: string
|
||||
email: string
|
||||
tenant_id: number | null
|
||||
is_admin?: boolean
|
||||
}
|
||||
role: string
|
||||
permissions: string[]
|
||||
|
||||
@@ -58,8 +58,6 @@ const queryConfigPlugin: FastifyPluginAsync<QueryConfigPluginOptions> = async (
|
||||
|
||||
const query = req.query as Record<string, any>
|
||||
|
||||
console.log(query)
|
||||
|
||||
// Pagination deaktivieren?
|
||||
const disablePagination =
|
||||
query.noPagination === 'true' ||
|
||||
|
||||
@@ -1,19 +1,761 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
|
||||
import {
|
||||
authTenantUsers,
|
||||
authProfiles,
|
||||
authRoles,
|
||||
authUserRoles,
|
||||
authUsers,
|
||||
filetags,
|
||||
folders,
|
||||
tenants,
|
||||
} from "../../db/schema";
|
||||
import { generateRandomPassword, hashPassword } from "../utils/password";
|
||||
|
||||
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
|
||||
// -------------------------------------------------------------
|
||||
server.post("/admin/add-user-to-tenant", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const body = req.body as {
|
||||
user_id: string;
|
||||
tenant_id: number;
|
||||
@@ -44,11 +786,10 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
|
||||
await server.db
|
||||
.insert(authTenantUsers)
|
||||
// @ts-ignore
|
||||
.values({
|
||||
user_id: body.user_id,
|
||||
tenantId: body.tenant_id,
|
||||
role: body.role ?? "member",
|
||||
tenant_id: body.tenant_id,
|
||||
created_by: currentUser.id,
|
||||
});
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const { user_id } = req.params as { user_id: string };
|
||||
|
||||
if (!user_id) {
|
||||
|
||||
@@ -31,6 +31,7 @@ export default async function meRoutes(server: FastifyInstance) {
|
||||
email: authUsers.email,
|
||||
created_at: authUsers.created_at,
|
||||
must_change_password: authUsers.must_change_password,
|
||||
is_admin: authUsers.is_admin,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
@@ -51,10 +52,12 @@ export default async function meRoutes(server: FastifyInstance) {
|
||||
name: tenants.name,
|
||||
short: tenants.short,
|
||||
locked: tenants.locked,
|
||||
features: tenants.features,
|
||||
extraModules: tenants.extraModules,
|
||||
businessInfo: tenants.businessInfo,
|
||||
numberRanges: tenants.numberRanges,
|
||||
accountChart: tenants.accountChart,
|
||||
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
|
||||
dokuboxkey: tenants.dokuboxkey,
|
||||
standardEmailForInvoices: tenants.standardEmailForInvoices,
|
||||
standardPaymentDays: tenants.standardPaymentDays,
|
||||
|
||||
@@ -29,6 +29,13 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
const normalizeIban = (value?: string | null) =>
|
||||
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") => {
|
||||
if (!statement) return null
|
||||
|
||||
@@ -60,6 +67,26 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
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) => {
|
||||
if (!iban && !bankAccountId) return 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) => {
|
||||
if (!createdDocumentId) return
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { FastifyInstance } from "fastify"
|
||||
import multipart from "@fastify/multipart"
|
||||
import { s3 } from "../utils/s3"
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand
|
||||
GetObjectCommand
|
||||
} from "@aws-sdk/client-s3"
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||
import archiver from "archiver"
|
||||
import { secrets } from "../utils/secrets"
|
||||
import { saveFile } from "../utils/files"
|
||||
|
||||
import { eq, inArray } from "drizzle-orm"
|
||||
import {
|
||||
@@ -40,39 +40,28 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
const fileBuffer = await data.toBuffer()
|
||||
|
||||
const meta = data.fields?.meta?.value ? JSON.parse(data.fields.meta.value) : {}
|
||||
const { folder = null, type = null, ...otherMeta } = meta
|
||||
|
||||
// 1️⃣ DB-Eintrag erzeugen
|
||||
const inserted = await server.db
|
||||
.insert(files)
|
||||
.values({ tenant: tenantId })
|
||||
.returning()
|
||||
const created = await saveFile(
|
||||
server,
|
||||
tenantId,
|
||||
null,
|
||||
{
|
||||
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")
|
||||
|
||||
// 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 {
|
||||
id: created.id,
|
||||
filename: data.filename,
|
||||
path: fileKey
|
||||
filename: created.filename,
|
||||
path: created.key
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
@@ -248,7 +237,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
// MULTIPLE PRESIGNED URLs
|
||||
// -------------------------------------------------
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return reply.code(400).send({ error: "No ids provided" })
|
||||
return { files: [] }
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
||||
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 { ready as zplReady } from 'zpl-renderer-js'
|
||||
//import { renderZPL } from "zpl-image";
|
||||
@@ -13,9 +18,12 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
|
||||
import duration from "dayjs/plugin/duration.js";
|
||||
import timezone from "dayjs/plugin/timezone.js";
|
||||
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
||||
import {citys} from "../../db/schema";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {citys, files} from "../../db/schema";
|
||||
import {and, eq, isNull, not} from "drizzle-orm";
|
||||
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(isoWeek)
|
||||
dayjs.extend(isBetween)
|
||||
@@ -24,7 +32,40 @@ dayjs.extend(isSameOrBefore)
|
||||
dayjs.extend(duration)
|
||||
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) {
|
||||
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) => {
|
||||
const body = req.body as {
|
||||
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) => {
|
||||
console.log(req.body)
|
||||
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)
|
||||
})
|
||||
|
||||
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) => {
|
||||
|
||||
await server.services.dokuboxSync.run()
|
||||
|
||||
@@ -130,6 +130,12 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any)
|
||||
return whereCond
|
||||
}
|
||||
|
||||
function getTenantColumn(resource: string, table: any) {
|
||||
const config = resourceConfig[resource]
|
||||
const tenantKey = config?.tenantKey || "tenant"
|
||||
return table[tenantKey]
|
||||
}
|
||||
|
||||
function isDateLikeField(key: string) {
|
||||
if (key === "deliveryDateType") return false
|
||||
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 config = resourceConfig[resource]
|
||||
if (!config) {
|
||||
return reply.code(404).send({ error: "Unknown resource" })
|
||||
}
|
||||
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)
|
||||
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 config = resourceConfig[resource];
|
||||
if (!config) {
|
||||
return reply.code(404).send({ error: "Unknown resource" });
|
||||
}
|
||||
const table = config.table;
|
||||
|
||||
const { queryConfig } = req;
|
||||
const { pagination, sort, filters } = queryConfig;
|
||||
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)
|
||||
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
|
||||
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)
|
||||
|
||||
if (search) {
|
||||
|
||||
@@ -217,6 +217,7 @@ export const diffTranslations: Record<
|
||||
web: { label: "Webseite" },
|
||||
email: { label: "E-Mail" },
|
||||
tel: { label: "Telefon" },
|
||||
mobileTel: { label: "Mobilnummer" },
|
||||
ustid: { label: "USt-ID" },
|
||||
role: { label: "Rolle" },
|
||||
phoneHome: { label: "Festnetz" },
|
||||
|
||||
315
backend/src/utils/documentText.ts
Normal file
315
backend/src/utils/documentText.ts
Normal 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;
|
||||
}
|
||||
53
backend/src/utils/filename.ts
Normal file
53
backend/src/utils/filename.ts
Normal 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
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { secrets } from "./secrets"
|
||||
import { files } from "../../db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { storeExtractedTextForFile } from "./documentText"
|
||||
import { sanitizeFilename } from "./filename"
|
||||
|
||||
export const saveFile = async (
|
||||
server: FastifyInstance,
|
||||
@@ -17,6 +19,13 @@ export const saveFile = async (
|
||||
other: Record<string, any> = {}
|
||||
) => {
|
||||
try {
|
||||
const {
|
||||
filename: providedFilename,
|
||||
filesize: _providedFilesize,
|
||||
mimeType: providedMimeType,
|
||||
...dbFields
|
||||
} = other
|
||||
|
||||
// ---------------------------------------------------
|
||||
// 1️⃣ FILE ENTRY ANLEGEN
|
||||
// ---------------------------------------------------
|
||||
@@ -26,7 +35,7 @@ export const saveFile = async (
|
||||
tenant,
|
||||
folder,
|
||||
type,
|
||||
...other
|
||||
...dbFields
|
||||
})
|
||||
.returning()
|
||||
|
||||
@@ -38,13 +47,16 @@ export const saveFile = async (
|
||||
|
||||
// Name ermitteln (Fallback Logik)
|
||||
// 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
|
||||
// ---------------------------------------------------
|
||||
let body: Buffer | Uint8Array | string
|
||||
let contentType = type || "application/octet-stream"
|
||||
let contentType = providedMimeType || "application/octet-stream"
|
||||
|
||||
if (Buffer.isBuffer(attachment)) {
|
||||
// FALL 1: RAW BUFFER (von finishManualGeneration)
|
||||
@@ -83,13 +95,26 @@ export const saveFile = async (
|
||||
// ---------------------------------------------------
|
||||
await server.db
|
||||
.update(files)
|
||||
.set({ path: key })
|
||||
.set({
|
||||
path: key,
|
||||
mimeType: contentType,
|
||||
name: filename,
|
||||
size: body.length
|
||||
})
|
||||
.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}`)
|
||||
return { id: created.id, key }
|
||||
return { id: created.id, key, filename }
|
||||
} catch (err) {
|
||||
console.error("saveFile error:", err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import dayjs from "dayjs";
|
||||
import axios from "axios";
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { zodResponseFormat } from "openai/helpers/zod";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { Blob } from "buffer";
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
import { s3 } from "./s3";
|
||||
import { secrets } from "./secrets";
|
||||
import { storeExtractedTextForFile } from "./documentText";
|
||||
|
||||
// Drizzle schema
|
||||
import { vendors, accounts, tenants } from "../../db/schema";
|
||||
@@ -16,6 +15,9 @@ import {eq} from "drizzle-orm";
|
||||
|
||||
let openai: OpenAI | null = null;
|
||||
|
||||
const nullableString = z.string().trim().nullable();
|
||||
const nullableNumber = z.number().nullable();
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// INITIALIZE OPENAI
|
||||
// ---------------------------------------------------------
|
||||
@@ -41,48 +43,48 @@ async function streamToBuffer(stream: any): Promise<Buffer> {
|
||||
// GPT RESPONSE FORMAT (Zod Schema)
|
||||
// ---------------------------------------------------------
|
||||
const InstructionFormat = z.object({
|
||||
invoice_number: z.string(),
|
||||
invoice_date: z.string(),
|
||||
invoice_duedate: z.string(),
|
||||
invoice_type: z.string(),
|
||||
delivery_type: z.string(),
|
||||
delivery_note_number: z.string(),
|
||||
reference: z.string(),
|
||||
invoice_number: nullableString,
|
||||
invoice_date: nullableString,
|
||||
invoice_duedate: nullableString,
|
||||
invoice_type: nullableString,
|
||||
delivery_type: nullableString,
|
||||
delivery_note_number: nullableString,
|
||||
reference: nullableString,
|
||||
issuer: z.object({
|
||||
id: z.number().nullable().optional(),
|
||||
name: z.string(),
|
||||
address: z.string(),
|
||||
phone: z.string(),
|
||||
email: z.string(),
|
||||
bank: z.string(),
|
||||
bic: z.string(),
|
||||
iban: z.string(),
|
||||
id: nullableNumber.optional(),
|
||||
name: nullableString,
|
||||
address: nullableString,
|
||||
phone: nullableString,
|
||||
email: nullableString,
|
||||
bank: nullableString,
|
||||
bic: nullableString,
|
||||
iban: nullableString,
|
||||
}),
|
||||
recipient: z.object({
|
||||
name: z.string(),
|
||||
address: z.string(),
|
||||
phone: z.string(),
|
||||
email: z.string(),
|
||||
name: nullableString,
|
||||
address: nullableString,
|
||||
phone: nullableString,
|
||||
email: nullableString,
|
||||
}),
|
||||
invoice_items: z.array(
|
||||
z.object({
|
||||
description: z.string(),
|
||||
unit: z.string(),
|
||||
quantity: z.number(),
|
||||
total: z.number(),
|
||||
total_without_tax: z.number(),
|
||||
tax_rate: z.number(),
|
||||
ean: z.number().nullable().optional(),
|
||||
article_number: z.number().nullable().optional(),
|
||||
account_number: z.number().nullable().optional(),
|
||||
account_id: z.number().nullable().optional(),
|
||||
description: nullableString,
|
||||
unit: nullableString,
|
||||
quantity: nullableNumber,
|
||||
total: nullableNumber,
|
||||
total_without_tax: nullableNumber,
|
||||
tax_rate: nullableNumber,
|
||||
ean: nullableNumber.optional(),
|
||||
article_number: nullableNumber.optional(),
|
||||
account_number: nullableNumber.optional(),
|
||||
account_id: nullableNumber.optional(),
|
||||
})
|
||||
),
|
||||
subtotal: z.number(),
|
||||
tax_rate: z.number(),
|
||||
tax: z.number(),
|
||||
total: z.number(),
|
||||
terms: z.string(),
|
||||
subtotal: nullableNumber,
|
||||
tax_rate: nullableNumber,
|
||||
tax: nullableNumber,
|
||||
total: nullableNumber,
|
||||
terms: nullableString,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
@@ -91,8 +93,7 @@ const InstructionFormat = z.object({
|
||||
export const getInvoiceDataFromGPT = async function (
|
||||
server: FastifyInstance,
|
||||
file: any,
|
||||
tenantId: number,
|
||||
learningContext?: string
|
||||
tenantId: number
|
||||
) {
|
||||
await initOpenAi();
|
||||
|
||||
@@ -126,32 +127,27 @@ export const getInvoiceDataFromGPT = async function (
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileBlob = new Blob([fileData], { type: "application/pdf" });
|
||||
let extractedText = file.extractedText;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 2) SEND FILE TO PDF → TEXT API
|
||||
// ---------------------------------------------------------
|
||||
const form = new FormData();
|
||||
form.append("fileInput", fileBlob, file.path.split("/").pop());
|
||||
form.append("outputFormat", "txt");
|
||||
if (!extractedText?.trim()) {
|
||||
try {
|
||||
const result = await storeExtractedTextForFile(
|
||||
server,
|
||||
file.id,
|
||||
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;
|
||||
|
||||
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);
|
||||
if (!extractedText?.trim()) {
|
||||
server.log.warn(`No extractable PDF text found for file ${file.id}. Scanned PDFs require OCR.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -198,13 +194,16 @@ export const getInvoiceDataFromGPT = async function (
|
||||
"You extract structured invoice data.\n\n" +
|
||||
`VENDORS: ${JSON.stringify(vendorList)}\n` +
|
||||
`ACCOUNTS: ${JSON.stringify(accountList)}\n\n` +
|
||||
(learningContext
|
||||
? `HISTORICAL_PATTERNS: ${learningContext}\n\n`
|
||||
: "") +
|
||||
"Use only values that are explicitly present in the invoice text.\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 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" +
|
||||
"Keep invoice items in original order.\n",
|
||||
},
|
||||
|
||||
26
backend/src/utils/handlebars.ts
Normal file
26
backend/src/utils/handlebars.ts
Normal 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();
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
accounts,
|
||||
authProfiles,
|
||||
bankaccounts,
|
||||
bankrequisitions,
|
||||
bankstatements,
|
||||
entitybankaccounts,
|
||||
events,
|
||||
contacts,
|
||||
contracts,
|
||||
contracttypes,
|
||||
@@ -166,6 +168,16 @@ export const resourceConfig = {
|
||||
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: {
|
||||
table: letterheads,
|
||||
|
||||
|
||||
BIN
backend/tmp-invoice-1453.png
Normal file
BIN
backend/tmp-invoice-1453.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 291 KiB |
@@ -1,7 +1,9 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
primary: 'green',
|
||||
gray: 'slate',
|
||||
colors: {
|
||||
primary: 'green',
|
||||
neutral: 'slate'
|
||||
},
|
||||
tooltip: {
|
||||
background: '!bg-background'
|
||||
},
|
||||
@@ -35,4 +37,4 @@ export default defineAppConfig({
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,14 +47,16 @@ useSeoMeta({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="safearea">
|
||||
<NuxtLayout>
|
||||
<NuxtPage/>
|
||||
</NuxtLayout>
|
||||
<UNotifications/>
|
||||
<USlideovers />
|
||||
<UModals/>
|
||||
</div>
|
||||
<UApp>
|
||||
<div class="safearea">
|
||||
<NuxtLayout>
|
||||
<NuxtPage/>
|
||||
</NuxtLayout>
|
||||
<UNotifications/>
|
||||
<USlideovers />
|
||||
<UModals/>
|
||||
</div>
|
||||
</UApp>
|
||||
|
||||
|
||||
|
||||
@@ -136,4 +138,4 @@ useSeoMeta({
|
||||
.scroll {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
26
frontend/assets/css/main.css
Normal file
26
frontend/assets/css/main.css
Normal 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);
|
||||
}
|
||||
@@ -38,37 +38,39 @@ const emitConfirm = () => {
|
||||
>
|
||||
Archivieren
|
||||
</UButton>
|
||||
<UModal v-model="showModal">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<span class="text-md font-bold">Archivieren bestätigen</span>
|
||||
</template>
|
||||
Möchten Sie diese/-s/-n {{dataType.labelSingle}} wirklich archivieren?
|
||||
<UModal v-model:open="showModal">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<span class="text-md font-bold">Archivieren bestätigen</span>
|
||||
</template>
|
||||
Möchten Sie diese/-s/-n {{dataType.labelSingle}} wirklich archivieren?
|
||||
|
||||
<template #footer>
|
||||
<div class="text-right">
|
||||
<UButtonGroup>
|
||||
<UButton
|
||||
variant="outline"
|
||||
@click="showModal = false"
|
||||
>
|
||||
Abbrechen
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="emitConfirm"
|
||||
class="ml-2"
|
||||
color="rose"
|
||||
>
|
||||
Archivieren
|
||||
</UButton>
|
||||
</UButtonGroup>
|
||||
<template #footer>
|
||||
<div class="text-right">
|
||||
<UButtonGroup>
|
||||
<UButton
|
||||
variant="outline"
|
||||
@click="showModal = false"
|
||||
>
|
||||
Abbrechen
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="emitConfirm"
|
||||
class="ml-2"
|
||||
color="error"
|
||||
>
|
||||
Archivieren
|
||||
</UButton>
|
||||
</UButtonGroup>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -49,7 +49,7 @@ const assignByIban = async () => {
|
||||
|
||||
const match = accounts.value.find((a) => normalizeIban(a.iban) === search)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ const removeAssigned = (id) => {
|
||||
|
||||
const createAndAssign = async () => {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -140,43 +140,45 @@ loadAccounts()
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
<UModal v-model="showCreate">
|
||||
<UCard>
|
||||
<template #header>Neue Bankverbindung erstellen</template>
|
||||
<div class="space-y-3">
|
||||
<UFormGroup label="IBAN">
|
||||
<InputGroup>
|
||||
<UInput
|
||||
v-model="createPayload.iban"
|
||||
@blur="resolveCreatePayloadFromIban"
|
||||
@keydown.enter.prevent="resolveCreatePayloadFromIban"
|
||||
/>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="outline"
|
||||
:loading="resolvingIban"
|
||||
@click="resolveCreatePayloadFromIban"
|
||||
>
|
||||
Ermitteln
|
||||
</UButton>
|
||||
</InputGroup>
|
||||
</UFormGroup>
|
||||
<UFormGroup label="BIC">
|
||||
<UInput v-model="createPayload.bic" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Bankinstitut">
|
||||
<UInput v-model="createPayload.bankName" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Beschreibung (optional)">
|
||||
<UInput v-model="createPayload.description" />
|
||||
</UFormGroup>
|
||||
</div>
|
||||
<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>
|
||||
<UModal v-model:open="showCreate">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>Neue Bankverbindung erstellen</template>
|
||||
<div class="space-y-3">
|
||||
<UFormField label="IBAN">
|
||||
<InputGroup>
|
||||
<UInput
|
||||
v-model="createPayload.iban"
|
||||
@blur="resolveCreatePayloadFromIban"
|
||||
@keydown.enter.prevent="resolveCreatePayloadFromIban"
|
||||
/>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="outline"
|
||||
:loading="resolvingIban"
|
||||
@click="resolveCreatePayloadFromIban"
|
||||
>
|
||||
Ermitteln
|
||||
</UButton>
|
||||
</InputGroup>
|
||||
</UFormField>
|
||||
<UFormField label="BIC">
|
||||
<UInput v-model="createPayload.bic" />
|
||||
</UFormField>
|
||||
<UFormField label="Bankinstitut">
|
||||
<UInput v-model="createPayload.bankName" />
|
||||
</UFormField>
|
||||
<UFormField label="Beschreibung (optional)">
|
||||
<UInput v-model="createPayload.description" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
<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>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
@@ -31,36 +31,38 @@ const emitConfirm = () => {
|
||||
>
|
||||
<slot name="button"></slot>
|
||||
</UButton>
|
||||
<UModal v-model="showModal">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<slot name="header"></slot>
|
||||
</template>
|
||||
<slot/>
|
||||
<template #footer>
|
||||
<div class="text-right">
|
||||
<UButtonGroup>
|
||||
<UButton
|
||||
variant="outline"
|
||||
@click="showModal = false"
|
||||
>
|
||||
Abbrechen
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="emitConfirm"
|
||||
class="ml-2"
|
||||
color="rose"
|
||||
>
|
||||
Archivieren
|
||||
</UButton>
|
||||
</UButtonGroup>
|
||||
<UModal v-model:open="showModal">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<slot name="header"></slot>
|
||||
</template>
|
||||
<slot/>
|
||||
<template #footer>
|
||||
<div class="text-right">
|
||||
<UButtonGroup>
|
||||
<UButton
|
||||
variant="outline"
|
||||
@click="showModal = false"
|
||||
>
|
||||
Abbrechen
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="emitConfirm"
|
||||
class="ml-2"
|
||||
color="error"
|
||||
>
|
||||
Archivieren
|
||||
</UButton>
|
||||
</UButtonGroup>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
<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 (+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 (-7%)"><UButton color="rose" variant="soft" block size="xs" @click="removeTax(7)">-7%</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="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="Speicher +"><UButton color="gray" variant="ghost" block @click="addToSum">M+</UButton></UTooltip>
|
||||
@@ -227,9 +227,14 @@ defineShortcuts({
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
background: transparent;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -27,6 +27,11 @@ const date = computed({
|
||||
}
|
||||
})
|
||||
|
||||
const selectToday = () => {
|
||||
emit('update:model-value', new Date())
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const attrs = [{
|
||||
key: 'today',
|
||||
highlight: {
|
||||
@@ -37,18 +42,31 @@ const attrs = [{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCalendarDatePicker
|
||||
show-weeknumbers
|
||||
v-model="date"
|
||||
:mode="props.mode"
|
||||
is24hr
|
||||
transparent
|
||||
borderless
|
||||
color="green"
|
||||
:attributes="attrs"
|
||||
:is-dark="isDark"
|
||||
title-position="left"
|
||||
trim-weeks
|
||||
:first-day-of-week="2"
|
||||
/>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<VCalendarDatePicker
|
||||
show-weeknumbers
|
||||
v-model="date"
|
||||
:mode="props.mode"
|
||||
is24hr
|
||||
transparent
|
||||
borderless
|
||||
color="green"
|
||||
:attributes="attrs"
|
||||
:is-dark="isDark"
|
||||
title-position="left"
|
||||
trim-weeks
|
||||
:first-day-of-week="2"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -156,7 +156,8 @@ const moveFile = async () => {
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -186,7 +187,7 @@ const moveFile = async () => {
|
||||
<div class="w-2/3 p-5" v-if="!false">
|
||||
<UButtonGroup>
|
||||
<ArchiveButton
|
||||
color="rose"
|
||||
color="error"
|
||||
variant="outline"
|
||||
type="files"
|
||||
@confirmed="archiveDocument"
|
||||
@@ -202,7 +203,7 @@ const moveFile = async () => {
|
||||
</UButton>
|
||||
</UButtonGroup>
|
||||
|
||||
<UDivider>Zuweisungen</UDivider>
|
||||
<USeparator label="Zuweisungen"/>
|
||||
<table class="w-full">
|
||||
<tr v-if="props.documentData.project">
|
||||
<td>Projekt</td>
|
||||
@@ -278,44 +279,44 @@ const moveFile = async () => {
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<UDivider class="my-3">Datei zuweisen</UDivider>
|
||||
<USeparator class="my-3" label="Datei zuweisen"/>
|
||||
|
||||
<UFormGroup
|
||||
<UFormField
|
||||
label="Resource auswählen"
|
||||
>
|
||||
<USelectMenu
|
||||
:options="resourceOptions"
|
||||
:items="resourceOptions"
|
||||
v-model="resourceToAssign"
|
||||
value-attribute="value"
|
||||
option-attribute="label"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
@change="getItemsBySelectedResource"
|
||||
>
|
||||
|
||||
</USelectMenu>
|
||||
</UFormGroup>
|
||||
<UFormGroup
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Eintrag auswählen:"
|
||||
>
|
||||
<USelectMenu
|
||||
:options="itemOptions"
|
||||
:items="itemOptions"
|
||||
v-model="idToAssign"
|
||||
:option-attribute="resourceOptions.find(i => i.value === resourceToAssign)? resourceOptions.find(i => i.value === resourceToAssign).optionAttr : 'name'"
|
||||
value-attribute="id"
|
||||
:label-key="resourceOptions.find(i => i.value === resourceToAssign)? resourceOptions.find(i => i.value === resourceToAssign).optionAttr : 'name'"
|
||||
value-key="id"
|
||||
@change="updateDocumentAssignment"
|
||||
></USelectMenu>
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
|
||||
|
||||
|
||||
<UDivider class="my-5">Datei verschieben</UDivider>
|
||||
<USeparator class="my-5" label="Datei verschieben"/>
|
||||
|
||||
<InputGroup class="w-full">
|
||||
<USelectMenu
|
||||
class="flex-auto"
|
||||
v-model="folderToMoveTo"
|
||||
value-attribute="id"
|
||||
option-attribute="name"
|
||||
:options="folders"
|
||||
value-key="id"
|
||||
label-key="name"
|
||||
:items="folders"
|
||||
/>
|
||||
<UButton
|
||||
@click="moveFile"
|
||||
@@ -324,34 +325,35 @@ const moveFile = async () => {
|
||||
>Verschieben</UButton>
|
||||
</InputGroup>
|
||||
|
||||
<UDivider class="my-5">Dateityp</UDivider>
|
||||
<USeparator class="my-5" label="Dateityp"/>
|
||||
|
||||
<InputGroup class="w-full">
|
||||
<USelectMenu
|
||||
class="flex-auto"
|
||||
v-model="props.documentData.type"
|
||||
value-attribute="id"
|
||||
option-attribute="name"
|
||||
:options="filetypes"
|
||||
value-key="id"
|
||||
label-key="name"
|
||||
:items="filetypes"
|
||||
@change="updateDocument"
|
||||
/>
|
||||
</InputGroup>
|
||||
<UDivider class="my-5">Dokumentenbox</UDivider>
|
||||
<USeparator class="my-5" label="Dokumentenbox" />
|
||||
|
||||
<InputGroup class="w-full">
|
||||
<USelectMenu
|
||||
class="flex-auto"
|
||||
v-model="props.documentData.documentbox"
|
||||
value-attribute="id"
|
||||
option-attribute="key"
|
||||
:options="documentboxes"
|
||||
value-key="id"
|
||||
label-key="key"
|
||||
:items="documentboxes"
|
||||
@change="updateDocument"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
@@ -362,4 +364,4 @@ const moveFile = async () => {
|
||||
aspect-ratio: 1/ 1.414;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -78,84 +78,86 @@ const fileNames = computed(() => {
|
||||
|
||||
<template>
|
||||
<UModal>
|
||||
<div ref="dropZoneRef" class="relative h-full flex flex-col">
|
||||
<template #content>
|
||||
<div ref="dropZoneRef" class="relative h-full flex flex-col">
|
||||
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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'"
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<UInput
|
||||
v-if="selectedFiles.length === 0"
|
||||
type="file"
|
||||
id="fileUploadInput"
|
||||
multiple
|
||||
accept="image/jpeg, image/png, image/gif, application/pdf"
|
||||
@change="onFileInputChange"
|
||||
/>
|
||||
<span class="text-xl font-bold text-primary-600 bg-white/80 px-4 py-2 rounded shadow-sm">
|
||||
Dateien hier ablegen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedFiles.length > 0" class="mt-2 text-sm text-gray-500">
|
||||
Ausgewählt: <span class="font-medium text-gray-700 dark:text-gray-300">{{ fileNames }}</span>
|
||||
</div>
|
||||
</UFormGroup>
|
||||
<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="Typ:"
|
||||
class="mt-3"
|
||||
>
|
||||
<USelectMenu
|
||||
option-attribute="name"
|
||||
value-attribute="id"
|
||||
searchable
|
||||
searchable-placeholder="Suchen..."
|
||||
:options="availableFiletypes"
|
||||
v-model="props.fileData.type"
|
||||
:disabled="!props.fileData.typeEnabled"
|
||||
<UFormField
|
||||
label="Datei:"
|
||||
:help="selectedFiles.length > 0 ? `${selectedFiles.length} Datei(en) ausgewählt` : 'Ziehen Sie Dateien hierher oder klicken Sie'"
|
||||
>
|
||||
<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>
|
||||
</UFormGroup>
|
||||
<UInput
|
||||
v-if="selectedFiles.length === 0"
|
||||
type="file"
|
||||
id="fileUploadInput"
|
||||
multiple
|
||||
accept="image/jpeg, image/png, image/gif, application/pdf"
|
||||
@change="onFileInputChange"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<UButton
|
||||
@click="uploadFiles"
|
||||
:loading="uploadInProgress"
|
||||
:disabled="uploadInProgress || selectedFiles.length === 0"
|
||||
>Hochladen</UButton>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
<div v-if="selectedFiles.length > 0" class="mt-2 text-sm text-gray-500">
|
||||
Ausgewählt: <span class="font-medium text-gray-700 dark:text-gray-300">{{ fileNames }}</span>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Typ:"
|
||||
class="mt-3"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Optional: Animationen für das Overlay */
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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 () => {
|
||||
let ret = null
|
||||
@@ -264,7 +280,7 @@ const updateItem = async () => {
|
||||
</template>
|
||||
<template #right>
|
||||
<ArchiveButton
|
||||
color="rose"
|
||||
color="error"
|
||||
v-if="platform !== 'mobile'"
|
||||
variant="outline"
|
||||
:type="type"
|
||||
@@ -336,12 +352,12 @@ const updateItem = async () => {
|
||||
v-for="(columnName,index) in dataType.inputColumns"
|
||||
:class="platform === 'mobile' ? ['w-full'] : [`w-1/${dataType.inputColumns.length}`, ... index < dataType.inputColumns.length -1 ? ['mr-5'] : []]"
|
||||
>
|
||||
<UDivider>{{ columnName }}</UDivider>
|
||||
<USeparator :label="columnName"/>
|
||||
|
||||
<div
|
||||
v-for="datapoint in dataType.templateColumns.filter(i => i.inputType && i.inputColumn === columnName)"
|
||||
>
|
||||
<UFormGroup
|
||||
<UFormField
|
||||
v-if="(datapoint.showFunction ? datapoint.showFunction(item) : true)"
|
||||
:label="datapoint.label"
|
||||
>
|
||||
@@ -354,7 +370,7 @@ const updateItem = async () => {
|
||||
</template>
|
||||
<InputGroup class="w-full" v-if="datapoint.key.includes('.')">
|
||||
<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"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
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>
|
||||
</template>
|
||||
</UInput>
|
||||
<UToggle
|
||||
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'primary'"
|
||||
<USwitch
|
||||
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'primary'"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-else-if="datapoint.inputType === 'bool'"
|
||||
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
/>
|
||||
<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"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-else-if="datapoint.inputType === 'select'"
|
||||
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
:option-attribute="datapoint.selectOptionAttribute"
|
||||
:value-attribute="datapoint.selectValueAttribute || 'id'"
|
||||
:options="datapoint.selectManualOptions || loadedOptions[datapoint.selectDataType]"
|
||||
:searchable="datapoint.selectSearchAttributes"
|
||||
:search-attributes="datapoint.selectSearchAttributes"
|
||||
:items="getSelectItems(datapoint)"
|
||||
:label-key="getSelectLabelKey(datapoint)"
|
||||
:value-key="getSelectValueKey(datapoint)"
|
||||
:search-input="getSelectSearchInput(datapoint)"
|
||||
:filter-fields="datapoint.selectSearchAttributes"
|
||||
:multiple="datapoint.selectMultiple"
|
||||
>
|
||||
<template #empty>
|
||||
@@ -393,7 +409,7 @@ const updateItem = async () => {
|
||||
</template>
|
||||
</USelectMenu>
|
||||
<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"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-else-if="datapoint.inputType === 'textarea'"
|
||||
@@ -401,9 +417,9 @@ const updateItem = async () => {
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
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
|
||||
: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"
|
||||
: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"
|
||||
@@ -411,17 +427,17 @@ const updateItem = async () => {
|
||||
|
||||
/>
|
||||
|
||||
<template #panel="{ close }">
|
||||
<template #content>
|
||||
<LazyDatePicker
|
||||
@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"
|
||||
/>
|
||||
</template>
|
||||
</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
|
||||
: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"
|
||||
: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"
|
||||
@@ -429,10 +445,10 @@ const updateItem = async () => {
|
||||
|
||||
/>
|
||||
|
||||
<template #panel="{ close }">
|
||||
<template #content>
|
||||
<LazyDatePicker
|
||||
@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"
|
||||
mode="datetime"
|
||||
/>
|
||||
@@ -460,7 +476,7 @@ const updateItem = async () => {
|
||||
<InputGroup class="w-full" v-else>
|
||||
<UInput
|
||||
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"
|
||||
v-if="['text','number'].includes(datapoint.inputType)"
|
||||
v-model="item[datapoint.key]"
|
||||
@@ -472,34 +488,33 @@ const updateItem = async () => {
|
||||
{{ datapoint.inputTrailing }}
|
||||
</template>
|
||||
</UInput>
|
||||
<UToggle
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'primary'"
|
||||
<USwitch
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'primary'"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-else-if="datapoint.inputType === 'bool'"
|
||||
v-model="item[datapoint.key]"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
/>
|
||||
<USelectMenu
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
|
||||
class="flex-auto"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-else-if="datapoint.inputType === 'select'"
|
||||
v-model="item[datapoint.key]"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
:option-attribute="datapoint.selectOptionAttribute"
|
||||
:value-attribute="datapoint.selectValueAttribute || 'id'"
|
||||
:options="datapoint.selectManualOptions || loadedOptions[datapoint.selectDataType]"
|
||||
:searchable="datapoint.selectSearchAttributes"
|
||||
:search-attributes="datapoint.selectSearchAttributes"
|
||||
:items="getSelectItems(datapoint)"
|
||||
:label-key="getSelectLabelKey(datapoint)"
|
||||
:value-key="getSelectValueKey(datapoint)"
|
||||
:search-input="getSelectSearchInput(datapoint)"
|
||||
:filter-fields="datapoint.selectSearchAttributes"
|
||||
:multiple="datapoint.selectMultiple"
|
||||
searchable-placeholder="Suche..."
|
||||
>
|
||||
<template #empty>
|
||||
Keine Optionen verfügbar
|
||||
</template>
|
||||
</USelectMenu>
|
||||
<UTextarea
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
|
||||
class="flex-auto"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-else-if="datapoint.inputType === 'textarea'"
|
||||
@@ -507,37 +522,36 @@ const updateItem = async () => {
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
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
|
||||
: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"
|
||||
:label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YYYY') : 'Datum auswählen'"
|
||||
variant="outline"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
/>
|
||||
|
||||
<template #panel="{ close }">
|
||||
<template #content>
|
||||
<LazyDatePicker
|
||||
@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"
|
||||
/>
|
||||
</template>
|
||||
</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
|
||||
: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"
|
||||
:label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YY HH:mm') : 'Datum auswählen'"
|
||||
variant="outline"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
/>
|
||||
|
||||
<template #panel="{ close }">
|
||||
<template #content>
|
||||
<LazyDatePicker
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-model="item[datapoint.key]"
|
||||
@close="close"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
mode="datetime"
|
||||
/>
|
||||
@@ -572,11 +586,11 @@ const updateItem = async () => {
|
||||
icon="i-heroicons-x-mark"
|
||||
/>
|
||||
</InputGroup>
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UFormGroup
|
||||
<UFormField
|
||||
v-for="datapoint in dataType.templateColumns.filter(i => i.inputType && !i.inputColumn)"
|
||||
:label="datapoint.label"
|
||||
>
|
||||
@@ -589,7 +603,7 @@ const updateItem = async () => {
|
||||
</template>
|
||||
<InputGroup class="w-full" v-if="datapoint.key.includes('.')">
|
||||
<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"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
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>
|
||||
</template>
|
||||
</UInput>
|
||||
<UToggle
|
||||
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'rose') : 'primary'"
|
||||
<USwitch
|
||||
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'primary'"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-else-if="datapoint.inputType === 'bool'"
|
||||
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
/>
|
||||
<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"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-else-if="datapoint.inputType === 'select'"
|
||||
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
:option-attribute="datapoint.selectOptionAttribute"
|
||||
:value-attribute="datapoint.selectValueAttribute || 'id'"
|
||||
:options="datapoint.selectManualOptions || loadedOptions[datapoint.selectDataType]"
|
||||
:searchable="datapoint.selectSearchAttributes"
|
||||
:search-attributes="datapoint.selectSearchAttributes"
|
||||
:items="getSelectItems(datapoint)"
|
||||
:label-key="getSelectLabelKey(datapoint)"
|
||||
:value-key="getSelectValueKey(datapoint)"
|
||||
:search-input="getSelectSearchInput(datapoint)"
|
||||
:filter-fields="datapoint.selectSearchAttributes"
|
||||
:multiple="datapoint.selectMultiple"
|
||||
>
|
||||
<template #empty>
|
||||
@@ -628,7 +642,7 @@ const updateItem = async () => {
|
||||
</template>
|
||||
</USelectMenu>
|
||||
<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"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-else-if="datapoint.inputType === 'textarea'"
|
||||
@@ -636,9 +650,9 @@ const updateItem = async () => {
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
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
|
||||
: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"
|
||||
: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"
|
||||
@@ -646,17 +660,17 @@ const updateItem = async () => {
|
||||
|
||||
/>
|
||||
|
||||
<template #panel="{ close }">
|
||||
<template #content>
|
||||
<LazyDatePicker
|
||||
@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"
|
||||
/>
|
||||
</template>
|
||||
</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
|
||||
: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"
|
||||
: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"
|
||||
@@ -664,10 +678,10 @@ const updateItem = async () => {
|
||||
|
||||
/>
|
||||
|
||||
<template #panel="{ close }">
|
||||
<template #content>
|
||||
<LazyDatePicker
|
||||
@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"
|
||||
mode="datetime"
|
||||
/>
|
||||
@@ -695,7 +709,7 @@ const updateItem = async () => {
|
||||
<InputGroup class="w-full" v-else>
|
||||
<UInput
|
||||
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"
|
||||
v-if="['text','number'].includes(datapoint.inputType)"
|
||||
v-model="item[datapoint.key]"
|
||||
@@ -707,34 +721,33 @@ const updateItem = async () => {
|
||||
{{ datapoint.inputTrailing }}
|
||||
</template>
|
||||
</UInput>
|
||||
<UToggle
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'primary'"
|
||||
<USwitch
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'primary'"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-else-if="datapoint.inputType === 'bool'"
|
||||
v-model="item[datapoint.key]"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
/>
|
||||
<USelectMenu
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
|
||||
class="flex-auto"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-else-if="datapoint.inputType === 'select'"
|
||||
v-model="item[datapoint.key]"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
:option-attribute="datapoint.selectOptionAttribute"
|
||||
:value-attribute="datapoint.selectValueAttribute || 'id'"
|
||||
:options="datapoint.selectManualOptions || loadedOptions[datapoint.selectDataType]"
|
||||
:searchable="datapoint.selectSearchAttributes"
|
||||
:search-attributes="datapoint.selectSearchAttributes"
|
||||
:items="getSelectItems(datapoint)"
|
||||
:label-key="getSelectLabelKey(datapoint)"
|
||||
:value-key="getSelectValueKey(datapoint)"
|
||||
:search-input="getSelectSearchInput(datapoint)"
|
||||
:filter-fields="datapoint.selectSearchAttributes"
|
||||
:multiple="datapoint.selectMultiple"
|
||||
searchable-placeholder="Suche..."
|
||||
>
|
||||
<template #empty>
|
||||
Keine Optionen verfügbar
|
||||
</template>
|
||||
</USelectMenu>
|
||||
<UTextarea
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'rose') : 'white'"
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
|
||||
class="flex-auto"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-else-if="datapoint.inputType === 'textarea'"
|
||||
@@ -742,37 +755,36 @@ const updateItem = async () => {
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
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
|
||||
: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"
|
||||
:label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YYYY') : 'Datum auswählen'"
|
||||
variant="outline"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
/>
|
||||
|
||||
<template #panel="{ close }">
|
||||
<template #content>
|
||||
<LazyDatePicker
|
||||
@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"
|
||||
/>
|
||||
</template>
|
||||
</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
|
||||
: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"
|
||||
:label="item[datapoint.key] ? dayjs(item[datapoint.key]).format('DD.MM.YY HH:mm') : 'Datum auswählen'"
|
||||
variant="outline"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
/>
|
||||
|
||||
<template #panel="{ close }">
|
||||
<template #content>
|
||||
<LazyDatePicker
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
v-model="item[datapoint.key]"
|
||||
@close="close"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
mode="datetime"
|
||||
/>
|
||||
@@ -807,7 +819,7 @@ const updateItem = async () => {
|
||||
icon="i-heroicons-x-mark"
|
||||
/>
|
||||
</InputGroup>
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
</UForm>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
|
||||
@@ -110,12 +110,6 @@ const filteredRows = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingActionButton
|
||||
:label="`+ ${dataType.labelSingle}`"
|
||||
variant="outline"
|
||||
v-if="platform === 'mobile'"
|
||||
@click="router.push(`/standardEntity/${type}/create`)"
|
||||
/>
|
||||
<UDashboardNavbar :title="dataType.label" :badge="filteredRows.length">
|
||||
<template #toggle>
|
||||
<div v-if="platform === 'mobile'"></div>
|
||||
@@ -138,7 +132,7 @@ const filteredRows = computed(() => {
|
||||
<UButton
|
||||
icon="i-heroicons-x-mark"
|
||||
variant="outline"
|
||||
color="rose"
|
||||
color="error"
|
||||
@click="clearSearchString()"
|
||||
v-if="searchString.length > 0"
|
||||
/>
|
||||
@@ -161,15 +155,15 @@ const filteredRows = computed(() => {
|
||||
<USelectMenu
|
||||
v-model="selectedColumns"
|
||||
icon="i-heroicons-adjustments-horizontal-solid"
|
||||
:options="dataType.templateColumns.filter(i => !i.disabledInTable)"
|
||||
:items="dataType.templateColumns.filter(i => !i.disabledInTable)"
|
||||
multiple
|
||||
class="hidden lg:block"
|
||||
by="key"
|
||||
: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)"
|
||||
>
|
||||
<template #label>
|
||||
<template #default>
|
||||
Spalten
|
||||
</template>
|
||||
</USelectMenu>
|
||||
@@ -178,11 +172,11 @@ const filteredRows = computed(() => {
|
||||
icon="i-heroicons-adjustments-horizontal-solid"
|
||||
multiple
|
||||
v-model="selectedFilters"
|
||||
:options="selectableFilters"
|
||||
:items="selectableFilters"
|
||||
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
|
||||
:ui-menu="{ width: 'min-w-max' }"
|
||||
:content="{ width: 'min-w-max' }"
|
||||
>
|
||||
<template #label>
|
||||
<template #default>
|
||||
Filter
|
||||
</template>
|
||||
</USelectMenu>
|
||||
@@ -191,14 +185,14 @@ const filteredRows = computed(() => {
|
||||
<EntityTableMobile
|
||||
v-if="platform === 'mobile'"
|
||||
:type="props.type"
|
||||
:columns="columns"
|
||||
:columns="normalizeTableColumns(columns)"
|
||||
:rows="filteredRows"
|
||||
/>
|
||||
<EntityTable
|
||||
v-else
|
||||
@sort="(i) => emit('sort',i)"
|
||||
:type="props.type"
|
||||
:columns="columns"
|
||||
:columns="normalizeTableColumns(columns)"
|
||||
:rows="filteredRows"
|
||||
:loading="props.loading"
|
||||
/>
|
||||
|
||||
@@ -28,14 +28,16 @@ defineShortcuts({
|
||||
router.back()
|
||||
},
|
||||
'arrowleft': () => {
|
||||
if(openTab.value > 0){
|
||||
openTab.value -= 1
|
||||
const currentIndex = Number(openTab.value)
|
||||
if(currentIndex > 0){
|
||||
openTab.value = String(currentIndex - 1)
|
||||
router.push(`${router.currentRoute.value.path}?tabIndex=${openTab.value}`)
|
||||
}
|
||||
},
|
||||
'arrowright': () => {
|
||||
if(openTab.value < dataType.showTabs.length - 1) {
|
||||
openTab.value += 1
|
||||
const currentIndex = Number(openTab.value)
|
||||
if(currentIndex < dataType.showTabs.length - 1) {
|
||||
openTab.value = String(currentIndex + 1)
|
||||
router.push(`${router.currentRoute.value.path}?tabIndex=${openTab.value}`)
|
||||
}
|
||||
},
|
||||
@@ -51,7 +53,7 @@ const auth = useAuthStore()
|
||||
|
||||
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) => {
|
||||
router.push(`${router.currentRoute.value.path}?tabIndex=${index}`)
|
||||
openTab.value = String(index)
|
||||
router.push(`${router.currentRoute.value.path}?tabIndex=${openTab.value}`)
|
||||
}
|
||||
|
||||
const changePinned = async () => {
|
||||
@@ -255,9 +258,9 @@ const openCustomerInventoryLabelPrint = () => {
|
||||
v-if="props.item.id && platform !== 'mobile'"
|
||||
class="p-5"
|
||||
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">
|
||||
|
||||
<EntityShowSubInformation
|
||||
|
||||
@@ -96,15 +96,15 @@ setup()
|
||||
<USelectMenu
|
||||
v-model="selectedColumns"
|
||||
icon="i-heroicons-adjustments-horizontal-solid"
|
||||
:options="dataType.templateColumns.filter(i => !i.disabledInTable)"
|
||||
:items="dataType.templateColumns.filter(i => !i.disabledInTable)"
|
||||
multiple
|
||||
class="hidden lg:block"
|
||||
by="key"
|
||||
: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)"
|
||||
>
|
||||
<template #label>
|
||||
<template #default>
|
||||
Spalten
|
||||
</template>
|
||||
</USelectMenu>
|
||||
@@ -114,7 +114,7 @@ setup()
|
||||
<div class="scroll" style="height: 70vh">
|
||||
<EntityTable
|
||||
:type="type"
|
||||
:columns="columns"
|
||||
:columns="normalizeTableColumns(columns)"
|
||||
:rows="props.item[type]"
|
||||
style
|
||||
/>
|
||||
|
||||
@@ -181,49 +181,51 @@ const selectItem = (item) => {
|
||||
</UButton>
|
||||
<UModal
|
||||
prevent-close
|
||||
v-model="showFinalInvoiceConfig"
|
||||
v-model:open="showFinalInvoiceConfig"
|
||||
>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
Schlussrechnung konfigurieren
|
||||
</h3>
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
Schlussrechnung konfigurieren
|
||||
</h3>
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="showFinalInvoiceConfig = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UFormGroup
|
||||
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"
|
||||
<UFormField
|
||||
label="Rechnungsvorlage"
|
||||
>
|
||||
Weiter
|
||||
</UButton>
|
||||
</template>
|
||||
</UCard>
|
||||
<USelectMenu
|
||||
:items="props.item.createddocuments.filter(i => ['confirmationOrders','quotes'].includes(i.type))"
|
||||
value-key="id"
|
||||
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>
|
||||
<UButton
|
||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'invoices'})}`)"
|
||||
@@ -235,48 +237,48 @@ const selectItem = (item) => {
|
||||
<USelectMenu
|
||||
v-model="selectedColumns"
|
||||
icon="i-heroicons-adjustments-horizontal-solid"
|
||||
:options="templateColumns"
|
||||
:items="templateColumns"
|
||||
multiple
|
||||
class="hidden lg:block"
|
||||
by="key"
|
||||
@change="tempStore.modifyColumns('createddocuments',selectedColumns)"
|
||||
>
|
||||
<template #label>
|
||||
<template #default>
|
||||
Spalten
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
</Toolbar>
|
||||
<UTable
|
||||
:rows="props.item.createddocuments.filter(i => !i.archived)"
|
||||
:columns="columns"
|
||||
:data="props.item.createddocuments.filter(i => !i.archived)"
|
||||
:columns="normalizeTableColumns(columns)"
|
||||
class="w-full"
|
||||
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
||||
@select="selectItem"
|
||||
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
|
||||
:on-select="(row) => selectItem(row.original)"
|
||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
|
||||
style="height: 70vh"
|
||||
>
|
||||
<template #type-data="{row}">
|
||||
{{dataStore.documentTypesForCreation[row.type].labelSingle}}
|
||||
<template #type-cell="{ row }">
|
||||
{{ dataStore.documentTypesForCreation[row.original.type].labelSingle }}
|
||||
</template>
|
||||
<template #state-data="{row}">
|
||||
<template #state-cell="{ row }">
|
||||
<span
|
||||
v-if="row.state === 'Entwurf'"
|
||||
class="text-rose-500"
|
||||
v-if="row.original.state === 'Entwurf'"
|
||||
class="text-error-500"
|
||||
>
|
||||
{{row.state}}
|
||||
{{ row.original.state }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.state === 'Gebucht'"
|
||||
v-if="row.original.state === 'Gebucht'"
|
||||
class="text-cyan-500"
|
||||
>
|
||||
{{row.state}}
|
||||
{{ row.original.state }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.state === 'Abgeschlossen'"
|
||||
v-if="row.original.state === 'Abgeschlossen'"
|
||||
class="text-primary-500"
|
||||
>
|
||||
{{row.state}}
|
||||
{{ row.original.state }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- <template #paid-data="{row}">
|
||||
@@ -285,19 +287,19 @@ const selectItem = (item) => {
|
||||
<span v-else class="text-rose-600">Offen</span>
|
||||
</div>
|
||||
</template>-->
|
||||
<template #reference-data="{row}">
|
||||
<span v-if="row === props.item.createddocuments[selectedItem]" class="text-primary-500 font-bold">{{row.documentNumber}}</span>
|
||||
<span v-else>{{row.documentNumber}}</span>
|
||||
<template #reference-cell="{ row }">
|
||||
<span v-if="row.original === props.item.createddocuments[selectedItem]" class="text-primary-500 font-bold">{{ row.original.documentNumber }}</span>
|
||||
<span v-else>{{ row.original.documentNumber }}</span>
|
||||
</template>
|
||||
<template #date-data="{row}">
|
||||
<span v-if="row.date">{{row.date ? dayjs(row.date).format("DD.MM.YY") : ''}}</span>
|
||||
<span v-if="row.documentDate">{{row.documentDate ? dayjs(row.documentDate).format("DD.MM.YY") : ''}}</span>
|
||||
<template #date-cell="{ row }">
|
||||
<span v-if="row.original.date">{{ row.original.date ? dayjs(row.original.date).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 #dueDate-data="{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>
|
||||
<template #dueDate-cell="{ row }">
|
||||
<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 #amount-data="{row}">
|
||||
<span v-if="row.type !== 'deliveryNotes'">{{useCurrency(useSum().getCreatedDocumentSum(row, createddocuments))}}</span>
|
||||
<template #amount-cell="{ row }">
|
||||
<span v-if="row.original.type !== 'deliveryNotes'">{{ useCurrency(useSum().getCreatedDocumentSum(row.original, createddocuments)) }}</span>
|
||||
</template>
|
||||
</UTable>
|
||||
|
||||
|
||||
@@ -94,41 +94,43 @@ function isImage(file) {
|
||||
</UCard>
|
||||
|
||||
<!-- 📱 PDF / IMG Viewer Slideover -->
|
||||
<UModal v-model="showViewer" side="bottom" class="h-[100dvh]" fullscreen>
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b flex justify-between items-center flex-shrink-0">
|
||||
<h3 class="font-bold truncate max-w-[70vw]">{{ activeFile?.path?.split("/").pop() }}</h3>
|
||||
<UButton icon="i-heroicons-x-mark" variant="ghost" @click="closeViewer" />
|
||||
</div>
|
||||
|
||||
<!-- 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"
|
||||
/>
|
||||
<UModal v-model:open="showViewer" side="bottom" class="h-[100dvh]" fullscreen>
|
||||
<template #content>
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b flex justify-between items-center flex-shrink-0">
|
||||
<h3 class="font-bold truncate max-w-[70vw]">{{ activeFile?.path?.split("/").pop() }}</h3>
|
||||
<UButton icon="i-heroicons-x-mark" variant="ghost" @click="closeViewer" />
|
||||
</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"
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<UAlert
|
||||
v-else
|
||||
title="Nicht unterstützter Dateityp"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
@@ -65,7 +65,7 @@ const renderDatapointValue = (datapoint) => {
|
||||
</template>
|
||||
<UAlert
|
||||
v-if="props.item.archived"
|
||||
color="rose"
|
||||
color="error"
|
||||
variant="outline"
|
||||
:title="`${dataType.labelSingle} archiviert`"
|
||||
icon="i-heroicons-archive-box"
|
||||
|
||||
@@ -77,21 +77,21 @@ const renderedAllocations = computed(() => {
|
||||
<UCard class="mt-5">
|
||||
<UTable
|
||||
v-if="props.item.statementallocations"
|
||||
:rows="renderedAllocations"
|
||||
:columns="[{key:'amount', label:'Betrag'},{key:'date', label:'Datum'},{key:'partner', label:'Partner'},{key:'description', label:'Beschreibung'}]"
|
||||
@select="(i) => selectAllocation(i)"
|
||||
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
|
||||
:data="renderedAllocations"
|
||||
:columns="normalizeTableColumns([{key:'amount', label:'Betrag'},{key:'date', label:'Datum'},{key:'partner', label:'Partner'},{key:'description', label:'Beschreibung'}])"
|
||||
:on-select="(i) => selectAllocation(i)"
|
||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
|
||||
>
|
||||
<template #amount-data="{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-primary-500" v-else-if="row.amount > 0 || row.color === 'green'">{{useCurrency(row.amount)}}</span>
|
||||
<span v-else>{{useCurrency(row.amount)}}</span>
|
||||
<template #amount-cell="{row}">
|
||||
<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.original.amount > 0 || row.original.color === 'green'">{{useCurrency(row.original.amount)}}</span>
|
||||
<span v-else>{{useCurrency(row.original.amount)}}</span>
|
||||
</template>
|
||||
<template #date-data="{row}">
|
||||
{{row.date ? dayjs(row.date).format('DD.MM.YYYY') : ''}}
|
||||
<template #date-cell="{row}">
|
||||
{{row.original.date ? dayjs(row.original.date).format('DD.MM.YYYY') : ''}}
|
||||
</template>
|
||||
<template #description-data="{row}">
|
||||
{{row.description ? row.description : ''}}
|
||||
<template #description-cell="{row}">
|
||||
{{row.original.description ? row.original.description : ''}}
|
||||
</template>
|
||||
</UTable>
|
||||
</UCard>
|
||||
|
||||
@@ -95,26 +95,26 @@ const changeActivePhase = async (key) => {
|
||||
<UAccordion
|
||||
:items="renderedPhases"
|
||||
>
|
||||
<template #default="{item,index,open}">
|
||||
<template #default="slotProps">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
:color="item.active ? 'primary' : 'white'"
|
||||
:color="slotProps.item.active ? 'primary' : 'white'"
|
||||
class="mb-1"
|
||||
:disabled="true"
|
||||
>
|
||||
<template #leading>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<span class="truncate"> {{item.label}}</span>
|
||||
<span class="truncate"> {{ slotProps.item.label }}</span>
|
||||
|
||||
<template #trailing>
|
||||
<UIcon
|
||||
name="i-heroicons-chevron-right-20-solid"
|
||||
class="w-5 h-5 ms-auto transform transition-transform duration-200"
|
||||
:class="[open && 'rotate-90']"
|
||||
:class="[slotProps?.open && 'rotate-90']"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -67,40 +67,40 @@ const columns = [
|
||||
<UCard class="mt-5">
|
||||
<UTable
|
||||
class="mt-3"
|
||||
:columns="columns"
|
||||
:rows="props.item.times"
|
||||
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Noch keine Einträge' }"
|
||||
:columns="normalizeTableColumns(columns)"
|
||||
:data="props.item.times"
|
||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Noch keine Einträge' }"
|
||||
>
|
||||
<template #state-data="{row}">
|
||||
<template #state-cell="{ row }">
|
||||
<span
|
||||
v-if="row.state === 'Entwurf'"
|
||||
class="text-rose-500"
|
||||
>{{row.state}}</span>
|
||||
v-if="row.original.state === 'Entwurf'"
|
||||
class="text-error-500"
|
||||
>{{ row.original.state }}</span>
|
||||
<span
|
||||
v-if="row.state === 'Eingereicht'"
|
||||
v-if="row.original.state === 'Eingereicht'"
|
||||
class="text-cyan-500"
|
||||
>{{row.state}}</span>
|
||||
>{{ row.original.state }}</span>
|
||||
<span
|
||||
v-if="row.state === 'Bestätigt'"
|
||||
v-if="row.original.state === 'Bestätigt'"
|
||||
class="text-primary-500"
|
||||
>{{row.state}}</span>
|
||||
>{{ row.original.state }}</span>
|
||||
</template>
|
||||
<template #user-data="{row}">
|
||||
{{row.profile ? row.profile.fullName : "" }}
|
||||
<template #user-cell="{ row }">
|
||||
{{ row.original.profile ? row.original.profile.fullName : "" }}
|
||||
</template>
|
||||
|
||||
<template #startDate-data="{row}">
|
||||
{{dayjs(row.startDate).format("DD.MM.YY HH:mm")}}
|
||||
<template #startDate-cell="{ row }">
|
||||
{{ dayjs(row.original.startDate).format("DD.MM.YY HH:mm") }}
|
||||
</template>
|
||||
<template #endDate-data="{row}">
|
||||
{{dayjs(row.endDate).format("DD.MM.YY HH:mm")}}
|
||||
<template #endDate-cell="{ row }">
|
||||
{{ dayjs(row.original.endDate).format("DD.MM.YY HH:mm") }}
|
||||
</template>
|
||||
<template #duration-data="{row}">
|
||||
{{Math.floor(dayjs(row.endDate).diff(row.startDate, "minutes")/60)}}:{{String(dayjs(row.endDate).diff(row.startDate, "minutes") % 60).padStart(2,"0")}} h
|
||||
<template #duration-cell="{ row }">
|
||||
{{ 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 #project-data="{row}">
|
||||
{{row.project ? row.project.name : "" }}
|
||||
<template #project-cell="{ row }">
|
||||
{{ row.original.project ? row.original.project.name : "" }}
|
||||
</template>
|
||||
</UTable>
|
||||
</UCard>
|
||||
|
||||
@@ -58,76 +58,101 @@
|
||||
const dataType = dataStore.dataTypes[props.type]
|
||||
|
||||
const selectedItem = ref(0)
|
||||
const sort = ref({
|
||||
column: dataType.sortColumn || "date",
|
||||
direction: 'desc'
|
||||
})
|
||||
const sorting = ref([{
|
||||
id: dataType.sortColumn || "date",
|
||||
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>
|
||||
|
||||
<template>
|
||||
<UTable
|
||||
:loading="props.loading"
|
||||
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
|
||||
sort-mode="manual"
|
||||
v-model:sort="sort"
|
||||
@update:sort="emit('sort',{sort_column: sort.column, sort_direction: sort.direction})"
|
||||
v-model:sorting="sorting"
|
||||
@update:sorting="handleSortChange"
|
||||
v-if="dataType && columns"
|
||||
:rows="props.rows"
|
||||
:columns="props.columns"
|
||||
:data="props.rows"
|
||||
:columns="normalizedColumns"
|
||||
class="w-full"
|
||||
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
||||
@select="(i) => router.push(getShowRoute(type, i.id))"
|
||||
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: `Keine ${dataType.label} anzuzeigen` }"
|
||||
:on-select="handleSelect"
|
||||
:empty="`Keine ${dataType.label} anzuzeigen`"
|
||||
>
|
||||
<!-- <template
|
||||
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}">
|
||||
<template #name-cell="{ row }">
|
||||
<span
|
||||
v-if="row.id === props.rows[selectedItem].id"
|
||||
class="text-primary-500 font-bold">
|
||||
<UTooltip
|
||||
:text="row.name"
|
||||
>
|
||||
{{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}}
|
||||
v-if="row.original.id === props.rows[selectedItem]?.id"
|
||||
class="block truncate text-primary-500 font-bold"
|
||||
>
|
||||
<UTooltip :text="row.original.name">
|
||||
<span class="block truncate">
|
||||
{{ truncateValue(row.original.name, dataType.templateColumns.find(i => i.key === "name")?.maxLength) }}
|
||||
</span>
|
||||
</UTooltip> </span>
|
||||
<span v-else>
|
||||
<UTooltip
|
||||
:text="row.name"
|
||||
>
|
||||
{{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 v-else class="block truncate">
|
||||
<UTooltip :text="row.original.name">
|
||||
<span class="block truncate">
|
||||
{{ truncateValue(row.original.name, dataType.templateColumns.find(i => i.key === "name")?.maxLength) }}
|
||||
</span>
|
||||
</UTooltip>
|
||||
</span>
|
||||
</template>
|
||||
<template #fullName-data="{row}">
|
||||
<template #fullName-cell="{ row }">
|
||||
<span
|
||||
v-if="row.id === props.rows[selectedItem].id"
|
||||
class="text-primary-500 font-bold">{{row.fullName}}
|
||||
v-if="row.original.id === props.rows[selectedItem]?.id"
|
||||
class="block truncate text-primary-500 font-bold">{{ row.original.fullName }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{row.fullName}}
|
||||
<span v-else class="block truncate">
|
||||
{{ row.original.fullName }}
|
||||
</span>
|
||||
</template>
|
||||
<template #licensePlate-data="{row}">
|
||||
<template #licensePlate-cell="{ row }">
|
||||
<span
|
||||
v-if="row.id === props.rows[selectedItem].id"
|
||||
class="text-primary-500 font-bold">{{row.licensePlate}}
|
||||
v-if="row.original.id === props.rows[selectedItem]?.id"
|
||||
class="block truncate text-primary-500 font-bold">{{ row.original.licensePlate }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{row.licensePlate}}
|
||||
<span v-else class="block truncate">
|
||||
{{ row.original.licensePlate }}
|
||||
</span>
|
||||
</template>
|
||||
<template
|
||||
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}">
|
||||
<component v-if="column.component" :is="column.component" :row="row"></component>
|
||||
<span v-else-if="row[column.key]">
|
||||
<UTooltip :text="row[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 : ''}`: ''}}
|
||||
</UTooltip>
|
||||
v-slot:[`${column.key}-cell`]="{ row }">
|
||||
<component v-if="column.component" :is="column.component" :row="row.original"></component>
|
||||
<span v-else-if="row.original[column.key]" class="block truncate">
|
||||
<UTooltip :text="String(row.original[column.key])">
|
||||
<span class="block truncate">
|
||||
{{ `${truncateValue(row.original[column.key], column.maxLength)}${column.unit ? ` ${column.unit}` : ''}` }}
|
||||
</span>
|
||||
</UTooltip>
|
||||
</span>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
<!-- <UTable
|
||||
v-if="dataType && columns"
|
||||
:rows="props.rows"
|
||||
:data="props.rows"
|
||||
:columns="props.columns"
|
||||
class="w-full"
|
||||
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
||||
|
||||
@@ -55,17 +55,19 @@ setup()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal v-model="showMessageModal" prevent-close>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<span class="font-bold">{{messageToShow.title}}</span>
|
||||
</template>
|
||||
<p class=" my-2" v-html="messageToShow.description"></p>
|
||||
<UButton
|
||||
variant="outline"
|
||||
@click="markMessageAsRead"
|
||||
>Gelesen</UButton>
|
||||
</UCard>
|
||||
<UModal v-model:open="showMessageModal" prevent-close>
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<span class="font-bold">{{messageToShow.title}}</span>
|
||||
</template>
|
||||
<p class=" my-2" v-html="messageToShow.description"></p>
|
||||
<UButton
|
||||
variant="outline"
|
||||
@click="markMessageAsRead"
|
||||
>Gelesen</UButton>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<!-- <UCard
|
||||
@@ -79,7 +81,7 @@ setup()
|
||||
variant="ghost"
|
||||
@click="showMessage(globalMessages[0])"
|
||||
/>
|
||||
<UModal v-model="showMessageModal">
|
||||
<UModal v-model:open="showMessageModal">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<span class="font-bold">{{messageToShow.title}}</span>
|
||||
|
||||
@@ -123,18 +123,20 @@ function onSelect (option) {
|
||||
/>
|
||||
|
||||
<UModal
|
||||
v-model="showCommandPalette"
|
||||
v-model:open="showCommandPalette"
|
||||
>
|
||||
<UCommandPalette
|
||||
v-model="selectedCommand"
|
||||
:groups="groups"
|
||||
:autoselect="false"
|
||||
@update:model-value="onSelect"
|
||||
ref="commandPaletteRef"
|
||||
/>
|
||||
<template #content>
|
||||
<UCommandPalette
|
||||
v-model="selectedCommand"
|
||||
:groups="groups"
|
||||
:autoselect="false"
|
||||
@update:model-value="onSelect"
|
||||
ref="commandPaletteRef"
|
||||
/>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
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 query = ref('')
|
||||
@@ -122,7 +132,7 @@ const addContactRequest = async () => {
|
||||
toast.add({title: "Anfrage erfolgreich erstellt"})
|
||||
resetContactRequest()
|
||||
} else {
|
||||
toast.add({title: "Anfrage konnte nicht erstellt werden",color:"rose"})
|
||||
toast.add({title: "Anfrage konnte nicht erstellt werden",color:"error"})
|
||||
}
|
||||
loadingContactRequest.value = false
|
||||
}
|
||||
@@ -133,10 +143,25 @@ const resetContactRequest = () => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<UDashboardSlideover v-model="isHelpSlideoverOpen">
|
||||
<USlideover v-model:open="isHelpSlideoverOpen" side="right">
|
||||
<template #title>
|
||||
<UButton
|
||||
v-if="shortcuts"
|
||||
@@ -150,30 +175,94 @@ const resetContactRequest = () => {
|
||||
{{ shortcuts ? 'Shortcuts' : 'Hilfe & Information' }}
|
||||
</template>
|
||||
|
||||
<div v-if="shortcuts" class="space-y-6">
|
||||
<UInput v-model="query" icon="i-heroicons-magnifying-glass" placeholder="Search..." autofocus color="gray" />
|
||||
<template #body>
|
||||
<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">
|
||||
<p class="mb-3 text-sm text-gray-900 dark:text-white font-semibold">
|
||||
{{ category.title }}
|
||||
</p>
|
||||
<div v-for="(category, index) in filteredCategories" :key="index">
|
||||
<p class="mb-3 text-sm text-gray-900 dark:text-white font-semibold">
|
||||
{{ category.title }}
|
||||
</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<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>
|
||||
<div class="space-y-2">
|
||||
<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>
|
||||
|
||||
<div class="flex items-center justify-end flex-shrink-0 gap-0.5">
|
||||
<UKbd v-for="(shortcut, j) in item.shortcuts" :key="j">
|
||||
{{ shortcut }}
|
||||
</UKbd>
|
||||
<div class="flex items-center justify-end flex-shrink-0 gap-0.5">
|
||||
<UKbd v-for="(shortcut, j) in item.shortcuts" :key="j">
|
||||
{{ shortcut }}
|
||||
</UKbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-y-3">
|
||||
<UButton v-for="(link, index) in links" :key="index" color="white" v-bind="link" />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-y-6">
|
||||
<div class="flex flex-col gap-y-3">
|
||||
<UButton v-for="(link, index) in links" :key="index" color="white" v-bind="link" />
|
||||
</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">
|
||||
<h1 class="font-semibold">Kontaktanfrage:</h1>
|
||||
<UForm
|
||||
@@ -181,29 +270,29 @@ const resetContactRequest = () => {
|
||||
@submit="addContactRequest"
|
||||
@reset="resetContactRequest"
|
||||
>
|
||||
<!– <UFormGroup
|
||||
<!– <UFormField
|
||||
label="Art:"
|
||||
>
|
||||
<USelectMenu
|
||||
:options="['Hilfe','Software Problem / Bug','Funktionsanfrage','Kontakt','Sonstiges']"
|
||||
v-model="contactRequestData.contactType"
|
||||
/>
|
||||
</UFormGroup>–>
|
||||
<UFormGroup
|
||||
</UFormField>–>
|
||||
<UFormField
|
||||
label="Titel:"
|
||||
>
|
||||
<UInput
|
||||
v-model="contactRequestData.title"
|
||||
/>
|
||||
</UFormGroup>
|
||||
<UFormGroup
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Nachricht:"
|
||||
>
|
||||
<UTextarea
|
||||
v-model="contactRequestData.message"
|
||||
rows="6"
|
||||
/>
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
<InputGroup class="mt-3">
|
||||
<UButton
|
||||
type="submit"
|
||||
@@ -213,7 +302,7 @@ const resetContactRequest = () => {
|
||||
</UButton>
|
||||
<UButton
|
||||
type="reset"
|
||||
color="rose"
|
||||
color="error"
|
||||
variant="outline"
|
||||
:disabled="!contactRequestData.title && !contactRequestData.message"
|
||||
>
|
||||
@@ -224,5 +313,6 @@ const resetContactRequest = () => {
|
||||
</UForm>
|
||||
</div>
|
||||
<UProgress class="mt-5" animation="carousel" v-else/>-->
|
||||
</UDashboardSlideover>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
|
||||
@@ -76,38 +76,40 @@ const renderText = (text) => {
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
v-model="showAddHistoryItemModal"
|
||||
v-model:open="showAddHistoryItemModal"
|
||||
|
||||
>
|
||||
<UCard class="h-full">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
Eintrag hinzufügen
|
||||
</h3>
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="showAddHistoryItemModal = false" />
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<UCard class="h-full">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
Eintrag hinzufügen
|
||||
</h3>
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="showAddHistoryItemModal = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UFormGroup
|
||||
label="Text:"
|
||||
>
|
||||
<UTextarea
|
||||
v-model="addHistoryItemData.text"
|
||||
@keyup.meta.enter="addHistoryItem"
|
||||
/>
|
||||
<!-- TODO: Add Dropdown and Checking for Usernames -->
|
||||
<!-- <template #help>
|
||||
<UKbd>{{metaSymbol}}</UKbd> <UKbd>Enter</UKbd> Speichern
|
||||
</template>-->
|
||||
<UFormField
|
||||
label="Text:"
|
||||
>
|
||||
<UTextarea
|
||||
v-model="addHistoryItemData.text"
|
||||
@keyup.meta.enter="addHistoryItem"
|
||||
/>
|
||||
<!-- TODO: Add Dropdown and Checking for Usernames -->
|
||||
<!-- <template #help>
|
||||
<UKbd>{{metaSymbol}}</UKbd> <UKbd>Enter</UKbd> Speichern
|
||||
</template>-->
|
||||
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
|
||||
|
||||
<template #footer>
|
||||
<UButton @click="addHistoryItem">Speichern</UButton>
|
||||
</template>
|
||||
</UCard>
|
||||
<template #footer>
|
||||
<UButton @click="addHistoryItem">Speichern</UButton>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
<Toolbar
|
||||
v-if="!props.renderHeadline && props.elementId && props.type"
|
||||
@@ -127,7 +129,7 @@ const renderText = (text) => {
|
||||
+ Eintrag
|
||||
</UButton>
|
||||
</div>
|
||||
<UDivider class="my-3"/>
|
||||
<USeparator class="my-3"/>
|
||||
</div>
|
||||
|
||||
<!-- ITEM LIST -->
|
||||
@@ -136,7 +138,7 @@ const renderText = (text) => {
|
||||
v-if="items.length > 0"
|
||||
v-for="(item,index) in items.slice().reverse()"
|
||||
>
|
||||
<UDivider
|
||||
<USeparator
|
||||
class="my-3"
|
||||
v-if="index !== 0"
|
||||
/>
|
||||
|
||||
@@ -86,7 +86,7 @@ defineShortcuts({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UDivider />
|
||||
<USeparator />
|
||||
</div>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
@@ -34,7 +34,7 @@ defineProps({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UDivider class="my-5" />
|
||||
<USeparator class="my-5" />
|
||||
|
||||
<div class="flex-1">
|
||||
<p class="text-lg">
|
||||
@@ -42,7 +42,7 @@ defineProps({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UDivider class="my-5" />
|
||||
<USeparator class="my-5" />
|
||||
|
||||
<form @submit.prevent>
|
||||
<UTextarea color="gray" required size="xl" :rows="5" :placeholder="`Reply to ${mail.from.name}`">
|
||||
|
||||
@@ -90,7 +90,8 @@ watch(() => labelPrinter.connected, (connected) => {
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -133,6 +134,7 @@ watch(() => labelPrinter.connected, (connected) => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</UCard>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
@@ -18,21 +18,23 @@ const handleClick = async () => {
|
||||
<template>
|
||||
<!-- Printer Button -->
|
||||
|
||||
<UModal v-model="showPrinterInfo">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Drucker Informationen</h3>
|
||||
<UButton icon="i-heroicons-x-mark" variant="ghost" @click="showPrinterInfo = false" />
|
||||
</div>
|
||||
</template>
|
||||
<p>Seriennummer: {{labelPrinter.info.serial}}</p>
|
||||
<p>MAC: {{labelPrinter.info.mac}}</p>
|
||||
<p>Modell: {{labelPrinter.info.modelId}}</p>
|
||||
<p>Charge: {{labelPrinter.info.charge}}</p>
|
||||
<p>Hardware Version: {{labelPrinter.info.hardwareVersion}}</p>
|
||||
<p>Software Version: {{labelPrinter.info.softwareVersion}}</p>
|
||||
</UCard>
|
||||
<UModal v-model:open="showPrinterInfo">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Drucker Informationen</h3>
|
||||
<UButton icon="i-heroicons-x-mark" variant="ghost" @click="showPrinterInfo = false" />
|
||||
</div>
|
||||
</template>
|
||||
<p>Seriennummer: {{labelPrinter.info.serial}}</p>
|
||||
<p>MAC: {{labelPrinter.info.mac}}</p>
|
||||
<p>Modell: {{labelPrinter.info.modelId}}</p>
|
||||
<p>Charge: {{labelPrinter.info.charge}}</p>
|
||||
<p>Hardware Version: {{labelPrinter.info.hardwareVersion}}</p>
|
||||
<p>Software Version: {{labelPrinter.info.softwareVersion}}</p>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UButton
|
||||
@@ -50,4 +52,4 @@ const handleClick = async () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const { has } = usePermission()
|
||||
|
||||
// Lokaler State für den Taschenrechner
|
||||
const showCalculator = ref(false)
|
||||
const tenantExtraModules = computed(() => {
|
||||
const modules = auth.activeTenantData?.extraModules
|
||||
return Array.isArray(modules) ? modules : []
|
||||
@@ -15,389 +19,457 @@ const showMembersNav = computed(() => {
|
||||
const showMemberRelationsNav = computed(() => {
|
||||
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(() => {
|
||||
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 => {
|
||||
if (pin.type === "external") {
|
||||
return {
|
||||
label: pin.label,
|
||||
to: pin.link,
|
||||
icon: pin.icon,
|
||||
target: "_blank",
|
||||
pinned: true
|
||||
target: "_blank"
|
||||
}
|
||||
} else if (pin.type === "standardEntity") {
|
||||
return {
|
||||
label: pin.label,
|
||||
to: pin.datatype === "tasks" ? `/tasks/show/${pin.id}` : `/standardEntity/${pin.datatype}/show/${pin.id}`,
|
||||
icon: pin.icon,
|
||||
pinned: true
|
||||
icon: pin.icon
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
{
|
||||
featureEnabled("dashboard") ? {
|
||||
id: 'dashboard',
|
||||
label: "Dashboard",
|
||||
to: "/",
|
||||
icon: "i-heroicons-home"
|
||||
},
|
||||
{
|
||||
} : null,
|
||||
featureEnabled("historyitems") ? {
|
||||
id: 'historyitems',
|
||||
label: "Logbuch",
|
||||
to: "/historyitems",
|
||||
icon: "i-heroicons-book-open"
|
||||
},
|
||||
{
|
||||
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") ? [{
|
||||
} : null,
|
||||
...(has("projects") && featureEnabled("projects")) ? [{
|
||||
label: "Projekte",
|
||||
to: "/standardEntity/projects",
|
||||
icon: "i-heroicons-clipboard-document-check"
|
||||
}] : [],
|
||||
...has("contracts") ? [{
|
||||
...(has("contracts") && featureEnabled("contracts")) ? [{
|
||||
label: "Verträge",
|
||||
to: "/standardEntity/contracts",
|
||||
icon: "i-heroicons-clipboard-document"
|
||||
}] : [],
|
||||
...has("plants") ? [{
|
||||
...(has("plants") && featureEnabled("plants")) ? [{
|
||||
label: "Objekte",
|
||||
to: "/standardEntity/plants",
|
||||
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",
|
||||
defaultOpen: false,
|
||||
icon: "i-heroicons-cog-8-tooth",
|
||||
children: [
|
||||
{
|
||||
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"
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
children: visibleSettingsChildren
|
||||
}] : []),
|
||||
])
|
||||
})
|
||||
|
||||
const accordionItems = computed(() =>
|
||||
links.value.filter(item => Array.isArray(item.children) && item.children.length > 0)
|
||||
)
|
||||
const navItems = computed(() =>
|
||||
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(() =>
|
||||
links.value.filter(item => !item.children || item.children.length === 0)
|
||||
const active = item.active || isRouteActive(item.to) || Boolean(children?.some(child => child.active))
|
||||
|
||||
return {
|
||||
...item,
|
||||
children,
|
||||
value: item.id || item.label || String(index),
|
||||
defaultOpen: item.defaultOpen || active,
|
||||
active,
|
||||
tooltip: true,
|
||||
popover: true,
|
||||
trailingIcon: children?.length ? undefined : ''
|
||||
}
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<UButton
|
||||
v-for="item in buttonItems"
|
||||
:key="item.label"
|
||||
variant="ghost"
|
||||
:color="(item.to && route.path === item.to) ? 'primary' : (item.pinned ? 'amber' : 'gray')"
|
||||
:icon="item.pinned ? 'i-heroicons-star' : item.icon"
|
||||
class="w-full"
|
||||
:to="item.to"
|
||||
:target="item.target"
|
||||
@click="item.click ? item.click() : null"
|
||||
>
|
||||
<UIcon
|
||||
v-if="item.pinned"
|
||||
:name="item.icon"
|
||||
class="w-5 h-5 me-2"
|
||||
/>
|
||||
{{ item.label }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<UDivider class="my-2"/>
|
||||
|
||||
<UAccordion
|
||||
:items="accordionItems"
|
||||
:multiple="false"
|
||||
class="mt-2"
|
||||
<UNavigationMenu
|
||||
:items="navItems"
|
||||
orientation="vertical"
|
||||
:collapsed="props.collapsed"
|
||||
tooltip
|
||||
popover
|
||||
color="neutral"
|
||||
highlight
|
||||
highlight-color="primary"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
root: 'w-full',
|
||||
list: 'space-y-1',
|
||||
link: 'min-w-0 rounded-lg px-2.5 py-2',
|
||||
linkLeadingIcon: 'size-5 shrink-0',
|
||||
linkLabel: 'truncate',
|
||||
childList: 'ms-0 space-y-1 border-l border-default ps-3',
|
||||
childLink: 'min-w-0 rounded-lg px-2 py-1.5',
|
||||
childLinkLabel: 'truncate'
|
||||
}"
|
||||
>
|
||||
<template #default="{ item, open }">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
:color="(item.children?.some(c => route.path.includes(c.to))) ? 'primary' : 'gray'"
|
||||
:icon="item.icon"
|
||||
class="w-full"
|
||||
<template #item-leading="{ item, active }">
|
||||
<UIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
class="size-5 shrink-0"
|
||||
: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 }}
|
||||
<template #trailing>
|
||||
<UIcon
|
||||
name="i-heroicons-chevron-right-20-solid"
|
||||
class="w-5 h-5 ms-auto transform transition-transform duration-200"
|
||||
:class="[open && 'rotate-90']"
|
||||
/>
|
||||
</template>
|
||||
</UButton>
|
||||
{{ item.badge }}
|
||||
</UBadge>
|
||||
<UIcon
|
||||
v-else-if="item.children?.length"
|
||||
name="i-heroicons-chevron-down-20-solid"
|
||||
class="size-4 shrink-0 transition-transform"
|
||||
:class="active ? 'text-primary' : 'text-muted'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<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"/>
|
||||
</UNavigationMenu>
|
||||
</template>
|
||||
|
||||
@@ -36,28 +36,30 @@ const setNotificationAsRead = async (notification) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardSlideover v-model="isNotificationsSlideoverOpen" title="Benachrichtigungen">
|
||||
<NuxtLink
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:to="notification.link"
|
||||
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>
|
||||
<USlideover v-model:open="isNotificationsSlideoverOpen" title="Benachrichtigungen" side="right">
|
||||
<template #body>
|
||||
<NuxtLink
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:to="notification.link"
|
||||
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>
|
||||
|
||||
<div class="text-sm flex-1">
|
||||
<p class="flex items-center justify-between">
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{notification.title}}</span>
|
||||
<div class="text-sm flex-1">
|
||||
<p class="flex items-center justify-between">
|
||||
<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))" />
|
||||
</p>
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{{ notification.message }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</UDashboardSlideover>
|
||||
<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 class="text-gray-500 dark:text-gray-400">
|
||||
{{ notification.message }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
|
||||
@@ -102,6 +102,10 @@ const currentUnit = computed(() => {
|
||||
const selectedService = data.value.services?.find(s => s.id === form.value.service)
|
||||
return selectedService?.unitSymbol || data.value?.units?.[0]?.symbol || 'h'
|
||||
})
|
||||
|
||||
const setDeliveryDateToToday = () => {
|
||||
form.value.deliveryDate = dayjs().format('YYYY-MM-DD')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -115,20 +119,24 @@ const currentUnit = computed(() => {
|
||||
|
||||
<div class="space-y-5">
|
||||
|
||||
<UFormGroup
|
||||
<UFormField
|
||||
label="Datum der Ausführung"
|
||||
:error="errors.deliveryDate"
|
||||
required
|
||||
>
|
||||
<UInput
|
||||
v-model="form.deliveryDate"
|
||||
type="date"
|
||||
size="lg"
|
||||
icon="i-heroicons-calendar-days"
|
||||
/>
|
||||
</UFormGroup>
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput
|
||||
v-model="form.deliveryDate"
|
||||
type="date"
|
||||
size="lg"
|
||||
icon="i-heroicons-calendar-days"
|
||||
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"
|
||||
label="Mitarbeiter"
|
||||
:error="errors.profile"
|
||||
@@ -136,16 +144,16 @@ const currentUnit = computed(() => {
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="form.profile"
|
||||
:options="data.profiles"
|
||||
option-attribute="fullName"
|
||||
value-attribute="id"
|
||||
:items="data.profiles"
|
||||
label-key="fullName"
|
||||
value-key="id"
|
||||
placeholder="Name auswählen..."
|
||||
searchable
|
||||
size="lg"
|
||||
/>
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
|
||||
<UFormGroup
|
||||
<UFormField
|
||||
v-if="data?.projects?.length > 0"
|
||||
:label="config.ui?.labels?.project || 'Projekt / Auftrag'"
|
||||
:error="errors.project"
|
||||
@@ -153,16 +161,16 @@ const currentUnit = computed(() => {
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="form.project"
|
||||
:options="data.projects"
|
||||
option-attribute="name"
|
||||
value-attribute="id"
|
||||
:items="data.projects"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Wählen..."
|
||||
searchable
|
||||
size="lg"
|
||||
/>
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
|
||||
<UFormGroup
|
||||
<UFormField
|
||||
v-if="data?.services?.length > 0"
|
||||
:label="config?.ui?.labels?.service || 'Tätigkeit'"
|
||||
:error="errors.service"
|
||||
@@ -170,16 +178,16 @@ const currentUnit = computed(() => {
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="form.service"
|
||||
:options="data.services"
|
||||
option-attribute="name"
|
||||
value-attribute="id"
|
||||
:items="data.services"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Wählen..."
|
||||
searchable
|
||||
size="lg"
|
||||
/>
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
|
||||
<UFormGroup
|
||||
<UFormField
|
||||
label="Menge / Dauer"
|
||||
:error="errors.quantity"
|
||||
required
|
||||
@@ -195,9 +203,9 @@ const currentUnit = computed(() => {
|
||||
<span class="text-gray-500 text-sm pr-2">{{ currentUnit }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
|
||||
<UFormGroup
|
||||
<UFormField
|
||||
v-if="config?.features?.agriculture?.showDieselUsage"
|
||||
label="Dieselverbrauch"
|
||||
:error="errors.diesel"
|
||||
@@ -208,11 +216,11 @@ const currentUnit = computed(() => {
|
||||
<span class="text-gray-500 text-xs">Liter</span>
|
||||
</template>
|
||||
</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..." />
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -226,4 +234,4 @@ const currentUnit = computed(() => {
|
||||
/>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -16,28 +16,30 @@ const onLogout = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal v-model="auth.sessionWarningVisible" prevent-close>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Sitzung läuft bald ab</h3>
|
||||
</template>
|
||||
<UModal v-model:open="auth.sessionWarningVisible" prevent-close>
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Sitzung läuft bald ab</h3>
|
||||
</template>
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Deine Sitzung endet in
|
||||
<span class="font-semibold">{{ remainingTimeLabel }}</span>.
|
||||
Bitte bestätige, um eingeloggt zu bleiben.
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Deine Sitzung endet in
|
||||
<span class="font-semibold">{{ remainingTimeLabel }}</span>.
|
||||
Bitte bestätige, um eingeloggt zu bleiben.
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton variant="outline" color="gray" @click="onLogout">
|
||||
Abmelden
|
||||
</UButton>
|
||||
<UButton color="primary" @click="onRefresh">
|
||||
Eingeloggt bleiben
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton variant="outline" color="gray" @click="onLogout">
|
||||
Abmelden
|
||||
</UButton>
|
||||
<UButton color="primary" @click="onRefresh">
|
||||
Eingeloggt bleiben
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
@@ -50,6 +50,9 @@ const isOpen = computed({
|
||||
|
||||
const toDateStr = (dateStr: string) => dateStr ? $dayjs(dateStr).format('YYYY-MM-DD') : ''
|
||||
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) => {
|
||||
if (newVal) {
|
||||
@@ -113,7 +116,7 @@ async function onSubmit(event: FormSubmitEvent<any>) {
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} catch (error: any) {
|
||||
toast.add({ title: 'Fehler', description: error.message, color: 'red' })
|
||||
toast.add({ title: 'Fehler', description: error.message, color: 'error' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -121,51 +124,63 @@ async function onSubmit(event: FormSubmitEvent<any>) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal v-model="isOpen">
|
||||
<UCard :ui="{ 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">
|
||||
{{ entry ? 'Eintrag bearbeiten' : 'Neue Zeit erfassen' }}
|
||||
</h3>
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
<UModal v-model:open="isOpen">
|
||||
<template #content>
|
||||
<UCard :ui="{ 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">
|
||||
{{ entry ? 'Eintrag bearbeiten' : 'Neue Zeit erfassen' }}
|
||||
</h3>
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" />
|
||||
</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">
|
||||
<USelectMenu v-model="state.type" :options="types" value-attribute="value" option-attribute="label" />
|
||||
</UFormGroup>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Start Datum" name="start_date">
|
||||
<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">
|
||||
<UFormGroup label="Start Datum" name="start_date">
|
||||
<UInput type="date" v-model="state.start_date" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Start Zeit" name="start_time">
|
||||
<UInput type="time" v-model="state.start_time" />
|
||||
</UFormGroup>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Ende Datum" name="end_date">
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput type="date" v-model="state.end_date" class="flex-1" />
|
||||
<UButton color="gray" variant="soft" label="Heute" @click="setDateFieldToToday('end_date')" />
|
||||
</div>
|
||||
</UFormField>
|
||||
<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">
|
||||
<UFormGroup label="Ende Datum" name="end_date">
|
||||
<UInput type="date" v-model="state.end_date" />
|
||||
</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>
|
||||
<UFormField label="Beschreibung / Notiz" name="description">
|
||||
<UTextarea v-model="state.description" placeholder="Was wurde gemacht?" />
|
||||
</UFormField>
|
||||
|
||||
<UFormGroup label="Beschreibung / Notiz" name="description">
|
||||
<UTextarea v-model="state.description" placeholder="Was wurde gemacht?" />
|
||||
</UFormGroup>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<UButton label="Abbrechen" color="gray" variant="ghost" @click="isOpen = false" />
|
||||
<UButton type="submit" label="Speichern" color="primary" :loading="loading" />
|
||||
</div>
|
||||
</UForm>
|
||||
</UCard>
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<UButton label="Abbrechen" color="gray" variant="ghost" @click="isOpen = false" />
|
||||
<UButton type="submit" label="Speichern" color="primary" :loading="loading" />
|
||||
</div>
|
||||
</UForm>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -61,33 +61,35 @@ setupPage()
|
||||
|
||||
<template>
|
||||
<UModal :fullscreen="props.mode === 'show'">
|
||||
<EntityShow
|
||||
v-if="loaded && props.mode === 'show'"
|
||||
:type="props.type"
|
||||
:item="item"
|
||||
@updateNeeded="setupPage"
|
||||
:key="item"
|
||||
:in-modal="true"
|
||||
/>
|
||||
<EntityEdit
|
||||
v-else-if="loaded && (props.mode === 'edit' || props.mode === 'create')"
|
||||
:type="props.type"
|
||||
:item="item"
|
||||
:inModal="true"
|
||||
@return-data="(data) => emit('return-data',data)"
|
||||
:createQuery="props.createQuery"
|
||||
:mode="props.mode"
|
||||
/>
|
||||
<!-- <EntityList
|
||||
v-else-if="loaded && props.mode === 'list'"
|
||||
:type="props.type"
|
||||
:items="items"
|
||||
/>-->
|
||||
<UProgress
|
||||
v-else
|
||||
animation="carousel"
|
||||
class="p-5 mt-10"
|
||||
/>
|
||||
<template #content>
|
||||
<EntityShow
|
||||
v-if="loaded && props.mode === 'show'"
|
||||
:type="props.type"
|
||||
:item="item"
|
||||
@updateNeeded="setupPage"
|
||||
:key="item"
|
||||
:in-modal="true"
|
||||
/>
|
||||
<EntityEdit
|
||||
v-else-if="loaded && (props.mode === 'edit' || props.mode === 'create')"
|
||||
:type="props.type"
|
||||
:item="item"
|
||||
:inModal="true"
|
||||
@return-data="(data) => emit('return-data',data)"
|
||||
:createQuery="props.createQuery"
|
||||
:mode="props.mode"
|
||||
/>
|
||||
<!-- <EntityList
|
||||
v-else-if="loaded && props.mode === 'list'"
|
||||
:type="props.type"
|
||||
:items="items"
|
||||
/>-->
|
||||
<UProgress
|
||||
v-else
|
||||
animation="carousel"
|
||||
class="p-5 mt-10"
|
||||
/>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,27 +1,59 @@
|
||||
<script setup>
|
||||
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>
|
||||
|
||||
<template>
|
||||
<USelectMenu
|
||||
:options="auth.tenants"
|
||||
value-attribute="id"
|
||||
class="w-40"
|
||||
@change="auth.switchTenant(selectedTenant)"
|
||||
v-model="selectedTenant"
|
||||
:items="tenantItems"
|
||||
:content="{ align: 'start', side: 'bottom', sideOffset: 6 }"
|
||||
:ui="{ content: 'w-[var(--reka-dropdown-menu-trigger-width)] max-w-[var(--reka-dropdown-menu-trigger-width)]' }"
|
||||
class="block w-40"
|
||||
:avatar="{
|
||||
alt: activeTenantName,
|
||||
text: tenantInitials,
|
||||
loading: 'lazy'
|
||||
}"
|
||||
>
|
||||
<UButton color="gray" variant="ghost" :class="[open && 'bg-gray-50 dark:bg-gray-800']" class="w-full">
|
||||
<UAvatar :alt="auth.activeTenantData?.name" size="md" />
|
||||
|
||||
<span class="truncate text-gray-900 dark:text-white font-semibold">{{auth.tenants.find(i => auth.activeTenant === i.id).name}}</span>
|
||||
</UButton>
|
||||
|
||||
<template #option="{option}">
|
||||
{{option.name}}
|
||||
<template #default="{ open }">
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
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']"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-gray-900 dark:text-white">
|
||||
{{ activeTenantName }}
|
||||
</span>
|
||||
</UButton>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<slot name="right"/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<UDivider class="my-3"/>
|
||||
<USeparator class="my-3"/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
27
frontend/components/UDashboardPanelContent.vue
Normal file
27
frontend/components/UDashboardPanelContent.vue
Normal 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>
|
||||
@@ -1,57 +1,44 @@
|
||||
<script setup>
|
||||
|
||||
const { isHelpSlideoverOpen } = useDashboard()
|
||||
const { isDashboardSearchModalOpen } = useUIState()
|
||||
const { metaSymbol } = useShortcuts()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const items = computed(() => [
|
||||
[{
|
||||
slot: 'account',
|
||||
label: '',
|
||||
disabled: true
|
||||
}], [/*{
|
||||
label: 'Mein Profil',
|
||||
icon: 'i-heroicons-user',
|
||||
to: `/profiles/show/${profileStore.activeProfile.id}`
|
||||
},*/{
|
||||
label: 'Passwort ändern',
|
||||
const userItems = computed(() => [[
|
||||
{
|
||||
label: 'Passwort aendern',
|
||||
icon: 'i-heroicons-shield-check',
|
||||
to: `/password-change`
|
||||
},{
|
||||
to: '/password-change'
|
||||
},
|
||||
{
|
||||
label: 'Abmelden',
|
||||
icon: 'i-heroicons-arrow-left-on-rectangle',
|
||||
click: async () => {
|
||||
onSelect: async () => {
|
||||
await auth.logout()
|
||||
|
||||
}
|
||||
}]
|
||||
])
|
||||
}
|
||||
]])
|
||||
</script>
|
||||
|
||||
<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 }">
|
||||
<UButton color="gray" variant="ghost" class="w-full" :label="auth.user.email" :class="[open && 'bg-gray-50 dark:bg-gray-800']">
|
||||
<!-- <template #leading>
|
||||
<UAvatar :alt="auth.user.email" size="xs" />
|
||||
</template>-->
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
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>
|
||||
<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>
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
|
||||
@@ -6,8 +6,20 @@ const props = defineProps({
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div v-if="props.row.description" v-html="props.row.description.html"/>
|
||||
<div v-if="descriptionText">{{ descriptionText }}</div>
|
||||
</template>
|
||||
|
||||
@@ -67,12 +67,13 @@ const startImport = () => {
|
||||
|
||||
<template>
|
||||
<UModal :fullscreen="false">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
Erstelltes Dokument Kopieren
|
||||
</template>
|
||||
|
||||
<UFormGroup
|
||||
<UFormField
|
||||
label="Dokumententyp:"
|
||||
class="mb-3"
|
||||
>
|
||||
@@ -84,7 +85,7 @@ const startImport = () => {
|
||||
>
|
||||
|
||||
</USelectMenu>
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
<UCheckbox
|
||||
v-for="key in Object.keys(optionsToImport).filter(i => documentTypeToUse !== props.type ? !['startText','endText'].includes(i) : true)"
|
||||
v-model="optionsToImport[key]"
|
||||
@@ -101,9 +102,10 @@ const startImport = () => {
|
||||
</template>
|
||||
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,239 +1,340 @@
|
||||
<script setup>
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import dayjs from "dayjs";
|
||||
import { Line } from "vue-chartjs";
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
|
||||
let incomeData = ref({})
|
||||
let expenseData = ref({})
|
||||
|
||||
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,
|
||||
|
||||
const props = defineProps({
|
||||
headerTarget: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
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(() => {
|
||||
return {
|
||||
labels: days.value,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Einnahmen',
|
||||
data: [2, 1, 16, 3, 2],
|
||||
backgroundColor: 'rgba(20, 255, 0, 0.3)',
|
||||
borderColor: 'red',
|
||||
borderWidth: 2,
|
||||
}
|
||||
]
|
||||
const tempStore = useTempStore()
|
||||
const isMounted = ref(false)
|
||||
|
||||
const amountMode = ref("net")
|
||||
const granularity = ref("year")
|
||||
const selectedYear = ref(dayjs().year())
|
||||
const selectedMonth = ref(dayjs().month() + 1)
|
||||
|
||||
const incomeDocuments = ref([])
|
||||
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 keys = Object.keys(buckets.value.income).sort()
|
||||
|
||||
return {
|
||||
labels: ["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"],
|
||||
labels: chartLabels.value,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Ausgaben',
|
||||
backgroundColor: '#f87979',
|
||||
borderColor: '#f87979',
|
||||
data: Object.keys(expenseData.value).sort().map(i => expenseData.value[i]),
|
||||
label: "Ausgaben",
|
||||
backgroundColor: "#f87979",
|
||||
borderColor: "#f87979",
|
||||
data: keys.map((key) => buckets.value.expense[key]),
|
||||
tension: 0.3,
|
||||
},{
|
||||
label: 'Einnahmen',
|
||||
backgroundColor: '#69c350',
|
||||
borderColor: '#69c350',
|
||||
data: Object.keys(incomeData.value).sort().map(i => incomeData.value[i]),
|
||||
},
|
||||
{
|
||||
label: "Einnahmen",
|
||||
backgroundColor: "#69c350",
|
||||
borderColor: "#69c350",
|
||||
data: keys.map((key) => buckets.value.income[key]),
|
||||
tension: 0.3
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = ref({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
})
|
||||
|
||||
setup()
|
||||
const showHeaderControls = computed(() => isMounted.value && !!props.headerTarget)
|
||||
const showInlineControls = computed(() => !showHeaderControls.value)
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true
|
||||
})
|
||||
|
||||
loadData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Line
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
<div class="h-full flex flex-col gap-2">
|
||||
<Teleport v-if="showHeaderControls" :to="props.headerTarget">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<USelectMenu
|
||||
v-model="granularity"
|
||||
: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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const profileStore = useProfileStore();
|
||||
|
||||
let unpaidInvoicesSum = ref(0)
|
||||
let unpaidInvoicesCount = 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 draftDocuments = documents.filter(i => i.state === "Entwurf")
|
||||
let finalizedDocuments = documents.filter(i => i.state === "Gebucht")
|
||||
|
||||
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))
|
||||
let finalizedDocuments = documents.filter(i => useSum().isOpenCreatedDocument(i, items))
|
||||
|
||||
|
||||
|
||||
finalizedDocuments.forEach(i => {
|
||||
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
|
||||
} 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 = finalizedDocuments.length
|
||||
|
||||
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
|
||||
|
||||
@@ -50,43 +44,91 @@ setupPage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table>
|
||||
<tr>
|
||||
<td class="break-all">Offene Rechnungen:</td>
|
||||
<td
|
||||
v-if="unpaidInvoicesSum > 0"
|
||||
class="text-orange-500 font-bold text-nowrap"
|
||||
>{{unpaidInvoicesCount}} Stk /<br> {{useCurrency(unpaidInvoicesSum)}}</td>
|
||||
<td v-else class="text-primary-500 font-bold text-no-wrap">0 Stk / 0,00€</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="break-all">Überfällige Rechnungen:</td>
|
||||
<td
|
||||
v-if="unpaidOverdueInvoicesSum !== 0"
|
||||
class="text-rose-600 font-bold text-nowrap"
|
||||
>{{unpaidOverdueInvoicesCount}} Stk /<br> {{useCurrency(unpaidOverdueInvoicesSum)}}</td>
|
||||
<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="space-y-3">
|
||||
<div class="balance-row">
|
||||
<p class="balance-label">Offene Rechnungen</p>
|
||||
<div
|
||||
v-if="unpaidInvoicesSum > 0"
|
||||
class="balance-value text-orange-500"
|
||||
>
|
||||
<span>{{ unpaidInvoicesCount }} Stk</span>
|
||||
<span>{{ useCurrency(unpaidInvoicesSum) }}</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">Ü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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -4,11 +4,15 @@ const openTasks = ref([])
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
function isCompletedTask(task) {
|
||||
return ["Abgeschlossen", "Erledigt"].includes(String(task?.categorie || "").trim())
|
||||
}
|
||||
|
||||
const setupPage = async () => {
|
||||
openTasks.value = (await useEntities("tasks").select()).filter((task) => {
|
||||
const assignee = task.userId || task.user_id || task.profile
|
||||
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>
|
||||
<UTable
|
||||
v-if="openTasks.length > 0"
|
||||
:rows="openTasks"
|
||||
:columns="[{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}]"
|
||||
@select="(i) => router.push(`/tasks/show/${i.id}`)"
|
||||
:data="openTasks"
|
||||
:columns="normalizeTableColumns([{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}])"
|
||||
:on-select="(i) => router.push(`/tasks/show/${i.id}`)"
|
||||
/>
|
||||
<div v-else>
|
||||
<p class="text-center font-bold">Keine offenen Aufgaben</p>
|
||||
|
||||
@@ -29,7 +29,7 @@ const startTime = async () => {
|
||||
await setupPage()
|
||||
} catch (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) {
|
||||
console.log(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>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"
|
||||
label="Notizen:"
|
||||
>
|
||||
<UTextarea
|
||||
v-model="runningTimeInfo.notes"
|
||||
/>
|
||||
</UFormGroup>
|
||||
<UFormGroup
|
||||
</UFormField>
|
||||
<UFormField
|
||||
class="mt-2"
|
||||
label="Projekt:"
|
||||
>
|
||||
@@ -74,7 +74,7 @@ const stopStartedTime = async () => {
|
||||
value-attribute="id"
|
||||
option-attribute="name"
|
||||
/>
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
<UButton
|
||||
class="mt-3"
|
||||
@click="stopStartedTime"
|
||||
|
||||
@@ -25,7 +25,7 @@ const startTime = async () => {
|
||||
await setupPage()
|
||||
} catch (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) {
|
||||
console.log(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>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"
|
||||
label="Notizen:"
|
||||
>
|
||||
<UTextarea
|
||||
v-model="runningTimeInfo.notes"
|
||||
/>
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
<UButton
|
||||
class="mt-3"
|
||||
@click="stopStartedTime"
|
||||
|
||||
183
frontend/components/displayTaxSummary.vue
Normal file
183
frontend/components/displayTaxSummary.vue
Normal 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>
|
||||
@@ -12,6 +12,10 @@ const props = defineProps({
|
||||
const products = ref([])
|
||||
const units = ref([])
|
||||
|
||||
const productSearchInput = {
|
||||
placeholder: 'Artikel suchen...'
|
||||
}
|
||||
|
||||
const setup = async () => {
|
||||
products.value = await useEntities("products").select()
|
||||
units.value = await useEntities("units").selectSpecial()
|
||||
@@ -80,16 +84,16 @@ const setRowData = (row) => {
|
||||
>
|
||||
<td>
|
||||
<USelectMenu
|
||||
searchable
|
||||
:search-attributes="['name']"
|
||||
:options="products"
|
||||
value-attribute="id"
|
||||
option-attribute="name"
|
||||
:items="products"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
:search-input="productSearchInput"
|
||||
:filter-fields="['name']"
|
||||
v-model="product.product"
|
||||
:color="product.product ? 'primary' : 'rose'"
|
||||
:color="product.product ? 'primary' : 'error'"
|
||||
@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'}}
|
||||
</template>
|
||||
</USelectMenu>
|
||||
@@ -104,9 +108,9 @@ const setRowData = (row) => {
|
||||
</td>
|
||||
<td>
|
||||
<USelectMenu
|
||||
:options="units"
|
||||
value-attribute="id"
|
||||
option-attribute="name"
|
||||
:items="units"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
v-model="product.unit"
|
||||
></USelectMenu>
|
||||
</td>
|
||||
@@ -123,7 +127,7 @@ const setRowData = (row) => {
|
||||
icon="i-heroicons-x-mark"
|
||||
@click="removeProductFromMaterialComposition(product.id)"
|
||||
variant="outline"
|
||||
color="rose"
|
||||
color="error"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -135,4 +139,4 @@ const setRowData = (row) => {
|
||||
td {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -53,7 +53,10 @@ const emit = defineEmits(["click"])
|
||||
<style scoped>
|
||||
/* FAB Basis */
|
||||
.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 Label + Icon → Extended FAB */
|
||||
@@ -61,6 +64,12 @@ const emit = defineEmits(["click"])
|
||||
|
||||
/* Optional: Auto-Kreisen wenn kein 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>
|
||||
|
||||
@@ -47,14 +47,14 @@ async function handlePrint() {
|
||||
|
||||
{{labelPrinter.printProgress}}
|
||||
|
||||
<UFormGroup label="Breite">
|
||||
<UFormField label="Breite">
|
||||
<UInput v-model="labelWidth"><template #trailing>mm</template></UInput>
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Höhe">
|
||||
</UFormField>
|
||||
<UFormField label="Höhe">
|
||||
<UInput v-model="labelHeight"><template #trailing>mm</template></UInput>
|
||||
</UFormGroup>
|
||||
<UFormGroup label="ZPL">
|
||||
</UFormField>
|
||||
<UFormField label="ZPL">
|
||||
<UTextarea v-model="zpl" rows="6" />
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,10 @@ const props = defineProps({
|
||||
const hourrates = ref([])
|
||||
const units = ref([])
|
||||
|
||||
const hourrateSearchInput = {
|
||||
placeholder: 'Stundensatz suchen...'
|
||||
}
|
||||
|
||||
const setup = async () => {
|
||||
hourrates.value = await useEntities("hourrates").select()
|
||||
units.value = await useEntities("units").selectSpecial()
|
||||
@@ -82,14 +86,14 @@ const setRowData = (row) => {
|
||||
>
|
||||
<td>
|
||||
<USelectMenu
|
||||
searchable
|
||||
:search-attributes="['name']"
|
||||
:options="hourrates"
|
||||
value-attribute="id"
|
||||
option-attribute="name"
|
||||
v-model="row.hourrate"
|
||||
:color="row.hourrate ? 'primary' : 'rose'"
|
||||
@change="setRowData(row)"
|
||||
:items="hourrates"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
:search-input="hourrateSearchInput"
|
||||
:filter-fields="['name']"
|
||||
v-model="row.hourrate"
|
||||
:color="row.hourrate ? 'primary' : 'error'"
|
||||
@change="setRowData(row)"
|
||||
>
|
||||
<!-- <template #label>
|
||||
{{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>
|
||||
<USelectMenu
|
||||
:options="units"
|
||||
:items="units"
|
||||
disabled
|
||||
value-attribute="id"
|
||||
option-attribute="name"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
v-model="row.unit"
|
||||
></USelectMenu>
|
||||
</td>
|
||||
@@ -134,7 +138,7 @@ const setRowData = (row) => {
|
||||
icon="i-heroicons-x-mark"
|
||||
@click="removeRowFromPersonalComposition(row.id)"
|
||||
variant="outline"
|
||||
color="rose"
|
||||
color="error"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -146,4 +150,4 @@ const setRowData = (row) => {
|
||||
td {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -206,16 +206,62 @@ const addVideo = () => {
|
||||
<style scoped>
|
||||
/* Toolbar & Buttons */
|
||||
.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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@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 */
|
||||
@@ -235,20 +281,48 @@ const addVideo = () => {
|
||||
/* MENTION */
|
||||
.wiki-mention {
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.wiki-mention::before {
|
||||
@apply text-gray-400 dark:text-gray-500 mr-0.5;
|
||||
color: #9ca3af;
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
: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 { 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; }
|
||||
@@ -258,7 +332,7 @@ const addVideo = () => {
|
||||
.column-resize-handle { background-color: #3b82f6; width: 4px; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* IMG */
|
||||
@@ -269,4 +343,4 @@ const addVideo = () => {
|
||||
mark { background-color: #fef08a; padding: 0.1rem 0.2rem; border-radius: 0.2rem; }
|
||||
.ProseMirror-selectednode { outline: 2px solid #3b82f6; }
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -98,17 +98,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UModal v-model="isCreateModalOpen">
|
||||
<div class="p-5">
|
||||
<h3 class="font-bold mb-4">Neue Seite</h3>
|
||||
<form @submit.prevent="createPage">
|
||||
<UInput v-model="newTitle" placeholder="Titel..." autofocus />
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<UButton color="gray" variant="ghost" @click="isCreateModalOpen = false">Abbrechen</UButton>
|
||||
<UButton type="submit" color="primary" :loading="isCreating">Erstellen</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<UModal v-model:open="isCreateModalOpen">
|
||||
<template #content>
|
||||
<div class="p-5">
|
||||
<h3 class="font-bold mb-4">Neue Seite</h3>
|
||||
<form @submit.prevent="createPage">
|
||||
<UInput v-model="newTitle" placeholder="Titel..." autofocus />
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<UButton color="gray" variant="ghost" @click="isCreateModalOpen = false">Abbrechen</UButton>
|
||||
<UButton type="submit" color="primary" :loading="isCreating">Erstellen</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
</div>
|
||||
@@ -163,7 +165,7 @@ async function selectPage(id: string) {
|
||||
const data = await $api(`/api/wiki/${id}`, { method: 'GET' })
|
||||
selectedPage.value = data
|
||||
} catch (e) {
|
||||
toast.add({ title: 'Fehler beim Laden', color: 'red' })
|
||||
toast.add({ title: 'Fehler beim Laden', color: 'error' })
|
||||
} finally {
|
||||
loadingContent.value = false
|
||||
}
|
||||
@@ -233,4 +235,4 @@ watch(() => [props.entityId, props.entityUuid], fetchList)
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background-color: #ddd; border-radius: 4px; }
|
||||
</style>
|
||||
</style>
|
||||
|
||||
155
frontend/composables/useChangelog.ts
Normal file
155
frontend/composables/useChangelog.ts
Normal 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)
|
||||
@@ -36,6 +36,9 @@ export const useFiles = () => {
|
||||
let data = []
|
||||
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",{
|
||||
@@ -138,4 +141,4 @@ export const useFiles = () => {
|
||||
|
||||
|
||||
return {uploadFiles, selectDocuments, selectSomeDocuments, selectDocument, downloadFile, dataURLtoFile}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user