Compare commits

..

4 Commits

Author SHA1 Message Date
bace26c084 KI-AGENT: Vereinfache Matrix Selfhost auf eine Domain
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 51s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-05-19 18:49:04 +02:00
274f3d5795 KI-AGENT: Ergänze Matrix im Selfhost-Stack 2026-05-19 18:40:30 +02:00
168d2fce6e KI-AGENT: StandardEntity Empty-State vereinheitlichen
Rendert die StandardEntity-Tabelle auch ohne Einträge und nutzt den gemeinsamen Empty-State statt der separaten Card.
2026-05-19 18:40:10 +02:00
6dcd8b1863 KI-AGENT: Tabellen-Empty-States ohne JSON rendern
Ersetzt ungültige UTable-Empty-Props durch einen gemeinsamen Empty-State-Slot, damit leere Tabellen keine Objekt-/JSON-Ausgabe mehr anzeigen.
2026-05-19 18:36:54 +02:00
36 changed files with 478 additions and 73 deletions

View File

@@ -64,13 +64,11 @@ FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
# FEDEO Matrix-Kommunikation
#
# Diese Werte werden von docker-compose.yml gelesen, wenn das Profil "matrix"
# genutzt wird. Für produktive Systeme müssen alle Geheimnisse ersetzt werden.
# Diese Werte werden von docker-compose.yml und docker-compose.selfhost.yml
# gelesen, wenn das Profil "matrix" genutzt wird. Für produktive Systeme
# müssen alle Geheimnisse ersetzt werden.
MATRIX_SERVER_NAME=fedeo.de
MATRIX_HOMESERVER_HOST=matrix.fedeo.de
MATRIX_RTC_HOST=call.fedeo.de
MATRIX_TURN_HOST=turn.fedeo.de
MATRIX_SERVER_NAME=app.example.com
MATRIX_POSTGRES_DB=synapse
MATRIX_POSTGRES_USER=synapse
@@ -81,6 +79,15 @@ MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
LIVEKIT_KEY=fedeo-livekit
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
# Backend-Integration im Selfhost-Stack
MATRIX_HOMESERVER_URL=http://matrix-synapse:8008
MATRIX_RTC_HOST=app.example.com
MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt
MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu
MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-synapse-homeserver-yaml
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element
# Lokale Matrix-Entwicklung
MATRIX_DEV_SYNAPSE_PORT=8008
MATRIX_DEV_ELEMENT_PORT=8080
@@ -94,10 +101,9 @@ MATRIX_DEV_TURN_PORT=3478
MATRIX_DEV_TURN_MIN_PORT=49160
MATRIX_DEV_TURN_MAX_PORT=49200
# Backend-Integration gegen den lokalen Matrix-Stack
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_RTC_JWT_URL=http://localhost:8081
MATRIX_LIVEKIT_URL=ws://localhost:7880
MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
NUXT_PUBLIC_MATRIX_ELEMENT_URL=http://localhost:8080
# Lokale Backend-Integration gegen den Matrix-Entwicklungsstack
# MATRIX_HOMESERVER_URL=http://localhost:8008
# MATRIX_RTC_JWT_URL=http://localhost:8081
# MATRIX_LIVEKIT_URL=ws://localhost:7880
# MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml
# NUXT_PUBLIC_MATRIX_ELEMENT_URL=http://localhost:8080

View File

@@ -191,14 +191,31 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
MATRIX_SERVER_NAME=app.example.com
MATRIX_POSTGRES_DB=synapse
MATRIX_POSTGRES_USER=synapse
MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password
MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
MATRIX_HOMESERVER_URL=http://matrix-synapse:8008
MATRIX_RTC_HOST=app.example.com
MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt
MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu
MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-synapse-homeserver-yaml
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
LIVEKIT_KEY=fedeo-livekit
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element
```
Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern.
## Docker Compose mit optionaler S3-MinIO-Option
## Docker Compose mit optionalem S3 und Matrix
Die Selfhost-Konfiguration liegt in `docker-compose.selfhost.yml`. Sie startet MinIO standardmäßig 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.
Der Matrix-Stack ist im Selfhost-Compose als optionales Profil `matrix` enthalten. Er umfasst Synapse, eine eigene PostgreSQL-Datenbank für Synapse, Redis, `.well-known/matrix`, coturn, LiveKit, den LiveKit-JWT-Service und Element Web. Das einfache Selfhost-Setup nutzt nur `DOMAIN`: Synapse läuft unter `https://DOMAIN/_matrix`, Matrix-Well-Known unter `https://DOMAIN/.well-known/matrix`, LiveKit unter `https://DOMAIN/livekit/sfu`, der JWT-Service unter `https://DOMAIN/livekit/jwt` und Element Web unter `https://DOMAIN/element`. Vor dem ersten Start musst du `matrix/synapse/homeserver.yaml` erzeugen und `matrix/selfhost/element-config.json` auf deine Domain anpassen.
Das Backend führt beim Containerstart standardmäßig `npm run migrate` aus. Setze `FEDEO_RUN_MIGRATIONS=false`, wenn du Migrationen bewusst manuell ausführen möchtest.
```yaml
@@ -399,6 +416,23 @@ docker compose -f docker-compose.selfhost.yml build
docker compose -f docker-compose.selfhost.yml up -d
```
Mit Matrix-Profil:
```bash
docker compose -f docker-compose.selfhost.yml --profile matrix up -d
```
Synapse-Konfiguration vor dem ersten Matrix-Start erzeugen:
```bash
docker compose -f docker-compose.selfhost.yml --profile matrix run --rm \
-e SYNAPSE_SERVER_NAME="${MATRIX_SERVER_NAME}" \
-e SYNAPSE_REPORT_STATS=no \
matrix-synapse generate
```
Danach in `matrix/synapse/homeserver.yaml` mindestens Datenbank, Redis, `public_baseurl`, TURN und `registration_shared_secret` setzen. Der Wert von `registration_shared_secret` muss zusätzlich als `MATRIX_REGISTRATION_SHARED_SECRET` in die `.env`, damit FEDEO Matrix-Nutzer provisionieren kann.
Danach Status prufen:
```bash
@@ -449,6 +483,8 @@ Regelmassig sichern:
- `./postgres`
- `./minio` falls MinIO lokal genutzt wird
- `./matrix/postgres` falls Matrix lokal betrieben wird
- `./matrix/synapse` falls Matrix lokal betrieben wird
- `./traefik/letsencrypt/acme.json`
- deine `.env`
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management

View File

@@ -0,0 +1,58 @@
CREATE TABLE IF NOT EXISTS "communication_rooms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"key" text NOT NULL,
"name" text NOT NULL,
"topic" text,
"type" text DEFAULT 'room' NOT NULL,
"entity_type" text,
"entity_id" bigint,
"entity_uuid" uuid,
"matrix_room_id" text,
"matrix_alias" text,
"parent_space_room_id" text,
"archived" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" uuid,
"updated_by" uuid
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'communication_rooms_tenant_id_tenants_id_fk'
) THEN
ALTER TABLE "communication_rooms"
ADD CONSTRAINT "communication_rooms_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'communication_rooms_created_by_auth_users_id_fk'
) THEN
ALTER TABLE "communication_rooms"
ADD CONSTRAINT "communication_rooms_created_by_auth_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'communication_rooms_updated_by_auth_users_id_fk'
) THEN
ALTER TABLE "communication_rooms"
ADD CONSTRAINT "communication_rooms_updated_by_auth_users_id_fk"
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "communication_rooms_tenant_key_idx"
ON "communication_rooms" USING btree ("tenant_id", "key");
CREATE INDEX IF NOT EXISTS "communication_rooms_tenant_idx"
ON "communication_rooms" USING btree ("tenant_id");
CREATE INDEX IF NOT EXISTS "communication_rooms_entity_idx"
ON "communication_rooms" USING btree ("tenant_id", "entity_type", "entity_id", "entity_uuid");

View File

@@ -302,6 +302,13 @@
"when": 1780153200000,
"tag": "0042_profile_availability_note",
"breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1780156800000,
"tag": "0043_communication_rooms",
"breakpoints": true
}
]
}

View File

@@ -130,6 +130,15 @@ services:
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_LAST_NAME:-Benutzer}
FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO}
FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO}
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL:-http://matrix-synapse:8008}
MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}}
MATRIX_RTC_HOST: ${MATRIX_RTC_HOST:-${DOMAIN}}
MATRIX_RTC_JWT_URL: ${MATRIX_RTC_JWT_URL:-}
MATRIX_LIVEKIT_URL: ${MATRIX_LIVEKIT_URL:-}
MATRIX_REGISTRATION_SHARED_SECRET: ${MATRIX_REGISTRATION_SHARED_SECRET:-}
MATRIX_SERVICE_USER_LOCALPART: ${MATRIX_SERVICE_USER_LOCALPART:-fedeo_service}
LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit}
LIVEKIT_SECRET: ${LIVEKIT_SECRET:-}
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
@@ -165,6 +174,201 @@ services:
networks:
- web
matrix-db:
image: postgres:16-alpine
container_name: fedeo-matrix-db
restart: unless-stopped
profiles:
- matrix
environment:
POSTGRES_DB: ${MATRIX_POSTGRES_DB:-synapse}
POSTGRES_USER: ${MATRIX_POSTGRES_USER:-synapse}
POSTGRES_PASSWORD: ${MATRIX_POSTGRES_PASSWORD:-change-this-matrix-db-password}
POSTGRES_INITDB_ARGS: --encoding=UTF8 --lc-collate=C --lc-ctype=C
volumes:
- ./matrix/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${MATRIX_POSTGRES_USER:-synapse} -d ${MATRIX_POSTGRES_DB:-synapse}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- internal
matrix-redis:
image: redis:7-alpine
container_name: fedeo-matrix-redis
restart: unless-stopped
profiles:
- matrix
networks:
- internal
matrix-synapse:
image: ghcr.io/element-hq/synapse:latest
container_name: fedeo-matrix-synapse
restart: unless-stopped
profiles:
- matrix
depends_on:
matrix-db:
condition: service_healthy
matrix-redis:
condition: service_started
environment:
SYNAPSE_CONFIG_PATH: /data/homeserver.yaml
volumes:
- ./matrix/synapse:/data
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-matrix.rule=Host(`${DOMAIN}`) && PathPrefix(`/_matrix`)
- traefik.http.routers.fedeo-matrix.entrypoints=websecure
- traefik.http.routers.fedeo-matrix.tls.certresolver=letsencrypt
- traefik.http.services.fedeo-matrix.loadbalancer.server.port=8008
- traefik.docker.network=fedeo_web
networks:
- web
- internal
matrix-well-known:
image: nginx:1.27-alpine
container_name: fedeo-matrix-well-known
restart: unless-stopped
profiles:
- matrix
volumes:
- ./matrix/well-known:/usr/share/nginx/html/.well-known/matrix:ro
labels:
- traefik.enable=true
- traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolalloworiginlist=*
- traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolallowmethods=GET,OPTIONS
- traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolallowheaders=Content-Type,Authorization
- traefik.http.routers.fedeo-matrix-well-known.rule=Host(`${DOMAIN}`) && PathPrefix(`/.well-known/matrix`)
- traefik.http.routers.fedeo-matrix-well-known.entrypoints=websecure
- traefik.http.routers.fedeo-matrix-well-known.tls.certresolver=letsencrypt
- traefik.http.routers.fedeo-matrix-well-known.middlewares=fedeo-matrix-well-known-cors
- traefik.http.services.fedeo-matrix-well-known.loadbalancer.server.port=80
- traefik.docker.network=fedeo_web
networks:
- web
matrix-turn:
image: instrumentisto/coturn:4
container_name: fedeo-matrix-turn
restart: unless-stopped
profiles:
- matrix
command:
- --fingerprint
- --use-auth-secret
- --static-auth-secret=${MATRIX_TURN_SHARED_SECRET:-change-this-turn-secret}
- --realm=${MATRIX_SERVER_NAME:-${DOMAIN}}
- --listening-port=3478
- --tls-listening-port=5349
- --min-port=49160
- --max-port=49200
- --no-cli
- --no-tlsv1
- --no-tlsv1_1
ports:
- "3478:3478/tcp"
- "3478:3478/udp"
- "5349:5349/tcp"
- "49160-49200:49160-49200/udp"
networks:
- internal
matrix-livekit:
image: livekit/livekit-server:v1.9
container_name: fedeo-matrix-livekit
restart: unless-stopped
profiles:
- matrix
depends_on:
- matrix-redis
entrypoint: /bin/sh
command:
- -ec
- |
cat >/tmp/livekit.yaml <<EOF
port: 7880
redis:
address: matrix-redis:6379
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
use_external_ip: true
keys:
${LIVEKIT_KEY:-fedeo-livekit}: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
room:
auto_create: true
EOF
exec /livekit-server --config /tmp/livekit.yaml
ports:
- "7881:7881/tcp"
- "50000-50100:50000-50100/udp"
labels:
- traefik.enable=true
- traefik.http.middlewares.fedeo-matrix-livekit-strip.stripprefix.prefixes=/livekit/sfu
- traefik.http.routers.fedeo-matrix-livekit.rule=Host(`${DOMAIN}`) && PathPrefix(`/livekit/sfu`)
- traefik.http.routers.fedeo-matrix-livekit.entrypoints=websecure
- traefik.http.routers.fedeo-matrix-livekit.tls.certresolver=letsencrypt
- traefik.http.routers.fedeo-matrix-livekit.middlewares=fedeo-matrix-livekit-strip
- traefik.http.services.fedeo-matrix-livekit.loadbalancer.server.port=7880
- traefik.docker.network=fedeo_web
networks:
- web
- internal
matrix-rtc-jwt:
image: ghcr.io/element-hq/lk-jwt-service:latest
container_name: fedeo-matrix-rtc-jwt
restart: unless-stopped
profiles:
- matrix
depends_on:
- matrix-livekit
- matrix-synapse
environment:
LIVEKIT_URL: wss://${DOMAIN}/livekit/sfu
LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit}
LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
LIVEKIT_FULL_ACCESS_HOMESERVERS: ${MATRIX_SERVER_NAME:-${DOMAIN}}
LIVEKIT_JWT_BIND: :8080
labels:
- traefik.enable=true
- traefik.http.middlewares.fedeo-matrix-rtc-jwt-strip.stripprefix.prefixes=/livekit/jwt
- traefik.http.routers.fedeo-matrix-rtc-jwt.rule=Host(`${DOMAIN}`) && PathPrefix(`/livekit/jwt`)
- traefik.http.routers.fedeo-matrix-rtc-jwt.entrypoints=websecure
- traefik.http.routers.fedeo-matrix-rtc-jwt.tls.certresolver=letsencrypt
- traefik.http.routers.fedeo-matrix-rtc-jwt.middlewares=fedeo-matrix-rtc-jwt-strip
- traefik.http.services.fedeo-matrix-rtc-jwt.loadbalancer.server.port=8080
- traefik.docker.network=fedeo_web
networks:
- web
- internal
matrix-element:
image: vectorim/element-web:latest
container_name: fedeo-matrix-element
restart: unless-stopped
profiles:
- matrix
volumes:
- ./matrix/selfhost/element-config.json:/app/config.json:ro
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-matrix-element.rule=Host(`${DOMAIN}`) && PathPrefix(`/element`)
- traefik.http.routers.fedeo-matrix-element.entrypoints=websecure
- traefik.http.routers.fedeo-matrix-element.tls.certresolver=letsencrypt
- traefik.http.middlewares.fedeo-matrix-element-strip.stripprefix.prefixes=/element
- traefik.http.routers.fedeo-matrix-element.middlewares=fedeo-matrix-element-strip
- traefik.http.services.fedeo-matrix-element.loadbalancer.server.port=80
- traefik.docker.network=fedeo_web
networks:
- web
networks:
web:
name: fedeo_web

View File

@@ -267,7 +267,6 @@ const selectItem = (item) => {
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="(row) => selectItem(row.original)"
style="height: 70vh"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
>
<template #type-cell="{ row }">
{{ dataStore.documentTypesForCreation[row.original.type].labelSingle }}
@@ -312,6 +311,9 @@ const selectItem = (item) => {
<template #amount-cell="{ row }">
<span v-if="!deliveryNoteLikeDocumentTypes.includes(row.original.type)">{{ useCurrency(useSum().getCreatedDocumentSum(row.original, createddocuments)) }}</span>
</template>
<template #empty>
<TableEmptyState label="Keine Belege anzuzeigen" />
</template>
</UTable>

View File

@@ -254,7 +254,6 @@ const selectAllocation = (allocationLike) => {
:columns="normalizeTableColumns(columns)"
:on-select="selectAllocation"
class="w-full"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen im ausgewählten Zeitraum' }"
>
<template #amount-cell="{ row }">
<span class="text-right text-error" v-if="row.original.amount < 0 || row.original.color === 'red'">{{ useCurrency(row.original.amount) }}</span>
@@ -275,6 +274,9 @@ const selectAllocation = (allocationLike) => {
<div class="max-w-[22rem] truncate">{{ hasContent(row.original.description) ? row.original.description : '-' }}</div>
</UTooltip>
</template>
<template #empty>
<TableEmptyState label="Keine Buchungen im ausgewählten Zeitraum" />
</template>
</UTable>
</div>

View File

@@ -69,7 +69,6 @@ const columns = [
class="mt-3"
:columns="normalizeTableColumns(columns)"
:data="props.item.times"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Noch keine Einträge' }"
>
<template #state-cell="{ row }">
<span
@@ -102,6 +101,9 @@ const columns = [
<template #project-cell="{ row }">
{{ row.original.project ? row.original.project.name : "" }}
</template>
<template #empty>
<TableEmptyState label="Noch keine Einträge" />
</template>
</UTable>
</UCard>

View File

@@ -110,7 +110,6 @@
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="handleSelect"
:empty="`Keine ${dataType.label} anzuzeigen`"
>
<template #name-cell="{ row }">
<span
@@ -161,6 +160,9 @@
</span>
</template>
<template #empty>
<TableEmptyState :label="`Keine ${dataType.label} anzuzeigen`" />
</template>
</UTable>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
defineProps({
icon: {
type: String,
default: 'i-heroicons-circle-stack-20-solid'
},
label: {
type: String,
required: true
}
})
</script>
<template>
<div class="flex flex-col items-center justify-center gap-2 py-6 text-center text-muted">
<UIcon :name="icon" class="size-6" />
<span>{{ label }}</span>
</div>
</template>

View File

@@ -224,7 +224,6 @@ setupPage()
v-if="!loading"
:data="reportRows"
:columns="columns"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Eingangsbelege mit dieser Kostenstelle oder ihren Unterkostenstellen gefunden' }"
class="w-full"
>
<template #reference-cell="{ row }">
@@ -264,6 +263,9 @@ setupPage()
<template #amountGross-cell="{ row }">
<div class="text-right font-medium tabular-nums">{{ currency(row.original.amountGross) }}</div>
</template>
<template #empty>
<TableEmptyState label="Keine Eingangsbelege mit dieser Kostenstelle oder ihren Unterkostenstellen gefunden" />
</template>
</UTable>
<UProgress v-else animation="carousel" class="w-3/4 mx-auto" />

View File

@@ -25,8 +25,11 @@ setupPage()
:data="openTasks"
:columns="normalizeTableColumns([{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}])"
:on-select="(i) => router.push(`/tasks/show/${i.id}`)"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine offenen Aufgaben' }"
/>
>
<template #empty>
<TableEmptyState label="Keine offenen Aufgaben" />
</template>
</UTable>
</template>
<style scoped>

View File

@@ -585,7 +585,6 @@ onMounted(setupPage)
:columns="normalizeTableColumns(accountColumns)"
:loading="loading"
:on-select="openAccount"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungskonten im ausgewählten Zeitraum' }"
>
<template #label-cell="{ row }">
<div class="truncate font-medium">{{ row.original.label }}</div>
@@ -606,6 +605,9 @@ onMounted(setupPage)
<template #gross-cell="{ row }">
<div class="text-right font-medium tabular-nums">{{ useCurrency(row.original.gross) }}</div>
</template>
<template #empty>
<TableEmptyState label="Keine Buchungskonten im ausgewählten Zeitraum" />
</template>
</UTable>
</div>
</UCard>
@@ -624,7 +626,6 @@ onMounted(setupPage)
:columns="normalizeTableColumns(ownAccountColumns)"
:loading="loading"
:on-select="openOwnAccount"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine eigenen Buchungen im ausgewählten Zeitraum' }"
>
<template #label-cell="{ row }">
<div class="truncate font-medium">{{ row.original.label }}</div>
@@ -647,6 +648,9 @@ onMounted(setupPage)
{{ useCurrency(row.original.balance) }}
</div>
</template>
<template #empty>
<TableEmptyState label="Keine eigenen Buchungen im ausgewählten Zeitraum" />
</template>
</UTable>
</div>
</UCard>
@@ -664,11 +668,13 @@ onMounted(setupPage)
:data="depreciationRows"
:columns="normalizeTableColumns(depreciationColumns)"
:loading="loading"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Abschreibungen im ausgewählten Zeitraum' }"
>
<template #amount-cell="{ row }">
<div class="text-right text-amber-600 dark:text-amber-400 tabular-nums">{{ useCurrency(row.original.amount) }}</div>
</template>
<template #empty>
<TableEmptyState label="Keine Abschreibungen im ausgewählten Zeitraum" />
</template>
</UTable>
</UCard>
</UDashboardPanelContent>

View File

@@ -117,7 +117,6 @@ onMounted(loadCashbooks)
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="(row) => router.push(`/accounting/cashbooks/${row.original?.id || row.id}`)"
:empty="{ icon: 'i-heroicons-banknotes', label: 'Keine Kassenbücher angelegt' }"
>
<template #datevNumber-cell="{ row }">
<span class="font-mono">{{ row.original.datevNumber }}</span>
@@ -128,6 +127,9 @@ onMounted(loadCashbooks)
<template #syncedAt-cell="{ row }">
{{ row.original.createdAt ? new Date(row.original.createdAt).toLocaleDateString("de-DE") : "-" }}
</template>
<template #empty>
<TableEmptyState label="Keine Kassenbücher angelegt" icon="i-heroicons-banknotes" />
</template>
</UTable>
<UModal v-model:open="createCashbookModalOpen">

View File

@@ -255,7 +255,6 @@ onMounted(loadData)
:columns="normalizeTableColumns(columns)"
:data="periods"
:loading="loading"
:empty="{ icon: 'i-heroicons-calculator', label: 'Keine Daten für die USt-Auswertung vorhanden' }"
>
<template #label-cell="{ row }">
<div class="flex items-center gap-2">
@@ -281,6 +280,9 @@ onMounted(loadData)
<template #documents-cell="{ row }">
{{ row.original.outputCount }} / {{ row.original.inputCount }}
</template>
<template #empty>
<TableEmptyState label="Keine Daten für die USt-Auswertung vorhanden" icon="i-heroicons-calculator" />
</template>
</UTable>
</UCard>
</UDashboardPanelContent>

View File

@@ -199,7 +199,6 @@ setupPage()
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="(row) => router.push(`/accounts/show/${row.original.id}`)"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
>
<template #allocations-cell="{row}">
<span v-if="dataLoaded">{{row.original.allocations ? row.original.allocations : null}}</span>
@@ -210,6 +209,9 @@ setupPage()
<span v-if="dataLoaded">{{row.original.allocations ? useCurrency(row.original.saldo) : null}}</span>
<USkeleton v-else class="h-4 w-[250px]" />
</template>
<template #empty>
<TableEmptyState label="Keine Buchungen anzuzeigen" />
</template>
</UTable>

View File

@@ -347,8 +347,11 @@ onMounted(async () => {
])"
:on-select="(row) => router.push(`/administration/users/${row.original?.id || row.id}`)"
class="mt-4"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine zugeordneten Benutzer gefunden' }"
/>
>
<template #empty>
<TableEmptyState label="Keine zugeordneten Benutzer gefunden" />
</template>
</UTable>
</UCard>
<USkeleton v-if="loading" class="h-80" />

View File

@@ -179,8 +179,11 @@ onMounted(async () => {
:columns="normalizeTableColumns(templateColumns)"
:loading="loading"
:on-select="(row) => router.push(`/administration/tenants/${row.original?.id || row.id}`)"
:empty="{ icon: 'i-heroicons-building-office-2', label: 'Keine Tenants gefunden' }"
/>
>
<template #empty>
<TableEmptyState label="Keine Tenants gefunden" icon="i-heroicons-building-office-2" />
</template>
</UTable>
<UModal v-model:open="createTenantModalOpen">
<template #content>

View File

@@ -136,8 +136,11 @@ onMounted(async () => {
:columns="normalizeTableColumns(templateColumns)"
:loading="loading"
:on-select="(row) => router.push(`/administration/users/${row.original?.id || row.id}`)"
:empty="{ icon: 'i-heroicons-users', label: 'Keine Benutzer gefunden' }"
/>
>
<template #empty>
<TableEmptyState label="Keine Benutzer gefunden" icon="i-heroicons-users" />
</template>
</UTable>
<UModal v-model:open="createUserModalOpen">
<template #content>

View File

@@ -60,7 +60,6 @@
<div style="height: 80vh; overflow-y: scroll">
<UTable
:columns="normalizeTableColumns(getColumnsForTab(item.key))"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
:data="getRowsForTab(item.key)"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
class="w-full"
@@ -134,6 +133,9 @@
{{ displayCurrency(useSum().getCreatedDocumentOpenAmount(row.original, items)) }}
</span>
</template>
<template #empty>
<TableEmptyState label="Keine Belege anzuzeigen" />
</template>
</UTable>
</div>
</template>

View File

@@ -98,7 +98,6 @@
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="(row) => router.push(`/createDocument/edit/${row.original?.id || row.id}`)"
empty="Keine Belege anzuzeigen"
>
<template #actions-cell="{ row }">
<div @click.stop>
@@ -137,6 +136,9 @@
<span v-if="row.original.payment_type === 'transfer'">Überweisung</span>
<span v-else-if="row.original.payment_type === 'direct-debit'">SEPA - Einzug</span>
</template>
<template #empty>
<TableEmptyState label="Keine Belege anzuzeigen" />
</template>
</UTable>
<UModal v-model:open="showExecutionModal" :ui="{ width: 'sm:max-w-4xl' }">
@@ -211,7 +213,6 @@
:get-row-id="(row) => row.id"
:ui="{ th: { base: 'whitespace-nowrap' } }"
:on-select="toggleExecutionRow"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
>
<template #select-header="{ table }">
<div class="flex justify-center" @click.stop>
@@ -244,6 +245,9 @@
<template #plant-cell="{row}">
{{ row.original.plant?.name || "-" }}
</template>
<template #empty>
<TableEmptyState label="Keine Belege anzuzeigen" />
</template>
</UTable>
</div>

View File

@@ -97,7 +97,6 @@ const createExport = async () => {
{ key: 'documentDate', label: 'Belegdatum' },
{ key: 'outgoingsepamandate', label: 'SEPA-Mandat' },
])"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine SEPA-Belege anzuzeigen' }"
>
<template #customer-cell="{ row }">
{{ row.original.customer?.name || "-" }}
@@ -105,6 +104,9 @@ const createExport = async () => {
<template #outgoingsepamandate-cell="{ row }">
{{ row.original.outgoingsepamandate?.reference || "-" }}
</template>
<template #empty>
<TableEmptyState label="Keine SEPA-Belege anzuzeigen" />
</template>
</UTable>
</template>

View File

@@ -154,7 +154,6 @@ const createExport = async () => {
{ key: 'type', label: 'Typ' },
{ key: 'download', label: 'Download' },
])"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Exporte anzuzeigen' }"
>
<template #created_at-cell="{row}">
{{dayjs(row.original.created_at).format("DD.MM.YYYY HH:mm")}}
@@ -171,6 +170,9 @@ const createExport = async () => {
<template #download-cell="{row}">
<UButton @click="downloadFile(row.original)">Download</UButton>
</template>
<template #empty>
<TableEmptyState label="Keine Exporte anzuzeigen" />
</template>
</UTable>
<UModal v-model:open="showCreateExportModal">

View File

@@ -278,7 +278,6 @@ const selectIncomingInvoice = (invoiceLike) => {
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="selectIncomingInvoice"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
>
<template #reference-cell="{row}">
<span v-if="row.original === filteredRows[selectedItem]" class="text-primary-500 font-bold">{{row.original.reference}}</span>
@@ -310,6 +309,9 @@ const selectIncomingInvoice = (invoiceLike) => {
<span v-if="isPaid(row.original)" class="text-primary-500">Bezahlt</span>
<span v-else class="text-rose-600">Offen</span>
</template>
<template #empty>
<TableEmptyState label="Keine Belege anzuzeigen" />
</template>
</UTable>
</div>

View File

@@ -92,13 +92,14 @@ const filteredRows = computed(() => {
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="(i) => router.push(`/projecttypes/show/${i.id}`) "
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Projekttypen anzuzeigen' }"
>
<template #name-cell="{row}">
<span class="text-primary-500 font-bold" v-if="row.original === filteredRows[selectedItem]">{{ row.original.name }}</span>
<span v-else>{{ row.original.name }}</span>
</template>
<template #empty>
<TableEmptyState label="Keine Projekttypen anzuzeigen" />
</template>
</UTable>
</template>

View File

@@ -203,7 +203,6 @@ setupPage()
label: 'Saldo'
},
])"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Bankkonten anzuzeigen' }"
>
<template #expired-cell="{ row }">
<span v-if="row.original.expired" class="text-error-600">Ausgelaufen</span>
@@ -221,6 +220,9 @@ setupPage()
<template #iban-cell="{ row }">
{{ row.original.iban.match(/.{1,5}/g).join(" ") }}
</template>
<template #empty>
<TableEmptyState label="Keine Bankkonten anzuzeigen" />
</template>
</UTable>
</template>

View File

@@ -87,8 +87,10 @@ const columns = computed(() => templateColumns.filter((column) => selectedColumn
class="w-full"
:on-select="(i) => navigateTo(`/settings/emailaccounts/edit/${i.id}`)"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine E-Mail Konten anzuzeigen' }"
>
<template #empty>
<TableEmptyState label="Keine E-Mail Konten anzuzeigen" />
</template>
</UTable>
</template>

View File

@@ -197,7 +197,6 @@ onMounted(refreshData)
{ key: 'path', label: 'Datei' },
{ key: 'actions', label: '' }
])"
:empty="{ icon: 'i-heroicons-document', label: 'Keine Briefpapiere gefunden' }"
>
<template #name-cell="{ row }">
<span class="font-medium text-gray-900 dark:text-white">
@@ -260,6 +259,9 @@ onMounted(refreshData)
</ButtonWithConfirm>
</div>
</template>
<template #empty>
<TableEmptyState label="Keine Briefpapiere gefunden" icon="i-heroicons-document" />
</template>
</UTable>
</UDashboardPanelContent>

View File

@@ -163,7 +163,6 @@ const getDocLabel = (type) => {
:data="texttemplates"
:loading="loading"
v-model:expand="expand"
:empty="{ icon: 'i-heroicons-document-text', label: 'Keine Textvorlagen gefunden' }"
:columns="normalizeTableColumns([
{ key: 'name', label: 'Bezeichnung' },
{ key: 'documentType', label: 'Verwendung' },
@@ -233,6 +232,9 @@ const getDocLabel = (type) => {
</div>
</div>
</template>
<template #empty>
<TableEmptyState label="Keine Textvorlagen gefunden" icon="i-heroicons-document-text" />
</template>
</UTable>
</UDashboardPanelContent>

View File

@@ -79,8 +79,11 @@
:columns="normalizeTableColumns(columns)"
:loading="pending"
:on-select="(row) => navigateTo(`/staff/profiles/${row.original?.id || row.id}`)"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Mitarbeiterprofile gefunden' }"
/>
>
<template #empty>
<TableEmptyState label="Keine Mitarbeiterprofile gefunden" />
</template>
</UTable>
</template>
<style scoped>

View File

@@ -296,7 +296,6 @@ await setupPage()
<UTable
v-if="workingTimeInfo"
:data="workingTimeInfo.spans"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Anwesenheiten' }"
:columns="normalizeTableColumns([
{ key: 'status', label: 'Status' },
{ key: 'startedAt', label: 'Start' },
@@ -329,6 +328,9 @@ await setupPage()
<template #type-cell="{ row }">
{{ row.original.type.charAt(0).toUpperCase() + row.original.type.slice(1).replace('_', ' ') }}
</template>
<template #empty>
<TableEmptyState label="Keine Anwesenheiten" />
</template>
</UTable>
</UDashboardPanel>
</div>

View File

@@ -241,7 +241,6 @@ onMounted(async () => {
{ key: 'type', label: 'Typ' },
{ key: 'description', label: 'Beschreibung' },
])"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Zeiten anzuzeigen' }"
>
<template #state-cell="{ row }">
<UBadge v-if="row.original.state === 'approved'" color="green" variant="subtle">Genehmigt</UBadge>
@@ -285,6 +284,9 @@ onMounted(async () => {
<span v-else-if="row.original.type === 'sick'">{{ row.original.sick_reason }}</span>
<span v-else>{{ row.original.description }}</span>
</template>
<template #empty>
<TableEmptyState label="Keine Zeiten anzuzeigen" />
</template>
</UTable>
</UCard>

View File

@@ -439,14 +439,13 @@ const isDistinctFilterActive = (columnKey) => {
<UTable
:loading="loading"
v-model:sorting="sorting"
v-if="dataType && columns && items.length > 0 && !loading"
v-if="dataType && columns && !loading"
:data="items"
:columns="normalizeTableColumns(columns)"
class="w-full"
style="height: 85dvh"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="(row) => router.push(`/standardEntity/${type}/show/${row.original.id}`)"
:empty="`Keine ${dataType.label} anzuzeigen`"
>
<template
v-for="column in dataType.templateColumns.filter(i => !i.disabledInTable)"
@@ -566,23 +565,10 @@ const isDistinctFilterActive = (columnKey) => {
</span>
</template>
<template #empty>
<TableEmptyState :label="`Keine ${dataType.label} anzuzeigen`" />
</template>
</UTable>
<UCard
class="w-1/3 mx-auto mt-10"
v-else-if="!loading"
>
<div
class="flex flex-col text-center"
>
<UIcon
class="mx-auto w-10 h-10 mb-5"
name="i-heroicons-circle-stack-20-solid"/>
<span class="font-bold">Keine {{dataType.label}} anzuzeigen</span>
</div>
</UCard>
<UProgress v-else animation="carousel" class="w-3/4 mx-auto mt-5"></UProgress>
</div>
<div v-else class="relative flex flex-col h-[calc(100dvh-80px)]">
@@ -679,8 +665,7 @@ const isDistinctFilterActive = (columnKey) => {
v-if="!loading && items.length === 0"
class="mx-auto mt-10 p-6 text-center"
>
<UIcon name="i-heroicons-circle-stack-20-solid" class="mx-auto w-10 h-10 mb-3"/>
<p class="font-bold">Keine {{ dataType.label }} gefunden</p>
<TableEmptyState :label="`Keine ${dataType.label} anzuzeigen`" />
</UCard>
<div v-if="loading" class="mt-5">

View File

@@ -73,7 +73,6 @@ const filteredRows = computed(() => {
</UDashboardToolbar>
<UTable
:data="filteredRows"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: `Keine Tickets anzuzeigen` }"
:on-select="(i) => router.push(`/support/${i.id}`)"
:columns="normalizeTableColumns([{key:'created_at',label:'Datum'}, ...profileStore.currentTenant === 5 ? [{key:'tenant',label:'Tenant'}] : [],{key:'status',label:'Status'},{key:'title',label:'Titel'},{key:'created_by',label:'Ersteller'},{key:'ticketmessages',label:'Nachrichten'}])"
>
@@ -93,6 +92,9 @@ const filteredRows = computed(() => {
<template #ticketmessages-cell="{ row }">
{{row.original.ticketmessages.length}}
</template>
<template #empty>
<TableEmptyState :label="`Keine Tickets anzuzeigen`" />
</template>
</UTable>
</template>

View File

@@ -461,7 +461,6 @@ onMounted(async () => {
:data="filteredTasks"
:columns="normalizedListColumns"
:on-select="(task) => openTaskViaRoute(task)"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Aufgaben anzuzeigen' }"
>
<template #actions-cell="{ row }">
<UButton
@@ -490,13 +489,19 @@ onMounted(async () => {
<template #plant-cell="{ row }">
{{ getEntityLabel(plantOptions, row.original.plant?.id || row.original.plant) || "-" }}
</template>
<template #empty>
<TableEmptyState label="Keine Aufgaben anzuzeigen" />
</template>
</UTable>
<UTable
v-else
:data="[]"
:columns="normalizedListColumns"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Aufgaben anzuzeigen' }"
/>
>
<template #empty>
<TableEmptyState label="Keine Aufgaben anzuzeigen" />
</template>
</UTable>
</UDashboardPanelContent>
<UModal v-model:open="isModalOpen" :prevent-close="saving || deleting">

View File

@@ -0,0 +1,21 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://app.example.com",
"server_name": "app.example.com"
}
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://app.example.com/livekit/jwt"
}
],
"disable_custom_urls": false,
"disable_guests": true,
"brand": "FEDEO Matrix",
"default_theme": "light",
"features": {
"feature_video_rooms": true
}
}