Compare commits
24 Commits
1c39b69513
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a50ff256ee | |||
| ebbd1e0cd0 | |||
| 2ec162c0e7 | |||
| d0bc8a5e0f | |||
| a6f55b61e4 | |||
| c383a291e1 | |||
| 0eadf42973 | |||
| 616bb5d7f3 | |||
| 6297480b89 | |||
| 48199b0ed7 | |||
| bb18a974dd | |||
| 4e05906556 | |||
| df2308d407 | |||
| 5b6579423b | |||
| b3dd46195d | |||
| 54294ce7e9 | |||
| 5141a1f3ac | |||
| 718ecefd46 | |||
| e90cf819e7 | |||
| 15e21a97e2 | |||
| 358e40d749 | |||
| 47bd8e80e4 | |||
| 92c354c1b9 | |||
| 5e1631e2d3 |
47
.env.example
47
.env.example
@@ -117,50 +117,3 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
|
||||
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
|
||||
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
|
||||
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
|
||||
FEDEO_BOOTSTRAP_MATRIX=true
|
||||
|
||||
# FEDEO Matrix-Kommunikation
|
||||
#
|
||||
# Diese Werte werden von docker-compose.selfhost.yml für den integrierten
|
||||
# Matrix-Stack gelesen. Für produktive Systeme müssen alle Geheimnisse ersetzt
|
||||
# werden.
|
||||
|
||||
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
|
||||
|
||||
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=change-this-matrix-registration-secret
|
||||
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
|
||||
MATRIX_DEV_RTC_JWT_PORT=8081
|
||||
MATRIX_DEV_LIVEKIT_PORT=7880
|
||||
MATRIX_DEV_LIVEKIT_TCP_PORT=7881
|
||||
MATRIX_DEV_LIVEKIT_RTC_MIN_PORT=50000
|
||||
MATRIX_DEV_LIVEKIT_RTC_MAX_PORT=50100
|
||||
MATRIX_DEV_LIVEKIT_NODE_IP=127.0.0.1
|
||||
MATRIX_DEV_TURN_PORT=3478
|
||||
MATRIX_DEV_TURN_MIN_PORT=49160
|
||||
MATRIX_DEV_TURN_MAX_PORT=49200
|
||||
|
||||
# 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
|
||||
|
||||
22
README.md
22
README.md
@@ -239,33 +239,16 @@ 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=change-this-matrix-registration-secret
|
||||
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 optionalem S3 und Matrix
|
||||
## Docker Compose mit optionalem S3
|
||||
|
||||
Die Selfhost-Konfiguration wird im Betriebsverzeichnis als `docker-compose.yml` abgelegt. 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.
|
||||
|
||||
Seafile wird bewusst nicht im Standard-Compose-Stack gestartet. FEDEO kann später gegen einen extern betriebenen Seafile-Dienst sprechen; dafür bleiben `SEAFILE_BASE_URL`, `SEAFILE_INTERNAL_URL`, `SEAFILE_ADMIN_EMAIL` und `SEAFILE_ADMIN_PASSWORD` als generische Anbindungswerte vorgesehen. `FEDEO_FILE_BACKEND=s3` bleibt der Standard, bis die Backend-Integration für Seafile vollständig umgesetzt ist.
|
||||
|
||||
Der Matrix-Stack ist im Selfhost-Compose direkt 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`.
|
||||
|
||||
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
|
||||
@@ -463,7 +446,6 @@ Im Deploy-Verzeichnis:
|
||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Synapse erzeugt `matrix/synapse/homeserver.yaml` beim ersten Start automatisch und aktualisiert die für FEDEO relevanten Werte aus der `.env`. `MATRIX_REGISTRATION_SHARED_SECRET` muss in der `.env` gesetzt und geheim bleiben, weil FEDEO damit Matrix-Nutzer provisioniert.
|
||||
|
||||
Danach Status prufen:
|
||||
|
||||
@@ -541,8 +523,6 @@ 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
|
||||
|
||||
@@ -8,9 +8,6 @@ CREATE TABLE "communication_rooms" (
|
||||
"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,
|
||||
|
||||
@@ -8,9 +8,6 @@ CREATE TABLE IF NOT EXISTS "communication_rooms" (
|
||||
"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,
|
||||
|
||||
42
backend/db/migrations/0070_native_communication_chat.sql
Normal file
42
backend/db/migrations/0070_native_communication_chat.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "matrix_room_id";
|
||||
ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "matrix_alias";
|
||||
ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "parent_space_room_id";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "communication_room_members" (
|
||||
"room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade,
|
||||
"user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE cascade,
|
||||
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "communication_room_members_room_id_user_id_pk" PRIMARY KEY ("room_id", "user_id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "communication_room_members_user_idx"
|
||||
ON "communication_room_members" ("user_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "communication_messages" (
|
||||
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE cascade,
|
||||
"room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade,
|
||||
"author_user_id" uuid NOT NULL REFERENCES "auth_users"("id"),
|
||||
"body" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "communication_messages_room_message_idx"
|
||||
ON "communication_messages" ("room_id", "id");
|
||||
CREATE INDEX IF NOT EXISTS "communication_messages_tenant_idx"
|
||||
ON "communication_messages" ("tenant_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "communication_room_reads" (
|
||||
"room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade,
|
||||
"user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE cascade,
|
||||
"last_read_message_id" bigint,
|
||||
"read_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "communication_room_reads_room_id_user_id_pk" PRIMARY KEY ("room_id", "user_id")
|
||||
);
|
||||
|
||||
INSERT INTO "communication_room_members" ("room_id", "user_id")
|
||||
SELECT room.id, tenant_user.user_id
|
||||
FROM "communication_rooms" room
|
||||
JOIN "auth_tenant_users" tenant_user ON tenant_user.tenant_id = room.tenant_id
|
||||
WHERE room.type IN ('general', 'room')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -470,6 +470,13 @@
|
||||
"when": 1788803000000,
|
||||
"tag": "0069_reset_email_entity_suggestions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "7",
|
||||
"when": 1788850800000,
|
||||
"tag": "0070_native_communication_chat",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
34
backend/db/schema/communication_messages.ts
Normal file
34
backend/db/schema/communication_messages.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { bigint, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authUsers } from "./auth_users"
|
||||
import { communicationRooms } from "./communication_rooms"
|
||||
import { tenants } from "./tenants"
|
||||
|
||||
export const communicationMessages = pgTable(
|
||||
"communication_messages",
|
||||
{
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
tenantId: bigint("tenant_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
roomId: uuid("room_id")
|
||||
.notNull()
|
||||
.references(() => communicationRooms.id, { onDelete: "cascade" }),
|
||||
authorUserId: uuid("author_user_id")
|
||||
.notNull()
|
||||
.references(() => authUsers.id),
|
||||
body: text("body").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
roomMessageIdx: index("communication_messages_room_message_idx").on(table.roomId, table.id),
|
||||
tenantIdx: index("communication_messages_tenant_idx").on(table.tenantId),
|
||||
})
|
||||
)
|
||||
|
||||
export type CommunicationMessage = typeof communicationMessages.$inferSelect
|
||||
export type NewCommunicationMessage = typeof communicationMessages.$inferInsert
|
||||
26
backend/db/schema/communication_room_members.ts
Normal file
26
backend/db/schema/communication_room_members.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authUsers } from "./auth_users"
|
||||
import { communicationRooms } from "./communication_rooms"
|
||||
|
||||
export const communicationRoomMembers = pgTable(
|
||||
"communication_room_members",
|
||||
{
|
||||
roomId: uuid("room_id")
|
||||
.notNull()
|
||||
.references(() => communicationRooms.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => authUsers.id, { onDelete: "cascade" }),
|
||||
joinedAt: timestamp("joined_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.roomId, table.userId] }),
|
||||
userIdx: index("communication_room_members_user_idx").on(table.userId),
|
||||
})
|
||||
)
|
||||
|
||||
export type CommunicationRoomMember = typeof communicationRoomMembers.$inferSelect
|
||||
export type NewCommunicationRoomMember = typeof communicationRoomMembers.$inferInsert
|
||||
26
backend/db/schema/communication_room_reads.ts
Normal file
26
backend/db/schema/communication_room_reads.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { bigint, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authUsers } from "./auth_users"
|
||||
import { communicationRooms } from "./communication_rooms"
|
||||
|
||||
export const communicationRoomReads = pgTable(
|
||||
"communication_room_reads",
|
||||
{
|
||||
roomId: uuid("room_id")
|
||||
.notNull()
|
||||
.references(() => communicationRooms.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => authUsers.id, { onDelete: "cascade" }),
|
||||
lastReadMessageId: bigint("last_read_message_id", { mode: "number" }),
|
||||
readAt: timestamp("read_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.roomId, table.userId] }),
|
||||
})
|
||||
)
|
||||
|
||||
export type CommunicationRoomRead = typeof communicationRoomReads.$inferSelect
|
||||
export type NewCommunicationRoomRead = typeof communicationRoomReads.$inferInsert
|
||||
@@ -30,10 +30,6 @@ export const communicationRooms = pgTable(
|
||||
entityId: bigint("entity_id", { mode: "number" }),
|
||||
entityUuid: uuid("entity_uuid"),
|
||||
|
||||
matrixRoomId: text("matrix_room_id"),
|
||||
matrixAlias: text("matrix_alias"),
|
||||
parentSpaceRoomId: text("parent_space_room_id"),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
|
||||
@@ -16,6 +16,9 @@ export * from "./checkexecutions"
|
||||
export * from "./checks"
|
||||
export * from "./citys"
|
||||
export * from "./communication_rooms"
|
||||
export * from "./communication_room_members"
|
||||
export * from "./communication_messages"
|
||||
export * from "./communication_room_reads"
|
||||
export * from "./contacts"
|
||||
export * from "./contracts"
|
||||
export * from "./contracttypes"
|
||||
|
||||
@@ -61,7 +61,6 @@ import {loadSecrets, secrets} from "./utils/secrets";
|
||||
import {initMailer} from "./utils/mailer"
|
||||
import {initS3} from "./utils/s3";
|
||||
import { runBootstrap } from "./modules/bootstrap.service";
|
||||
import { startMatrixPushWorker } from "./modules/matrix-push-worker.service";
|
||||
import { startCentralServicesHeartbeat } from "./modules/central-services-heartbeat.service";
|
||||
import { startDocumentImportWorker } from "./modules/document-import/document-import.worker";
|
||||
|
||||
@@ -89,7 +88,6 @@ async function main() {
|
||||
await app.register(dbPlugin);
|
||||
await app.register(servicesPlugin);
|
||||
await runBootstrap(app);
|
||||
startMatrixPushWorker(app);
|
||||
startCentralServicesHeartbeat(app);
|
||||
startDocumentImportWorker(app);
|
||||
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
import { McpToolResult } from "./types"
|
||||
|
||||
const OMIT_ARCHIVED = Symbol("omit-archived")
|
||||
|
||||
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(omitArchivedRecords)
|
||||
.filter((item) => item !== OMIT_ARCHIVED)
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") return value
|
||||
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) return value
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
if (record.archived === true) return OMIT_ARCHIVED
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(record)
|
||||
.map(([key, item]) => [key, omitArchivedRecords(item)] as const)
|
||||
.filter(([, item]) => item !== OMIT_ARCHIVED),
|
||||
)
|
||||
}
|
||||
|
||||
export function asToolResult(payload: unknown): McpToolResult {
|
||||
const sanitizedPayload = omitArchivedRecords(payload)
|
||||
const resultPayload = sanitizedPayload === OMIT_ARCHIVED ? {} : sanitizedPayload
|
||||
const structuredContent =
|
||||
payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? payload as Record<string, unknown>
|
||||
: { result: payload }
|
||||
resultPayload && typeof resultPayload === "object" && !Array.isArray(resultPayload)
|
||||
? resultPayload as Record<string, unknown>
|
||||
: { result: resultPayload }
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(payload, null, 2),
|
||||
text: JSON.stringify(resultPayload, null, 2),
|
||||
},
|
||||
],
|
||||
structuredContent,
|
||||
@@ -33,4 +59,3 @@ export function asToolError(error: unknown): McpToolResult {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,15 @@ import {
|
||||
folders,
|
||||
incominginvoices,
|
||||
ownaccounts,
|
||||
serialExecutions,
|
||||
statementallocations,
|
||||
vendors,
|
||||
} from "../../../db/schema"
|
||||
import { useNextNumberRangeNumber } from "../../utils/functions"
|
||||
import { saveFile } from "../../utils/files"
|
||||
import { insertHistoryItem } from "../../utils/history"
|
||||
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
|
||||
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
|
||||
import {
|
||||
prepareStatementAllocationInput,
|
||||
statementAllocationUuidArg,
|
||||
@@ -43,6 +46,22 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
|
||||
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
|
||||
const MAX_MCP_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
const serialConfigSchema = {
|
||||
type: "object",
|
||||
description: "Ausführungsplan einer Serienrechnungsvorlage.",
|
||||
properties: {
|
||||
firstExecution: { type: "string", description: "Datum der ersten Ausführung als ISO-8601-Wert." },
|
||||
executionUntil: { type: ["string", "null"], description: "Optionales Datum der letzten Ausführung als ISO-8601-Wert." },
|
||||
intervall: {
|
||||
type: "string",
|
||||
enum: ["wöchentlich", "2 - wöchentlich", "monatlich", "vierteljährlich", "halbjährlich", "jährlich"],
|
||||
},
|
||||
active: { type: "boolean" },
|
||||
dateDirection: { type: "string", enum: ["Rückwirkend", "Im Voraus"] },
|
||||
},
|
||||
}
|
||||
|
||||
const allowedOutgoingDocumentTypes = new Set([
|
||||
"quotes",
|
||||
@@ -437,7 +456,6 @@ export const accountingTools: McpTool[] = [
|
||||
state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." },
|
||||
customer: { type: "number" },
|
||||
project: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -455,7 +473,7 @@ export const accountingTools: McpTool[] = [
|
||||
if (state) conditions.push(eq(createddocuments.state, state))
|
||||
if (customer) conditions.push(eq(createddocuments.customer, customer))
|
||||
if (project) conditions.push(eq(createddocuments.project, project))
|
||||
if (args.includeArchived !== true) conditions.push(eq(createddocuments.archived, false))
|
||||
conditions.push(eq(createddocuments.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -486,7 +504,7 @@ export const accountingTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(createddocuments)
|
||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||
@@ -502,7 +520,7 @@ export const accountingTools: McpTool[] = [
|
||||
type: "object",
|
||||
required: ["type"],
|
||||
properties: {
|
||||
type: { type: "string" },
|
||||
type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
|
||||
customer: { type: "number" },
|
||||
contact: { type: "number" },
|
||||
contract: { type: "number" },
|
||||
@@ -530,6 +548,7 @@ export const accountingTools: McpTool[] = [
|
||||
availableInPortal: { type: "boolean" },
|
||||
customSurchargePercentage: { type: "number" },
|
||||
report: { type: "object" },
|
||||
serialConfig: serialConfigSchema,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -556,7 +575,7 @@ export const accountingTools: McpTool[] = [
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
type: { type: "string" },
|
||||
type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
|
||||
state: { type: "string" },
|
||||
customer: { type: "number" },
|
||||
contact: { type: "number" },
|
||||
@@ -585,6 +604,7 @@ export const accountingTools: McpTool[] = [
|
||||
availableInPortal: { type: "boolean" },
|
||||
customSurchargePercentage: { type: "number" },
|
||||
report: { type: "object" },
|
||||
serialConfig: serialConfigSchema,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -614,6 +634,72 @@ export const accountingTools: McpTool[] = [
|
||||
return { document: updated }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.cost_centres.update",
|
||||
title: "Kostenstellen eines Ausgangsbelegs ändern",
|
||||
description: "Ändert ausschließlich die Beleg- und Positionskostenstellen eines bereits fertiggestellten Ausgangsbelegs.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
anyOf: [
|
||||
{ required: ["costcentre"] },
|
||||
{ required: ["rowCostCentres"] },
|
||||
],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
costcentre: { type: ["string", "null"], description: "UUID der Beleg-Kostenstelle oder null." },
|
||||
rowCostCentres: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["rowId", "costcentre"],
|
||||
properties: {
|
||||
rowId: { type: "string", description: "ID der Belegposition." },
|
||||
costcentre: { type: ["string", "null"], description: "UUID der Positionskostenstelle oder null." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const input: {
|
||||
costcentre?: string | null
|
||||
rowCostCentres?: Array<{ rowId: string, costcentre: string | null }>
|
||||
} = {}
|
||||
if (Object.prototype.hasOwnProperty.call(args, "costcentre")) {
|
||||
const costcentre = args.costcentre
|
||||
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
|
||||
throw new Error("costcentre muss eine gültige UUID oder null sein")
|
||||
}
|
||||
input.costcentre = costcentre as string | null
|
||||
}
|
||||
if (args.rowCostCentres !== undefined) {
|
||||
if (!Array.isArray(args.rowCostCentres)) throw new Error("rowCostCentres muss ein Array sein")
|
||||
input.rowCostCentres = args.rowCostCentres.map((assignment: any) => {
|
||||
const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : ""
|
||||
const costcentre = assignment?.costcentre
|
||||
if (!rowId) throw new Error("Jede Positionszuordnung benötigt eine rowId")
|
||||
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
|
||||
throw new Error(`Ungültige Kostenstelle für Position ${rowId}`)
|
||||
}
|
||||
return { rowId, costcentre }
|
||||
})
|
||||
}
|
||||
|
||||
const document = await updateOutgoingDocumentCostCentres(
|
||||
context.server,
|
||||
context.tenantId,
|
||||
context.userId,
|
||||
id,
|
||||
input,
|
||||
)
|
||||
return { document }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.finalize",
|
||||
title: "Ausgangsbeleg finalisieren",
|
||||
@@ -669,6 +755,90 @@ export const accountingTools: McpTool[] = [
|
||||
return { document: updated }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.serial_invoices.execute",
|
||||
title: "Serienrechnungslauf starten",
|
||||
description: "Erzeugt aus ausgewählten aktiven Serienrechnungsvorlagen einen neuen Rechnungslauf.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["executionDate", "templateIds"],
|
||||
properties: {
|
||||
executionDate: { type: "string", description: "Ausführungsdatum als ISO-8601-Wert." },
|
||||
templateIds: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
uniqueItems: true,
|
||||
items: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const executionDate = stringArg(args, "executionDate")
|
||||
const parsedExecutionDate = executionDate ? new Date(executionDate) : null
|
||||
const templateIds = Array.isArray(args.templateIds)
|
||||
? [...new Set(args.templateIds.map(Number).filter((id) => Number.isFinite(id) && id > 0))]
|
||||
: []
|
||||
|
||||
if (!parsedExecutionDate || Number.isNaN(parsedExecutionDate.getTime())) {
|
||||
throw new Error("executionDate muss ein gültiger ISO-8601-Wert sein")
|
||||
}
|
||||
if (!templateIds.length) throw new Error("templateIds muss mindestens eine gültige ID enthalten")
|
||||
|
||||
return executeManualGeneration(
|
||||
context.server,
|
||||
parsedExecutionDate,
|
||||
templateIds,
|
||||
context.tenantId,
|
||||
context.userId,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.serial_invoice_executions.list",
|
||||
title: "Serienrechnungsläufe auflisten",
|
||||
description: "Listet die zuletzt gestarteten Serienrechnungsläufe des aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["draft", "completed", "error"] },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(serialExecutions.tenant, context.tenantId)]
|
||||
const status = stringArg(args, "status")
|
||||
if (status) conditions.push(eq(serialExecutions.status, status))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(serialExecutions)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(serialExecutions.createdAt))
|
||||
.limit(limitFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.serial_invoice_executions.finish",
|
||||
title: "Serienrechnungslauf abschließen",
|
||||
description: "Finalisiert die erzeugten Rechnungen eines Serienrechnungslaufs und schließt den Lauf ab.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "string", description: "UUID des Serienrechnungslaufs." },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = stringArg(args, "id")
|
||||
if (!id || !UUID_PATTERN.test(id)) throw new Error("id muss eine gültige UUID sein")
|
||||
return finishManualGeneration(context.server, id, context.tenantId)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.archive",
|
||||
title: "Ausgangsbeleg archivieren",
|
||||
@@ -754,7 +924,6 @@ export const accountingTools: McpTool[] = [
|
||||
properties: {
|
||||
state: { type: "string", description: "Optionaler Statusfilter." },
|
||||
paid: { type: "boolean", description: "Optionaler Zahlungsstatus." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -764,7 +933,7 @@ export const accountingTools: McpTool[] = [
|
||||
|
||||
if (state) conditions.push(eq(incominginvoices.state, state))
|
||||
if (typeof args.paid === "boolean") conditions.push(eq(incominginvoices.paid, args.paid))
|
||||
if (args.includeArchived !== true) conditions.push(eq(incominginvoices.archived, false))
|
||||
conditions.push(eq(incominginvoices.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -795,7 +964,7 @@ export const accountingTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId), eq(incominginvoices.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Eingangsrechnung nicht gefunden")
|
||||
@@ -1142,7 +1311,6 @@ export const accountingTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
account: { type: "number", description: "Optionale Bankkonto-ID." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -1151,7 +1319,7 @@ export const accountingTools: McpTool[] = [
|
||||
const account = numberArg(args, "account")
|
||||
|
||||
if (account) conditions.push(eq(bankstatements.account, account))
|
||||
if (args.includeArchived !== true) conditions.push(eq(bankstatements.archived, false))
|
||||
conditions.push(eq(bankstatements.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -1182,7 +1350,7 @@ export const accountingTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(bankstatements)
|
||||
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId)))
|
||||
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId), eq(bankstatements.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Bankumsatz nicht gefunden")
|
||||
@@ -1199,7 +1367,6 @@ export const accountingTools: McpTool[] = [
|
||||
properties: {
|
||||
bankstatement: { type: "number" },
|
||||
incominginvoice: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -1210,7 +1377,7 @@ export const accountingTools: McpTool[] = [
|
||||
|
||||
if (bankstatement) conditions.push(eq(statementallocations.bankstatement, bankstatement))
|
||||
if (incominginvoice) conditions.push(eq(statementallocations.incominginvoice, incominginvoice))
|
||||
if (args.includeArchived !== true) conditions.push(eq(statementallocations.archived, false))
|
||||
conditions.push(eq(statementallocations.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
|
||||
@@ -55,7 +55,7 @@ export const masterdataTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId)))
|
||||
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId), eq(customers.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Kunde nicht gefunden")
|
||||
@@ -71,7 +71,6 @@ export const masterdataTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -86,7 +85,7 @@ export const masterdataTools: McpTool[] = [
|
||||
ilike(vendors.notes, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(vendors.archived, false))
|
||||
conditions.push(eq(vendors.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -115,7 +114,7 @@ export const masterdataTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(vendors)
|
||||
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId)))
|
||||
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId), eq(vendors.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Lieferant nicht gefunden")
|
||||
@@ -133,7 +132,6 @@ export const masterdataTools: McpTool[] = [
|
||||
query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." },
|
||||
customer: { type: "number" },
|
||||
vendor: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -157,7 +155,7 @@ export const masterdataTools: McpTool[] = [
|
||||
}
|
||||
if (customer) conditions.push(eq(contacts.customer, customer))
|
||||
if (vendor) conditions.push(eq(contacts.vendor, vendor))
|
||||
if (args.includeArchived !== true) conditions.push(eq(contacts.archived, false))
|
||||
conditions.push(eq(contacts.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -178,7 +176,6 @@ export const masterdataTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -197,7 +194,7 @@ export const masterdataTools: McpTool[] = [
|
||||
ilike(products.description, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(products.archived, false))
|
||||
conditions.push(eq(products.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -226,7 +223,7 @@ export const masterdataTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId)))
|
||||
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId), eq(products.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Artikel nicht gefunden")
|
||||
@@ -242,7 +239,6 @@ export const masterdataTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -256,7 +252,7 @@ export const masterdataTools: McpTool[] = [
|
||||
ilike(services.description, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(services.archived, false))
|
||||
conditions.push(eq(services.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -285,7 +281,7 @@ export const masterdataTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId)))
|
||||
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId), eq(services.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Leistung nicht gefunden")
|
||||
@@ -303,7 +299,6 @@ export const masterdataTools: McpTool[] = [
|
||||
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||
branch: { type: "number" },
|
||||
project: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -322,7 +317,7 @@ export const masterdataTools: McpTool[] = [
|
||||
}
|
||||
if (branch) conditions.push(eq(costcentres.branch, branch))
|
||||
if (project) conditions.push(eq(costcentres.project, project))
|
||||
if (args.includeArchived !== true) conditions.push(eq(costcentres.archived, false))
|
||||
conditions.push(eq(costcentres.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -351,7 +346,7 @@ export const masterdataTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(costcentres)
|
||||
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId)))
|
||||
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId), eq(costcentres.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Kostenstelle nicht gefunden")
|
||||
@@ -367,7 +362,6 @@ export const masterdataTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -382,7 +376,7 @@ export const masterdataTools: McpTool[] = [
|
||||
ilike(branches.description, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(branches.archived, false))
|
||||
conditions.push(eq(branches.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -404,7 +398,6 @@ export const masterdataTools: McpTool[] = [
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
|
||||
branch: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -420,7 +413,7 @@ export const masterdataTools: McpTool[] = [
|
||||
))
|
||||
}
|
||||
if (branch) conditions.push(eq(teams.branch, branch))
|
||||
if (args.includeArchived !== true) conditions.push(eq(teams.archived, false))
|
||||
conditions.push(eq(teams.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -441,7 +434,6 @@ export const masterdataTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -457,7 +449,7 @@ export const masterdataTools: McpTool[] = [
|
||||
ilike(vehicles.color, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(vehicles.archived, false))
|
||||
conditions.push(eq(vehicles.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -479,7 +471,6 @@ export const masterdataTools: McpTool[] = [
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
|
||||
vendor: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -499,7 +490,7 @@ export const masterdataTools: McpTool[] = [
|
||||
))
|
||||
}
|
||||
if (vendor) conditions.push(eq(inventoryitems.vendor, vendor))
|
||||
if (args.includeArchived !== true) conditions.push(eq(inventoryitems.archived, false))
|
||||
conditions.push(eq(inventoryitems.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -528,7 +519,7 @@ export const masterdataTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(inventoryitems)
|
||||
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId)))
|
||||
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId), eq(inventoryitems.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Inventar nicht gefunden")
|
||||
@@ -568,4 +559,3 @@ export const masterdataTools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ export const organisationTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -47,7 +46,7 @@ export const organisationTools: McpTool[] = [
|
||||
ilike(customers.notes, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(customers.archived, false))
|
||||
conditions.push(eq(customers.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select({
|
||||
@@ -80,7 +79,6 @@ export const organisationTools: McpTool[] = [
|
||||
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
|
||||
customer: { type: "number" },
|
||||
activePhase: { type: "string" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -100,7 +98,7 @@ export const organisationTools: McpTool[] = [
|
||||
}
|
||||
if (customer) conditions.push(eq(projects.customer, customer))
|
||||
if (activePhase) conditions.push(eq(projects.active_phase, activePhase))
|
||||
if (args.includeArchived !== true) conditions.push(eq(projects.archived, false))
|
||||
conditions.push(eq(projects.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -131,7 +129,7 @@ export const organisationTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
||||
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId), eq(projects.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Projekt nicht gefunden")
|
||||
@@ -219,7 +217,6 @@ export const organisationTools: McpTool[] = [
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name." },
|
||||
customer: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -230,7 +227,7 @@ export const organisationTools: McpTool[] = [
|
||||
|
||||
if (query) conditions.push(ilike(plants.name, `%${query}%`))
|
||||
if (customer) conditions.push(eq(plants.customer, customer))
|
||||
if (args.includeArchived !== true) conditions.push(eq(plants.archived, false))
|
||||
conditions.push(eq(plants.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -254,7 +251,6 @@ export const organisationTools: McpTool[] = [
|
||||
project: { type: "number" },
|
||||
customer: { type: "number" },
|
||||
eventtype: { type: "string" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -275,7 +271,7 @@ export const organisationTools: McpTool[] = [
|
||||
if (project) conditions.push(eq(events.project, project))
|
||||
if (customer) conditions.push(eq(events.customer, customer))
|
||||
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
|
||||
if (args.includeArchived !== true) conditions.push(eq(events.archived, false))
|
||||
conditions.push(eq(events.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -298,7 +294,6 @@ export const organisationTools: McpTool[] = [
|
||||
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
|
||||
project: { type: "number" },
|
||||
customer: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
},
|
||||
},
|
||||
@@ -317,7 +312,7 @@ export const organisationTools: McpTool[] = [
|
||||
}
|
||||
if (project) conditions.push(eq(tasks.project, project))
|
||||
if (customer) conditions.push(eq(tasks.customer, customer))
|
||||
if (args.includeArchived !== true) conditions.push(eq(tasks.archived, false))
|
||||
conditions.push(eq(tasks.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
@@ -348,7 +343,7 @@ export const organisationTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
||||
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId), eq(tasks.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Aufgabe nicht gefunden")
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
tenants,
|
||||
texttemplates,
|
||||
} from "../../db/schema"
|
||||
import { matrixService } from "./matrix.service"
|
||||
|
||||
const adminPermissions = [
|
||||
"mcp.tokens.write",
|
||||
@@ -456,18 +455,4 @@ export async function runBootstrap(server: FastifyInstance) {
|
||||
await ensureTenantBaseData(server, tenant.id, adminUser.id)
|
||||
console.log("✅ Bootstrap-Grunddaten geprüft")
|
||||
|
||||
if (process.env.FEDEO_BOOTSTRAP_MATRIX === "true") {
|
||||
try {
|
||||
const matrix = matrixService(server)
|
||||
await matrix.provisionTenantRoom(adminUser.id, tenant.id, {
|
||||
key: "allgemein",
|
||||
name: "Allgemeiner Chat",
|
||||
type: "general",
|
||||
})
|
||||
console.log("✅ Bootstrap-Matrix-Kommunikation geprüft")
|
||||
} catch (err) {
|
||||
console.error("❌ Bootstrap-Matrix-Kommunikation fehlgeschlagen:", err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { and, desc, eq, inArray, isNotNull, ne } from "drizzle-orm"
|
||||
import { authProfiles, authTenantUsers, authUsers, communicationRooms, notificationsItems } from "../../db/schema"
|
||||
import { matrixService } from "./matrix.service"
|
||||
import { NotificationService, UserDirectory } from "./notification.service"
|
||||
|
||||
type ChatRecipient = {
|
||||
userId: string
|
||||
email?: string | null
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
fullName?: string | null
|
||||
matrixUserId?: string
|
||||
}
|
||||
|
||||
type MatrixPushWorkerEvent = {
|
||||
at: string
|
||||
type: string
|
||||
roomKey?: string
|
||||
roomId?: string | null
|
||||
messageId?: string
|
||||
sender?: string
|
||||
targets?: number
|
||||
created?: number
|
||||
delivered?: number
|
||||
failed?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
const matrixPushWorkerState = {
|
||||
enabled: false,
|
||||
startedAt: null as string | null,
|
||||
lastRunAt: null as string | null,
|
||||
lastJoinAt: null as string | null,
|
||||
lastJoinTotal: 0,
|
||||
lastJoinJoined: 0,
|
||||
lastJoinFailed: 0,
|
||||
hasSyncToken: false,
|
||||
lastSyncRooms: 0,
|
||||
lastSyncMessages: 0,
|
||||
lastMatchedRooms: 0,
|
||||
lastNotificationsCreated: 0,
|
||||
lastNotificationsDelivered: 0,
|
||||
lastNotificationsFailed: 0,
|
||||
lastError: null as string | null,
|
||||
events: [] as MatrixPushWorkerEvent[],
|
||||
}
|
||||
|
||||
const rememberWorkerEvent = (event: MatrixPushWorkerEvent) => {
|
||||
matrixPushWorkerState.events = [
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
...event,
|
||||
},
|
||||
...matrixPushWorkerState.events,
|
||||
].slice(0, 25)
|
||||
}
|
||||
|
||||
export const getMatrixPushWorkerState = () => ({
|
||||
...matrixPushWorkerState,
|
||||
events: [...matrixPushWorkerState.events],
|
||||
})
|
||||
|
||||
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
|
||||
const rows = await server.db
|
||||
.select({ email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.limit(1)
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const displayUserName = (user: { fullName?: string | null; firstName?: string | null; lastName?: string | null; email?: string | null }) => {
|
||||
const name = user.fullName || [user.firstName, user.lastName].filter(Boolean).join(" ")
|
||||
return name || user.email || "Benutzer"
|
||||
}
|
||||
|
||||
const directRoomKey = (firstUserId: string, secondUserId: string) => {
|
||||
const hash = createHash("sha256")
|
||||
.update([firstUserId, secondUserId].sort().join(":"))
|
||||
.digest("hex")
|
||||
.slice(0, 16)
|
||||
|
||||
return `direct_${hash}`
|
||||
}
|
||||
|
||||
const mentionAliasesForUser = (user: ChatRecipient) => {
|
||||
const name = displayUserName(user)
|
||||
return Array.from(new Set([
|
||||
name,
|
||||
user.fullName,
|
||||
[user.firstName, user.lastName].filter(Boolean).join(" "),
|
||||
user.firstName,
|
||||
user.email,
|
||||
].filter(Boolean).map((value) => String(value).toLowerCase())))
|
||||
}
|
||||
|
||||
const mentionedRecipientIds = (text: string, recipients: ChatRecipient[]) => {
|
||||
const normalizedText = text.toLowerCase()
|
||||
|
||||
return recipients
|
||||
.filter((recipient) => mentionAliasesForUser(recipient).some((alias) =>
|
||||
normalizedText.includes(`@${alias}`)
|
||||
))
|
||||
.map((recipient) => recipient.userId)
|
||||
}
|
||||
|
||||
export function startMatrixPushWorker(server: FastifyInstance) {
|
||||
if (process.env.MATRIX_PUSH_WORKER_DISABLED === "1") {
|
||||
server.log.info("Matrix-Push-Worker ist deaktiviert")
|
||||
return
|
||||
}
|
||||
|
||||
matrixPushWorkerState.enabled = true
|
||||
matrixPushWorkerState.startedAt = new Date().toISOString()
|
||||
rememberWorkerEvent({ at: new Date().toISOString(), type: "started" })
|
||||
|
||||
const matrix = matrixService(server)
|
||||
const notifications = new NotificationService(server, getUserDirectory)
|
||||
const intervalMs = Math.max(Number(process.env.MATRIX_PUSH_WORKER_INTERVAL_MS || 3000), 1000)
|
||||
let since: string | undefined
|
||||
let running = false
|
||||
let stopped = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let lastServiceJoinSyncAt = 0
|
||||
let errorBackoffMs = 0
|
||||
|
||||
const getTenantRecipients = async (tenantId: number) => {
|
||||
const rows = await server.db
|
||||
.select({
|
||||
userId: authTenantUsers.user_id,
|
||||
email: authUsers.email,
|
||||
firstName: authProfiles.first_name,
|
||||
lastName: authProfiles.last_name,
|
||||
fullName: authProfiles.full_name,
|
||||
})
|
||||
.from(authTenantUsers)
|
||||
.innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id))
|
||||
.leftJoin(authProfiles, and(
|
||||
eq(authProfiles.user_id, authTenantUsers.user_id),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
))
|
||||
.where(eq(authTenantUsers.tenant_id, tenantId))
|
||||
|
||||
return await Promise.all(rows.map(async (row) => ({
|
||||
...row,
|
||||
matrixUserId: await matrix.matrixUserIdForUser(row.userId, tenantId),
|
||||
})))
|
||||
}
|
||||
|
||||
const hasChatNotificationForMessage = async (tenantId: number, userId: string, messageId: string) => {
|
||||
const rows = await server.db
|
||||
.select({
|
||||
payload: notificationsItems.payload,
|
||||
})
|
||||
.from(notificationsItems)
|
||||
.where(and(
|
||||
eq(notificationsItems.tenantId, tenantId),
|
||||
eq(notificationsItems.userId, userId),
|
||||
eq(notificationsItems.eventType, "communication.message.new")
|
||||
))
|
||||
.orderBy(desc(notificationsItems.createdAt))
|
||||
.limit(200)
|
||||
|
||||
return rows.some((row) => (row.payload as any)?.messageId === messageId)
|
||||
}
|
||||
|
||||
const recipientsForMessage = (
|
||||
room: typeof communicationRooms.$inferSelect,
|
||||
recipients: ChatRecipient[],
|
||||
senderUserId: string | null,
|
||||
text: string
|
||||
) => {
|
||||
const candidates = senderUserId
|
||||
? recipients.filter((recipient) => recipient.userId !== senderUserId)
|
||||
: recipients
|
||||
const mentioned = new Set(mentionedRecipientIds(text, candidates))
|
||||
const directRecipients = new Set<string>()
|
||||
|
||||
if (room.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) {
|
||||
directRecipients.add(room.entityUuid)
|
||||
} else if (room.type === "direct" && senderUserId) {
|
||||
candidates
|
||||
.filter((recipient) => directRoomKey(senderUserId, recipient.userId) === room.key)
|
||||
.forEach((recipient) => directRecipients.add(recipient.userId))
|
||||
}
|
||||
|
||||
return candidates
|
||||
.filter((recipient) => directRecipients.has(recipient.userId) || mentioned.has(recipient.userId))
|
||||
.map((recipient) => ({
|
||||
...recipient,
|
||||
mentioned: mentioned.has(recipient.userId),
|
||||
direct: directRecipients.has(recipient.userId),
|
||||
}))
|
||||
}
|
||||
|
||||
const deliverMessageNotification = async (
|
||||
room: typeof communicationRooms.$inferSelect,
|
||||
message: any,
|
||||
recipients: ChatRecipient[]
|
||||
) => {
|
||||
if (!message.id || message.own) return
|
||||
|
||||
const sender = recipients.find((recipient) => recipient.matrixUserId === message.sender) || null
|
||||
const text = message.body || message.attachment?.fileName || "Neue Nachricht"
|
||||
const targets = recipientsForMessage(room, recipients, sender?.userId || null, text)
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "message_seen",
|
||||
roomKey: room.key,
|
||||
roomId: room.matrixRoomId,
|
||||
messageId: message.id,
|
||||
sender: message.sender,
|
||||
targets: targets.length,
|
||||
})
|
||||
if (!targets.length) return
|
||||
|
||||
const senderName = sender ? displayUserName(sender) : message.senderDisplayName || message.sender || "Matrix"
|
||||
const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text
|
||||
|
||||
for (const target of targets) {
|
||||
if (await hasChatNotificationForMessage(room.tenantId, target.userId, message.id)) {
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "notification_skipped_duplicate",
|
||||
roomKey: room.key,
|
||||
roomId: room.matrixRoomId,
|
||||
messageId: message.id,
|
||||
sender: message.sender,
|
||||
targets: 1,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await notifications.trigger({
|
||||
tenantId: room.tenantId,
|
||||
userId: target.userId,
|
||||
eventType: "communication.message.new",
|
||||
title: target.mentioned ? `${senderName} hat dich erwähnt` : `Neue Direktnachricht von ${senderName}`,
|
||||
message: preview,
|
||||
payload: {
|
||||
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
|
||||
roomKey: room.key,
|
||||
roomName: room.name,
|
||||
roomType: room.type,
|
||||
messageId: message.id,
|
||||
matrixSender: message.sender,
|
||||
mentioned: target.mentioned,
|
||||
direct: target.direct,
|
||||
},
|
||||
channels: ["inapp", "push"],
|
||||
})
|
||||
matrixPushWorkerState.lastNotificationsCreated += result.created || 0
|
||||
matrixPushWorkerState.lastNotificationsDelivered += result.delivered || 0
|
||||
matrixPushWorkerState.lastNotificationsFailed += result.failed || 0
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "notification_triggered",
|
||||
roomKey: room.key,
|
||||
roomId: room.matrixRoomId,
|
||||
messageId: message.id,
|
||||
sender: message.sender,
|
||||
targets: 1,
|
||||
created: result.created || 0,
|
||||
delivered: result.delivered || 0,
|
||||
failed: result.failed || 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const runOnce = async () => {
|
||||
if (running || stopped) return
|
||||
running = true
|
||||
|
||||
try {
|
||||
matrixPushWorkerState.lastRunAt = new Date().toISOString()
|
||||
matrixPushWorkerState.lastError = null
|
||||
matrixPushWorkerState.lastSyncRooms = 0
|
||||
matrixPushWorkerState.lastSyncMessages = 0
|
||||
matrixPushWorkerState.lastMatchedRooms = 0
|
||||
matrixPushWorkerState.lastNotificationsCreated = 0
|
||||
matrixPushWorkerState.lastNotificationsDelivered = 0
|
||||
matrixPushWorkerState.lastNotificationsFailed = 0
|
||||
|
||||
if (!lastServiceJoinSyncAt || Date.now() - lastServiceJoinSyncAt > 60_000) {
|
||||
const joinResult = await matrix.syncServiceJoinedTenantRooms()
|
||||
lastServiceJoinSyncAt = Date.now()
|
||||
matrixPushWorkerState.lastJoinAt = new Date().toISOString()
|
||||
matrixPushWorkerState.lastJoinTotal = joinResult.total
|
||||
matrixPushWorkerState.lastJoinJoined = joinResult.joined
|
||||
matrixPushWorkerState.lastJoinFailed = joinResult.failed
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "service_join_sync",
|
||||
targets: joinResult.total,
|
||||
delivered: joinResult.joined,
|
||||
failed: joinResult.failed,
|
||||
})
|
||||
if (joinResult.failed) {
|
||||
console.warn("Matrix-Push-Worker: Service-User konnte nicht alle Räume joinen", {
|
||||
total: joinResult.total,
|
||||
joined: joinResult.joined,
|
||||
failed: joinResult.failed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const initial = !since
|
||||
const sync = await matrix.syncServiceRoomEvents(since, initial)
|
||||
since = sync.nextBatch || since
|
||||
matrixPushWorkerState.hasSyncToken = Boolean(since)
|
||||
matrixPushWorkerState.lastSyncRooms = sync.rooms?.length || 0
|
||||
matrixPushWorkerState.lastSyncMessages = (sync.rooms || [])
|
||||
.reduce((sum: number, room: any) => sum + (room.messages?.length || 0), 0)
|
||||
|
||||
if (!initial && sync.rooms?.length) {
|
||||
const roomIds = sync.rooms.map((room: any) => room.roomId).filter(Boolean)
|
||||
const rooms = roomIds.length
|
||||
? await server.db
|
||||
.select()
|
||||
.from(communicationRooms)
|
||||
.where(and(
|
||||
inArray(communicationRooms.matrixRoomId, roomIds),
|
||||
ne(communicationRooms.archived, true),
|
||||
isNotNull(communicationRooms.matrixRoomId)
|
||||
))
|
||||
: []
|
||||
const roomsByMatrixId = new Map(rooms.map((room) => [room.matrixRoomId, room]))
|
||||
matrixPushWorkerState.lastMatchedRooms = rooms.length
|
||||
const recipientsByTenant = new Map<number, ChatRecipient[]>()
|
||||
|
||||
for (const syncedRoom of sync.rooms) {
|
||||
const room = roomsByMatrixId.get(syncedRoom.roomId)
|
||||
if (!room || !syncedRoom.messages?.length) continue
|
||||
|
||||
if (!recipientsByTenant.has(room.tenantId)) {
|
||||
recipientsByTenant.set(room.tenantId, await getTenantRecipients(room.tenantId))
|
||||
}
|
||||
|
||||
const recipients = recipientsByTenant.get(room.tenantId) || []
|
||||
for (const message of syncedRoom.messages) {
|
||||
await deliverMessageNotification(room, message, recipients)
|
||||
}
|
||||
}
|
||||
}
|
||||
errorBackoffMs = 0
|
||||
} catch (err) {
|
||||
matrixPushWorkerState.lastError = err instanceof Error ? err.message : String(err)
|
||||
const retryAfterMs = Number((err as any)?.retryAfterMs || (err as any)?.body?.retry_after_ms || 0)
|
||||
errorBackoffMs = Math.min(
|
||||
Math.max(retryAfterMs || (errorBackoffMs ? errorBackoffMs * 2 : 30_000), 30_000),
|
||||
5 * 60_000
|
||||
)
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "error",
|
||||
error: matrixPushWorkerState.lastError,
|
||||
})
|
||||
console.error("Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten", err)
|
||||
server.log.error({ err }, "Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten")
|
||||
} finally {
|
||||
running = false
|
||||
if (!stopped) {
|
||||
const nextDelay = errorBackoffMs || (since ? 0 : intervalMs)
|
||||
timer = setTimeout(() => void runOnce(), nextDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timer = setTimeout(() => void runOnce(), intervalMs)
|
||||
server.addHook("onClose", async () => {
|
||||
stopped = true
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
146
backend/src/modules/outgoing-document-cost-centres.service.ts
Normal file
146
backend/src/modules/outgoing-document-cost-centres.service.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { costcentres, createddocuments } from "../../db/schema"
|
||||
import { insertHistoryItem } from "../utils/history"
|
||||
|
||||
export type RowCostCentreAssignment = {
|
||||
rowId: string
|
||||
costcentre: string | null
|
||||
}
|
||||
|
||||
export type OutgoingDocumentCostCentreUpdate = {
|
||||
costcentre?: string | null
|
||||
rowCostCentres?: RowCostCentreAssignment[]
|
||||
}
|
||||
|
||||
const serviceError = (message: string, statusCode = 400) =>
|
||||
Object.assign(new Error(message), { statusCode })
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
export function applyRowCostCentreAssignments(
|
||||
rows: unknown,
|
||||
assignments: RowCostCentreAssignment[] = [],
|
||||
) {
|
||||
if (!Array.isArray(rows)) return []
|
||||
if (!assignments.length) return rows
|
||||
|
||||
const assignmentMap = new Map(assignments.map((assignment) => [String(assignment.rowId), assignment.costcentre]))
|
||||
const existingIds = new Set(rows
|
||||
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
|
||||
.map((row) => (row as Record<string, unknown>).id)
|
||||
.filter((id) => id !== null && id !== undefined && id !== "")
|
||||
.map(String))
|
||||
|
||||
for (const rowId of assignmentMap.keys()) {
|
||||
if (!existingIds.has(rowId)) throw serviceError(`Position ${rowId} wurde im Ausgangsbeleg nicht gefunden`)
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) return row
|
||||
const rowRecord = row as Record<string, unknown>
|
||||
if (rowRecord.id === null || rowRecord.id === undefined || rowRecord.id === "") return row
|
||||
const rowId = String(rowRecord.id)
|
||||
if (!assignmentMap.has(rowId)) return row
|
||||
|
||||
const { costcentre: _legacyCostCentre, ...rest } = rowRecord
|
||||
return { ...rest, costCentre: assignmentMap.get(rowId) ?? null }
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateOutgoingDocumentCostCentres(
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
userId: string,
|
||||
documentId: number,
|
||||
input: OutgoingDocumentCostCentreUpdate,
|
||||
) {
|
||||
const hasDocumentCostCentre = Object.prototype.hasOwnProperty.call(input, "costcentre")
|
||||
if (hasDocumentCostCentre && input.costcentre !== null && !UUID_PATTERN.test(String(input.costcentre))) {
|
||||
throw serviceError("costcentre muss eine gültige UUID oder null sein")
|
||||
}
|
||||
if (input.rowCostCentres !== undefined && !Array.isArray(input.rowCostCentres)) {
|
||||
throw serviceError("rowCostCentres muss ein Array sein")
|
||||
}
|
||||
|
||||
const rowAssignments = (input.rowCostCentres || []).map((assignment) => {
|
||||
const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : ""
|
||||
const costcentre = assignment?.costcentre
|
||||
if (!rowId) throw serviceError("Jede Positionszuordnung benötigt eine rowId")
|
||||
if (costcentre !== null && !UUID_PATTERN.test(String(costcentre))) {
|
||||
throw serviceError(`Ungültige Kostenstelle für Position ${rowId}`)
|
||||
}
|
||||
return { rowId, costcentre }
|
||||
})
|
||||
if (!hasDocumentCostCentre && !rowAssignments.length) {
|
||||
throw serviceError("Mindestens eine Beleg- oder Positionskostenstelle ist erforderlich")
|
||||
}
|
||||
|
||||
const [existing] = await server.db
|
||||
.select()
|
||||
.from(createddocuments)
|
||||
.where(and(
|
||||
eq(createddocuments.id, documentId),
|
||||
eq(createddocuments.tenant, tenantId),
|
||||
eq(createddocuments.archived, false),
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw serviceError("Ausgangsbeleg nicht gefunden", 404)
|
||||
if (existing.state !== "Gebucht") {
|
||||
throw serviceError("Kostenstellen können über diesen Weg nur bei fertiggestellten Ausgangsbelegen geändert werden")
|
||||
}
|
||||
|
||||
const referencedCostCentreIds = [...new Set([
|
||||
...(hasDocumentCostCentre && input.costcentre ? [input.costcentre] : []),
|
||||
...rowAssignments.map((assignment) => assignment.costcentre).filter((id): id is string => Boolean(id)),
|
||||
])]
|
||||
|
||||
if (referencedCostCentreIds.length) {
|
||||
const validCostCentres = await server.db
|
||||
.select({ id: costcentres.id })
|
||||
.from(costcentres)
|
||||
.where(and(
|
||||
eq(costcentres.tenant, tenantId),
|
||||
eq(costcentres.archived, false),
|
||||
inArray(costcentres.id, referencedCostCentreIds),
|
||||
))
|
||||
|
||||
if (validCostCentres.length !== referencedCostCentreIds.length) {
|
||||
throw serviceError("Mindestens eine Kostenstelle wurde nicht gefunden oder ist archiviert")
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRows = applyRowCostCentreAssignments(existing.rows, rowAssignments)
|
||||
const update: Record<string, unknown> = {
|
||||
rows: updatedRows,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
}
|
||||
if (hasDocumentCostCentre) update.costcentre = input.costcentre ?? null
|
||||
|
||||
const [updated] = await server.db
|
||||
.update(createddocuments)
|
||||
.set(update)
|
||||
.where(and(
|
||||
eq(createddocuments.id, documentId),
|
||||
eq(createddocuments.tenant, tenantId),
|
||||
eq(createddocuments.archived, false),
|
||||
eq(createddocuments.state, "Gebucht"),
|
||||
))
|
||||
.returning()
|
||||
|
||||
if (!updated) throw serviceError("Ausgangsbeleg nicht gefunden", 404)
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
tenant_id: tenantId,
|
||||
created_by: userId,
|
||||
entity: "createddocuments",
|
||||
entityId: documentId,
|
||||
action: "updated",
|
||||
oldVal: existing,
|
||||
newVal: updated,
|
||||
text: "Kostenstellen des fertiggestellten Ausgangsbelegs geändert",
|
||||
})
|
||||
|
||||
return updated
|
||||
}
|
||||
@@ -14,9 +14,13 @@ import { documentTemplateHandlebars } from "../utils/handlebars";
|
||||
dayjs.extend(quarterOfYear);
|
||||
|
||||
|
||||
export const executeManualGeneration = async (server:FastifyInstance,executionDate,templateIds,tenantId,executedBy) => {
|
||||
try {
|
||||
console.log(executedBy)
|
||||
export const executeManualGeneration = async (
|
||||
server: FastifyInstance,
|
||||
executionDate: string | Date,
|
||||
templateIds: number[],
|
||||
tenantId: number,
|
||||
executedBy: string,
|
||||
) => {
|
||||
|
||||
const executionDayjs = dayjs(executionDate);
|
||||
|
||||
@@ -33,20 +37,22 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
|
||||
if (!tenant) throw new Error(`Tenant mit ID ${tenantId} nicht gefunden.`);
|
||||
|
||||
// 2. Templates laden
|
||||
const templates = await server.db
|
||||
const uniqueTemplateIds = [...new Set(templateIds)]
|
||||
const templates = (await server.db
|
||||
.select()
|
||||
.from(schema.createddocuments)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.createddocuments.tenant, tenantId),
|
||||
eq(schema.createddocuments.type, "serialInvoices"),
|
||||
inArray(schema.createddocuments.id, templateIds)
|
||||
eq(schema.createddocuments.archived, false),
|
||||
inArray(schema.createddocuments.id, uniqueTemplateIds)
|
||||
)
|
||||
);
|
||||
))
|
||||
.filter((template) => Boolean((template.serialConfig as any)?.active));
|
||||
|
||||
if (templates.length === 0) {
|
||||
console.warn("Keine passenden Vorlagen gefunden.");
|
||||
return [];
|
||||
if (templates.length !== uniqueTemplateIds.length) {
|
||||
throw new Error("Mindestens eine Serienrechnungsvorlage wurde nicht gefunden, ist archiviert oder inaktiv.");
|
||||
}
|
||||
|
||||
// 3. Folder & FileType IDs holen (Hilfsfunktionen unten)
|
||||
@@ -62,7 +68,7 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
|
||||
executionDate: executionDayjs.toDate(),
|
||||
status: "draft",
|
||||
createdBy: executedBy,
|
||||
summary: `${templateIds.length} Vorlagen verarbeitet`
|
||||
summary: `${uniqueTemplateIds.length} Vorlagen verarbeitet`
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -88,13 +94,10 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
return { execution: executionRecord, results };
|
||||
}
|
||||
|
||||
export const finishManualGeneration = async (server: FastifyInstance, executionId: number) => {
|
||||
export const finishManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => {
|
||||
try {
|
||||
console.log(`Beende Ausführung ${executionId}...`);
|
||||
|
||||
@@ -103,15 +106,16 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
||||
const [executionRecord] = await server.db
|
||||
.select()
|
||||
.from(schema.serialExecutions)// @ts-ignore
|
||||
.where(eq(schema.serialExecutions.id, executionId))
|
||||
.where(and(
|
||||
eq(schema.serialExecutions.id, executionId),
|
||||
eq(schema.serialExecutions.tenant, tenantId),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!executionRecord) throw new Error("Execution nicht gefunden");
|
||||
|
||||
console.log(executionRecord);
|
||||
|
||||
const tenantId = executionRecord.tenant;
|
||||
|
||||
console.log(tenantId)
|
||||
|
||||
// Tenant laden (für Settings etc.)
|
||||
@@ -132,7 +136,11 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
||||
const documents = await server.db
|
||||
.select()
|
||||
.from(schema.createddocuments)
|
||||
.where(eq(schema.createddocuments.serialexecution, executionId));
|
||||
.where(and(
|
||||
eq(schema.createddocuments.serialexecution, executionId),
|
||||
eq(schema.createddocuments.tenant, tenantId),
|
||||
eq(schema.createddocuments.archived, false),
|
||||
));
|
||||
|
||||
console.log(`${documents.length} Dokumente werden finalisiert...`);
|
||||
|
||||
@@ -228,7 +236,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
||||
status: finalStatus,
|
||||
summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`
|
||||
})// @ts-ignore
|
||||
.where(eq(schema.serialExecutions.id, executionId));
|
||||
.where(and(
|
||||
eq(schema.serialExecutions.id, executionId),
|
||||
eq(schema.serialExecutions.tenant, tenantId),
|
||||
));
|
||||
|
||||
return { success: true, processed: successCount, errors: errorCount };
|
||||
|
||||
@@ -240,7 +251,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
||||
.update(schema.serialExecutions)
|
||||
.set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." })
|
||||
//@ts-ignore
|
||||
.where(eq(schema.serialExecutions.id, executionId));
|
||||
.where(and(
|
||||
eq(schema.serialExecutions.id, executionId),
|
||||
eq(schema.serialExecutions.tenant, tenantId),
|
||||
));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { matrixService } from "./matrix.service"
|
||||
|
||||
type MetricSample = {
|
||||
labels: Record<string, string>
|
||||
@@ -117,10 +116,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
|
||||
const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null
|
||||
|
||||
const databaseCheck = await server.db.execute("SELECT NOW() as now")
|
||||
const matrixStatus = await matrixService(server).getStatus().catch((err: any) => ({
|
||||
reachable: false,
|
||||
error: err?.message || "Matrix-Status nicht verfügbar",
|
||||
}))
|
||||
const minioUrl = s3EndpointUrl()
|
||||
|
||||
return {
|
||||
@@ -165,7 +160,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
|
||||
url: nodeExporterMetricsUrl,
|
||||
error: nodeExporterError,
|
||||
}),
|
||||
matrix: serviceState(Boolean((matrixStatus as any).reachable), matrixStatus as Record<string, any>),
|
||||
minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, {
|
||||
error: "S3_ENDPOINT ist nicht gesetzt",
|
||||
}),
|
||||
|
||||
@@ -84,10 +84,12 @@ export default fp(async (server: FastifyInstance) => {
|
||||
|
||||
const urlPath = req.url.split("?")[0]
|
||||
const queryToken = (req.query as any)?.downloadToken
|
||||
const isDownloadTokenRoute =
|
||||
(urlPath.startsWith("/api/email/attachments/") && urlPath.endsWith("/download"))
|
||||
|| urlPath.startsWith("/api/files/content/")
|
||||
const downloadToken =
|
||||
typeof queryToken === "string"
|
||||
&& urlPath.startsWith("/api/email/attachments/")
|
||||
&& urlPath.endsWith("/download")
|
||||
&& isDownloadTokenRoute
|
||||
? queryToken
|
||||
: null
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from "../utils/tenantFullExport";
|
||||
import type { TenantFullExport } from "../utils/tenantFullExport";
|
||||
import { buildSystemStatus } from "../modules/system-status.service";
|
||||
import { matrixService } from "../modules/matrix.service";
|
||||
import { s3 } from "../utils/s3";
|
||||
import { secrets } from "../utils/secrets";
|
||||
|
||||
@@ -385,27 +384,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
let matrixProvisioned = false;
|
||||
let matrixProvisioningError: string | null = null;
|
||||
if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) {
|
||||
try {
|
||||
const matrix = matrixService(server);
|
||||
await matrix.provisionTenantRoom(currentUser.id, result.tenantId, {
|
||||
key: "allgemein",
|
||||
name: "Allgemeiner Chat",
|
||||
type: "general",
|
||||
});
|
||||
matrixProvisioned = true;
|
||||
} catch (err: any) {
|
||||
matrixProvisioningError = err?.message || String(err);
|
||||
server.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matrixProvisioned,
|
||||
matrixProvisioningError,
|
||||
};
|
||||
return { chatReady: true };
|
||||
};
|
||||
|
||||
const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import {
|
||||
GetObjectCommand
|
||||
} from "@aws-sdk/client-s3"
|
||||
import archiver from "archiver"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { secrets } from "../utils/secrets"
|
||||
import { saveFile } from "../utils/files"
|
||||
|
||||
@@ -19,6 +20,17 @@ import {
|
||||
|
||||
|
||||
export default async function fileRoutes(server: FastifyInstance) {
|
||||
const createDownloadUrl = (req: any, fileId: string) => {
|
||||
const downloadToken = jwt.sign({
|
||||
user_id: req.user.user_id,
|
||||
email: req.user.email,
|
||||
tenant_id: req.user.tenant_id,
|
||||
is_admin: Boolean(req.user.is_admin),
|
||||
}, secrets.JWT_SECRET!, { expiresIn: "15m" })
|
||||
|
||||
return `/api/files/content/${fileId}?downloadToken=${encodeURIComponent(downloadToken)}`
|
||||
}
|
||||
|
||||
const getPortalCustomerId = async (req: any) => {
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
@@ -282,7 +294,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
return { ...file, url: `/api/files/content/${file.id}` }
|
||||
return { ...file, url: createDownloadUrl(req, file.id) }
|
||||
} else {
|
||||
// -------------------------------------------------
|
||||
// MULTIPLE PRESIGNED URLs
|
||||
@@ -300,7 +312,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
|
||||
const output = selected.map(file => ({
|
||||
...file,
|
||||
url: `/api/files/content/${file.id}`
|
||||
url: createDownloadUrl(req, file.id)
|
||||
}))
|
||||
|
||||
return { files: output }
|
||||
|
||||
@@ -21,6 +21,7 @@ import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
||||
import {citys, files} from "../../db/schema";
|
||||
import {and, eq, isNull, not} from "drizzle-orm";
|
||||
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
||||
import { s3 } from "../utils/s3";
|
||||
import { secrets } from "../utils/secrets";
|
||||
import { storeExtractedTextForFile } from "../utils/documentText";
|
||||
@@ -296,15 +297,34 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
})
|
||||
|
||||
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}
|
||||
await executeManualGeneration(server,executionDate,templateIds,tenantId,req.user.user_id)
|
||||
const {executionDate, templateIds} = req.body as {executionDate:string, templateIds:number[]}
|
||||
return executeManualGeneration(server, executionDate, templateIds, req.user.tenant_id, req.user.user_id)
|
||||
})
|
||||
|
||||
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
|
||||
const {execution_id} = req.params as { execution_id: string }
|
||||
//@ts-ignore
|
||||
await finishManualGeneration(server,execution_id)
|
||||
return finishManualGeneration(server, execution_id, req.user.tenant_id)
|
||||
})
|
||||
|
||||
server.put('/functions/outgoing-documents/:id/cost-centres', async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id: string }
|
||||
const documentId = Number(id)
|
||||
if (!Number.isFinite(documentId)) return reply.code(400).send({ error: "Ungültige Ausgangsbeleg-ID" })
|
||||
|
||||
const document = await updateOutgoingDocumentCostCentres(
|
||||
server,
|
||||
req.user.tenant_id,
|
||||
req.user.user_id,
|
||||
documentId,
|
||||
req.body as any,
|
||||
)
|
||||
return { document }
|
||||
} catch (error) {
|
||||
const statusCode = (error as any)?.statusCode || 500
|
||||
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Kostenstellen konnten nicht geändert werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { authProfiles, historyitems } from "../../db/schema";
|
||||
import { sanitizeHistoryItem, sanitizeHistoryText, sanitizeHistoryValue } from "../utils/historySanitization";
|
||||
|
||||
const columnMap: Record<string, any> = {
|
||||
customers: historyitems.customer,
|
||||
@@ -28,6 +29,7 @@ const columnMap: Record<string, any> = {
|
||||
customerinventoryitems: historyitems.customerinventoryitem,
|
||||
memberrelations: historyitems.memberrelation,
|
||||
outgoingsepamandates: historyitems.outgoingsepamandate,
|
||||
projecttypes: historyitems.projecttype,
|
||||
};
|
||||
|
||||
const insertFieldMap: Record<string, string> = {
|
||||
@@ -55,6 +57,7 @@ const insertFieldMap: Record<string, string> = {
|
||||
customerinventoryitems: "customerinventoryitem",
|
||||
memberrelations: "memberrelation",
|
||||
outgoingsepamandates: "outgoingsepamandate",
|
||||
projecttypes: "projecttype",
|
||||
}
|
||||
|
||||
const parseId = (value: string) => {
|
||||
@@ -93,7 +96,7 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
profiles.map((profile) => [profile.user_id, profile])
|
||||
);
|
||||
|
||||
return data.map((historyitem) => ({
|
||||
return data.map((historyitem) => sanitizeHistoryItem({
|
||||
...historyitem,
|
||||
created_at: historyitem.createdAt,
|
||||
created_by: historyitem.createdBy,
|
||||
@@ -127,7 +130,10 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
const data = await server.db
|
||||
.select()
|
||||
.from(historyitems)
|
||||
.where(eq(column, parseId(id)))
|
||||
.where(and(
|
||||
eq(historyitems.tenant, req.user?.tenant_id),
|
||||
eq(column, parseId(id))
|
||||
))
|
||||
.orderBy(asc(historyitems.createdAt));
|
||||
|
||||
const userIds = Array.from(
|
||||
@@ -148,7 +154,7 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
profiles.map((profile) => [profile.user_id, profile])
|
||||
)
|
||||
|
||||
const dataCombined = data.map((historyitem) => ({
|
||||
const dataCombined = data.map((historyitem) => sanitizeHistoryItem({
|
||||
...historyitem,
|
||||
created_at: historyitem.createdAt,
|
||||
created_by: historyitem.createdBy,
|
||||
@@ -218,11 +224,11 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
const inserted = await server.db
|
||||
.insert(historyitems)
|
||||
.values({
|
||||
text,
|
||||
text: sanitizeHistoryText(text),
|
||||
[fkField]: parseId(id),
|
||||
oldVal: old_val || null,
|
||||
newVal: new_val || null,
|
||||
config: config || null,
|
||||
oldVal: sanitizeHistoryValue(old_val) || null,
|
||||
newVal: sanitizeHistoryValue(new_val) || null,
|
||||
config: sanitizeHistoryValue(config) || null,
|
||||
tenant: (req.user as any)?.tenant_id,
|
||||
createdBy: userId
|
||||
})
|
||||
@@ -233,10 +239,10 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
return reply.code(500).send({ error: "Failed to create history entry" });
|
||||
}
|
||||
|
||||
return reply.code(201).send({
|
||||
return reply.code(201).send(sanitizeHistoryItem({
|
||||
...data,
|
||||
created_at: data.createdAt,
|
||||
created_by: data.createdBy
|
||||
});
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import { authUsers, notificationMobilePushDevices } from "../../db/schema"
|
||||
import { authProfiles, authUsers, notificationMobilePushDevices } from "../../db/schema"
|
||||
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||
import { pushServerClient } from "../modules/push-server.client"
|
||||
|
||||
@@ -174,6 +174,77 @@ export default async function notificationsRoutes(server: FastifyInstance) {
|
||||
})
|
||||
})
|
||||
|
||||
server.post("/notifications/test-push/profile/:profileId", async (req, reply) => {
|
||||
const tenantId = requireTenant(req.user.tenant_id)
|
||||
const { profileId } = req.params as { profileId: string }
|
||||
const [profile] = await server.db
|
||||
.select({
|
||||
userId: authProfiles.user_id,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.id, profileId),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (!profile) {
|
||||
return reply.code(404).send({ error: "Mitarbeiter nicht gefunden" })
|
||||
}
|
||||
|
||||
if (!profile.userId) {
|
||||
return reply.code(409).send({
|
||||
error: "Der Mitarbeiter ist noch nicht mit einem Benutzerkonto verknüpft",
|
||||
})
|
||||
}
|
||||
|
||||
const devices = await server.db
|
||||
.select({ centralDeviceId: notificationMobilePushDevices.centralDeviceId })
|
||||
.from(notificationMobilePushDevices)
|
||||
.where(and(
|
||||
eq(notificationMobilePushDevices.tenantId, tenantId),
|
||||
eq(notificationMobilePushDevices.userId, profile.userId),
|
||||
isNull(notificationMobilePushDevices.disabledAt)
|
||||
))
|
||||
|
||||
if (!devices.length) {
|
||||
return reply.code(409).send({
|
||||
error: "Für diesen Mitarbeiter ist kein aktives mobiles Push-Gerät registriert",
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pushServerClient.sendPush({
|
||||
idempotencyKey: `profile-mobile-test:${tenantId}:${profile.userId}:${Date.now()}`,
|
||||
devices: devices.map((device) => device.centralDeviceId),
|
||||
priority: "high",
|
||||
ttlSeconds: 600,
|
||||
notification: {
|
||||
title: "FEDEO Push ist aktiv",
|
||||
body: "Diese Testbenachrichtigung wurde einmalig über das Mitarbeiterprofil ausgelöst.",
|
||||
},
|
||||
data: {
|
||||
type: "system.test_mobile_push",
|
||||
link: "/",
|
||||
},
|
||||
})
|
||||
|
||||
if (result.accepted === 0) {
|
||||
return reply.code(502).send({
|
||||
error: "Der zentrale Push-Server hat kein Gerät zur Zustellung angenommen",
|
||||
result,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error: any) {
|
||||
server.log.error({ err: error, profileId, userId: profile.userId }, "Mitarbeiter-Test-Push fehlgeschlagen")
|
||||
return reply.code(502).send({
|
||||
error: error?.message || "Der zentrale Push-Server konnte die Nachricht nicht annehmen",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/notifications/trigger", async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as any
|
||||
|
||||
@@ -1035,7 +1035,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
|
||||
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
||||
const numberRangeResource = resource === "members" ? "customers" : resource
|
||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
||||
const numberRangeColumn = table[config.numberRangeHolder]
|
||||
const tenantColumn = getTenantColumn(resource, table)
|
||||
const result = await useNextNumberRangeNumber(
|
||||
server,
|
||||
req.user.tenant_id,
|
||||
numberRangeResource,
|
||||
async (candidate, tx) => {
|
||||
const existing = await tx
|
||||
.select({ id: table.id })
|
||||
.from(table)
|
||||
.where(and(
|
||||
eq(tenantColumn, req.user!.tenant_id),
|
||||
eq(numberRangeColumn, candidate)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return existing.length === 0
|
||||
}
|
||||
)
|
||||
createData[config.numberRangeHolder] = result.usedNumber
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import { eq, sql } from "drizzle-orm"
|
||||
export const useNextNumberRangeNumber = async (
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
numberRange: string
|
||||
numberRange: string,
|
||||
isAvailable: (candidate: string, tx: any) => Promise<boolean> = async () => true
|
||||
) => {
|
||||
const numberRangeFallbacks: Record<string, string> = {
|
||||
costEstimates: "quotes",
|
||||
@@ -44,18 +45,39 @@ export const useNextNumberRangeNumber = async (
|
||||
}
|
||||
|
||||
const current = numberRanges[resolvedNumberRange]
|
||||
let nextNumber = Number(current.nextNumber)
|
||||
|
||||
const usedNumber =
|
||||
(current.prefix || "") +
|
||||
current.nextNumber +
|
||||
(current.suffix || "")
|
||||
if (!Number.isFinite(nextNumber)) {
|
||||
throw new Error(`Number range '${resolvedNumberRange}' has an invalid nextNumber`)
|
||||
}
|
||||
|
||||
let usedNumber = ""
|
||||
const maxAttempts = 10000
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const candidate =
|
||||
(current.prefix || "") +
|
||||
nextNumber +
|
||||
(current.suffix || "")
|
||||
|
||||
if (await isAvailable(candidate, tx)) {
|
||||
usedNumber = candidate
|
||||
break
|
||||
}
|
||||
|
||||
nextNumber++
|
||||
}
|
||||
|
||||
if (!usedNumber) {
|
||||
throw new Error(`Could not find an available number in range '${resolvedNumberRange}'`)
|
||||
}
|
||||
|
||||
const updatedRanges = {
|
||||
// @ts-ignore
|
||||
...numberRanges,
|
||||
[resolvedNumberRange]: {
|
||||
...current,
|
||||
nextNumber: current.nextNumber + 1,
|
||||
nextNumber: nextNumber + 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { historyitems } from "../../db/schema";
|
||||
import { sanitizeHistoryText, sanitizeHistoryValue } from "./historySanitization";
|
||||
|
||||
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||
customers: "Kunden",
|
||||
@@ -35,6 +36,7 @@ const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||
memberrelations: "Mitgliedsverhältnisse",
|
||||
teams: "Teams",
|
||||
outgoingsepamandates: "Ausgehende SEPA-Mandate",
|
||||
projecttypes: "Projekttypen",
|
||||
}
|
||||
|
||||
export function getHistoryEntityLabel(entity: string) {
|
||||
@@ -96,6 +98,7 @@ export async function insertHistoryItem(
|
||||
files: "file",
|
||||
memberrelations: "memberrelation",
|
||||
outgoingsepamandates: "outgoingsepamandate",
|
||||
projecttypes: "projecttype",
|
||||
}
|
||||
|
||||
const fkColumn = columnMap[params.entity]
|
||||
@@ -112,11 +115,11 @@ export async function insertHistoryItem(
|
||||
const entry = {
|
||||
tenant: params.tenant_id,
|
||||
createdBy: params.created_by,
|
||||
text: params.text || textMap[params.action],
|
||||
text: sanitizeHistoryText(params.text || textMap[params.action]),
|
||||
action: params.action,
|
||||
[fkColumn]: params.entityId,
|
||||
oldVal: stringifyHistoryValue(params.oldVal),
|
||||
newVal: stringifyHistoryValue(params.newVal)
|
||||
oldVal: stringifyHistoryValue(sanitizeHistoryValue(params.oldVal)),
|
||||
newVal: stringifyHistoryValue(sanitizeHistoryValue(params.newVal))
|
||||
}
|
||||
|
||||
await server.db.insert(historyitems).values(entry as any)
|
||||
|
||||
45
backend/src/utils/historySanitization.ts
Normal file
45
backend/src/utils/historySanitization.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
const IBAN_IN_TEXT_PATTERN = /\b([A-Z]{2}\d{2}(?:[\s-]?[A-Z0-9]){11,30})(?=["'\]},.;:!?)]|$)/gi
|
||||
|
||||
export function maskIban(iban: string): string {
|
||||
const normalized = iban.replace(/[\s-]+/g, "").toUpperCase()
|
||||
if (normalized.length <= 8) return normalized
|
||||
return `${normalized.slice(0, 4)} **** **** ${normalized.slice(-4)}`
|
||||
}
|
||||
|
||||
export function sanitizeHistoryText(text: string): string {
|
||||
return text.replace(IBAN_IN_TEXT_PATTERN, (candidate) => maskIban(candidate))
|
||||
}
|
||||
|
||||
function sanitizeIbanField(value: any): any {
|
||||
if (typeof value === "string") return maskIban(value)
|
||||
if (Array.isArray(value)) return value.map(sanitizeIbanField)
|
||||
return sanitizeHistoryValue(value)
|
||||
}
|
||||
|
||||
export function sanitizeHistoryValue(value: any): any {
|
||||
if (typeof value === "string") return sanitizeHistoryText(value)
|
||||
if (Array.isArray(value)) return value.map(sanitizeHistoryValue)
|
||||
if (!value || typeof value !== "object" || value instanceof Date) return value
|
||||
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) return value
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, nestedValue]) => [
|
||||
key,
|
||||
key.toLowerCase().includes("iban")
|
||||
? sanitizeIbanField(nestedValue)
|
||||
: sanitizeHistoryValue(nestedValue),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeHistoryItem<T extends Record<string, any>>(item: T): T {
|
||||
return {
|
||||
...item,
|
||||
text: typeof item.text === "string" ? sanitizeHistoryText(item.text) : item.text,
|
||||
oldVal: sanitizeHistoryValue(item.oldVal),
|
||||
newVal: sanitizeHistoryValue(item.newVal),
|
||||
config: sanitizeHistoryValue(item.config),
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,7 @@ export const resourceConfig = {
|
||||
},
|
||||
products: {
|
||||
table: products,
|
||||
mtoLoad: ["unit"],
|
||||
searchColumns: ["name","manufacturer","ean","barcode","description","manfacturer_number","article_number"],
|
||||
},
|
||||
productcategories: {
|
||||
|
||||
@@ -38,15 +38,6 @@ export let secrets = {
|
||||
DOKUBOX_IMAP_PASSWORD: string
|
||||
OPENAI_API_KEY: string
|
||||
STIRLING_API_KEY: string
|
||||
MATRIX_HOMESERVER_URL?: string
|
||||
MATRIX_SERVER_NAME?: string
|
||||
MATRIX_RTC_HOST?: string
|
||||
MATRIX_RTC_JWT_URL?: string
|
||||
MATRIX_LIVEKIT_URL?: string
|
||||
MATRIX_REGISTRATION_SHARED_SECRET?: string
|
||||
MATRIX_SERVICE_USER_LOCALPART?: string
|
||||
LIVEKIT_KEY?: string
|
||||
LIVEKIT_SECRET?: string
|
||||
WEB_PUSH_PUBLIC_KEY?: string
|
||||
WEB_PUSH_PRIVATE_KEY?: string
|
||||
WEB_PUSH_SUBJECT?: string
|
||||
@@ -88,15 +79,6 @@ const secretKeys = [
|
||||
"DOKUBOX_IMAP_PASSWORD",
|
||||
"OPENAI_API_KEY",
|
||||
"STIRLING_API_KEY",
|
||||
"MATRIX_HOMESERVER_URL",
|
||||
"MATRIX_SERVER_NAME",
|
||||
"MATRIX_RTC_HOST",
|
||||
"MATRIX_RTC_JWT_URL",
|
||||
"MATRIX_LIVEKIT_URL",
|
||||
"MATRIX_REGISTRATION_SHARED_SECRET",
|
||||
"MATRIX_SERVICE_USER_LOCALPART",
|
||||
"LIVEKIT_KEY",
|
||||
"LIVEKIT_SECRET",
|
||||
"WEB_PUSH_PUBLIC_KEY",
|
||||
"WEB_PUSH_PRIVATE_KEY",
|
||||
"WEB_PUSH_SUBJECT",
|
||||
|
||||
@@ -86,47 +86,6 @@ const ENTITY_BANKACCOUNT_PLAIN_FIELDS = {
|
||||
const GLOBAL_MIGRATION_TABLES = new Set(["accounts", "units", "citys", "countrys"])
|
||||
|
||||
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`
|
||||
const matrixServerName = () =>
|
||||
process.env.MATRIX_SERVER_NAME ||
|
||||
secrets.MATRIX_SERVER_NAME ||
|
||||
process.env.DOMAIN ||
|
||||
"localhost"
|
||||
|
||||
const normalizeMatrixLocalpartSeed = (value: string) => {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/ä/g, "a")
|
||||
.replace(/ö/g, "o")
|
||||
.replace(/ü/g, "u")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9._=-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^[._=-]+|[._=-]+$/g, "")
|
||||
|
||||
return normalized || "user"
|
||||
}
|
||||
|
||||
const normalizeMatrixAliasSeed = (value: string) =>
|
||||
normalizeMatrixLocalpartSeed(value)
|
||||
.replace(/[.=]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
|
||||
const tenantRoomAliasLocalpart = (
|
||||
tenant: { id: number, short?: string | null, name?: string | null },
|
||||
roomKey: string
|
||||
) => {
|
||||
const tenantSeed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`)
|
||||
const roomSeed = normalizeMatrixAliasSeed(roomKey)
|
||||
return `fedeo_${tenantSeed}_${tenant.id}_${roomSeed}`
|
||||
}
|
||||
|
||||
const tenantRoomAlias = (
|
||||
tenant: { id: number, short?: string | null, name?: string | null },
|
||||
roomKey: string
|
||||
) => `#${tenantRoomAliasLocalpart(tenant, roomKey)}:${matrixServerName()}`
|
||||
|
||||
const tableColumns = async (client: any) => {
|
||||
const result = await client.query(`
|
||||
select table_name, column_name, data_type, is_generated
|
||||
@@ -343,6 +302,12 @@ export const buildTenantFullExport = async (
|
||||
addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds]))
|
||||
}
|
||||
|
||||
const communicationRoomIds = collectIds(tables.communication_rooms || [], "id")
|
||||
if (communicationRoomIds.length) {
|
||||
addRows(tables, "communication_room_members", await loadRows(client, "communication_room_members", "room_id = any($1::uuid[])", [communicationRoomIds]))
|
||||
addRows(tables, "communication_room_reads", await loadRows(client, "communication_room_reads", "room_id = any($1::uuid[])", [communicationRoomIds]))
|
||||
}
|
||||
|
||||
if (tables.entitybankaccounts?.length) {
|
||||
tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts)
|
||||
}
|
||||
@@ -636,73 +601,6 @@ const encryptEntityBankAccountRowsForImport = (exportData: TenantFullExport) =>
|
||||
}
|
||||
}
|
||||
|
||||
const prepareCommunicationRoomsForImport = (exportData: TenantFullExport) => {
|
||||
const rows = exportData.tables.communication_rooms || []
|
||||
if (!rows.length) return
|
||||
|
||||
const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [
|
||||
Number(tenant.id),
|
||||
{
|
||||
id: Number(tenant.id),
|
||||
name: tenant.name,
|
||||
short: tenant.short,
|
||||
},
|
||||
]))
|
||||
|
||||
for (const row of rows) {
|
||||
const tenantId = Number(row.tenant_id)
|
||||
const tenant = tenantById.get(tenantId)
|
||||
|
||||
row.matrix_room_id = null
|
||||
row.parent_space_room_id = null
|
||||
|
||||
if (tenant && row.key) {
|
||||
row.matrix_alias = tenantRoomAlias(tenant, String(row.key))
|
||||
} else {
|
||||
row.matrix_alias = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupImportedCommunicationRooms = async (client: any, exportData: TenantFullExport) => {
|
||||
const rows = exportData.tables.communication_rooms || []
|
||||
if (!rows.length) return 0
|
||||
|
||||
const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [
|
||||
Number(tenant.id),
|
||||
{
|
||||
id: Number(tenant.id),
|
||||
name: tenant.name,
|
||||
short: tenant.short,
|
||||
},
|
||||
]))
|
||||
let cleaned = 0
|
||||
|
||||
for (const row of rows) {
|
||||
const tenantId = Number(row.tenant_id)
|
||||
const key = String(row.key || "")
|
||||
const tenant = tenantById.get(tenantId)
|
||||
if (!tenantId || !key || !tenant) continue
|
||||
|
||||
const alias = tenantRoomAlias(tenant, key)
|
||||
const result = await client.query(
|
||||
`
|
||||
update communication_rooms
|
||||
set matrix_room_id = null,
|
||||
parent_space_room_id = null,
|
||||
matrix_alias = $3,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and key = $2
|
||||
`,
|
||||
[tenantId, key, alias]
|
||||
)
|
||||
|
||||
cleaned += result.rowCount || 0
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const prepareColumnValue = (value: any, isJsonColumn: boolean) => {
|
||||
if (!isJsonColumn || value === null || typeof value === "undefined") return value
|
||||
if (typeof value === "string") return value
|
||||
@@ -853,7 +751,6 @@ export const importTenantFullExport = async (
|
||||
|
||||
const exportData = rawExportData
|
||||
encryptEntityBankAccountRowsForImport(exportData)
|
||||
prepareCommunicationRoomsForImport(exportData)
|
||||
const client = await pool.connect()
|
||||
const importOrder = [
|
||||
"tenants",
|
||||
@@ -955,13 +852,6 @@ export const importTenantFullExport = async (
|
||||
await reportProgress(`${table} importiert`)
|
||||
}
|
||||
|
||||
const cleanedCommunicationRooms = await cleanupImportedCommunicationRooms(client, exportData)
|
||||
if (cleanedCommunicationRooms) {
|
||||
importedTables.push({ table: "communication_rooms_matrix_reset", rows: cleanedCommunicationRooms })
|
||||
}
|
||||
progressDone += 1
|
||||
await reportProgress("Kommunikationsräume bereinigt")
|
||||
|
||||
await refreshSequences(client, columnsByTable)
|
||||
progressDone = progressTotal
|
||||
await reportProgress("Import abgeschlossen")
|
||||
|
||||
55
backend/tests/historySanitization.test.ts
Normal file
55
backend/tests/historySanitization.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import {
|
||||
maskIban,
|
||||
sanitizeHistoryItem,
|
||||
sanitizeHistoryText,
|
||||
sanitizeHistoryValue,
|
||||
} from "../src/utils/historySanitization"
|
||||
|
||||
const IBAN = "DE89370400440532013000"
|
||||
|
||||
test("masks all but the first and last four IBAN characters", () => {
|
||||
assert.equal(maskIban(IBAN), "DE89 **** **** 3000")
|
||||
assert.equal(maskIban("DE89 3704 0044 0532 0130 00"), "DE89 **** **** 3000")
|
||||
})
|
||||
|
||||
test("masks IBAN values in nested history data", () => {
|
||||
const sanitized = sanitizeHistoryValue({
|
||||
name: "Beispielkunde",
|
||||
infoData: {
|
||||
bankingIban: IBAN,
|
||||
bankingIbans: [IBAN, "AT61 1904 3002 3457 3201"],
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(sanitized, {
|
||||
name: "Beispielkunde",
|
||||
infoData: {
|
||||
bankingIban: "DE89 **** **** 3000",
|
||||
bankingIbans: ["DE89 **** **** 3000", "AT61 **** **** 3201"],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("masks IBANs embedded in generated history text", () => {
|
||||
const text = `Kunden: Info Daten geändert von "{\"bankingIbans\":[\"${IBAN}\"]}"`
|
||||
const sanitized = sanitizeHistoryText(text)
|
||||
|
||||
assert.equal(sanitized.includes(IBAN), false)
|
||||
assert.equal(sanitized.includes("DE89 **** **** 3000"), true)
|
||||
})
|
||||
|
||||
test("sanitizes existing history items before they are returned", () => {
|
||||
const sanitized = sanitizeHistoryItem({
|
||||
text: `IBAN: ${IBAN}.`,
|
||||
oldVal: JSON.stringify({ bankingIban: IBAN }),
|
||||
newVal: { iban: IBAN },
|
||||
config: null,
|
||||
})
|
||||
|
||||
assert.equal(sanitized.text, "IBAN: DE89 **** **** 3000.")
|
||||
assert.equal(sanitized.oldVal.includes(IBAN), false)
|
||||
assert.deepEqual(sanitized.newVal, { iban: "DE89 **** **** 3000" })
|
||||
})
|
||||
37
backend/tests/mcpArchivedResults.test.ts
Normal file
37
backend/tests/mcpArchivedResults.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { mcpTools } from "../src/mcp/registry"
|
||||
import { asToolResult } from "../src/mcp/result"
|
||||
|
||||
test("removes archived records from MCP text and structured output", () => {
|
||||
const result = asToolResult({
|
||||
rows: [
|
||||
{ id: 1, archived: false, name: "Aktiv" },
|
||||
{ id: 2, archived: true, name: "Archiviert" },
|
||||
],
|
||||
nested: {
|
||||
current: { id: 3, archived: false },
|
||||
previous: { id: 4, archived: true },
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(result.structuredContent, {
|
||||
rows: [{ id: 1, archived: false, name: "Aktiv" }],
|
||||
nested: { current: { id: 3, archived: false } },
|
||||
})
|
||||
assert.deepEqual(JSON.parse(result.content[0].text), result.structuredContent)
|
||||
})
|
||||
|
||||
test("does not expose a singular archived record", () => {
|
||||
const result = asToolResult({ task: { id: 1, archived: true } })
|
||||
|
||||
assert.deepEqual(result.structuredContent, {})
|
||||
assert.deepEqual(JSON.parse(result.content[0].text), {})
|
||||
})
|
||||
|
||||
test("does not advertise includeArchived on MCP tools", () => {
|
||||
for (const tool of mcpTools) {
|
||||
const properties = (tool.inputSchema.properties || {}) as Record<string, unknown>
|
||||
assert.equal(properties.includeArchived, undefined, tool.name)
|
||||
}
|
||||
})
|
||||
34
backend/tests/mcpSerialInvoices.test.ts
Normal file
34
backend/tests/mcpSerialInvoices.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { mcpToolMap } from "../src/mcp/registry"
|
||||
|
||||
test("publishes the complete serial invoice configuration on create and update", () => {
|
||||
for (const name of ["accounting.outgoing_documents.create", "accounting.outgoing_documents.update"]) {
|
||||
const tool = mcpToolMap.get(name)
|
||||
const properties = tool?.inputSchema.properties as Record<string, any>
|
||||
|
||||
assert.ok(properties.serialConfig, name)
|
||||
assert.deepEqual(properties.serialConfig.properties.intervall.enum, [
|
||||
"wöchentlich",
|
||||
"2 - wöchentlich",
|
||||
"monatlich",
|
||||
"vierteljährlich",
|
||||
"halbjährlich",
|
||||
"jährlich",
|
||||
])
|
||||
assert.deepEqual(properties.serialConfig.properties.dateDirection.enum, ["Rückwirkend", "Im Voraus"])
|
||||
assert.ok(properties.type.enum.includes("serialInvoices"))
|
||||
}
|
||||
})
|
||||
|
||||
test("registers tenant-scoped serial invoice execution tools", () => {
|
||||
const execute = mcpToolMap.get("accounting.serial_invoices.execute")
|
||||
const list = mcpToolMap.get("accounting.serial_invoice_executions.list")
|
||||
const finish = mcpToolMap.get("accounting.serial_invoice_executions.finish")
|
||||
|
||||
assert.deepEqual(execute?.requiredPermissions, ["accounting.outgoing_documents.write"])
|
||||
assert.deepEqual(execute?.inputSchema.required, ["executionDate", "templateIds"])
|
||||
assert.deepEqual(list?.requiredPermissions, ["accounting.outgoing_documents.read"])
|
||||
assert.deepEqual(finish?.requiredPermissions, ["accounting.outgoing_documents.write"])
|
||||
assert.deepEqual(finish?.inputSchema.required, ["id"])
|
||||
})
|
||||
50
backend/tests/numberRangeAvailability.test.ts
Normal file
50
backend/tests/numberRangeAvailability.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
|
||||
import { useNextNumberRangeNumber } from "../src/utils/functions"
|
||||
|
||||
test("number range skips identifiers that are already in use", async () => {
|
||||
const tenant = {
|
||||
id: 7,
|
||||
numberRanges: {
|
||||
customerinventoryitems: {
|
||||
prefix: "KIA-",
|
||||
suffix: "",
|
||||
nextNumber: 1000,
|
||||
},
|
||||
},
|
||||
}
|
||||
let savedNumberRanges: any = null
|
||||
|
||||
const tx = {
|
||||
execute: async () => undefined,
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: async () => [tenant],
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (data: any) => ({
|
||||
where: async () => {
|
||||
savedNumberRanges = data.numberRanges
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}
|
||||
const server = {
|
||||
db: {
|
||||
transaction: async (callback: (transaction: typeof tx) => Promise<any>) => callback(tx),
|
||||
},
|
||||
} as any
|
||||
const occupied = new Set(["KIA-1000", "KIA-1001"])
|
||||
|
||||
const result = await useNextNumberRangeNumber(
|
||||
server,
|
||||
tenant.id,
|
||||
"customerinventoryitems",
|
||||
async (candidate) => !occupied.has(candidate)
|
||||
)
|
||||
|
||||
assert.equal(result.usedNumber, "KIA-1002")
|
||||
assert.equal(savedNumberRanges.customerinventoryitems.nextNumber, 1003)
|
||||
})
|
||||
33
backend/tests/outgoingDocumentCostCentres.test.ts
Normal file
33
backend/tests/outgoingDocumentCostCentres.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { applyRowCostCentreAssignments } from "../src/modules/outgoing-document-cost-centres.service"
|
||||
import { mcpToolMap } from "../src/mcp/registry"
|
||||
|
||||
test("changes only requested position cost centres", () => {
|
||||
const rows = [
|
||||
{ id: "row-1", description: "Montage", price: 100, costCentre: "old-1" },
|
||||
{ id: "row-2", description: "Material", price: 50, costCentre: "old-2" },
|
||||
]
|
||||
|
||||
assert.deepEqual(applyRowCostCentreAssignments(rows, [
|
||||
{ rowId: "row-1", costcentre: "new-1" },
|
||||
]), [
|
||||
{ id: "row-1", description: "Montage", price: 100, costCentre: "new-1" },
|
||||
rows[1],
|
||||
])
|
||||
})
|
||||
|
||||
test("rejects assignments for unknown document positions", () => {
|
||||
assert.throws(
|
||||
() => applyRowCostCentreAssignments([{ id: "row-1" }], [{ rowId: "missing", costcentre: null }]),
|
||||
/Position missing.*nicht gefunden/,
|
||||
)
|
||||
})
|
||||
|
||||
test("registers a dedicated finalized-document cost centre tool", () => {
|
||||
const tool = mcpToolMap.get("accounting.outgoing_documents.cost_centres.update")
|
||||
|
||||
assert.deepEqual(tool?.requiredPermissions, ["accounting.outgoing_documents.write"])
|
||||
assert.deepEqual(tool?.inputSchema.required, ["id"])
|
||||
assert.ok(tool?.inputSchema.anyOf)
|
||||
})
|
||||
8
backend/tests/productUnitRelation.test.ts
Normal file
8
backend/tests/productUnitRelation.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
|
||||
import { resourceConfig } from "../src/utils/resource.config"
|
||||
|
||||
test("product details load their configured unit", () => {
|
||||
assert.ok(resourceConfig.products.mtoLoad.includes("unit"))
|
||||
})
|
||||
@@ -93,8 +93,6 @@ services:
|
||||
condition: service_healthy
|
||||
createbuckets:
|
||||
condition: service_completed_successfully
|
||||
matrix-synapse:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
FEDEO_RUN_MIGRATIONS: ${FEDEO_RUN_MIGRATIONS:-true}
|
||||
@@ -144,16 +142,6 @@ 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}
|
||||
FEDEO_BOOTSTRAP_MATRIX: ${FEDEO_BOOTSTRAP_MATRIX:-true}
|
||||
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:-change-this-matrix-registration-secret}
|
||||
MATRIX_SERVICE_USER_LOCALPART: ${MATRIX_SERVICE_USER_LOCALPART:-fedeo_service}
|
||||
LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit}
|
||||
LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
|
||||
NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
@@ -195,7 +183,6 @@ services:
|
||||
NODE_ENV: production
|
||||
NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend
|
||||
NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE}
|
||||
NUXT_PUBLIC_MATRIX_ELEMENT_URL: ${NUXT_PUBLIC_MATRIX_ELEMENT_URL:-}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
|
||||
@@ -207,306 +194,6 @@ services:
|
||||
networks:
|
||||
- web
|
||||
|
||||
matrix-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: fedeo-matrix-db
|
||||
restart: unless-stopped
|
||||
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
|
||||
networks:
|
||||
- internal
|
||||
|
||||
matrix-synapse:
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
container_name: fedeo-matrix-synapse
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
matrix-db:
|
||||
condition: service_healthy
|
||||
matrix-redis:
|
||||
condition: service_started
|
||||
environment:
|
||||
DOMAIN: ${DOMAIN}
|
||||
MATRIX_POSTGRES_DB: ${MATRIX_POSTGRES_DB:-synapse}
|
||||
MATRIX_POSTGRES_USER: ${MATRIX_POSTGRES_USER:-synapse}
|
||||
MATRIX_POSTGRES_PASSWORD: ${MATRIX_POSTGRES_PASSWORD:-change-this-matrix-db-password}
|
||||
MATRIX_REGISTRATION_SHARED_SECRET: ${MATRIX_REGISTRATION_SHARED_SECRET:-change-this-matrix-registration-secret}
|
||||
MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}}
|
||||
MATRIX_TURN_SHARED_SECRET: ${MATRIX_TURN_SHARED_SECRET:-change-this-turn-secret}
|
||||
SYNAPSE_CONFIG_PATH: /data/homeserver.yaml
|
||||
SYNAPSE_REPORT_STATS: "no"
|
||||
SYNAPSE_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}}
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -ec
|
||||
- |
|
||||
if [ ! -f /data/homeserver.yaml ]; then
|
||||
/start.py generate
|
||||
fi
|
||||
python - <<'PY'
|
||||
import os
|
||||
import yaml
|
||||
|
||||
path = "/data/homeserver.yaml"
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
config = yaml.safe_load(handle) or {}
|
||||
|
||||
domain = os.environ["DOMAIN"]
|
||||
server_name = os.environ.get("MATRIX_SERVER_NAME") or domain
|
||||
config["server_name"] = server_name
|
||||
config["public_baseurl"] = f"https://{domain}/"
|
||||
config["database"] = {
|
||||
"name": "psycopg2",
|
||||
"args": {
|
||||
"user": os.environ.get("MATRIX_POSTGRES_USER", "synapse"),
|
||||
"password": os.environ["MATRIX_POSTGRES_PASSWORD"],
|
||||
"database": os.environ.get("MATRIX_POSTGRES_DB", "synapse"),
|
||||
"host": "matrix-db",
|
||||
"cp_min": 5,
|
||||
"cp_max": 10,
|
||||
},
|
||||
}
|
||||
config["redis"] = {"enabled": True, "host": "matrix-redis"}
|
||||
config["registration_shared_secret"] = os.environ["MATRIX_REGISTRATION_SHARED_SECRET"]
|
||||
config["turn_uris"] = [
|
||||
f"turn:{domain}:3478?transport=udp",
|
||||
f"turn:{domain}:3478?transport=tcp",
|
||||
]
|
||||
config["turn_shared_secret"] = os.environ["MATRIX_TURN_SHARED_SECRET"]
|
||||
config["turn_user_lifetime"] = "1h"
|
||||
config["enable_registration"] = False
|
||||
config["experimental_features"] = {
|
||||
**(config.get("experimental_features") or {}),
|
||||
"msc3266_enabled": True,
|
||||
"msc4222_enabled": True,
|
||||
}
|
||||
config["login_via_existing_session"] = {
|
||||
"enabled": True,
|
||||
"require_ui_auth": False,
|
||||
"token_timeout": "5m",
|
||||
}
|
||||
config["max_event_delay_duration"] = "24h"
|
||||
config["rc_message"] = {"per_second": 0.5, "burst_count": 30}
|
||||
config["rc_delayed_event_mgmt"] = {"per_second": 1, "burst_count": 20}
|
||||
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
yaml.safe_dump(config, handle, sort_keys=False)
|
||||
PY
|
||||
exec /start.py
|
||||
volumes:
|
||||
- ./matrix/synapse:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8008/_matrix/client/versions', timeout=2)\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 20s
|
||||
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
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- |
|
||||
mkdir -p /usr/share/nginx/html/.well-known/matrix
|
||||
cat >/usr/share/nginx/html/.well-known/matrix/client <<EOF
|
||||
{
|
||||
"m.homeserver": {
|
||||
"base_url": "https://${DOMAIN}"
|
||||
},
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://${DOMAIN}/livekit/jwt"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
cat >/usr/share/nginx/html/.well-known/matrix/server <<EOF
|
||||
{
|
||||
"m.server": "${MATRIX_SERVER_NAME:-${DOMAIN}}:443"
|
||||
}
|
||||
EOF
|
||||
exec nginx -g 'daemon off;'
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
user: "0:0"
|
||||
restart: unless-stopped
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -ec
|
||||
- |
|
||||
cat >/app/config.json <<EOF
|
||||
{
|
||||
"default_server_config": {
|
||||
"m.homeserver": {
|
||||
"base_url": "https://${DOMAIN}",
|
||||
"server_name": "${MATRIX_SERVER_NAME:-${DOMAIN}}"
|
||||
}
|
||||
},
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://${DOMAIN}/livekit/jwt"
|
||||
}
|
||||
],
|
||||
"disable_custom_urls": false,
|
||||
"disable_guests": true,
|
||||
"brand": "FEDEO Matrix",
|
||||
"default_theme": "light",
|
||||
"features": {
|
||||
"feature_video_rooms": true
|
||||
}
|
||||
}
|
||||
EOF
|
||||
exec nginx -g 'daemon off;'
|
||||
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
|
||||
|
||||
@@ -11,18 +11,16 @@ services:
|
||||
- "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(`app.fedeo.de`) && PathPrefix(`/`)"
|
||||
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
|
||||
- "traefik.http.routers.fedeo-frontend.priority=1"
|
||||
# Web Secure Entrypoint
|
||||
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
|
||||
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
|
||||
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured"
|
||||
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-frontend-secure.priority=1"
|
||||
|
||||
docs:
|
||||
image: git.federspiel.tech/flfeders/fedeo/docs:dev
|
||||
restart: always
|
||||
@@ -32,20 +30,18 @@ services:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.port=3000"
|
||||
# Middlewares
|
||||
- "traefik.http.middlewares.fedeo-docs-redirect-web-secure.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.fedeo-docs-strip.stripprefix.prefixes=/docs"
|
||||
# Web Entrypoint
|
||||
- "traefik.http.routers.fedeo-docs.middlewares=fedeo-docs-redirect-web-secure"
|
||||
- "traefik.http.routers.fedeo-docs.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
|
||||
- "traefik.http.routers.fedeo-docs.entrypoints=web"
|
||||
- "traefik.http.routers.fedeo-docs.priority=120"
|
||||
# Web Secure Entrypoint
|
||||
- "traefik.http.routers.fedeo-docs-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
|
||||
- "traefik.http.routers.fedeo-docs-secure.entrypoints=web-secured"
|
||||
- "traefik.http.routers.fedeo-docs-secure.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-docs-secure.middlewares=fedeo-docs-strip"
|
||||
- "traefik.http.routers.fedeo-docs-secure.priority=120"
|
||||
|
||||
backend:
|
||||
image: git.federspiel.tech/flfeders/fedeo/backend:dev
|
||||
restart: always
|
||||
@@ -78,16 +74,13 @@ services:
|
||||
- "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(`app.fedeo.de`) && PathPrefix(`/backend`)"
|
||||
- "traefik.http.routers.fedeo-backend.entrypoints=web"
|
||||
# Web Secure Entrypoint
|
||||
- "traefik.http.routers.fedeo-backend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/backend`)"
|
||||
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
|
||||
- "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"
|
||||
|
||||
@@ -107,323 +100,6 @@ services:
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
matrix-db:
|
||||
image: postgres:16-alpine
|
||||
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
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
matrix-redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- matrix
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
matrix-synapse:
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- matrix
|
||||
depends_on:
|
||||
- matrix-db
|
||||
- matrix-redis
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
|
||||
volumes:
|
||||
- ./matrix/synapse:/data
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.port=8008"
|
||||
- "traefik.http.services.fedeo-matrix.loadbalancer.server.port=8008"
|
||||
# Matrix Client-Server API
|
||||
- "traefik.http.routers.fedeo-matrix.rule=Host(`${MATRIX_HOMESERVER_HOST:-matrix.fedeo.de}`) && PathPrefix(`/_matrix`)"
|
||||
- "traefik.http.routers.fedeo-matrix.entrypoints=web"
|
||||
- "traefik.http.routers.fedeo-matrix.middlewares=fedeo-matrix-redirect-web-secure"
|
||||
- "traefik.http.routers.fedeo-matrix.service=fedeo-matrix"
|
||||
- "traefik.http.middlewares.fedeo-matrix-redirect-web-secure.redirectscheme.scheme=https"
|
||||
- "traefik.http.routers.fedeo-matrix-secure.rule=Host(`${MATRIX_HOMESERVER_HOST:-matrix.fedeo.de}`) && PathPrefix(`/_matrix`)"
|
||||
- "traefik.http.routers.fedeo-matrix-secure.entrypoints=web-secured"
|
||||
- "traefik.http.routers.fedeo-matrix-secure.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-matrix-secure.service=fedeo-matrix"
|
||||
# Matrix Federation API, nur öffnen wenn Federation gewünscht ist.
|
||||
- "traefik.http.routers.fedeo-matrix-federation.rule=Host(`${MATRIX_HOMESERVER_HOST:-matrix.fedeo.de}`) && PathPrefix(`/_matrix/federation`)"
|
||||
- "traefik.http.routers.fedeo-matrix-federation.entrypoints=web-secured"
|
||||
- "traefik.http.routers.fedeo-matrix-federation.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-matrix-federation.service=fedeo-matrix"
|
||||
|
||||
matrix-well-known:
|
||||
image: nginx:1.27-alpine
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- matrix
|
||||
volumes:
|
||||
- ./matrix/well-known:/usr/share/nginx/html/.well-known/matrix:ro
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.port=80"
|
||||
- "traefik.http.services.fedeo-matrix-well-known.loadbalancer.server.port=80"
|
||||
- "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(`${MATRIX_SERVER_NAME:-fedeo.de}`) && PathPrefix(`/.well-known/matrix`)"
|
||||
- "traefik.http.routers.fedeo-matrix-well-known.entrypoints=web-secured"
|
||||
- "traefik.http.routers.fedeo-matrix-well-known.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-matrix-well-known.middlewares=fedeo-matrix-well-known-cors"
|
||||
- "traefik.http.routers.fedeo-matrix-well-known.service=fedeo-matrix-well-known"
|
||||
|
||||
matrix-turn:
|
||||
image: instrumentisto/coturn:4
|
||||
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:-fedeo.de}
|
||||
- --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:
|
||||
- traefik
|
||||
|
||||
matrix-livekit:
|
||||
image: livekit/livekit-server:v1.9
|
||||
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"
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.port=7880"
|
||||
- "traefik.http.services.fedeo-matrix-livekit.loadbalancer.server.port=7880"
|
||||
- "traefik.http.middlewares.fedeo-matrix-livekit-strip.stripprefix.prefixes=/livekit/sfu"
|
||||
- "traefik.http.routers.fedeo-matrix-livekit.rule=Host(`${MATRIX_RTC_HOST:-call.fedeo.de}`) && PathPrefix(`/livekit/sfu`)"
|
||||
- "traefik.http.routers.fedeo-matrix-livekit.entrypoints=web-secured"
|
||||
- "traefik.http.routers.fedeo-matrix-livekit.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-matrix-livekit.middlewares=fedeo-matrix-livekit-strip"
|
||||
- "traefik.http.routers.fedeo-matrix-livekit.service=fedeo-matrix-livekit"
|
||||
|
||||
matrix-rtc-jwt:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:latest
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- matrix
|
||||
depends_on:
|
||||
- matrix-livekit
|
||||
- matrix-synapse
|
||||
environment:
|
||||
- LIVEKIT_URL=wss://${MATRIX_RTC_HOST:-call.fedeo.de}/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:-fedeo.de}
|
||||
- LIVEKIT_JWT_BIND=:8080
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.port=8080"
|
||||
- "traefik.http.services.fedeo-matrix-rtc-jwt.loadbalancer.server.port=8080"
|
||||
- "traefik.http.middlewares.fedeo-matrix-rtc-jwt-strip.stripprefix.prefixes=/livekit/jwt"
|
||||
- "traefik.http.routers.fedeo-matrix-rtc-jwt.rule=Host(`${MATRIX_RTC_HOST:-call.fedeo.de}`) && PathPrefix(`/livekit/jwt`)"
|
||||
- "traefik.http.routers.fedeo-matrix-rtc-jwt.entrypoints=web-secured"
|
||||
- "traefik.http.routers.fedeo-matrix-rtc-jwt.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-matrix-rtc-jwt.middlewares=fedeo-matrix-rtc-jwt-strip"
|
||||
- "traefik.http.routers.fedeo-matrix-rtc-jwt.service=fedeo-matrix-rtc-jwt"
|
||||
|
||||
matrix-dev-db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- matrix-dev
|
||||
environment:
|
||||
- POSTGRES_DB=synapse
|
||||
- POSTGRES_USER=synapse
|
||||
- POSTGRES_PASSWORD=synapse-dev-password
|
||||
- POSTGRES_INITDB_ARGS=--encoding=UTF8 --lc-collate=C --lc-ctype=C
|
||||
volumes:
|
||||
- ./matrix/dev/postgres:/var/lib/postgresql/data
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
matrix-dev-redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- matrix-dev
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
matrix-dev-synapse:
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- matrix-dev
|
||||
depends_on:
|
||||
- matrix-dev-db
|
||||
- matrix-dev-redis
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
|
||||
volumes:
|
||||
- ./matrix/dev/synapse:/data
|
||||
ports:
|
||||
- "${MATRIX_DEV_SYNAPSE_PORT:-8008}:8008"
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
matrix-dev-turn:
|
||||
image: instrumentisto/coturn:4
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- matrix-dev
|
||||
command:
|
||||
- --fingerprint
|
||||
- --use-auth-secret
|
||||
- --static-auth-secret=matrix-dev-turn-secret
|
||||
- --realm=localhost
|
||||
- --listening-port=3478
|
||||
- --min-port=49160
|
||||
- --max-port=49200
|
||||
- --no-cli
|
||||
- --no-tls
|
||||
- --no-dtls
|
||||
ports:
|
||||
- "${MATRIX_DEV_TURN_PORT:-3478}:3478/tcp"
|
||||
- "${MATRIX_DEV_TURN_PORT:-3478}:3478/udp"
|
||||
- "${MATRIX_DEV_TURN_MIN_PORT:-49160}-${MATRIX_DEV_TURN_MAX_PORT:-49200}:49160-49200/udp"
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
matrix-dev-livekit:
|
||||
image: livekit/livekit-server:v1.9
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- matrix-dev
|
||||
depends_on:
|
||||
- matrix-dev-redis
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -ec
|
||||
- |
|
||||
cat >/tmp/livekit.yaml <<EOF
|
||||
port: 7880
|
||||
redis:
|
||||
address: matrix-dev-redis:6379
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
port_range_start: 50000
|
||||
port_range_end: 50100
|
||||
node_ip: ${MATRIX_DEV_LIVEKIT_NODE_IP:-127.0.0.1}
|
||||
use_external_ip: false
|
||||
enable_loopback_candidate: true
|
||||
keys:
|
||||
devkey: devsecret-local-matrix-stack-32-chars
|
||||
room:
|
||||
auto_create: true
|
||||
EOF
|
||||
exec /livekit-server --config /tmp/livekit.yaml
|
||||
ports:
|
||||
- "${MATRIX_DEV_LIVEKIT_PORT:-7880}:7880"
|
||||
- "${MATRIX_DEV_LIVEKIT_TCP_PORT:-7881}:7881/tcp"
|
||||
- "${MATRIX_DEV_LIVEKIT_RTC_MIN_PORT:-50000}-${MATRIX_DEV_LIVEKIT_RTC_MAX_PORT:-50100}:50000-50100/udp"
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
matrix-dev-rtc-jwt:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:latest
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- matrix-dev
|
||||
depends_on:
|
||||
- matrix-dev-livekit
|
||||
- matrix-dev-synapse
|
||||
environment:
|
||||
- LIVEKIT_URL=ws://localhost:${MATRIX_DEV_LIVEKIT_PORT:-7880}
|
||||
- LIVEKIT_KEY=devkey
|
||||
- LIVEKIT_SECRET=devsecret-local-matrix-stack-32-chars
|
||||
- LIVEKIT_FULL_ACCESS_HOMESERVERS=localhost
|
||||
- LIVEKIT_JWT_BIND=:8080
|
||||
ports:
|
||||
- "${MATRIX_DEV_RTC_JWT_PORT:-8081}:8080"
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
matrix-dev-element:
|
||||
image: vectorim/element-web:latest
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- matrix-dev
|
||||
volumes:
|
||||
- ./matrix/dev/element-config.json:/app/config.json:ro
|
||||
ports:
|
||||
- "${MATRIX_DEV_ELEMENT_PORT:-8080}:80"
|
||||
networks:
|
||||
- traefik
|
||||
# db:
|
||||
# image: postgres
|
||||
# restart: always
|
||||
# shm_size: 128mb
|
||||
# environment:
|
||||
# POSTGRES_PASSWORD: abc
|
||||
# POSTGRES_USER: sandelcom
|
||||
# POSTGRES_DB: sensorfy
|
||||
# volumes:
|
||||
# - ./pg-data:/var/lib/postgresql/data
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
traefik:
|
||||
image: traefik:v2.11
|
||||
restart: unless-stopped
|
||||
@@ -442,18 +118,19 @@ services:
|
||||
- "--accesslog.bufferingsize=5000"
|
||||
- "--accesslog.fields.defaultMode=keep"
|
||||
- "--accesslog.fields.headers.defaultMode=keep"
|
||||
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" #
|
||||
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true"
|
||||
- "--certificatesresolvers.mytlschallenge.acme.email=moin@fedeo.de"
|
||||
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS)
|
||||
- "./traefik/letsencrypt:/letsencrypt"
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
- "./traefik/logs:/logs"
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: false
|
||||
|
||||
@@ -5,4 +5,3 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
|
||||
## Einstieg
|
||||
|
||||
- [Bedienung](./bedienung/README.md)
|
||||
- [Kommunikationslösung auf Basis des Matrix-Standards](./kommunikationslösung-matrix.md)
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
# Kommunikationslösung auf Basis des Matrix-Standards
|
||||
|
||||
Dieser Entwurf beschreibt eine FEDEO-Kommunikationslösung für Chat, Anrufe und Videokonferenzen auf Basis des Matrix-Standards. Ziel ist eine souverän betreibbare Lösung, die Mandantenfähigkeit, Datenschutz, Rechteverwaltung und die bestehenden FEDEO-Workflows berücksichtigt.
|
||||
|
||||
## Zielbild
|
||||
|
||||
FEDEO erhält einen integrierten Kommunikationsbereich, der interne Zusammenarbeit und externe Kommunikation abdeckt:
|
||||
|
||||
- Chat in Einzel-, Gruppen-, Projekt-, Vorgangs- und Kundenräumen
|
||||
- Audioanrufe aus Direktchats, Gruppenräumen und Kontakten
|
||||
- Videokonferenzen mit Bildschirmfreigabe und Einladungslinks
|
||||
- Ende-zu-Ende-verschlüsselte private Kommunikation
|
||||
- revisionsfähige Verknüpfung von relevanten Kommunikationsereignissen mit FEDEO-Objekten
|
||||
- optional föderierte Kommunikation mit externen Matrix-Organisationen
|
||||
|
||||
Matrix wird dabei nicht als isolierter Messenger betrieben, sondern als Kommunikationsschicht neben dem bestehenden FEDEO-Backend.
|
||||
|
||||
## Empfohlene Architektur
|
||||
|
||||
```text
|
||||
Nutzerinnen und Nutzer
|
||||
|
|
||||
| FEDEO Web, Mobile App, optional Element Desktop/Mobile
|
||||
v
|
||||
FEDEO Frontend
|
||||
|
|
||||
| FEDEO API, SSO, Rechte, Objektkontext
|
||||
v
|
||||
FEDEO Backend
|
||||
|
|
||||
| Provisionierung, Webhooks, Audit-Metadaten
|
||||
v
|
||||
Matrix Homeserver
|
||||
|
|
||||
+-- PostgreSQL für Matrix-Daten
|
||||
+-- Redis für Worker und Caches
|
||||
+-- Medien-Repository für Anhänge
|
||||
+-- TURN/STUN für direkte Medienverbindungen
|
||||
+-- MatrixRTC / LiveKit SFU für Gruppenanrufe und Videokonferenzen
|
||||
```
|
||||
|
||||
### Kernkomponenten
|
||||
|
||||
| Komponente | Empfehlung | Aufgabe |
|
||||
| --- | --- | --- |
|
||||
| Matrix Homeserver | Synapse | Standardnaher, bewährter Homeserver mit guter Betriebsdokumentation |
|
||||
| Matrix Client im FEDEO Web | Matrix JS SDK oder eingebetteter Element-Web-Ausschnitt | Chat, Raumliste, Nachrichten, Reaktionen, Anhänge |
|
||||
| Mobile Integration | Matrix SDK über FEDEO Mobile oder Deep Link zu Element X | Pushfähige mobile Kommunikation |
|
||||
| Identität | OIDC/SSO über FEDEO Auth, perspektivisch Matrix Authentication Service | Einheitlicher Login und zentrale Nutzerverwaltung |
|
||||
| Audio/Video | MatrixRTC mit Element Call und LiveKit SFU | Moderne Anrufe und Videokonferenzen |
|
||||
| NAT Traversal | coturn | STUN/TURN für stabile Medienverbindungen |
|
||||
| Reverse Proxy | bestehender Traefik-Ansatz | TLS, Routing, `.well-known/matrix/*` |
|
||||
| Administration | FEDEO Admin-Oberfläche plus Synapse Admin API | Nutzer, Räume, Richtlinien, Sperren |
|
||||
|
||||
## Betriebsmodell
|
||||
|
||||
Für FEDEO ist ein eigener Matrix-Homeserver pro Installation oder pro großer Betreiberinstanz sinnvoll. Der Matrix-Server sollte nicht öffentlich als offener Registrierungsserver betrieben werden. Nutzer werden ausschließlich durch FEDEO angelegt, aktualisiert und deaktiviert.
|
||||
|
||||
Empfohlene Domains:
|
||||
|
||||
- `app.example.com`: FEDEO Oberfläche
|
||||
- `matrix.example.com`: Matrix Client-Server und Federation API
|
||||
- `call.example.com`: Element Call / MatrixRTC
|
||||
- `livekit.example.com`: LiveKit SFU
|
||||
- `turn.example.com`: TURN/STUN
|
||||
|
||||
Die öffentliche Matrix-Serverkennung kann trotzdem `example.com` lauten. Dafür werden `.well-known/matrix/client` und `.well-known/matrix/server` über Traefik ausgeliefert.
|
||||
|
||||
## Mandantenmodell
|
||||
|
||||
Matrix selbst ist raumbasiert, FEDEO ist mandantenbasiert. Deshalb sollte FEDEO die Mandantenlogik explizit auf Matrix-Räume und Spaces abbilden.
|
||||
|
||||
### Räume und Spaces
|
||||
|
||||
- Pro FEDEO-Mandant wird ein Matrix Space angelegt.
|
||||
- Projekte, Vorgänge, Helpdesk-Konversationen, interne Teams und Kundenkontakte werden als Räume im Mandanten-Space geführt.
|
||||
- Direkträume werden nutzerbezogen angelegt, aber über FEDEO mandantengebunden sichtbar gemacht.
|
||||
- Externe Räume erhalten einen klaren Status, zum Beispiel `intern`, `extern`, `kunde`, `lieferant`.
|
||||
|
||||
### Raumalias-Konvention
|
||||
|
||||
Beispiele:
|
||||
|
||||
- `#tenant-<mandant>-team:example.com`
|
||||
- `#tenant-<mandant>-project-<projekt>:example.com`
|
||||
- `#tenant-<mandant>-ticket-<ticket>:example.com`
|
||||
- `#tenant-<mandant>-customer-<kunde>:example.com`
|
||||
|
||||
Interne technische IDs sollten nicht als sichtbarer Anzeigename genutzt werden. Nutzerinnen und Nutzer sehen sprechende Namen wie `Projekt: Website Relaunch` oder `Kunde: Muster GmbH`.
|
||||
|
||||
## Rechte und Rollen
|
||||
|
||||
FEDEO bleibt führend für Berechtigungen. Matrix übernimmt die technische Durchsetzung im Raum.
|
||||
|
||||
| FEDEO-Rolle | Matrix-Abbildung |
|
||||
| --- | --- |
|
||||
| Mandantenadmin | Space-Admin und Raumadmin |
|
||||
| Teamleitung | Moderatorin oder Moderator in Team- und Projekträumen |
|
||||
| Mitarbeitende | Mitglied mit Schreibrechten |
|
||||
| Externe Kontakte | Eingeschränkte Mitgliedschaft in ausgewählten Räumen |
|
||||
| Automationen | Application-Service- oder Bot-Nutzer mit minimalen Rechten |
|
||||
|
||||
Änderungen an Rollen, Teams oder Mandantenzugehörigkeiten lösen im FEDEO-Backend eine Synchronisation mit Matrix aus. Beim Entzug eines Zugriffs wird die Person aus den betroffenen Räumen entfernt. Bei Ende-zu-Ende-verschlüsselten Räumen muss zusätzlich berücksichtigt werden, dass bereits erhaltene Nachrichten auf Geräten verbleiben können.
|
||||
|
||||
## Chat
|
||||
|
||||
Der Chat wird als erste Ausbaustufe umgesetzt.
|
||||
|
||||
### Funktionen
|
||||
|
||||
- Direktnachrichten
|
||||
- Gruppenräume
|
||||
- Mandanten-, Team-, Projekt- und Vorgangsräume
|
||||
- Datei- und Bildanhänge
|
||||
- Erwähnungen, Reaktionen und Lesestatus
|
||||
- Suche in nicht verschlüsselten Räumen über den Homeserver
|
||||
- lokale Suche in verschlüsselten Räumen über Client-Indizes
|
||||
- Verknüpfung von Nachrichten mit FEDEO-Objekten
|
||||
|
||||
### Integration in FEDEO
|
||||
|
||||
FEDEO sollte keine vollständige Kopie aller Nachrichten in der eigenen Datenbank speichern. Stattdessen speichert FEDEO nur Referenzen:
|
||||
|
||||
- Matrix Raum-ID
|
||||
- Matrix Event-ID
|
||||
- FEDEO Objekt-Typ und Objekt-ID
|
||||
- Zeitstempel
|
||||
- beteiligter FEDEO-Nutzer
|
||||
- optionale Vorschau, falls Datenschutzrichtlinie dies erlaubt
|
||||
|
||||
So bleibt Matrix das Kommunikationssystem, während FEDEO nachvollziehen kann, welche Kommunikation zu welchem Objekt gehört.
|
||||
|
||||
## Audioanrufe
|
||||
|
||||
Einzelanrufe können direkt über Matrix-VoIP in Direktchats gestartet werden. Der FEDEO-Client zeigt dafür in Kontakt-, Kunden-, Mitarbeitenden- und Chatansichten einen Anruf-Button.
|
||||
|
||||
### Anforderungen
|
||||
|
||||
- WebRTC-Unterstützung im Browser
|
||||
- STUN/TURN über coturn
|
||||
- Geräteauswahl für Mikrofon und Lautsprecher
|
||||
- Anrufbenachrichtigung im Web und mobil
|
||||
- Statusanzeige `verfügbar`, `beschäftigt`, `im Anruf`, `abwesend`
|
||||
|
||||
Für klassische Telefonie kann später ein SIP-Gateway ergänzt werden. Das sollte jedoch getrennt von der ersten Matrix-Einführung betrachtet werden, damit Chat und WebRTC-Kommunikation nicht durch Telefoniekomplexität ausgebremst werden.
|
||||
|
||||
## Videokonferenzen
|
||||
|
||||
Für Gruppenanrufe und Videokonferenzen wird MatrixRTC mit Element Call und LiveKit empfohlen. Matrix übernimmt dabei Raumzustand, Identität, Berechtigungen und Signalisierung; LiveKit übernimmt als SFU die effiziente Medienverteilung.
|
||||
|
||||
### Funktionen
|
||||
|
||||
- Videokonferenzen aus Matrix-Räumen
|
||||
- spontane Besprechungen aus Projekten, Vorgängen oder Kundenakten
|
||||
- Bildschirmfreigabe
|
||||
- Einladungslink für externe Gäste
|
||||
- Wartebereich für externe Gäste
|
||||
- Moderationsrechte für Stummschalten, Entfernen und Raumverwaltung
|
||||
- optionale Aufzeichnung erst in einer späteren, gesondert freizugebenden Ausbaustufe
|
||||
|
||||
### Konfiguration
|
||||
|
||||
Clients finden den MatrixRTC-Dienst über `.well-known/matrix/client`. Dort wird der LiveKit-JWT-Dienst als `org.matrix.msc4143.rtc_foci` angekündigt. Diese Datei muss öffentlich lesbar sein, als JSON ausgeliefert werden und CORS für Webclients erlauben.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```json
|
||||
{
|
||||
"m.homeserver": {
|
||||
"base_url": "https://matrix.example.com"
|
||||
},
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://call.example.com/livekit/jwt"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Authentifizierung und Nutzerverwaltung
|
||||
|
||||
FEDEO sollte Identität und Lebenszyklus der Nutzer zentral steuern.
|
||||
|
||||
### Empfohlener Ablauf
|
||||
|
||||
1. Nutzer wird in FEDEO angelegt.
|
||||
2. FEDEO erzeugt oder aktualisiert den Matrix-Nutzer.
|
||||
3. FEDEO weist den Nutzer den passenden Spaces und Räumen zu.
|
||||
4. Login erfolgt über FEDEO SSO/OIDC.
|
||||
5. Deaktivierung in FEDEO deaktiviert auch den Matrix-Zugang und entfernt Raumzugriffe.
|
||||
|
||||
Die Matrix User-ID sollte stabil und nicht personenbezogen änderungsanfällig sein:
|
||||
|
||||
```text
|
||||
@u_<fedeo_user_id>:example.com
|
||||
```
|
||||
|
||||
Der Anzeigename kann weiterhin den echten Namen enthalten und bei Änderungen synchronisiert werden.
|
||||
|
||||
## Datenschutz und Compliance
|
||||
|
||||
Matrix erlaubt starke Datenschutzkonzepte, erfordert aber klare Betriebsregeln.
|
||||
|
||||
### Empfehlungen
|
||||
|
||||
- Ende-zu-Ende-Verschlüsselung für Direktnachrichten und vertrauliche Projekträume aktivieren.
|
||||
- Nicht verschlüsselte Räume nur dort nutzen, wo serverseitige Suche, Archivierung oder Compliance-Funktionen ausdrücklich benötigt werden.
|
||||
- Medienaufbewahrung mandantenweit konfigurierbar machen.
|
||||
- Externe Gäste optisch klar kennzeichnen.
|
||||
- Federation standardmäßig deaktivieren oder auf erlaubte Domains beschränken.
|
||||
- Aufzeichnungen von Videokonferenzen nur mit expliziter Einwilligung und sichtbarem Status erlauben.
|
||||
- Administrative Zugriffe protokollieren.
|
||||
- Klare Löschfristen für Räume, Anhänge und Audit-Referenzen definieren.
|
||||
|
||||
## Federation
|
||||
|
||||
Matrix kann mit anderen Homeservern föderieren. Für FEDEO sollte Federation als kontrollierbare Option umgesetzt werden.
|
||||
|
||||
### Betriebsmodi
|
||||
|
||||
| Modus | Beschreibung | Empfehlung |
|
||||
| --- | --- | --- |
|
||||
| geschlossen | Keine Federation, nur interne Nutzer und explizite Gäste | Standard für kleine Installationen |
|
||||
| allowlist | Federation nur mit freigegebenen Domains | Empfehlung für B2B-Kommunikation |
|
||||
| offen | Federation mit beliebigen Matrix-Servern | Nur für bewusst öffentliche Communities |
|
||||
|
||||
Für steuer-, kunden- und projektnahe Kommunikation ist `allowlist` der beste Zielmodus.
|
||||
|
||||
## Brücken zu anderen Systemen
|
||||
|
||||
Matrix unterstützt Brücken zu anderen Kommunikationsdiensten. Für FEDEO sind Brücken nützlich, sollten aber nicht zur ersten Produktstufe gehören.
|
||||
|
||||
Mögliche spätere Erweiterungen:
|
||||
|
||||
- E-Mail-Brücke für Helpdesk- oder Kundenkommunikation
|
||||
- Slack- oder Teams-Brücke für externe Projektpartner
|
||||
- WhatsApp- oder SMS-Brücke nur nach gesonderter Datenschutzprüfung
|
||||
- SIP-Brücke für Telefonie
|
||||
|
||||
Brücken müssen pro Mandant aktivierbar sein und brauchen klare Hinweise, welche Daten an externe Dienste fließen.
|
||||
|
||||
## FEDEO-Produktoberfläche
|
||||
|
||||
Die Kommunikation sollte in FEDEO an zwei Stellen sichtbar sein.
|
||||
|
||||
### Globaler Kommunikationsbereich
|
||||
|
||||
- Raumliste
|
||||
- Direktnachrichten
|
||||
- Suche
|
||||
- Anrufe
|
||||
- laufende Besprechungen
|
||||
- Benachrichtigungen
|
||||
|
||||
### Objektbezogene Kommunikation
|
||||
|
||||
In Projekten, Kunden, Vorgängen, Helpdesk-Tickets und Dokumenten erscheint ein Kommunikations-Tab:
|
||||
|
||||
- zugeordneter Raum
|
||||
- relevante Nachrichtenreferenzen
|
||||
- Start von Chat, Anruf oder Besprechung
|
||||
- Teilnehmerverwaltung entsprechend FEDEO-Rechten
|
||||
|
||||
So bleibt Kommunikation dort, wo die Arbeit stattfindet.
|
||||
|
||||
## Backend-Integration
|
||||
|
||||
Das FEDEO-Backend erhält ein Kommunikationsmodul mit folgenden Aufgaben:
|
||||
|
||||
- Matrix-Nutzer provisionieren
|
||||
- Spaces und Räume anlegen
|
||||
- Raum-Mitgliedschaften synchronisieren
|
||||
- Matrix-Event-Webhooks empfangen
|
||||
- FEDEO-Objekte mit Matrix-Räumen verknüpfen
|
||||
- Benachrichtigungseinstellungen verwalten
|
||||
- Admin-Aktionen auditieren
|
||||
|
||||
Technisch kann dies über Matrix Admin API, Client-Server API und Application Services erfolgen. Für Automationen empfiehlt sich ein eigener Application Service, weil er reservierte Nutzer- und Raum-Namensräume sauber verwalten kann.
|
||||
|
||||
## Deployment-Erweiterung
|
||||
|
||||
Der bestehende Docker-/Traefik-Ansatz kann um folgende Dienste erweitert werden:
|
||||
|
||||
- `matrix-synapse`
|
||||
- `matrix-db` oder gemeinsame PostgreSQL-Instanz mit getrennter Datenbank
|
||||
- `redis`
|
||||
- `coturn`
|
||||
- `element-web` optional als Fallback-Client
|
||||
- `element-call`
|
||||
- `livekit`
|
||||
- `matrix-rtc-jwt-service`
|
||||
|
||||
Für produktive Installationen sollte Matrix eine eigene PostgreSQL-Datenbank erhalten. Medien sollten in S3-kompatiblen Speicher ausgelagert werden, damit große Anhänge und Konferenzartefakte nicht den Applikationsserver füllen.
|
||||
|
||||
## Monitoring
|
||||
|
||||
Wichtige Kennzahlen:
|
||||
|
||||
- aktive Nutzerinnen und Nutzer
|
||||
- Anzahl Räume pro Mandant
|
||||
- Nachrichtenrate
|
||||
- Medien-Speicherverbrauch
|
||||
- Zustellverzögerung
|
||||
- fehlgeschlagene Anrufe
|
||||
- LiveKit Paketverlust, Latenz und Teilnehmerzahl
|
||||
- TURN-Nutzung
|
||||
- Federation-Fehler
|
||||
|
||||
Logs von FEDEO, Synapse, LiveKit, coturn und Traefik sollten über eine gemeinsame Korrelation, zum Beispiel Request-ID oder Nutzer-ID, untersuchbar sein.
|
||||
|
||||
## Risiken und Gegenmaßnahmen
|
||||
|
||||
| Risiko | Gegenmaßnahme |
|
||||
| --- | --- |
|
||||
| Komplexität durch zwei Systeme | FEDEO bleibt führend für Nutzer, Rechte und Objektbezug |
|
||||
| Datenschutz bei externen Räumen | Externe Kennzeichnung, Federation-Allowlist, Mandantenrichtlinien |
|
||||
| E2EE erschwert Suche und Archivierung | Raumtyp bewusst wählen, lokale Suche, Metadatenreferenzen statt Vollkopie |
|
||||
| Medienverbindungen scheitern in Firmennetzen | coturn sauber betreiben, UDP und TCP/TLS-Fallback anbieten |
|
||||
| Betriebskosten durch Video | LiveKit skalierbar betreiben, Limits pro Mandant definieren |
|
||||
| Gästezugriff wird unübersichtlich | Einladungslinks mit Ablaufdatum, Wartebereich, Moderationsrechte |
|
||||
|
||||
## Umsetzung in Phasen
|
||||
|
||||
### Phase 1: Fundament und Chat
|
||||
|
||||
- Synapse mit PostgreSQL, Redis, Traefik und `.well-known` betreiben
|
||||
- FEDEO-Nutzer zu Matrix synchronisieren
|
||||
- Mandanten-Spaces und erste Teamräume anlegen
|
||||
- Chat im FEDEO-Frontend integrieren
|
||||
- Benachrichtigungen und Raumreferenzen speichern
|
||||
|
||||
### Phase 2: Objektbezogene Kommunikation
|
||||
|
||||
- Räume automatisch für Projekte, Vorgänge und Kunden anlegen
|
||||
- Kommunikations-Tab in FEDEO-Objekten ergänzen
|
||||
- Rechteänderungen aus FEDEO nach Matrix synchronisieren
|
||||
- externe Gäste einladen und kennzeichnen
|
||||
|
||||
### Phase 3: Audio und Video
|
||||
|
||||
- coturn bereitstellen
|
||||
- MatrixRTC, Element Call und LiveKit integrieren
|
||||
- Anruf- und Videobuttons in Chat, Kontakten und Projekten ergänzen
|
||||
- Gäste-Links und Wartebereich umsetzen
|
||||
|
||||
### Phase 4: Compliance und Skalierung
|
||||
|
||||
- Aufbewahrungsrichtlinien pro Mandant
|
||||
- Monitoring und Admin-Dashboards
|
||||
- Federation-Allowlist
|
||||
- optionale Brücken
|
||||
- optionale Aufzeichnung mit Einwilligungsworkflow
|
||||
|
||||
## Offene Entscheidungen
|
||||
|
||||
- Soll Federation initial deaktiviert oder direkt mit Allowlist ausgeliefert werden?
|
||||
- Welche Räume müssen serverseitig durchsuchbar sein und bleiben deshalb unverschlüsselt?
|
||||
- Sollen externe Gäste Matrix-Konten erhalten oder nur temporäre Konferenzzugänge?
|
||||
- Wird Element als sichtbarer Fallback-Client angeboten oder soll alles primär in FEDEO stattfinden?
|
||||
- Welche Mandantenlimits gelten für Speicher, Teilnehmerzahl und Videodauer?
|
||||
|
||||
## Quellen und Standards
|
||||
|
||||
- Matrix Specification: https://spec.matrix.org/
|
||||
- Matrix Application Services: https://matrix.org/docs/older/application-services/
|
||||
- Matrix Bridges: https://www.matrix.org/docs/communities/bridging/
|
||||
- Synapse Worker-Dokumentation: https://matrix-org.github.io/synapse/develop/workers.html
|
||||
- Element Call Self-Hosting: https://github.com/element-hq/element-call/blob/livekit/docs/self-hosting.md
|
||||
- Element MatrixRTC Konfiguration: https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/
|
||||
- LiveKit Self-Hosting: https://docs.livekit.io/transport/self-hosting/
|
||||
@@ -6,5 +6,4 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
|
||||
|
||||
- [Bedienung](./bedienung/README.md)
|
||||
- [Fach- und Technikkonzept für das Lagersystem](./lagersystem-konzept.md)
|
||||
- [Kommunikationslösung auf Basis des Matrix-Standards](./kommunikationslösung-matrix.md)
|
||||
- [Zentraler Push-Server für Selfhost-Instanzen](./zentraler-push-server.md)
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
# Kommunikationslösung auf Basis des Matrix-Standards
|
||||
|
||||
Dieser Entwurf beschreibt eine FEDEO-Kommunikationslösung für Chat, Anrufe und Videokonferenzen auf Basis des Matrix-Standards. Ziel ist eine souverän betreibbare Lösung, die Mandantenfähigkeit, Datenschutz, Rechteverwaltung und die bestehenden FEDEO-Workflows berücksichtigt.
|
||||
|
||||
## Zielbild
|
||||
|
||||
FEDEO erhält einen integrierten Kommunikationsbereich, der interne Zusammenarbeit und externe Kommunikation abdeckt:
|
||||
|
||||
- Chat in Einzel-, Gruppen-, Projekt-, Vorgangs- und Kundenräumen
|
||||
- Audioanrufe aus Direktchats, Gruppenräumen und Kontakten
|
||||
- Videokonferenzen mit Bildschirmfreigabe und Einladungslinks
|
||||
- Ende-zu-Ende-verschlüsselte private Kommunikation
|
||||
- revisionsfähige Verknüpfung von relevanten Kommunikationsereignissen mit FEDEO-Objekten
|
||||
- optional föderierte Kommunikation mit externen Matrix-Organisationen
|
||||
|
||||
Matrix wird dabei nicht als isolierter Messenger betrieben, sondern als Kommunikationsschicht neben dem bestehenden FEDEO-Backend.
|
||||
|
||||
## Empfohlene Architektur
|
||||
|
||||
```text
|
||||
Nutzerinnen und Nutzer
|
||||
|
|
||||
| FEDEO Web, Mobile App, optional Element Desktop/Mobile
|
||||
v
|
||||
FEDEO Frontend
|
||||
|
|
||||
| FEDEO API, SSO, Rechte, Objektkontext
|
||||
v
|
||||
FEDEO Backend
|
||||
|
|
||||
| Provisionierung, Webhooks, Audit-Metadaten
|
||||
v
|
||||
Matrix Homeserver
|
||||
|
|
||||
+-- PostgreSQL für Matrix-Daten
|
||||
+-- Redis für Worker und Caches
|
||||
+-- Medien-Repository für Anhänge
|
||||
+-- TURN/STUN für direkte Medienverbindungen
|
||||
+-- MatrixRTC / LiveKit SFU für Gruppenanrufe und Videokonferenzen
|
||||
```
|
||||
|
||||
### Kernkomponenten
|
||||
|
||||
| Komponente | Empfehlung | Aufgabe |
|
||||
| --- | --- | --- |
|
||||
| Matrix Homeserver | Synapse | Standardnaher, bewährter Homeserver mit guter Betriebsdokumentation |
|
||||
| Matrix Client im FEDEO Web | Matrix JS SDK oder eingebetteter Element-Web-Ausschnitt | Chat, Raumliste, Nachrichten, Reaktionen, Anhänge |
|
||||
| Mobile Integration | Matrix SDK über FEDEO Mobile oder Deep Link zu Element X | Pushfähige mobile Kommunikation |
|
||||
| Identität | OIDC/SSO über FEDEO Auth, perspektivisch Matrix Authentication Service | Einheitlicher Login und zentrale Nutzerverwaltung |
|
||||
| Audio/Video | MatrixRTC mit Element Call und LiveKit SFU | Moderne Anrufe und Videokonferenzen |
|
||||
| NAT Traversal | coturn | STUN/TURN für stabile Medienverbindungen |
|
||||
| Reverse Proxy | bestehender Traefik-Ansatz | TLS, Routing, `.well-known/matrix/*` |
|
||||
| Administration | FEDEO Admin-Oberfläche plus Synapse Admin API | Nutzer, Räume, Richtlinien, Sperren |
|
||||
|
||||
## Betriebsmodell
|
||||
|
||||
Für FEDEO ist ein eigener Matrix-Homeserver pro Installation oder pro großer Betreiberinstanz sinnvoll. Der Matrix-Server sollte nicht öffentlich als offener Registrierungsserver betrieben werden. Nutzer werden ausschließlich durch FEDEO angelegt, aktualisiert und deaktiviert.
|
||||
|
||||
Empfohlene Domains:
|
||||
|
||||
- `app.example.com`: FEDEO Oberfläche
|
||||
- `matrix.example.com`: Matrix Client-Server und Federation API
|
||||
- `call.example.com`: Element Call / MatrixRTC
|
||||
- `livekit.example.com`: LiveKit SFU
|
||||
- `turn.example.com`: TURN/STUN
|
||||
|
||||
Die öffentliche Matrix-Serverkennung kann trotzdem `example.com` lauten. Dafür werden `.well-known/matrix/client` und `.well-known/matrix/server` über Traefik ausgeliefert.
|
||||
|
||||
## Mandantenmodell
|
||||
|
||||
Matrix selbst ist raumbasiert, FEDEO ist mandantenbasiert. Deshalb sollte FEDEO die Mandantenlogik explizit auf Matrix-Räume und Spaces abbilden.
|
||||
|
||||
### Räume und Spaces
|
||||
|
||||
- Pro FEDEO-Mandant wird ein Matrix Space angelegt.
|
||||
- Projekte, Vorgänge, Helpdesk-Konversationen, interne Teams und Kundenkontakte werden als Räume im Mandanten-Space geführt.
|
||||
- Direkträume werden nutzerbezogen angelegt, aber über FEDEO mandantengebunden sichtbar gemacht.
|
||||
- Externe Räume erhalten einen klaren Status, zum Beispiel `intern`, `extern`, `kunde`, `lieferant`.
|
||||
|
||||
### Raumalias-Konvention
|
||||
|
||||
Beispiele:
|
||||
|
||||
- `#tenant-<mandant>-team:example.com`
|
||||
- `#tenant-<mandant>-project-<projekt>:example.com`
|
||||
- `#tenant-<mandant>-ticket-<ticket>:example.com`
|
||||
- `#tenant-<mandant>-customer-<kunde>:example.com`
|
||||
|
||||
Interne technische IDs sollten nicht als sichtbarer Anzeigename genutzt werden. Nutzerinnen und Nutzer sehen sprechende Namen wie `Projekt: Website Relaunch` oder `Kunde: Muster GmbH`.
|
||||
|
||||
## Rechte und Rollen
|
||||
|
||||
FEDEO bleibt führend für Berechtigungen. Matrix übernimmt die technische Durchsetzung im Raum.
|
||||
|
||||
| FEDEO-Rolle | Matrix-Abbildung |
|
||||
| --- | --- |
|
||||
| Mandantenadmin | Space-Admin und Raumadmin |
|
||||
| Teamleitung | Moderatorin oder Moderator in Team- und Projekträumen |
|
||||
| Mitarbeitende | Mitglied mit Schreibrechten |
|
||||
| Externe Kontakte | Eingeschränkte Mitgliedschaft in ausgewählten Räumen |
|
||||
| Automationen | Application-Service- oder Bot-Nutzer mit minimalen Rechten |
|
||||
|
||||
Änderungen an Rollen, Teams oder Mandantenzugehörigkeiten lösen im FEDEO-Backend eine Synchronisation mit Matrix aus. Beim Entzug eines Zugriffs wird die Person aus den betroffenen Räumen entfernt. Bei Ende-zu-Ende-verschlüsselten Räumen muss zusätzlich berücksichtigt werden, dass bereits erhaltene Nachrichten auf Geräten verbleiben können.
|
||||
|
||||
## Chat
|
||||
|
||||
Der Chat wird als erste Ausbaustufe umgesetzt.
|
||||
|
||||
### Funktionen
|
||||
|
||||
- Direktnachrichten
|
||||
- Gruppenräume
|
||||
- Mandanten-, Team-, Projekt- und Vorgangsräume
|
||||
- Datei- und Bildanhänge
|
||||
- Erwähnungen, Reaktionen und Lesestatus
|
||||
- Suche in nicht verschlüsselten Räumen über den Homeserver
|
||||
- lokale Suche in verschlüsselten Räumen über Client-Indizes
|
||||
- Verknüpfung von Nachrichten mit FEDEO-Objekten
|
||||
|
||||
### Integration in FEDEO
|
||||
|
||||
FEDEO sollte keine vollständige Kopie aller Nachrichten in der eigenen Datenbank speichern. Stattdessen speichert FEDEO nur Referenzen:
|
||||
|
||||
- Matrix Raum-ID
|
||||
- Matrix Event-ID
|
||||
- FEDEO Objekt-Typ und Objekt-ID
|
||||
- Zeitstempel
|
||||
- beteiligter FEDEO-Nutzer
|
||||
- optionale Vorschau, falls Datenschutzrichtlinie dies erlaubt
|
||||
|
||||
So bleibt Matrix das Kommunikationssystem, während FEDEO nachvollziehen kann, welche Kommunikation zu welchem Objekt gehört.
|
||||
|
||||
## Audioanrufe
|
||||
|
||||
Einzelanrufe können direkt über Matrix-VoIP in Direktchats gestartet werden. Der FEDEO-Client zeigt dafür in Kontakt-, Kunden-, Mitarbeitenden- und Chatansichten einen Anruf-Button.
|
||||
|
||||
### Anforderungen
|
||||
|
||||
- WebRTC-Unterstützung im Browser
|
||||
- STUN/TURN über coturn
|
||||
- Geräteauswahl für Mikrofon und Lautsprecher
|
||||
- Anrufbenachrichtigung im Web und mobil
|
||||
- Statusanzeige `verfügbar`, `beschäftigt`, `im Anruf`, `abwesend`
|
||||
|
||||
Für klassische Telefonie kann später ein SIP-Gateway ergänzt werden. Das sollte jedoch getrennt von der ersten Matrix-Einführung betrachtet werden, damit Chat und WebRTC-Kommunikation nicht durch Telefoniekomplexität ausgebremst werden.
|
||||
|
||||
## Videokonferenzen
|
||||
|
||||
Für Gruppenanrufe und Videokonferenzen wird MatrixRTC mit Element Call und LiveKit empfohlen. Matrix übernimmt dabei Raumzustand, Identität, Berechtigungen und Signalisierung; LiveKit übernimmt als SFU die effiziente Medienverteilung.
|
||||
|
||||
### Funktionen
|
||||
|
||||
- Videokonferenzen aus Matrix-Räumen
|
||||
- spontane Besprechungen aus Projekten, Vorgängen oder Kundenakten
|
||||
- Bildschirmfreigabe
|
||||
- Einladungslink für externe Gäste
|
||||
- Wartebereich für externe Gäste
|
||||
- Moderationsrechte für Stummschalten, Entfernen und Raumverwaltung
|
||||
- optionale Aufzeichnung erst in einer späteren, gesondert freizugebenden Ausbaustufe
|
||||
|
||||
### Konfiguration
|
||||
|
||||
Clients finden den MatrixRTC-Dienst über `.well-known/matrix/client`. Dort wird der LiveKit-JWT-Dienst als `org.matrix.msc4143.rtc_foci` angekündigt. Diese Datei muss öffentlich lesbar sein, als JSON ausgeliefert werden und CORS für Webclients erlauben.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```json
|
||||
{
|
||||
"m.homeserver": {
|
||||
"base_url": "https://matrix.example.com"
|
||||
},
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://call.example.com/livekit/jwt"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Authentifizierung und Nutzerverwaltung
|
||||
|
||||
FEDEO sollte Identität und Lebenszyklus der Nutzer zentral steuern.
|
||||
|
||||
### Empfohlener Ablauf
|
||||
|
||||
1. Nutzer wird in FEDEO angelegt.
|
||||
2. FEDEO erzeugt oder aktualisiert den Matrix-Nutzer.
|
||||
3. FEDEO weist den Nutzer den passenden Spaces und Räumen zu.
|
||||
4. Login erfolgt über FEDEO SSO/OIDC.
|
||||
5. Deaktivierung in FEDEO deaktiviert auch den Matrix-Zugang und entfernt Raumzugriffe.
|
||||
|
||||
Die Matrix User-ID sollte stabil und nicht personenbezogen änderungsanfällig sein:
|
||||
|
||||
```text
|
||||
@u_<fedeo_user_id>:example.com
|
||||
```
|
||||
|
||||
Der Anzeigename kann weiterhin den echten Namen enthalten und bei Änderungen synchronisiert werden.
|
||||
|
||||
## Datenschutz und Compliance
|
||||
|
||||
Matrix erlaubt starke Datenschutzkonzepte, erfordert aber klare Betriebsregeln.
|
||||
|
||||
### Empfehlungen
|
||||
|
||||
- Ende-zu-Ende-Verschlüsselung für Direktnachrichten und vertrauliche Projekträume aktivieren.
|
||||
- Nicht verschlüsselte Räume nur dort nutzen, wo serverseitige Suche, Archivierung oder Compliance-Funktionen ausdrücklich benötigt werden.
|
||||
- Medienaufbewahrung mandantenweit konfigurierbar machen.
|
||||
- Externe Gäste optisch klar kennzeichnen.
|
||||
- Federation standardmäßig deaktivieren oder auf erlaubte Domains beschränken.
|
||||
- Aufzeichnungen von Videokonferenzen nur mit expliziter Einwilligung und sichtbarem Status erlauben.
|
||||
- Administrative Zugriffe protokollieren.
|
||||
- Klare Löschfristen für Räume, Anhänge und Audit-Referenzen definieren.
|
||||
|
||||
## Federation
|
||||
|
||||
Matrix kann mit anderen Homeservern föderieren. Für FEDEO sollte Federation als kontrollierbare Option umgesetzt werden.
|
||||
|
||||
### Betriebsmodi
|
||||
|
||||
| Modus | Beschreibung | Empfehlung |
|
||||
| --- | --- | --- |
|
||||
| geschlossen | Keine Federation, nur interne Nutzer und explizite Gäste | Standard für kleine Installationen |
|
||||
| allowlist | Federation nur mit freigegebenen Domains | Empfehlung für B2B-Kommunikation |
|
||||
| offen | Federation mit beliebigen Matrix-Servern | Nur für bewusst öffentliche Communities |
|
||||
|
||||
Für steuer-, kunden- und projektnahe Kommunikation ist `allowlist` der beste Zielmodus.
|
||||
|
||||
## Brücken zu anderen Systemen
|
||||
|
||||
Matrix unterstützt Brücken zu anderen Kommunikationsdiensten. Für FEDEO sind Brücken nützlich, sollten aber nicht zur ersten Produktstufe gehören.
|
||||
|
||||
Mögliche spätere Erweiterungen:
|
||||
|
||||
- E-Mail-Brücke für Helpdesk- oder Kundenkommunikation
|
||||
- Slack- oder Teams-Brücke für externe Projektpartner
|
||||
- WhatsApp- oder SMS-Brücke nur nach gesonderter Datenschutzprüfung
|
||||
- SIP-Brücke für Telefonie
|
||||
|
||||
Brücken müssen pro Mandant aktivierbar sein und brauchen klare Hinweise, welche Daten an externe Dienste fließen.
|
||||
|
||||
## FEDEO-Produktoberfläche
|
||||
|
||||
Die Kommunikation sollte in FEDEO an zwei Stellen sichtbar sein.
|
||||
|
||||
### Globaler Kommunikationsbereich
|
||||
|
||||
- Raumliste
|
||||
- Direktnachrichten
|
||||
- Suche
|
||||
- Anrufe
|
||||
- laufende Besprechungen
|
||||
- Benachrichtigungen
|
||||
|
||||
### Objektbezogene Kommunikation
|
||||
|
||||
In Projekten, Kunden, Vorgängen, Helpdesk-Tickets und Dokumenten erscheint ein Kommunikations-Tab:
|
||||
|
||||
- zugeordneter Raum
|
||||
- relevante Nachrichtenreferenzen
|
||||
- Start von Chat, Anruf oder Besprechung
|
||||
- Teilnehmerverwaltung entsprechend FEDEO-Rechten
|
||||
|
||||
So bleibt Kommunikation dort, wo die Arbeit stattfindet.
|
||||
|
||||
## Backend-Integration
|
||||
|
||||
Das FEDEO-Backend erhält ein Kommunikationsmodul mit folgenden Aufgaben:
|
||||
|
||||
- Matrix-Nutzer provisionieren
|
||||
- Spaces und Räume anlegen
|
||||
- Raum-Mitgliedschaften synchronisieren
|
||||
- Matrix-Event-Webhooks empfangen
|
||||
- FEDEO-Objekte mit Matrix-Räumen verknüpfen
|
||||
- Benachrichtigungseinstellungen verwalten
|
||||
- Admin-Aktionen auditieren
|
||||
|
||||
Technisch kann dies über Matrix Admin API, Client-Server API und Application Services erfolgen. Für Automationen empfiehlt sich ein eigener Application Service, weil er reservierte Nutzer- und Raum-Namensräume sauber verwalten kann.
|
||||
|
||||
## Deployment-Erweiterung
|
||||
|
||||
Der bestehende Docker-/Traefik-Ansatz kann um folgende Dienste erweitert werden:
|
||||
|
||||
- `matrix-synapse`
|
||||
- `matrix-db` oder gemeinsame PostgreSQL-Instanz mit getrennter Datenbank
|
||||
- `redis`
|
||||
- `coturn`
|
||||
- `element-web` optional als Fallback-Client
|
||||
- `element-call`
|
||||
- `livekit`
|
||||
- `matrix-rtc-jwt-service`
|
||||
|
||||
Für produktive Installationen sollte Matrix eine eigene PostgreSQL-Datenbank erhalten. Medien sollten in S3-kompatiblen Speicher ausgelagert werden, damit große Anhänge und Konferenzartefakte nicht den Applikationsserver füllen.
|
||||
|
||||
## Monitoring
|
||||
|
||||
Wichtige Kennzahlen:
|
||||
|
||||
- aktive Nutzerinnen und Nutzer
|
||||
- Anzahl Räume pro Mandant
|
||||
- Nachrichtenrate
|
||||
- Medien-Speicherverbrauch
|
||||
- Zustellverzögerung
|
||||
- fehlgeschlagene Anrufe
|
||||
- LiveKit Paketverlust, Latenz und Teilnehmerzahl
|
||||
- TURN-Nutzung
|
||||
- Federation-Fehler
|
||||
|
||||
Logs von FEDEO, Synapse, LiveKit, coturn und Traefik sollten über eine gemeinsame Korrelation, zum Beispiel Request-ID oder Nutzer-ID, untersuchbar sein.
|
||||
|
||||
## Risiken und Gegenmaßnahmen
|
||||
|
||||
| Risiko | Gegenmaßnahme |
|
||||
| --- | --- |
|
||||
| Komplexität durch zwei Systeme | FEDEO bleibt führend für Nutzer, Rechte und Objektbezug |
|
||||
| Datenschutz bei externen Räumen | Externe Kennzeichnung, Federation-Allowlist, Mandantenrichtlinien |
|
||||
| E2EE erschwert Suche und Archivierung | Raumtyp bewusst wählen, lokale Suche, Metadatenreferenzen statt Vollkopie |
|
||||
| Medienverbindungen scheitern in Firmennetzen | coturn sauber betreiben, UDP und TCP/TLS-Fallback anbieten |
|
||||
| Betriebskosten durch Video | LiveKit skalierbar betreiben, Limits pro Mandant definieren |
|
||||
| Gästezugriff wird unübersichtlich | Einladungslinks mit Ablaufdatum, Wartebereich, Moderationsrechte |
|
||||
|
||||
## Umsetzung in Phasen
|
||||
|
||||
### Phase 1: Fundament und Chat
|
||||
|
||||
- Synapse mit PostgreSQL, Redis, Traefik und `.well-known` betreiben
|
||||
- FEDEO-Nutzer zu Matrix synchronisieren
|
||||
- Mandanten-Spaces und erste Teamräume anlegen
|
||||
- Chat im FEDEO-Frontend integrieren
|
||||
- Benachrichtigungen und Raumreferenzen speichern
|
||||
|
||||
### Phase 2: Objektbezogene Kommunikation
|
||||
|
||||
- Räume automatisch für Projekte, Vorgänge und Kunden anlegen
|
||||
- Kommunikations-Tab in FEDEO-Objekten ergänzen
|
||||
- Rechteänderungen aus FEDEO nach Matrix synchronisieren
|
||||
- externe Gäste einladen und kennzeichnen
|
||||
|
||||
### Phase 3: Audio und Video
|
||||
|
||||
- coturn bereitstellen
|
||||
- MatrixRTC, Element Call und LiveKit integrieren
|
||||
- Anruf- und Videobuttons in Chat, Kontakten und Projekten ergänzen
|
||||
- Gäste-Links und Wartebereich umsetzen
|
||||
|
||||
### Phase 4: Compliance und Skalierung
|
||||
|
||||
- Aufbewahrungsrichtlinien pro Mandant
|
||||
- Monitoring und Admin-Dashboards
|
||||
- Federation-Allowlist
|
||||
- optionale Brücken
|
||||
- optionale Aufzeichnung mit Einwilligungsworkflow
|
||||
|
||||
## Offene Entscheidungen
|
||||
|
||||
- Soll Federation initial deaktiviert oder direkt mit Allowlist ausgeliefert werden?
|
||||
- Welche Räume müssen serverseitig durchsuchbar sein und bleiben deshalb unverschlüsselt?
|
||||
- Sollen externe Gäste Matrix-Konten erhalten oder nur temporäre Konferenzzugänge?
|
||||
- Wird Element als sichtbarer Fallback-Client angeboten oder soll alles primär in FEDEO stattfinden?
|
||||
- Welche Mandantenlimits gelten für Speicher, Teilnehmerzahl und Videodauer?
|
||||
|
||||
## Quellen und Standards
|
||||
|
||||
- Matrix Specification: https://spec.matrix.org/
|
||||
- Matrix Application Services: https://matrix.org/docs/older/application-services/
|
||||
- Matrix Bridges: https://www.matrix.org/docs/communities/bridging/
|
||||
- Synapse Worker-Dokumentation: https://matrix-org.github.io/synapse/develop/workers.html
|
||||
- Element Call Self-Hosting: https://github.com/element-hq/element-call/blob/livekit/docs/self-hosting.md
|
||||
- Element MatrixRTC Konfiguration: https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/
|
||||
- LiveKit Self-Hosting: https://docs.livekit.io/transport/self-hosting/
|
||||
@@ -22,6 +22,10 @@ const props = defineProps({
|
||||
inModal: {
|
||||
type: Boolean,
|
||||
},
|
||||
floatingWindow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
platform: {
|
||||
type: String,
|
||||
}
|
||||
@@ -527,8 +531,8 @@ const updateItem = async () => {
|
||||
>
|
||||
<template #center>
|
||||
<h1
|
||||
v-if="item"
|
||||
:class="['text-xl','font-medium']"
|
||||
v-if="item && !props.floatingWindow"
|
||||
:class="['text-xl', 'font-medium']"
|
||||
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
||||
</template>
|
||||
<template #right>
|
||||
@@ -547,6 +551,7 @@ const updateItem = async () => {
|
||||
Erstellen
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="!props.floatingWindow"
|
||||
@click="modal.close()"
|
||||
color="red"
|
||||
class="ml-2"
|
||||
|
||||
396
frontend/components/HeroiconPicker.vue
Normal file
396
frontend/components/HeroiconPicker.vue
Normal file
@@ -0,0 +1,396 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const heroiconNames = [
|
||||
'academic-cap',
|
||||
'adjustments-horizontal',
|
||||
'adjustments-vertical',
|
||||
'archive-box',
|
||||
'archive-box-arrow-down',
|
||||
'archive-box-x-mark',
|
||||
'arrow-down',
|
||||
'arrow-down-circle',
|
||||
'arrow-down-left',
|
||||
'arrow-down-on-square',
|
||||
'arrow-down-on-square-stack',
|
||||
'arrow-down-right',
|
||||
'arrow-down-tray',
|
||||
'arrow-left',
|
||||
'arrow-left-circle',
|
||||
'arrow-left-end-on-rectangle',
|
||||
'arrow-left-on-rectangle',
|
||||
'arrow-left-start-on-rectangle',
|
||||
'arrow-long-down',
|
||||
'arrow-long-left',
|
||||
'arrow-long-right',
|
||||
'arrow-long-up',
|
||||
'arrow-path',
|
||||
'arrow-path-rounded-square',
|
||||
'arrow-right',
|
||||
'arrow-right-circle',
|
||||
'arrow-right-end-on-rectangle',
|
||||
'arrow-right-on-rectangle',
|
||||
'arrow-right-start-on-rectangle',
|
||||
'arrow-small-down',
|
||||
'arrow-small-left',
|
||||
'arrow-small-right',
|
||||
'arrow-small-up',
|
||||
'arrow-top-right-on-square',
|
||||
'arrow-trending-down',
|
||||
'arrow-trending-up',
|
||||
'arrow-turn-down-left',
|
||||
'arrow-turn-down-right',
|
||||
'arrow-turn-left-down',
|
||||
'arrow-turn-left-up',
|
||||
'arrow-turn-right-down',
|
||||
'arrow-turn-right-up',
|
||||
'arrow-turn-up-left',
|
||||
'arrow-turn-up-right',
|
||||
'arrow-up',
|
||||
'arrow-up-circle',
|
||||
'arrow-up-left',
|
||||
'arrow-up-on-square',
|
||||
'arrow-up-on-square-stack',
|
||||
'arrow-up-right',
|
||||
'arrow-up-tray',
|
||||
'arrow-uturn-down',
|
||||
'arrow-uturn-left',
|
||||
'arrow-uturn-right',
|
||||
'arrow-uturn-up',
|
||||
'arrows-pointing-in',
|
||||
'arrows-pointing-out',
|
||||
'arrows-right-left',
|
||||
'arrows-up-down',
|
||||
'at-symbol',
|
||||
'backspace',
|
||||
'backward',
|
||||
'banknotes',
|
||||
'bars-2',
|
||||
'bars-3',
|
||||
'bars-3-bottom-left',
|
||||
'bars-3-bottom-right',
|
||||
'bars-3-center-left',
|
||||
'bars-4',
|
||||
'bars-arrow-down',
|
||||
'bars-arrow-up',
|
||||
'battery-0',
|
||||
'battery-100',
|
||||
'battery-50',
|
||||
'beaker',
|
||||
'bell',
|
||||
'bell-alert',
|
||||
'bell-slash',
|
||||
'bell-snooze',
|
||||
'bold',
|
||||
'bolt',
|
||||
'bolt-slash',
|
||||
'book-open',
|
||||
'bookmark',
|
||||
'bookmark-slash',
|
||||
'bookmark-square',
|
||||
'briefcase',
|
||||
'bug-ant',
|
||||
'building-library',
|
||||
'building-office',
|
||||
'building-office-2',
|
||||
'building-storefront',
|
||||
'cake',
|
||||
'calculator',
|
||||
'calendar',
|
||||
'calendar-date-range',
|
||||
'calendar-days',
|
||||
'camera',
|
||||
'chart-bar',
|
||||
'chart-bar-square',
|
||||
'chart-pie',
|
||||
'chat-bubble-bottom-center',
|
||||
'chat-bubble-bottom-center-text',
|
||||
'chat-bubble-left',
|
||||
'chat-bubble-left-ellipsis',
|
||||
'chat-bubble-left-right',
|
||||
'chat-bubble-oval-left',
|
||||
'chat-bubble-oval-left-ellipsis',
|
||||
'check',
|
||||
'check-badge',
|
||||
'check-circle',
|
||||
'chevron-double-down',
|
||||
'chevron-double-left',
|
||||
'chevron-double-right',
|
||||
'chevron-double-up',
|
||||
'chevron-down',
|
||||
'chevron-left',
|
||||
'chevron-right',
|
||||
'chevron-up',
|
||||
'chevron-up-down',
|
||||
'circle-stack',
|
||||
'clipboard',
|
||||
'clipboard-document',
|
||||
'clipboard-document-check',
|
||||
'clipboard-document-list',
|
||||
'clock',
|
||||
'cloud',
|
||||
'cloud-arrow-down',
|
||||
'cloud-arrow-up',
|
||||
'code-bracket',
|
||||
'code-bracket-square',
|
||||
'cog',
|
||||
'cog-6-tooth',
|
||||
'cog-8-tooth',
|
||||
'command-line',
|
||||
'computer-desktop',
|
||||
'cpu-chip',
|
||||
'credit-card',
|
||||
'cube',
|
||||
'cube-transparent',
|
||||
'currency-bangladeshi',
|
||||
'currency-dollar',
|
||||
'currency-euro',
|
||||
'currency-pound',
|
||||
'currency-rupee',
|
||||
'currency-yen',
|
||||
'cursor-arrow-rays',
|
||||
'cursor-arrow-ripple',
|
||||
'device-phone-mobile',
|
||||
'device-tablet',
|
||||
'divide',
|
||||
'document',
|
||||
'document-arrow-down',
|
||||
'document-arrow-up',
|
||||
'document-chart-bar',
|
||||
'document-check',
|
||||
'document-currency-bangladeshi',
|
||||
'document-currency-dollar',
|
||||
'document-currency-euro',
|
||||
'document-currency-pound',
|
||||
'document-currency-rupee',
|
||||
'document-currency-yen',
|
||||
'document-duplicate',
|
||||
'document-magnifying-glass',
|
||||
'document-minus',
|
||||
'document-plus',
|
||||
'document-text',
|
||||
'ellipsis-horizontal',
|
||||
'ellipsis-horizontal-circle',
|
||||
'ellipsis-vertical',
|
||||
'envelope',
|
||||
'envelope-open',
|
||||
'equals',
|
||||
'exclamation-circle',
|
||||
'exclamation-triangle',
|
||||
'eye',
|
||||
'eye-dropper',
|
||||
'eye-slash',
|
||||
'face-frown',
|
||||
'face-smile',
|
||||
'film',
|
||||
'finger-print',
|
||||
'fire',
|
||||
'flag',
|
||||
'folder',
|
||||
'folder-arrow-down',
|
||||
'folder-minus',
|
||||
'folder-open',
|
||||
'folder-plus',
|
||||
'forward',
|
||||
'funnel',
|
||||
'gif',
|
||||
'gift',
|
||||
'gift-top',
|
||||
'globe-alt',
|
||||
'globe-americas',
|
||||
'globe-asia-australia',
|
||||
'globe-europe-africa',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'hand-raised',
|
||||
'hand-thumb-down',
|
||||
'hand-thumb-up',
|
||||
'hashtag',
|
||||
'heart',
|
||||
'home',
|
||||
'home-modern',
|
||||
'identification',
|
||||
'inbox',
|
||||
'inbox-arrow-down',
|
||||
'inbox-stack',
|
||||
'information-circle',
|
||||
'italic',
|
||||
'key',
|
||||
'language',
|
||||
'lifebuoy',
|
||||
'light-bulb',
|
||||
'link',
|
||||
'link-slash',
|
||||
'list-bullet',
|
||||
'lock-closed',
|
||||
'lock-open',
|
||||
'magnifying-glass',
|
||||
'magnifying-glass-circle',
|
||||
'magnifying-glass-minus',
|
||||
'magnifying-glass-plus',
|
||||
'map',
|
||||
'map-pin',
|
||||
'megaphone',
|
||||
'microphone',
|
||||
'minus',
|
||||
'minus-circle',
|
||||
'minus-small',
|
||||
'moon',
|
||||
'musical-note',
|
||||
'newspaper',
|
||||
'no-symbol',
|
||||
'numbered-list',
|
||||
'paint-brush',
|
||||
'paper-airplane',
|
||||
'paper-clip',
|
||||
'pause',
|
||||
'pause-circle',
|
||||
'pencil',
|
||||
'pencil-square',
|
||||
'percent-badge',
|
||||
'phone',
|
||||
'phone-arrow-down-left',
|
||||
'phone-arrow-up-right',
|
||||
'phone-x-mark',
|
||||
'photo',
|
||||
'play',
|
||||
'play-circle',
|
||||
'play-pause',
|
||||
'plus',
|
||||
'plus-circle',
|
||||
'plus-small',
|
||||
'power',
|
||||
'presentation-chart-bar',
|
||||
'presentation-chart-line',
|
||||
'printer',
|
||||
'puzzle-piece',
|
||||
'qr-code',
|
||||
'question-mark-circle',
|
||||
'queue-list',
|
||||
'radio',
|
||||
'receipt-percent',
|
||||
'receipt-refund',
|
||||
'rectangle-group',
|
||||
'rectangle-stack',
|
||||
'rocket-launch',
|
||||
'rss',
|
||||
'scale',
|
||||
'scissors',
|
||||
'server',
|
||||
'server-stack',
|
||||
'share',
|
||||
'shield-check',
|
||||
'shield-exclamation',
|
||||
'shopping-bag',
|
||||
'shopping-cart',
|
||||
'signal',
|
||||
'signal-slash',
|
||||
'slash',
|
||||
'sparkles',
|
||||
'speaker-wave',
|
||||
'speaker-x-mark',
|
||||
'square-2-stack',
|
||||
'square-3-stack-3d',
|
||||
'squares-2x2',
|
||||
'squares-plus',
|
||||
'star',
|
||||
'stop',
|
||||
'stop-circle',
|
||||
'strikethrough',
|
||||
'sun',
|
||||
'swatch',
|
||||
'table-cells',
|
||||
'tag',
|
||||
'ticket',
|
||||
'trash',
|
||||
'trophy',
|
||||
'truck',
|
||||
'tv',
|
||||
'underline',
|
||||
'user',
|
||||
'user-circle',
|
||||
'user-group',
|
||||
'user-minus',
|
||||
'user-plus',
|
||||
'users',
|
||||
'variable',
|
||||
'video-camera',
|
||||
'video-camera-slash',
|
||||
'view-columns',
|
||||
'viewfinder-circle',
|
||||
'wallet',
|
||||
'wifi',
|
||||
'window',
|
||||
'wrench',
|
||||
'wrench-screwdriver',
|
||||
'x-circle',
|
||||
'x-mark',
|
||||
]
|
||||
|
||||
const iconOptions = heroiconNames.map(name => ({
|
||||
label: name,
|
||||
value: `i-heroicons-${name}`
|
||||
}))
|
||||
|
||||
const selectedIcon = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value || '')
|
||||
})
|
||||
|
||||
const selectedName = computed(() => {
|
||||
return iconOptions.find(option => option.value === selectedIcon.value)?.label
|
||||
|| selectedIcon.value.replace(/^i-heroicons-/, '')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex size-9 shrink-0 items-center justify-center rounded-md border border-default">
|
||||
<UIcon
|
||||
:name="selectedIcon || 'i-heroicons-question-mark-circle'"
|
||||
class="size-5"
|
||||
:class="{ 'text-muted': !selectedIcon }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<USelectMenu
|
||||
v-model="selectedIcon"
|
||||
:items="iconOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
:search-input="{ placeholder: 'Heroicon suchen …' }"
|
||||
:filter-fields="['label', 'value']"
|
||||
placeholder="Heroicon auswählen"
|
||||
class="min-w-0 flex-1"
|
||||
clear-search-on-close
|
||||
>
|
||||
<template #default>
|
||||
<span class="truncate">{{ selectedName || 'Heroicon auswählen' }}</span>
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<UIcon :name="item.value" class="size-5 shrink-0" />
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
|
||||
<UButton
|
||||
v-if="selectedIcon"
|
||||
icon="i-heroicons-x-mark"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
aria-label="Icon entfernen"
|
||||
@click="selectedIcon = ''"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -352,8 +352,8 @@ const links = computed(() => {
|
||||
icon: "i-heroicons-phone",
|
||||
},
|
||||
{
|
||||
label: "Matrix-Setup",
|
||||
to: "/communication",
|
||||
label: "Chat",
|
||||
to: "/communication/chat",
|
||||
icon: "i-heroicons-chat-bubble-left-right",
|
||||
},
|
||||
featureEnabled("export") ? {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { useDraggable } from '@vueuse/core'
|
||||
|
||||
const toast = useToast()
|
||||
const dataStore = useDataStore()
|
||||
@@ -31,6 +32,27 @@ const loaded = ref(false)
|
||||
const items = ref([])
|
||||
const item = ref({})
|
||||
|
||||
const isDraggableVendorCreate = computed(() => props.type === "vendors" && props.mode === "create")
|
||||
const isDraggableWindowOpen = ref(true)
|
||||
const draggableWindow = ref(null)
|
||||
const draggableWindowHandle = ref(null)
|
||||
const initialWindowPosition = import.meta.client
|
||||
? {
|
||||
x: Math.max(16, (window.innerWidth - Math.min(1024, window.innerWidth - 32)) / 2),
|
||||
y: Math.max(16, Math.min(96, window.innerHeight * 0.1)),
|
||||
}
|
||||
: { x: 16, y: 16 }
|
||||
|
||||
const { style: draggableWindowStyle } = useDraggable(draggableWindow, {
|
||||
initialValue: initialWindowPosition,
|
||||
handle: draggableWindowHandle,
|
||||
})
|
||||
|
||||
const closeDraggableWindow = () => {
|
||||
isDraggableWindowOpen.value = false
|
||||
modal.close()
|
||||
}
|
||||
|
||||
const setupPage = async () => {
|
||||
if(props.mode === "show") {
|
||||
//Load Data for Show
|
||||
@@ -60,7 +82,49 @@ setupPage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal :fullscreen="props.mode === 'show'">
|
||||
<div
|
||||
v-if="isDraggableVendorCreate && isDraggableWindowOpen"
|
||||
ref="draggableWindow"
|
||||
:style="draggableWindowStyle"
|
||||
class="fixed z-[999] flex h-[80vh] max-h-[calc(100vh-2rem)] w-[calc(100vw-2rem)] max-w-5xl flex-col overflow-hidden resize rounded-xl border border-gray-200 bg-white shadow-2xl dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div
|
||||
ref="draggableWindowHandle"
|
||||
class="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3 select-none dark:border-gray-800 dark:bg-gray-800/50"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-gray-500">
|
||||
<UIcon name="i-heroicons-building-storefront" />
|
||||
<span class="text-xs font-bold uppercase tracking-wider">Lieferant erstellen</span>
|
||||
</div>
|
||||
<UTooltip text="Schließen">
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-x-mark"
|
||||
size="xs"
|
||||
@pointerdown.stop
|
||||
@click.stop="closeDraggableWindow"
|
||||
/>
|
||||
</UTooltip>
|
||||
</div>
|
||||
<EntityEdit
|
||||
v-if="loaded"
|
||||
:type="props.type"
|
||||
:item="item"
|
||||
:inModal="true"
|
||||
:floating-window="true"
|
||||
@return-data="(data) => emit('returnData', data)"
|
||||
:createQuery="props.createQuery"
|
||||
:mode="props.mode"
|
||||
/>
|
||||
<UProgress
|
||||
v-else
|
||||
animation="carousel"
|
||||
class="m-5 mt-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UModal v-else-if="!isDraggableVendorCreate" :fullscreen="props.mode === 'show'">
|
||||
<template #content>
|
||||
<EntityShow
|
||||
v-if="loaded && props.mode === 'show'"
|
||||
|
||||
@@ -76,6 +76,12 @@ export const useDesktopPush = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const sendTestPushToProfile = async (profileId) => {
|
||||
return await $api(`/api/notifications/test-push/profile/${encodeURIComponent(profileId)}`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
supported,
|
||||
permission,
|
||||
@@ -85,5 +91,6 @@ export const useDesktopPush = () => {
|
||||
loadConfig,
|
||||
subscribe,
|
||||
sendTestPush,
|
||||
sendTestPushToProfile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ onMounted(() => {
|
||||
</UCard>
|
||||
</UContainer>
|
||||
</div>
|
||||
<div class="safearea flex min-h-screen w-full flex-col overflow-hidden" v-else>
|
||||
<div class="safearea flex h-dvh min-h-0 w-full flex-col overflow-hidden" v-else>
|
||||
<!-- <div
|
||||
class="border-b border-default bg-default px-3 py-2"
|
||||
style="padding-top: max(env(safe-area-inset-top, 0px), 0.5rem);"
|
||||
@@ -248,7 +248,7 @@ onMounted(() => {
|
||||
<TenantDropdown class="min-w-0 w-full max-w-sm" />
|
||||
</div>-->
|
||||
|
||||
<UDashboardGroup class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<UDashboardGroup class="flex h-full min-h-0 flex-1 overflow-hidden">
|
||||
|
||||
|
||||
<UDashboardSidebar
|
||||
@@ -305,7 +305,7 @@ onMounted(() => {
|
||||
</UDashboardSidebar>
|
||||
|
||||
|
||||
<UDashboardPanel class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<UDashboardPanel class="flex h-full max-h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<slot/>
|
||||
|
||||
</UDashboardPanel>
|
||||
|
||||
@@ -81,7 +81,6 @@ export default defineNuxtConfig({
|
||||
public: {
|
||||
apiBase: '',
|
||||
pdfLicense: '',
|
||||
matrixElementUrl: process.env.NUXT_PUBLIC_MATRIX_ELEMENT_URL || 'http://localhost:8080'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -120,9 +119,10 @@ export default defineNuxtConfig({
|
||||
]
|
||||
},
|
||||
|
||||
/* WICHTIG FÜR SAFARI / iOS */
|
||||
// FEDEO wird serverseitig gerendert; es gibt daher keine vorab gecachte
|
||||
// index.html, die Workbox als Navigations-Fallback verwenden könnte.
|
||||
workbox: {
|
||||
navigateFallback: '/',
|
||||
navigateFallback: null,
|
||||
},
|
||||
|
||||
devOptions: {
|
||||
|
||||
131
frontend/package-lock.json
generated
131
frontend/package-lock.json
generated
@@ -74,7 +74,6 @@
|
||||
"image-js": "^1.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"license-checker": "^25.0.1",
|
||||
"livekit-client": "^2.19.0",
|
||||
"maplibre-gl": "^4.7.0",
|
||||
"nuxt-editorjs": "^1.0.4",
|
||||
"nuxt-viewport": "^2.0.6",
|
||||
@@ -1867,12 +1866,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@capacitor-community/bluetooth-le": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor-community/bluetooth-le/-/bluetooth-le-7.3.0.tgz",
|
||||
@@ -3167,21 +3160,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@livekit/mutex": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
|
||||
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.45.8",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
|
||||
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/geojson-rewind": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
|
||||
@@ -3763,18 +3741,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/cli/node_modules/commander": {
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
||||
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/devalue": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz",
|
||||
@@ -8719,13 +8685,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-record": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -12221,6 +12180,7 @@
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
@@ -14238,15 +14198,6 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-js": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
|
||||
@@ -14822,26 +14773,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.0.tgz",
|
||||
"integrity": "sha512-aolY1XDAtx0nHKBNm29W9OhzBnSz1CP5kq3phvRhFfi1NbvMXs8tcACjAkZTnIKgihkp+BiJScZZ3tZv0Gz8sA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.45.8",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"sdp-transform": "^2.15.0",
|
||||
"tslib": "2.8.1",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"webrtc-adapter": "9.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-record": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||
@@ -14916,19 +14847,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loglevel": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -18523,16 +18441,6 @@
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
@@ -18690,21 +18598,6 @@
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
|
||||
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
||||
@@ -20269,15 +20162,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"rxjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -21832,19 +21716,6 @@
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
|
||||
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0",
|
||||
"npm": ">=3.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
|
||||
@@ -87,7 +87,6 @@
|
||||
"image-js": "^1.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"license-checker": "^25.0.1",
|
||||
"livekit-client": "^2.19.0",
|
||||
"maplibre-gl": "^4.7.0",
|
||||
"nuxt-editorjs": "^1.0.4",
|
||||
"nuxt-viewport": "^2.0.6",
|
||||
|
||||
@@ -13,7 +13,6 @@ const serviceLabels: Record<string, string> = {
|
||||
backend: "Backend",
|
||||
database: "Datenbank",
|
||||
nodeExporter: "Node Exporter",
|
||||
matrix: "Matrix",
|
||||
minio: "Dateispeicher",
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,501 +1,7 @@
|
||||
<script setup>
|
||||
const toast = useToast()
|
||||
const { $api } = useNuxtApp()
|
||||
|
||||
const status = ref(null)
|
||||
const identity = ref(null)
|
||||
const tenantSpace = ref(null)
|
||||
const generalRoom = ref(null)
|
||||
const provisionResult = ref(null)
|
||||
const tenantSpaceProvisionResult = ref(null)
|
||||
const generalRoomProvisionResult = ref(null)
|
||||
const loading = ref(false)
|
||||
const provisioning = ref(false)
|
||||
const tenantSpaceProvisioning = ref(false)
|
||||
const generalRoomProvisioning = ref(false)
|
||||
const lastUpdated = ref(null)
|
||||
|
||||
const statusItems = computed(() => [
|
||||
{
|
||||
label: "Konfiguration",
|
||||
value: status.value?.configured ? "Aktiv" : "Nicht aktiv",
|
||||
icon: status.value?.configured ? "i-heroicons-check-circle" : "i-heroicons-x-circle",
|
||||
color: status.value?.configured ? "success" : "error"
|
||||
},
|
||||
{
|
||||
label: "Homeserver",
|
||||
value: status.value?.homeserverUrl || "-",
|
||||
icon: "i-heroicons-server-stack",
|
||||
color: "neutral"
|
||||
},
|
||||
{
|
||||
label: "Servername",
|
||||
value: status.value?.serverName || "-",
|
||||
icon: "i-heroicons-identification",
|
||||
color: "neutral"
|
||||
},
|
||||
{
|
||||
label: "Provisionierung",
|
||||
value: status.value?.provisioningConfigured ? "Bereit" : "Nicht eingerichtet",
|
||||
icon: status.value?.provisioningConfigured ? "i-heroicons-key" : "i-heroicons-exclamation-triangle",
|
||||
color: status.value?.provisioningConfigured ? "success" : "warning"
|
||||
},
|
||||
{
|
||||
label: "Erreichbarkeit",
|
||||
value: status.value?.reachable ? "Erreichbar" : "Nicht erreichbar",
|
||||
icon: status.value?.reachable ? "i-heroicons-signal" : "i-heroicons-signal-slash",
|
||||
color: status.value?.reachable ? "success" : "error"
|
||||
},
|
||||
{
|
||||
label: "Audio/Video",
|
||||
value: status.value?.calls?.configured ? "Bereit" : "Nicht eingerichtet",
|
||||
icon: status.value?.calls?.configured ? "i-heroicons-video-camera" : "i-heroicons-video-camera-slash",
|
||||
color: status.value?.calls?.configured ? "success" : "warning"
|
||||
}
|
||||
])
|
||||
|
||||
const canUseMatrixChat = computed(() =>
|
||||
Boolean(status.value?.reachable && status.value?.provisioningConfigured)
|
||||
)
|
||||
|
||||
const loadMatrixInfo = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [statusRes, identityRes, tenantSpaceRes, generalRoomRes] = await Promise.all([
|
||||
$api("/api/communication/matrix/status"),
|
||||
$api("/api/communication/matrix/me"),
|
||||
$api("/api/communication/matrix/tenant-space"),
|
||||
$api("/api/communication/matrix/rooms/general")
|
||||
])
|
||||
|
||||
status.value = statusRes
|
||||
identity.value = identityRes
|
||||
tenantSpace.value = tenantSpaceRes
|
||||
generalRoom.value = generalRoomRes
|
||||
lastUpdated.value = new Date()
|
||||
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
title: "Matrix-Status konnte nicht geladen werden",
|
||||
color: "error"
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const provisionMatrixAccount = async () => {
|
||||
provisioning.value = true
|
||||
try {
|
||||
const res = await $api("/api/communication/matrix/me/provision", {
|
||||
method: "POST"
|
||||
})
|
||||
|
||||
provisionResult.value = res
|
||||
identity.value = {
|
||||
...identity.value,
|
||||
matrixUserId: res.matrixUserId,
|
||||
displayName: res.displayName
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: res.alreadyExisted ? "Matrix-Konto ist bereits vorhanden" : "Matrix-Konto erstellt",
|
||||
color: "success"
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
title: "Matrix-Konto konnte nicht erstellt werden",
|
||||
color: "error"
|
||||
})
|
||||
} finally {
|
||||
provisioning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const provisionTenantSpace = async () => {
|
||||
tenantSpaceProvisioning.value = true
|
||||
try {
|
||||
const res = await $api("/api/communication/matrix/tenant-space/provision", {
|
||||
method: "POST"
|
||||
})
|
||||
|
||||
tenantSpaceProvisionResult.value = res
|
||||
tenantSpace.value = {
|
||||
tenantId: res.tenantId,
|
||||
tenantName: res.tenantName,
|
||||
alias: res.alias,
|
||||
exists: true,
|
||||
roomId: res.roomId,
|
||||
servers: res.servers || []
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: res.alreadyExisted ? "Mandanten-Space ist bereits vorhanden" : "Mandanten-Space erstellt",
|
||||
color: "success"
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
title: "Mandanten-Space konnte nicht erstellt werden",
|
||||
color: "error"
|
||||
})
|
||||
} finally {
|
||||
tenantSpaceProvisioning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const provisionGeneralRoom = async () => {
|
||||
generalRoomProvisioning.value = true
|
||||
try {
|
||||
const res = await $api("/api/communication/matrix/rooms/general/provision", {
|
||||
method: "POST"
|
||||
})
|
||||
|
||||
generalRoomProvisionResult.value = res
|
||||
generalRoom.value = {
|
||||
tenantId: res.tenantId,
|
||||
tenantName: res.tenantName,
|
||||
key: res.key,
|
||||
name: res.name,
|
||||
alias: res.alias,
|
||||
exists: true,
|
||||
roomId: res.roomId,
|
||||
servers: res.servers || []
|
||||
}
|
||||
tenantSpace.value = {
|
||||
...tenantSpace.value,
|
||||
exists: true,
|
||||
roomId: res.parentSpaceRoomId || tenantSpace.value?.roomId
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: res.alreadyExisted ? "Allgemeiner Chat ist bereits vorhanden" : "Allgemeiner Chat erstellt",
|
||||
color: "success"
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
title: "Allgemeiner Chat konnte nicht erstellt werden",
|
||||
color: "error"
|
||||
})
|
||||
} finally {
|
||||
generalRoomProvisioning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return "-"
|
||||
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short"
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMatrixInfo()
|
||||
})
|
||||
await navigateTo("/communication/chat", { replace: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-highlighted">
|
||||
Kommunikation
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Matrix-Verbindung und persönliche Kommunikationsidentität.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:loading="loading"
|
||||
@click="loadMatrixInfo"
|
||||
>
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<UCard
|
||||
v-for="item in statusItems"
|
||||
:key="item.label"
|
||||
:ui="{ root: 'rounded-lg', body: 'p-4 sm:p-4' }"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon
|
||||
:name="item.icon"
|
||||
class="mt-0.5 size-5 shrink-0"
|
||||
:class="{
|
||||
'text-success': item.color === 'success',
|
||||
'text-error': item.color === 'error',
|
||||
'text-warning': item.color === 'warning',
|
||||
'text-muted': item.color === 'neutral'
|
||||
}"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-medium uppercase text-muted">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="mt-1 break-words text-sm font-medium text-highlighted">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="space-y-4">
|
||||
<UCard :ui="{ root: 'rounded-lg' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-user-circle" class="size-5 text-primary" />
|
||||
<h2 class="text-base font-semibold text-highlighted">
|
||||
Eigene Matrix-Identität
|
||||
</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-muted">
|
||||
Matrix-ID
|
||||
</p>
|
||||
<p class="mt-1 break-all font-mono text-sm text-highlighted">
|
||||
{{ identity?.matrixUserId || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-muted">
|
||||
Anzeigename
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-highlighted">
|
||||
{{ identity?.displayName || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="provisionResult"
|
||||
icon="i-heroicons-check-circle"
|
||||
color="success"
|
||||
variant="soft"
|
||||
:title="provisionResult.alreadyExisted ? 'Matrix-Konto vorhanden' : 'Matrix-Konto erstellt'"
|
||||
:description="provisionResult.matrixUserId"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-if="status && !status.reachable"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
color="error"
|
||||
variant="soft"
|
||||
title="Matrix-Homeserver nicht erreichbar"
|
||||
:description="status.error || 'Bitte prüfe den lokalen Matrix-Stack und die Backend-Konfiguration.'"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-else-if="status && !status.provisioningConfigured"
|
||||
icon="i-heroicons-key"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
title="Matrix-Provisionierung nicht eingerichtet"
|
||||
description="Bitte setze MATRIX_REGISTRATION_SHARED_SECRET in der Backend-Umgebung."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-xs text-muted">
|
||||
Aktualisiert: {{ formatDateTime(lastUpdated) }}
|
||||
</p>
|
||||
<UButton
|
||||
icon="i-heroicons-user-plus"
|
||||
:loading="provisioning"
|
||||
:disabled="!status?.reachable || !status?.provisioningConfigured"
|
||||
@click="provisionMatrixAccount"
|
||||
>
|
||||
Matrix-Konto erstellen
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
|
||||
<UCard :ui="{ root: 'rounded-lg' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-building-office-2" class="size-5 text-primary" />
|
||||
<h2 class="text-base font-semibold text-highlighted">
|
||||
Mandanten-Space
|
||||
</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-muted">
|
||||
Alias
|
||||
</p>
|
||||
<p class="mt-1 break-all font-mono text-sm text-highlighted">
|
||||
{{ tenantSpace?.alias || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-muted">
|
||||
Status
|
||||
</p>
|
||||
<UBadge
|
||||
class="mt-1"
|
||||
:color="tenantSpace?.exists ? 'success' : 'neutral'"
|
||||
variant="soft"
|
||||
>
|
||||
{{ tenantSpace?.exists ? "Vorhanden" : "Noch nicht erstellt" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-muted">
|
||||
Raum-ID
|
||||
</p>
|
||||
<p class="mt-1 break-all font-mono text-sm text-highlighted">
|
||||
{{ tenantSpace?.roomId || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="tenantSpaceProvisionResult"
|
||||
icon="i-heroicons-check-circle"
|
||||
color="success"
|
||||
variant="soft"
|
||||
:title="tenantSpaceProvisionResult.alreadyExisted ? 'Mandanten-Space vorhanden' : 'Mandanten-Space erstellt'"
|
||||
:description="tenantSpaceProvisionResult.alias"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<UButton
|
||||
icon="i-heroicons-plus"
|
||||
:loading="tenantSpaceProvisioning"
|
||||
:disabled="!status?.reachable || !status?.provisioningConfigured"
|
||||
@click="provisionTenantSpace"
|
||||
>
|
||||
Mandanten-Space erstellen
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
|
||||
<UCard :ui="{ root: 'rounded-lg' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-chat-bubble-left-right" class="size-5 text-primary" />
|
||||
<h2 class="text-base font-semibold text-highlighted">
|
||||
Allgemeiner Chat
|
||||
</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-muted">
|
||||
Alias
|
||||
</p>
|
||||
<p class="mt-1 break-all font-mono text-sm text-highlighted">
|
||||
{{ generalRoom?.alias || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-muted">
|
||||
Status
|
||||
</p>
|
||||
<UBadge
|
||||
class="mt-1"
|
||||
:color="generalRoom?.exists ? 'success' : 'neutral'"
|
||||
variant="soft"
|
||||
>
|
||||
{{ generalRoom?.exists ? "Vorhanden" : "Noch nicht erstellt" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-muted">
|
||||
Raum-ID
|
||||
</p>
|
||||
<p class="mt-1 break-all font-mono text-sm text-highlighted">
|
||||
{{ generalRoom?.roomId || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="generalRoomProvisionResult"
|
||||
icon="i-heroicons-check-circle"
|
||||
color="success"
|
||||
variant="soft"
|
||||
:title="generalRoomProvisionResult.alreadyExisted ? 'Allgemeiner Chat vorhanden' : 'Allgemeiner Chat erstellt'"
|
||||
:description="generalRoomProvisionResult.alias"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<UButton
|
||||
icon="i-heroicons-plus"
|
||||
:loading="generalRoomProvisioning"
|
||||
:disabled="!status?.reachable || !status?.provisioningConfigured"
|
||||
@click="provisionGeneralRoom"
|
||||
>
|
||||
Allgemeinen Chat erstellen
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="generalRoom?.exists"
|
||||
to="/communication/chat"
|
||||
icon="i-heroicons-chat-bubble-left-right"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
>
|
||||
Zum Chat
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UCard :ui="{ root: 'rounded-lg' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-video-camera" class="size-5 text-primary" />
|
||||
<h2 class="text-base font-semibold text-highlighted">
|
||||
Nächste Ausbaustufe
|
||||
</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3 text-sm text-muted">
|
||||
<div class="flex gap-2">
|
||||
<UIcon name="i-heroicons-building-office-2" class="mt-0.5 size-4 shrink-0" />
|
||||
<span>Team- und Projekt-Räume im Mandanten-Space anlegen.</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UIcon name="i-heroicons-users" class="mt-0.5 size-4 shrink-0" />
|
||||
<span>FEDEO-Nutzer in Matrix-Räume synchronisieren.</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UIcon name="i-heroicons-chat-bubble-left-right" class="mt-0.5 size-4 shrink-0" />
|
||||
<span>Nativen FEDEO-Chat schrittweise an Matrix-Sync anbinden.</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
@@ -1896,6 +1896,31 @@ const saveDocument = async (state, resetup = false) => {
|
||||
if (resetup) await setupPage()
|
||||
}
|
||||
|
||||
const saveFinalizedCostCentres = async () => {
|
||||
const rowCostCentres = itemInfo.value.rows
|
||||
.filter(row => row?.id)
|
||||
.map(row => ({
|
||||
rowId: String(row.id),
|
||||
costcentre: row.costCentre || null,
|
||||
}))
|
||||
|
||||
const result = await $api(`/api/functions/outgoing-documents/${itemInfo.value.id}/cost-centres`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
costcentre: itemInfo.value.costcentre || null,
|
||||
rowCostCentres,
|
||||
},
|
||||
})
|
||||
|
||||
itemInfo.value.costcentre = result.document.costcentre
|
||||
itemInfo.value.rows = normalizeCreatedDocumentRows(result.document.rows)
|
||||
toast.add({
|
||||
title: "Kostenstellen gespeichert",
|
||||
description: "Die Kostenstellen des fertiggestellten Belegs wurden aktualisiert.",
|
||||
color: "success",
|
||||
})
|
||||
}
|
||||
|
||||
const selectedTab = ref("0")
|
||||
|
||||
const closeDocument = async () => {
|
||||
@@ -2077,7 +2102,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
<UButton
|
||||
icon="i-mdi-content-save"
|
||||
@click="saveDocument('Entwurf',true)"
|
||||
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode"
|
||||
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Entwurf'"
|
||||
:disabled="!itemInfo.customer"
|
||||
>
|
||||
Speichern
|
||||
@@ -2092,10 +2117,17 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="closeDocument"
|
||||
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode"
|
||||
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Entwurf'"
|
||||
>
|
||||
{{selectedTab === '0' ? "Vorschau zeigen" : "Fertigstellen"}}
|
||||
</UButton>
|
||||
<UButton
|
||||
icon="i-heroicons-tag"
|
||||
@click="saveFinalizedCostCentres"
|
||||
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Gebucht'"
|
||||
>
|
||||
Kostenstellen speichern
|
||||
</UButton>
|
||||
<UButton
|
||||
icon="i-mdi-content-save"
|
||||
@click="saveSerialInvoice"
|
||||
|
||||
@@ -652,7 +652,7 @@ const executeSerialInvoices = async () => {
|
||||
|
||||
toast.add({
|
||||
title: 'Ausführung gestartet',
|
||||
description: `${res.length} Rechnungen werden im Hintergrund generiert.`,
|
||||
description: `${res.results.length} Rechnungen wurden für den Lauf vorbereitet.`,
|
||||
icon: 'i-heroicons-check-circle',
|
||||
color: 'green'
|
||||
})
|
||||
|
||||
@@ -184,12 +184,16 @@ const resolvedPlanningBoardConfig = computed(() => {
|
||||
const normalizedStartTime = /^\d{2}:\d{2}$/.test(String(config.startTime || "")) ? String(config.startTime) : "06:00"
|
||||
const normalizedEndTime = /^\d{2}:\d{2}$/.test(String(config.endTime || "")) ? String(config.endTime) : "21:00"
|
||||
const normalizedSlotMinutes = Number(config.slotMinutes)
|
||||
const normalizedWeekStartsOn = Number(config.weekStartsOn)
|
||||
const allowedSlotMinutes = [15, 30, 60, 120, 180]
|
||||
|
||||
return {
|
||||
startTime: normalizedStartTime,
|
||||
endTime: normalizedEndTime,
|
||||
slotMinutes: allowedSlotMinutes.includes(normalizedSlotMinutes) ? normalizedSlotMinutes : 180
|
||||
slotMinutes: allowedSlotMinutes.includes(normalizedSlotMinutes) ? normalizedSlotMinutes : 180,
|
||||
weekStartsOn: Number.isInteger(normalizedWeekStartsOn) && normalizedWeekStartsOn >= 1 && normalizedWeekStartsOn <= 5
|
||||
? normalizedWeekStartsOn
|
||||
: 1
|
||||
}
|
||||
})
|
||||
|
||||
@@ -295,6 +299,7 @@ const visibleDraftEventIds = computed(() =>
|
||||
const calendarOptions = computed(() => ({
|
||||
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
|
||||
locale: deLocale,
|
||||
firstDay: resolvedPlanningBoardConfig.value.weekStartsOn,
|
||||
plugins: [resourceTimelinePlugin, interactionPlugin],
|
||||
initialView: calendarView.value,
|
||||
initialDate: calendarCurrentDate.value,
|
||||
@@ -349,6 +354,7 @@ const calendarOptions = computed(() => ({
|
||||
resourceTimelineWeek: {
|
||||
type: "resourceTimeline",
|
||||
duration: { days: 7 },
|
||||
dateAlignment: "week",
|
||||
buttonText: "Woche",
|
||||
slotDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
|
||||
snapDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
|
||||
|
||||
@@ -1,314 +1,392 @@
|
||||
<script setup>
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
const defaultPhases = [
|
||||
{ icon: 'i-heroicons-clipboard-document', label: 'Erstkontakt' },
|
||||
{ icon: 'i-heroicons-wrench-screwdriver', label: 'Umsetzung' },
|
||||
{ icon: 'i-heroicons-document-text', label: 'Rechnungsstellung' },
|
||||
{ icon: 'i-heroicons-check', label: 'Abgeschlossen' }
|
||||
]
|
||||
|
||||
const quickActionOptions = [
|
||||
{ label: '+ Angebot', title: 'Angebot erstellen', link: '/createDocument/edit/?type=quotes' },
|
||||
{ label: '+ Kostenschätzung', title: 'Kostenschätzung erstellen', link: '/createDocument/edit/?type=costEstimates' },
|
||||
{ label: '+ Auftrag', title: 'Auftrag erstellen', link: '/createDocument/edit/?type=confirmationOrders' },
|
||||
{ label: '+ Lieferschein', title: 'Lieferschein erstellen', link: '/createDocument/edit/?type=deliveryNotes' },
|
||||
{ label: '+ Packschein', title: 'Packschein erstellen', link: '/createDocument/edit/?type=packingSlips' },
|
||||
{ label: '+ Rechnung', title: 'Rechnung erstellen', link: '/createDocument/edit/?type=invoices' },
|
||||
{ label: '+ Aufgabe', title: 'Aufgabe erstellen', link: '/tasks?mode=create' },
|
||||
{ label: '+ Termin', title: 'Termin erstellen', link: '/standardEntity/events/create' }
|
||||
]
|
||||
|
||||
|
||||
defineShortcuts({
|
||||
'backspace': () => {
|
||||
router.push("/projecttypes")
|
||||
},
|
||||
'arrowleft': () => {
|
||||
const currentIndex = Number(openTab.value)
|
||||
if(currentIndex > 0){
|
||||
openTab.value = String(currentIndex - 1)
|
||||
}
|
||||
},
|
||||
'arrowright': () => {
|
||||
const currentIndex = Number(openTab.value)
|
||||
if(currentIndex < 3) {
|
||||
openTab.value = String(currentIndex + 1)
|
||||
}
|
||||
},
|
||||
const createPhase = (phase = {}, index = -1) => ({
|
||||
key: phase.key || uuidv4(),
|
||||
icon: phase.icon || '',
|
||||
label: phase.label || '',
|
||||
optional: Boolean(phase.optional),
|
||||
description: phase.description || '',
|
||||
quickactions: Array.isArray(phase.quickactions)
|
||||
? phase.quickactions.map(action => ({ ...action }))
|
||||
: [],
|
||||
active: index === 0
|
||||
})
|
||||
|
||||
const createEmptyItem = () => ({
|
||||
name: '',
|
||||
initialPhases: defaultPhases.map(createPhase)
|
||||
})
|
||||
|
||||
const openTab = ref("0")
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const entities = useEntities('projecttypes')
|
||||
|
||||
const mode = ref(route.params.mode || "show")
|
||||
const itemInfo = ref({
|
||||
name: "",
|
||||
initialPhases: [{ "key": "f31f6fcb-34d5-41a0-9b8f-6c85062f19be", "icon": "i-heroicons-clipboard-document", "label": "Erstkontakt", "active": true, "quickactions": [] }, { "key": "41995d1f-78fa-448b-b6ea-e206645ffb89", "icon": "i-heroicons-wrench-screwdriver", "label": "Umsetzung", "quickactions": [] }, { "key": "267e78ac-9eab-4736-b9c8-4b94c1724494", "icon": "i-heroicons-document-text", "label": "Rechnungsstellung", "quickactions": [] }, { "key": "038df888-53f2-4985-b08e-776ab82df5d3", "icon": "i-heroicons-check", "label": "Abgeschlossen", "quickactions": [] } ]
|
||||
})
|
||||
const oldItemInfo = ref({})
|
||||
|
||||
const mode = computed(() => String(route.params.mode || 'show'))
|
||||
const itemInfo = ref(createEmptyItem())
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref('')
|
||||
const openQuickActionModal = ref(false)
|
||||
const selectedKeyForQuickAction = ref("")
|
||||
const selectedPhaseKey = ref(null)
|
||||
const openArchiveModal = ref(false)
|
||||
|
||||
const setKeys = () => {
|
||||
itemInfo.value.initialPhases = itemInfo.value.initialPhases.map(i => {
|
||||
return {
|
||||
...i,
|
||||
key: uuidv4(),
|
||||
quickactions: i.quickactions || []
|
||||
const isEditMode = computed(() => mode.value === 'edit' || mode.value === 'create')
|
||||
const canSave = computed(() => {
|
||||
return itemInfo.value.name.trim().length > 0
|
||||
&& itemInfo.value.initialPhases.length > 0
|
||||
&& itemInfo.value.initialPhases.every(phase => phase.label.trim().length > 0)
|
||||
})
|
||||
|
||||
const normalizeItem = (item) => ({
|
||||
...item,
|
||||
name: item?.name || '',
|
||||
initialPhases: Array.isArray(item?.initialPhases)
|
||||
? item.initialPhases.map(createPhase)
|
||||
: []
|
||||
})
|
||||
|
||||
const loadItem = async () => {
|
||||
loadError.value = ''
|
||||
openQuickActionModal.value = false
|
||||
openArchiveModal.value = false
|
||||
|
||||
if (mode.value === 'create') {
|
||||
itemInfo.value = createEmptyItem()
|
||||
return
|
||||
}
|
||||
|
||||
if (!route.params.id) {
|
||||
loadError.value = 'Es wurde kein Projekttyp ausgewählt.'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const item = await entities.selectSingle(route.params.id, '*')
|
||||
if (!item) {
|
||||
loadError.value = 'Der Projekttyp wurde nicht gefunden.'
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
itemInfo.value.initialPhases[0].active = true
|
||||
|
||||
itemInfo.value = normalizeItem(item)
|
||||
} catch (error) {
|
||||
console.error('Projekttyp konnte nicht geladen werden', error)
|
||||
loadError.value = 'Der Projekttyp konnte nicht geladen werden.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setupPage = async() => {
|
||||
|
||||
if(mode.value === "show" ){
|
||||
itemInfo.value = await useEntities("projecttypes").selectSingle(route.params.id,"*")
|
||||
} else if (mode.value === "edit") {
|
||||
itemInfo.value = await useEntities("projecttypes").selectSingle(route.params.id,"*")
|
||||
}
|
||||
|
||||
if(mode.value === "create") {
|
||||
let query = route.query
|
||||
|
||||
|
||||
}
|
||||
if(itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value))
|
||||
|
||||
setKeys()
|
||||
}
|
||||
|
||||
setupPage()
|
||||
|
||||
watch(
|
||||
() => [route.params.mode, route.params.id],
|
||||
loadItem,
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineShortcuts({
|
||||
backspace: () => router.push('/projecttypes')
|
||||
})
|
||||
|
||||
const addPhase = () => {
|
||||
itemInfo.value.initialPhases.push({label: '', icon: ''}),
|
||||
setKeys
|
||||
itemInfo.value.initialPhases.push(createPhase())
|
||||
}
|
||||
|
||||
const removePhase = (phaseKey) => {
|
||||
itemInfo.value.initialPhases = itemInfo.value.initialPhases
|
||||
.filter(phase => phase.key !== phaseKey)
|
||||
.map(createPhase)
|
||||
}
|
||||
|
||||
const openQuickActions = (phaseKey) => {
|
||||
selectedPhaseKey.value = phaseKey
|
||||
openQuickActionModal.value = true
|
||||
}
|
||||
|
||||
const addQuickAction = (action) => {
|
||||
const phase = itemInfo.value.initialPhases.find(item => item.key === selectedPhaseKey.value)
|
||||
if (!phase) return
|
||||
|
||||
if (!phase.quickactions.some(item => item.link === action.link)) {
|
||||
phase.quickactions.push({ label: action.label, link: action.link })
|
||||
}
|
||||
openQuickActionModal.value = false
|
||||
}
|
||||
|
||||
const removeQuickAction = (phase, actionIndex) => {
|
||||
phase.quickactions.splice(actionIndex, 1)
|
||||
}
|
||||
|
||||
const payload = () => ({
|
||||
name: itemInfo.value.name.trim(),
|
||||
initialPhases: itemInfo.value.initialPhases.map((phase, index) => ({
|
||||
...createPhase(phase, index),
|
||||
label: phase.label.trim(),
|
||||
description: phase.description.trim()
|
||||
}))
|
||||
})
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave.value || saving.value) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (mode.value === 'create') {
|
||||
await entities.create(payload())
|
||||
} else {
|
||||
await entities.update(itemInfo.value.id, payload())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Projekttyp konnte nicht gespeichert werden', error)
|
||||
toast.add({ title: 'Projekttyp konnte nicht gespeichert werden', color: 'error' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const archiveItem = async () => {
|
||||
if (!itemInfo.value.id || saving.value) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await entities.archive(itemInfo.value.id)
|
||||
toast.add({ title: 'Projekttyp archiviert' })
|
||||
} catch (error) {
|
||||
console.error('Projekttyp konnte nicht archiviert werden', error)
|
||||
toast.add({ title: 'Projekttyp konnte nicht archiviert werden', color: 'error' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
openArchiveModal.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar :title="itemInfo ? itemInfo.name : (mode === 'create' ? 'Projekt erstellen' : 'Projekt bearbeiten')">
|
||||
<UDashboardNavbar :title="itemInfo.name || (mode === 'create' ? 'Projekttyp erstellen' : 'Projekttyp')">
|
||||
<template #left>
|
||||
<UButton
|
||||
icon="i-heroicons-chevron-left"
|
||||
variant="outline"
|
||||
@click="router.push(`/projecttypes`)"
|
||||
>
|
||||
<UButton icon="i-heroicons-chevron-left" variant="outline" @click="router.push('/projecttypes')">
|
||||
Projekttypen
|
||||
</UButton>
|
||||
</template>
|
||||
<template #center>
|
||||
<h1
|
||||
v-if="itemInfo"
|
||||
class="text-xl font-medium"
|
||||
>{{itemInfo.name ? `Projekttyp: ${itemInfo.name}` : (mode === 'create' ? 'Projekttyp erstellen' : 'Projekttyp bearbeiten')}}</h1>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<UButton
|
||||
v-if="mode === 'edit'"
|
||||
@click="useEntities('projecttypes').update(itemInfo.id, itemInfo)"
|
||||
>
|
||||
Speichern
|
||||
<UButton v-if="isEditMode" :loading="saving" :disabled="!canSave" @click="save">
|
||||
{{ mode === 'create' ? 'Erstellen' : 'Speichern' }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else-if="mode === 'create'"
|
||||
@click="useEntities('projecttypes').create( itemInfo)"
|
||||
>
|
||||
Erstellen
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="router.push(itemInfo.id ? `/projecttypes/show/${itemInfo.id}` : `/projecttypes`)"
|
||||
color="red"
|
||||
class="ml-2"
|
||||
v-if="mode === 'edit' || mode === 'create'"
|
||||
v-if="isEditMode"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="router.push(itemInfo.id ? `/projecttypes/show/${itemInfo.id}` : '/projecttypes')"
|
||||
>
|
||||
Abbrechen
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="mode === 'show'"
|
||||
@click="router.push(`/projecttypes/edit/${itemInfo.id}`)"
|
||||
>
|
||||
Bearbeiten
|
||||
</UButton>
|
||||
<template v-if="mode === 'show' && itemInfo.id">
|
||||
<UButton variant="outline" @click="router.push(`/projecttypes/edit/${itemInfo.id}`)">
|
||||
Bearbeiten
|
||||
</UButton>
|
||||
<UButton color="error" variant="outline" @click="openArchiveModal = true">
|
||||
Archivieren
|
||||
</UButton>
|
||||
</template>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardPanelContent>
|
||||
<UTabs
|
||||
:items="[{label: 'Informationen'}]"
|
||||
v-if="itemInfo.id && mode == 'show'"
|
||||
v-model="openTab"
|
||||
>
|
||||
<template #content="{ item }">
|
||||
<div v-if="item.label === 'Informationen'" class="flex flex-row">
|
||||
<div class="w-1/2 mr-3">
|
||||
<UCard class="mt-5">
|
||||
{{itemInfo}}
|
||||
</UCard>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<UCard class="mt-5">
|
||||
<HistoryDisplay
|
||||
type="project"
|
||||
v-if="itemInfo"
|
||||
:element-id="itemInfo.id"
|
||||
render-headline
|
||||
/>
|
||||
</UCard>
|
||||
<div v-if="loading" class="flex justify-center py-12">
|
||||
<UIcon name="i-heroicons-arrow-path" class="size-7 animate-spin" />
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-else-if="loadError"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
title="Fehler beim Laden"
|
||||
:description="loadError"
|
||||
/>
|
||||
|
||||
<div v-else-if="mode === 'show'" class="grid gap-5 lg:grid-cols-2">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h2 class="text-lg font-medium">Phasenvorlage</h2>
|
||||
</template>
|
||||
|
||||
<div v-if="itemInfo.initialPhases.length" class="space-y-3">
|
||||
<div
|
||||
v-for="(phase, index) in itemInfo.initialPhases"
|
||||
:key="phase.key"
|
||||
class="rounded-lg border border-default p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon :name="phase.icon || 'i-heroicons-clipboard-document'" class="size-5" />
|
||||
<span class="font-medium">{{ phase.label }}</span>
|
||||
<UBadge v-if="index === 0" variant="soft">Startphase</UBadge>
|
||||
<UBadge v-if="phase.optional" color="neutral" variant="soft">Optional</UBadge>
|
||||
</div>
|
||||
<p v-if="phase.description" class="mt-2 text-sm text-muted">{{ phase.description }}</p>
|
||||
<div v-if="phase.quickactions.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<UBadge v-for="action in phase.quickactions" :key="action.link" color="neutral" variant="outline">
|
||||
{{ action.label }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
<TableEmptyState v-else label="Keine Phasen hinterlegt" />
|
||||
</UCard>
|
||||
|
||||
<UForm v-else-if="mode === 'edit' || mode === 'create'">
|
||||
<UCard>
|
||||
<HistoryDisplay
|
||||
v-if="itemInfo.id"
|
||||
type="projecttypes"
|
||||
:element-id="String(itemInfo.id)"
|
||||
render-headline
|
||||
/>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isEditMode" class="space-y-5">
|
||||
<UAlert
|
||||
color="error"
|
||||
variant="outline"
|
||||
class="mb-5"
|
||||
v-if="mode === 'edit'"
|
||||
description="Achtung Änderungen an diesem Projekttypen betreffen nur Projekte die damit neu erstellt werden. Bestehende Projekte bleiben unverändert."
|
||||
v-if="mode === 'edit'"
|
||||
color="warning"
|
||||
variant="outline"
|
||||
description="Änderungen an diesem Projekttyp gelten nur für neu erstellte Projekte. Bestehende Projekte bleiben unverändert."
|
||||
/>
|
||||
|
||||
<UFormField
|
||||
label="Name:"
|
||||
>
|
||||
<UInput
|
||||
v-model="itemInfo.name"
|
||||
/>
|
||||
</UFormField>
|
||||
<UCard>
|
||||
<UFormField label="Name" required>
|
||||
<UInput v-model="itemInfo.name" class="w-full" placeholder="Name des Projekttyps" />
|
||||
</UFormField>
|
||||
</UCard>
|
||||
|
||||
<USeparator class="mt-5">
|
||||
Initiale Phasen
|
||||
</USeparator>
|
||||
<UButton
|
||||
class="mt-3"
|
||||
@click="addPhase"
|
||||
>
|
||||
+ Phase
|
||||
</UButton>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium">Initiale Phasen</h2>
|
||||
<p class="text-sm text-muted">Diese Phasen werden in neue Projekte übernommen.</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-plus" @click="addPhase">Phase hinzufügen</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<table class="mt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="text-left"><span class="ml-2">Name</span></th>
|
||||
<th class="text-left"><span class="ml-2">Icon</span></th>
|
||||
<th class="text-left"><span class="ml-2">Optional</span></th>
|
||||
<th class="text-left"><span class="ml-2">Beschreibung</span></th>
|
||||
<th class="text-left"><span class="ml-2">Schnellaktionen</span></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<draggable
|
||||
v-model="itemInfo.initialPhases"
|
||||
handle=".handle"
|
||||
tag="tbody"
|
||||
itemKey="pos"
|
||||
@end="setKeys"
|
||||
v-model="itemInfo.initialPhases"
|
||||
handle=".phase-handle"
|
||||
item-key="key"
|
||||
class="space-y-3"
|
||||
>
|
||||
<template #item="{ element: phase, index }">
|
||||
<div class="rounded-lg border border-default p-4">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<UIcon name="i-mdi-menu" class="phase-handle size-5 cursor-grab text-muted" />
|
||||
<span class="font-medium">Phase {{ index + 1 }}</span>
|
||||
<UBadge v-if="index === 0" variant="soft">Startphase</UBadge>
|
||||
<UButton
|
||||
class="ml-auto"
|
||||
icon="i-heroicons-trash"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
:disabled="itemInfo.initialPhases.length === 1"
|
||||
aria-label="Phase entfernen"
|
||||
@click="removePhase(phase.key)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #item="{element: phase}">
|
||||
<tr>
|
||||
<td>
|
||||
<UIcon
|
||||
class="handle"
|
||||
name="i-mdi-menu"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<UInput
|
||||
class="my-2 ml-2"
|
||||
v-model="phase.label"
|
||||
placeholder="Name"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<UInput
|
||||
class="my-2 ml-2"
|
||||
v-model="phase.icon"
|
||||
placeholder="Icon"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<UCheckbox
|
||||
class="my-2 ml-2"
|
||||
v-model="phase.optional"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<UInput
|
||||
class="my-2 ml-2"
|
||||
v-model="phase.description"
|
||||
placeholder="Beschreibung"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<UButton
|
||||
class="my-2 ml-2"
|
||||
variant="outline"
|
||||
@click="openQuickActionModal = true,
|
||||
selectedKeyForQuickAction = phase.key"
|
||||
>+ Schnellaktion</UButton>
|
||||
<UButton
|
||||
@click="phase.quickactions = phase.quickactions.filter(i => i.label !== button.label)"
|
||||
v-for="button in phase.quickactions"
|
||||
class="ml-1"
|
||||
>
|
||||
{{ button.label }}
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<UFormField label="Name" required>
|
||||
<UInput v-model="phase.label" class="w-full" placeholder="Phasenname" />
|
||||
</UFormField>
|
||||
<UFormField label="Icon">
|
||||
<HeroiconPicker v-model="phase.icon" />
|
||||
</UFormField>
|
||||
<UFormField label="Beschreibung" class="xl:col-span-2">
|
||||
<UInput v-model="phase.description" class="w-full" placeholder="Optionale Beschreibung" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<UCheckbox v-model="phase.optional" label="Optionale Phase" :disabled="index === 0" />
|
||||
<UButton class="ml-auto" variant="outline" icon="i-heroicons-bolt" @click="openQuickActions(phase.key)">
|
||||
Schnellaktion hinzufügen
|
||||
</UButton>
|
||||
|
||||
<UModal v-model:open="openQuickActionModal">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
Schnellaktion hinzufügen
|
||||
</h3>
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="openQuickActionModal = false" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<UButton
|
||||
class="my-1"
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Angebot',link:'/createDocument/edit/?type=quotes'})">Angebot Erstellen</UButton>
|
||||
<UButton
|
||||
class="my-1"
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Kostenschätzung',link:'/createDocument/edit/?type=costEstimates'})">Kostenschätzung Erstellen</UButton>
|
||||
<UButton
|
||||
class="my-1"
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Auftrag',link:'/createDocument/edit/?type=confirmationOrders'})">Auftrag Erstellen</UButton>
|
||||
<UButton
|
||||
class="my-1"
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Lieferschein',link:'/createDocument/edit/?type=deliveryNotes'})">Lieferschein Erstellen</UButton>
|
||||
<UButton
|
||||
class="my-1"
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Packschein',link:'/createDocument/edit/?type=packingSlips'})">Packschein Erstellen</UButton>
|
||||
<UButton
|
||||
class="my-1"
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Rechnung',link:'/createDocument/edit/?type=invoices'})">Rechnung Erstellen</UButton>
|
||||
<UButton
|
||||
class="my-1"
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Aufgabe',link:'/tasks?mode=create'})">Aufgabe Erstellen</UButton>
|
||||
<UButton
|
||||
class="my-1"
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Termin',link:'/standardEntity/events/create'})">Termin Erstellen</UButton>
|
||||
|
||||
</div>
|
||||
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</td>
|
||||
<td>
|
||||
<UButton
|
||||
class="my-2 ml-2"
|
||||
variant="outline"
|
||||
color="error"
|
||||
@click="itemInfo.initialPhases = itemInfo.initialPhases.filter(i => i !== phase)"
|
||||
>X</UButton>
|
||||
</td>
|
||||
</tr>
|
||||
v-for="(action, actionIndex) in phase.quickactions"
|
||||
:key="`${action.link}-${actionIndex}`"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
trailing-icon="i-heroicons-x-mark"
|
||||
@click="removeQuickAction(phase, actionIndex)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</table>
|
||||
|
||||
</UForm>
|
||||
<p v-if="!canSave" class="mt-4 text-sm text-error">
|
||||
Name und Phasennamen müssen ausgefüllt sein; mindestens eine Phase ist erforderlich.
|
||||
</p>
|
||||
</UCard>
|
||||
</div>
|
||||
</UDashboardPanelContent>
|
||||
|
||||
<UModal v-model:open="openQuickActionModal" title="Schnellaktion hinzufügen">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold">Schnellaktion hinzufügen</h3>
|
||||
<UButton icon="i-heroicons-x-mark" color="neutral" variant="ghost" @click="openQuickActionModal = false" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<UButton
|
||||
v-for="action in quickActionOptions"
|
||||
:key="action.link"
|
||||
variant="outline"
|
||||
@click="addQuickAction(action)"
|
||||
>
|
||||
{{ action.title }}
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="openArchiveModal" title="Projekttyp archivieren">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="font-semibold">Projekttyp archivieren?</h3>
|
||||
</template>
|
||||
<p>„{{ itemInfo.name }}“ wird aus der Projekttyp-Auswahl ausgeblendet.</p>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton color="neutral" variant="outline" @click="openArchiveModal = false">Abbrechen</UButton>
|
||||
<UButton color="error" :loading="saving" @click="archiveItem">Archivieren</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
|
||||
defineShortcuts({
|
||||
'/': () => {
|
||||
//console.log(searchinput)
|
||||
//searchinput.value.focus()
|
||||
document.getElementById("searchinput").focus()
|
||||
document.getElementById("searchinput")?.focus()
|
||||
},
|
||||
'+': () => {
|
||||
router.push("/projects/create")
|
||||
router.push("/projecttypes/create")
|
||||
},
|
||||
'Enter': {
|
||||
usingInput: true,
|
||||
handler: () => {
|
||||
router.push(`/projecttypes/show/${filteredRows.value[selectedItem.value].id}`)
|
||||
const selected = filteredRows.value[selectedItem.value]
|
||||
if (selected) router.push(`/projecttypes/show/${selected.id}`)
|
||||
}
|
||||
},
|
||||
'arrowdown': () => {
|
||||
@@ -38,10 +37,21 @@ const tempStore = useTempStore()
|
||||
|
||||
const items = ref([])
|
||||
const selectedItem = ref(0)
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
|
||||
|
||||
const setup = async () => {
|
||||
items.value = await useEntities("projecttypes").select()
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
items.value = await useEntities("projecttypes").select('*', 'name', true)
|
||||
} catch (error) {
|
||||
console.error('Projekttypen konnten nicht geladen werden', error)
|
||||
loadError.value = 'Die Projekttypen konnten nicht geladen werden.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
setup()
|
||||
@@ -62,6 +72,11 @@ const filteredRows = computed(() => {
|
||||
return useListFilter(searchString.value, items.value)
|
||||
})
|
||||
|
||||
const openProjecttype = (row) => {
|
||||
const item = row?.original || row
|
||||
if (item?.id) router.push(`/projecttypes/show/${item.id}`)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -87,11 +102,13 @@ const filteredRows = computed(() => {
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UTable
|
||||
v-if="!loadError"
|
||||
:data="filteredRows"
|
||||
:columns="normalizeTableColumns(columns)"
|
||||
class="w-full"
|
||||
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
||||
:on-select="(i) => router.push(`/projecttypes/show/${i.id}`) "
|
||||
:loading="loading"
|
||||
:on-select="openProjecttype"
|
||||
>
|
||||
<template #name-cell="{row}">
|
||||
<span class="text-primary-500 font-bold" v-if="row.original === filteredRows[selectedItem]">{{ row.original.name }}</span>
|
||||
@@ -101,6 +118,15 @@ const filteredRows = computed(() => {
|
||||
<TableEmptyState label="Keine Projekttypen anzuzeigen" />
|
||||
</template>
|
||||
</UTable>
|
||||
<UAlert
|
||||
v-else
|
||||
class="m-5"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
title="Fehler beim Laden"
|
||||
:description="loadError"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -49,29 +49,54 @@ const resources = {
|
||||
}
|
||||
}
|
||||
|
||||
const numberRanges = ref(auth.activeTenantData.numberRanges || {})
|
||||
const createNumberRanges = (ranges = {}) => {
|
||||
const freshRanges = structuredClone(ranges)
|
||||
|
||||
Object.keys(resources).forEach((key) => {
|
||||
if (!numberRanges.value[key]) {
|
||||
numberRanges.value[key] = {
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
nextNumber: 1000
|
||||
Object.keys(resources).forEach((key) => {
|
||||
if (!freshRanges[key]) {
|
||||
freshRanges[key] = {
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
nextNumber: 1000
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return freshRanges
|
||||
}
|
||||
|
||||
const numberRanges = ref({})
|
||||
const loading = ref(true)
|
||||
|
||||
const loadNumberRanges = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const tenant = await useNuxtApp().$api("/api/tenant")
|
||||
numberRanges.value = createNumberRanges(tenant?.numberRanges || {})
|
||||
|
||||
if (tenant?.id) {
|
||||
auth.activeTenantData = tenant
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadNumberRanges)
|
||||
|
||||
const updateNumberRanges = async (range) => {
|
||||
|
||||
const res = await useNuxtApp().$api(`/api/tenant/numberrange/${range}`,{
|
||||
const tenant = await useNuxtApp().$api(`/api/tenant/numberrange/${range}`,{
|
||||
method: "PUT",
|
||||
body: {
|
||||
numberRange: numberRanges.value[range]
|
||||
}
|
||||
})
|
||||
|
||||
console.log(res)
|
||||
|
||||
if (tenant?.id) {
|
||||
auth.activeTenantData = tenant
|
||||
numberRanges.value = createNumberRanges(tenant.numberRanges || {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +117,13 @@ const updateNumberRanges = async (range) => {
|
||||
/>
|
||||
</UDashboardToolbar>
|
||||
|
||||
<UProgress
|
||||
v-if="loading"
|
||||
animation="carousel"
|
||||
class="m-5 w-1/2"
|
||||
/>
|
||||
<table
|
||||
v-else
|
||||
class="m-3"
|
||||
>
|
||||
<tr class="text-left">
|
||||
@@ -103,6 +134,7 @@ const updateNumberRanges = async (range) => {
|
||||
</tr>
|
||||
<tr
|
||||
v-for="key in Object.keys(resources)"
|
||||
:key="key"
|
||||
>
|
||||
<td>{{resources[key].label}}</td>
|
||||
<td>
|
||||
|
||||
@@ -130,10 +130,19 @@ const planningBoardTimeGridOptions = [
|
||||
{ label: "180 Minuten", value: 180 }
|
||||
]
|
||||
|
||||
const planningBoardWeekStartOptions = [
|
||||
{ label: "Montag", value: 1 },
|
||||
{ label: "Dienstag", value: 2 },
|
||||
{ label: "Mittwoch", value: 3 },
|
||||
{ label: "Donnerstag", value: 4 },
|
||||
{ label: "Freitag", value: 5 }
|
||||
]
|
||||
|
||||
const planningBoardConfig = reactive({
|
||||
startTime: auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00",
|
||||
endTime: auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00",
|
||||
slotMinutes: auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180
|
||||
slotMinutes: auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180,
|
||||
weekStartsOn: auth.activeTenantData?.calendarConfig?.planningBoard?.weekStartsOn ?? 1
|
||||
})
|
||||
|
||||
const canManageMcpTokens = computed(() => Boolean(auth.user?.is_admin || auth.hasPermission("mcp.tokens.write")))
|
||||
@@ -238,6 +247,7 @@ const setupPage = async () => {
|
||||
planningBoardConfig.startTime = auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00"
|
||||
planningBoardConfig.endTime = auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00"
|
||||
planningBoardConfig.slotMinutes = auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180
|
||||
planningBoardConfig.weekStartsOn = auth.activeTenantData?.calendarConfig?.planningBoard?.weekStartsOn ?? 1
|
||||
}
|
||||
|
||||
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
|
||||
@@ -571,7 +581,8 @@ const savePlanningBoardConfig = async () => {
|
||||
planningBoard: {
|
||||
startTime: planningBoardConfig.startTime || "06:00",
|
||||
endTime: planningBoardConfig.endTime || "21:00",
|
||||
slotMinutes: Number(planningBoardConfig.slotMinutes) || 180
|
||||
slotMinutes: Number(planningBoardConfig.slotMinutes) || 180,
|
||||
weekStartsOn: Number(planningBoardConfig.weekStartsOn)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -741,7 +752,7 @@ onMounted(() => {
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-highlighted">Plantafel</h3>
|
||||
<p class="text-sm text-muted">Lege hier die Rahmenzeiten und den Rasterabstand für die Plantafel fest.</p>
|
||||
<p class="text-sm text-muted">Lege hier Rahmenzeiten, Rasterabstand und Wochenbeginn für die Plantafel fest.</p>
|
||||
</div>
|
||||
|
||||
<UFormField
|
||||
@@ -767,6 +778,17 @@ onMounted(() => {
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Wochenbeginn:"
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="planningBoardConfig.weekStartsOn"
|
||||
:items="planningBoardWeekStartOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UButton
|
||||
class="mt-1"
|
||||
@click="savePlanningBoardConfig"
|
||||
|
||||
@@ -4,6 +4,7 @@ const route = useRoute()
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
const admin = useAdmin()
|
||||
const desktopPush = useDesktopPush()
|
||||
const { $api } = useNuxtApp()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
|
||||
@@ -17,6 +18,7 @@ const creatingLinkedUser = ref(false)
|
||||
const createLinkedUserModalOpen = ref(false)
|
||||
const createdLinkedUserPassword = ref("")
|
||||
const generatingCalendarSubscription = ref(false)
|
||||
const sendingTestPush = ref(false)
|
||||
const createLinkedUserForm = reactive({
|
||||
email: "",
|
||||
})
|
||||
@@ -270,6 +272,30 @@ async function copyCalendarSubscriptionUrl(value: string, successTitle: string)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTestPush() {
|
||||
if (!profile.value?.user_id || sendingTestPush.value) return
|
||||
|
||||
sendingTestPush.value = true
|
||||
|
||||
try {
|
||||
await desktopPush.sendTestPushToProfile(profile.value.id)
|
||||
toast.add({
|
||||
title: "Test-Push gesendet",
|
||||
description: `Die Nachricht wurde einmalig an ${profile.value.full_name || "den Mitarbeiter"} gesendet.`,
|
||||
color: "green"
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error("[sendTestPush]", err)
|
||||
toast.add({
|
||||
title: "Test-Push fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message || "Die Testnachricht konnte nicht zugestellt werden.",
|
||||
color: "red"
|
||||
})
|
||||
} finally {
|
||||
sendingTestPush.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const weekdays = [
|
||||
{ key: '1', label: 'Montag' },
|
||||
{ key: '2', label: 'Dienstag' },
|
||||
@@ -456,6 +482,35 @@ onMounted(async () => {
|
||||
description="Lege fest, unter welcher Nebenstelle dieser Benutzer erreichbar ist."
|
||||
/>
|
||||
|
||||
<UCard v-if="!pending && profile" class="mt-3">
|
||||
<USeparator label="Push-Benachrichtigung" />
|
||||
|
||||
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Push-Zustellung testen</p>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<template v-if="profile.user_id">
|
||||
Sendet einmalig eine Testnachricht an alle aktiven mobilen Push-Geräte dieses Mitarbeiters.
|
||||
</template>
|
||||
<template v-else>
|
||||
Für einen Push-Test muss der Mitarbeiter zuerst mit einem Benutzerkonto verknüpft werden.
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
icon="i-heroicons-paper-airplane"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:loading="sendingTestPush"
|
||||
:disabled="!profile.user_id"
|
||||
@click="sendTestPush"
|
||||
>
|
||||
Test-Push senden
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard v-if="!pending && profile" class="mt-3">
|
||||
<USeparator label="Vertragsinformationen" />
|
||||
|
||||
|
||||
197
matrix/README.md
197
matrix/README.md
@@ -1,197 +0,0 @@
|
||||
# Matrix-Stack in der FEDEO Compose
|
||||
|
||||
Der Matrix-Stack liegt in derselben `docker-compose.yml` wie FEDEO und ist über das Compose-Profil `matrix` aktivierbar.
|
||||
|
||||
## Enthaltene Dienste
|
||||
|
||||
- `matrix-db`: PostgreSQL für Synapse
|
||||
- `matrix-redis`: Redis für Synapse und LiveKit
|
||||
- `matrix-synapse`: Matrix Homeserver
|
||||
- `matrix-well-known`: Auslieferung von `.well-known/matrix/client` und `.well-known/matrix/server`
|
||||
- `matrix-turn`: coturn für stabile WebRTC-Verbindungen
|
||||
- `matrix-livekit`: LiveKit SFU für MatrixRTC-Konferenzen
|
||||
- `matrix-rtc-jwt`: MatrixRTC Authorization Service für LiveKit-JWTs
|
||||
|
||||
## Vorbereitung
|
||||
|
||||
Lege im Repo eine `.env` auf Basis von `.env.example` an und passe mindestens diese Werte an:
|
||||
|
||||
- `MATRIX_SERVER_NAME`
|
||||
- `MATRIX_HOMESERVER_HOST`
|
||||
- `MATRIX_RTC_HOST`
|
||||
- `MATRIX_TURN_HOST`
|
||||
- `MATRIX_POSTGRES_PASSWORD`
|
||||
- `MATRIX_TURN_SHARED_SECRET`
|
||||
- `LIVEKIT_KEY`
|
||||
- `LIVEKIT_SECRET`
|
||||
|
||||
Passe außerdem die Dateien in `matrix/well-known/` an, falls die Domains nicht `fedeo.de`, `matrix.fedeo.de` und `call.fedeo.de` heißen.
|
||||
|
||||
## Synapse-Konfiguration erzeugen
|
||||
|
||||
Synapse benötigt vor dem ersten Start eine generierte `homeserver.yaml`. Der Befehl bleibt innerhalb derselben Compose:
|
||||
|
||||
```bash
|
||||
docker compose --profile matrix run --rm \
|
||||
-e SYNAPSE_SERVER_NAME="${MATRIX_SERVER_NAME}" \
|
||||
-e SYNAPSE_REPORT_STATS=no \
|
||||
matrix-synapse generate
|
||||
```
|
||||
|
||||
Danach `matrix/synapse/homeserver.yaml` prüfen und mindestens diese Punkte setzen:
|
||||
|
||||
```yaml
|
||||
public_baseurl: "https://matrix.fedeo.de/"
|
||||
|
||||
database:
|
||||
name: psycopg2
|
||||
args:
|
||||
user: synapse
|
||||
password: "<MATRIX_POSTGRES_PASSWORD>"
|
||||
database: synapse
|
||||
host: matrix-db
|
||||
cp_min: 5
|
||||
cp_max: 10
|
||||
|
||||
redis:
|
||||
enabled: true
|
||||
host: matrix-redis
|
||||
|
||||
turn_uris:
|
||||
- "turn:<MATRIX_TURN_HOST>:3478?transport=udp"
|
||||
- "turn:<MATRIX_TURN_HOST>:3478?transport=tcp"
|
||||
turn_shared_secret: "<MATRIX_TURN_SHARED_SECRET>"
|
||||
turn_user_lifetime: "1h"
|
||||
|
||||
experimental_features:
|
||||
msc3266_enabled: true
|
||||
msc4222_enabled: true
|
||||
|
||||
max_event_delay_duration: 24h
|
||||
rc_message:
|
||||
per_second: 0.5
|
||||
burst_count: 30
|
||||
rc_delayed_event_mgmt:
|
||||
per_second: 1
|
||||
burst_count: 20
|
||||
```
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
docker compose --profile matrix up -d
|
||||
```
|
||||
|
||||
Ohne Profil startet weiterhin nur der bisherige FEDEO-Stack:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Die Matrix-Services sind bewusst im bestehenden Compose-Stack definiert, damit FEDEO nicht in mehrere Deployment-Dateien zerfällt.
|
||||
- Die aktuellen Ports für TURN und LiveKit müssen auf der Firewall des Servers freigegeben werden.
|
||||
- Federation sollte erst nach einer expliziten Entscheidung geöffnet werden. Für B2B-Kommunikation ist eine Allowlist sinnvoll.
|
||||
- Die Werte in `.env.example` sind Platzhalter und nicht produktionssicher.
|
||||
|
||||
## Lokaler Entwicklungsstack
|
||||
|
||||
Für lokale Entwicklung gibt es zusätzlich das Profil `matrix-dev`. Es nutzt direkte Localhost-Ports und braucht keine öffentlichen Domains, kein ACME und keine Traefik-Router.
|
||||
|
||||
Lokale Dienste:
|
||||
|
||||
- Synapse: `http://localhost:8008`
|
||||
- Element Web: `http://localhost:8080`
|
||||
- MatrixRTC JWT-Service: `http://localhost:8081`
|
||||
- LiveKit: `ws://localhost:7880`
|
||||
- TURN: `localhost:3478`
|
||||
|
||||
### Lokale Synapse-Konfiguration erzeugen
|
||||
|
||||
```bash
|
||||
docker compose --profile matrix-dev run --rm \
|
||||
-e SYNAPSE_SERVER_NAME=localhost \
|
||||
-e SYNAPSE_REPORT_STATS=no \
|
||||
matrix-dev-synapse generate
|
||||
```
|
||||
|
||||
Danach `matrix/dev/synapse/homeserver.yaml` für die lokale Compose anpassen:
|
||||
|
||||
```yaml
|
||||
public_baseurl: "http://localhost:8008/"
|
||||
|
||||
database:
|
||||
name: psycopg2
|
||||
args:
|
||||
user: synapse
|
||||
password: "synapse-dev-password"
|
||||
database: synapse
|
||||
host: matrix-dev-db
|
||||
cp_min: 5
|
||||
cp_max: 10
|
||||
|
||||
redis:
|
||||
enabled: true
|
||||
host: matrix-dev-redis
|
||||
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
|
||||
turn_uris:
|
||||
- "turn:localhost:3478?transport=udp"
|
||||
- "turn:localhost:3478?transport=tcp"
|
||||
turn_shared_secret: "matrix-dev-turn-secret"
|
||||
turn_user_lifetime: "1h"
|
||||
|
||||
experimental_features:
|
||||
msc3266_enabled: true
|
||||
msc4222_enabled: true
|
||||
```
|
||||
|
||||
### Lokalen Stack starten
|
||||
|
||||
```bash
|
||||
docker compose --profile matrix-dev up -d \
|
||||
matrix-dev-db \
|
||||
matrix-dev-redis \
|
||||
matrix-dev-synapse \
|
||||
matrix-dev-turn \
|
||||
matrix-dev-livekit \
|
||||
matrix-dev-rtc-jwt \
|
||||
matrix-dev-element
|
||||
```
|
||||
|
||||
Einen lokalen Admin-Nutzer kannst du danach im Synapse-Container anlegen:
|
||||
|
||||
```bash
|
||||
docker compose --profile matrix-dev exec matrix-dev-synapse \
|
||||
register_new_matrix_user -c /data/homeserver.yaml http://localhost:8008
|
||||
```
|
||||
|
||||
Anschließend Element Web unter `http://localhost:8080` öffnen und mit dem lokalen Matrix-Nutzer anmelden.
|
||||
|
||||
Wenn FEDEO selbst parallel lokal laufen soll, starte die FEDEO-Dienste separat wie gewohnt. Der lokale Matrix-Stack ist absichtlich über direkte Ports erreichbar, damit er unabhängig von DNS, TLS und Traefik getestet werden kann.
|
||||
|
||||
## Erste FEDEO-Backend-Integration
|
||||
|
||||
Das Backend stellt geschützte Matrix-Endpunkte unter `/api/communication/matrix/*` bereit:
|
||||
|
||||
- `GET /api/communication/matrix/status`: prüft Konfiguration und Erreichbarkeit des Matrix-Homeservers
|
||||
- `GET /api/communication/matrix/me`: zeigt die aus dem FEDEO-Nutzer abgeleitete Matrix-ID
|
||||
- `POST /api/communication/matrix/me/provision`: legt den Matrix-Account für den angemeldeten FEDEO-Nutzer per Synapse-Shared-Secret-Registrierung an
|
||||
|
||||
Für lokale Provisionierung muss `MATRIX_REGISTRATION_SHARED_SECRET` aus `matrix/dev/synapse/homeserver.yaml` in der Backend-Umgebung gesetzt werden. Die lokale Synapse-Konfiguration ist absichtlich nicht versioniert, weil sie Secrets enthält.
|
||||
|
||||
In der lokalen Entwicklung liest das Backend dieses Secret als Fallback direkt aus `matrix/dev/synapse/homeserver.yaml`, sofern `NODE_ENV` nicht `production` ist. Auf Servern muss das Secret weiterhin explizit über die Umgebung oder das Secret-Management gesetzt werden.
|
||||
|
||||
Für den eingebetteten Element-Login in FEDEO muss in der lokalen Synapse-Konfiguration außerdem der kurzlebige Login-Token-Flow aktiv sein:
|
||||
|
||||
```yaml
|
||||
login_via_existing_session:
|
||||
enabled: true
|
||||
require_ui_auth: false
|
||||
token_timeout: "5m"
|
||||
```
|
||||
|
||||
Nach einer Änderung an `matrix/dev/synapse/homeserver.yaml` muss `matrix-dev-synapse` neu gestartet werden.
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"default_server_config": {
|
||||
"m.homeserver": {
|
||||
"base_url": "http://localhost:8008",
|
||||
"server_name": "localhost"
|
||||
}
|
||||
},
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "http://localhost:8081"
|
||||
}
|
||||
],
|
||||
"disable_custom_urls": false,
|
||||
"disable_guests": true,
|
||||
"brand": "FEDEO Matrix Dev",
|
||||
"default_theme": "light",
|
||||
"features": {
|
||||
"feature_video_rooms": true
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"m.homeserver": {
|
||||
"base_url": "https://matrix.fedeo.de"
|
||||
},
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://call.fedeo.de/livekit/jwt"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"m.server": "matrix.fedeo.de:443"
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "software.federspiel.fedeo",
|
||||
"buildNumber": "11",
|
||||
"buildNumber": "13",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
|
||||
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
|
||||
@@ -70,7 +70,8 @@
|
||||
},
|
||||
"disableAndroid": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"@react-native-community/datetimepicker"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { fetchMatrixUnreadCounts } from '@/src/lib/api';
|
||||
import { fetchChatUnreadCounts } from '@/src/lib/api';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
export default function TabLayout() {
|
||||
@@ -22,7 +22,7 @@ export default function TabLayout() {
|
||||
}
|
||||
|
||||
try {
|
||||
const unread = await fetchMatrixUnreadCounts(token);
|
||||
const unread = await fetchChatUnreadCounts(token);
|
||||
const total = Object.values(unread).reduce((sum, room) => sum + (room.count || 0), 0);
|
||||
setCommunicationUnread(total);
|
||||
await Notifications.setBadgeCountAsync(total);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,18 @@ const ITEMS = [
|
||||
subtitle: '',
|
||||
href: '/more/plants',
|
||||
},
|
||||
{
|
||||
key: 'events',
|
||||
title: 'Termine',
|
||||
subtitle: 'Termine anzeigen und eintragen',
|
||||
href: '/more/events',
|
||||
},
|
||||
{
|
||||
key: 'calendar',
|
||||
title: 'Kalender',
|
||||
subtitle: 'Monatsübersicht und Tagesplanung',
|
||||
href: '/more/calendar',
|
||||
},
|
||||
{
|
||||
key: 'inventory',
|
||||
title: 'Kundeninventar',
|
||||
|
||||
@@ -109,6 +109,33 @@ export default function RootLayout() {
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="more/events"
|
||||
options={{
|
||||
title: 'Termine',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="more/calendar"
|
||||
options={{
|
||||
title: 'Kalender',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="more/event/[id]"
|
||||
options={{
|
||||
title: 'Termin',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="more/plant/[id]"
|
||||
options={{
|
||||
|
||||
224
mobile/app/more/calendar.tsx
Normal file
224
mobile/app/more/calendar.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
import { EventCreateModal } from '@/components/event-create-modal';
|
||||
import { CalendarEvent, fetchEvents, fetchProjects, Project } from '@/src/lib/api';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
function startOfDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
function dateKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function buildCalendarDays(month: Date): Date[] {
|
||||
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const mondayOffset = (first.getDay() + 6) % 7;
|
||||
const start = new Date(first);
|
||||
start.setDate(first.getDate() - mondayOffset);
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + index);
|
||||
return date;
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export default function CalendarScreen() {
|
||||
const { token } = useAuth();
|
||||
const today = useMemo(() => startOfDay(new Date()), []);
|
||||
const [month, setMonth] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
const [selectedDate, setSelectedDate] = useState(today);
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (showSpinner = true) => {
|
||||
if (!token) return;
|
||||
if (showSpinner) setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [eventRows, projectRows] = await Promise.all([fetchEvents(token), fetchProjects(token)]);
|
||||
setEvents(eventRows);
|
||||
setProjects(projectRows);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Der Kalender konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
void load(true);
|
||||
}, [load]);
|
||||
|
||||
const eventsByDay = useMemo(() => {
|
||||
const map = new Map<string, CalendarEvent[]>();
|
||||
events.forEach((event) => {
|
||||
const start = new Date(event.startDate);
|
||||
if (Number.isNaN(start.getTime())) return;
|
||||
const key = dateKey(start);
|
||||
const rows = map.get(key) || [];
|
||||
rows.push(event);
|
||||
map.set(key, rows);
|
||||
});
|
||||
map.forEach((rows) => rows.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const days = useMemo(() => buildCalendarDays(month), [month]);
|
||||
const selectedEvents = eventsByDay.get(dateKey(selectedDate)) || [];
|
||||
|
||||
function changeMonth(delta: number) {
|
||||
const next = new Date(month.getFullYear(), month.getMonth() + delta, 1);
|
||||
setMonth(next);
|
||||
setSelectedDate(next);
|
||||
}
|
||||
|
||||
function selectDay(date: Date) {
|
||||
setSelectedDate(date);
|
||||
if (date.getMonth() !== month.getMonth() || date.getFullYear() !== month.getFullYear()) {
|
||||
setMonth(new Date(date.getFullYear(), date.getMonth(), 1));
|
||||
}
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
setRefreshing(true);
|
||||
await load(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.screen}
|
||||
contentContainerStyle={styles.container}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||
<View style={styles.monthHeader}>
|
||||
<Pressable style={styles.navButton} onPress={() => changeMonth(-1)}><Text style={styles.navButtonText}>‹</Text></Pressable>
|
||||
<Pressable onPress={() => { setMonth(new Date(today.getFullYear(), today.getMonth(), 1)); setSelectedDate(today); }}>
|
||||
<Text style={styles.monthTitle}>{month.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}</Text>
|
||||
<Text style={styles.todayHint}>Heute</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.navButton} onPress={() => changeMonth(1)}><Text style={styles.navButtonText}>›</Text></Pressable>
|
||||
</View>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
{loading ? <ActivityIndicator style={styles.loader} /> : null}
|
||||
|
||||
<View style={styles.calendarCard}>
|
||||
<View style={styles.weekRow}>
|
||||
{WEEKDAYS.map((weekday) => <Text key={weekday} style={styles.weekday}>{weekday}</Text>)}
|
||||
</View>
|
||||
<View style={styles.daysGrid}>
|
||||
{days.map((date) => {
|
||||
const key = dateKey(date);
|
||||
const dayEvents = eventsByDay.get(key) || [];
|
||||
const outside = date.getMonth() !== month.getMonth();
|
||||
const selected = key === dateKey(selectedDate);
|
||||
const isToday = key === dateKey(today);
|
||||
return (
|
||||
<Pressable key={key} style={[styles.dayCell, selected ? styles.dayCellSelected : null]} onPress={() => selectDay(date)}>
|
||||
<Text style={[styles.dayNumber, outside ? styles.dayNumberOutside : null, isToday ? styles.dayNumberToday : null]}>
|
||||
{date.getDate()}
|
||||
</Text>
|
||||
<View style={styles.eventDots}>
|
||||
{dayEvents.slice(0, 3).map((event) => (
|
||||
<View key={String(event.id)} style={[styles.eventDot, { backgroundColor: event.color || PRIMARY }]} />
|
||||
))}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.agendaCard}>
|
||||
<View style={styles.agendaHeader}>
|
||||
<View>
|
||||
<Text style={styles.agendaTitle}>{selectedDate.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })}</Text>
|
||||
<Text style={styles.agendaCount}>{selectedEvents.length} {selectedEvents.length === 1 ? 'Termin' : 'Termine'}</Text>
|
||||
</View>
|
||||
<Pressable style={styles.addButton} onPress={() => setCreateOpen(true)}><Text style={styles.addButtonText}>+ Termin</Text></Pressable>
|
||||
</View>
|
||||
{selectedEvents.length === 0 ? <Text style={styles.empty}>Keine Termine an diesem Tag.</Text> : null}
|
||||
{selectedEvents.map((event) => (
|
||||
<Pressable key={String(event.id)} style={styles.eventRow} onPress={() => router.push(`/more/event/${event.id}` as any)}>
|
||||
<View style={[styles.colorBar, { backgroundColor: event.color || PRIMARY }]} />
|
||||
<View style={styles.eventMain}>
|
||||
<Text style={styles.eventTime}>{formatTime(event.startDate)}</Text>
|
||||
<Text style={styles.eventName}>{event.name}</Text>
|
||||
{typeof event.project === 'object' && event.project?.name ? <Text style={styles.eventProject}>{event.project.name}</Text> : null}
|
||||
</View>
|
||||
<Text style={styles.arrow}>›</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<EventCreateModal
|
||||
visible={createOpen}
|
||||
projects={projects}
|
||||
initialDate={selectedDate}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => load(false)}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: '#f9fafb' },
|
||||
container: { padding: 12, gap: 12, paddingBottom: 28 },
|
||||
monthHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
navButton: { width: 42, height: 42, borderRadius: 21, backgroundColor: '#ffffff', alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#e5e7eb' },
|
||||
navButtonText: { color: '#111827', fontSize: 29, lineHeight: 31 },
|
||||
monthTitle: { color: '#111827', fontSize: 18, fontWeight: '700', textAlign: 'center' },
|
||||
todayHint: { color: '#3d7a30', fontSize: 11, fontWeight: '600', textAlign: 'center', marginTop: 2 },
|
||||
calendarCard: { backgroundColor: '#ffffff', borderRadius: 12, borderWidth: 1, borderColor: '#e5e7eb', overflow: 'hidden' },
|
||||
weekRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingVertical: 7 },
|
||||
weekday: { width: '14.2857%', textAlign: 'center', color: '#6b7280', fontSize: 12, fontWeight: '700' },
|
||||
daysGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||
dayCell: { width: '14.2857%', height: 52, borderRightWidth: 1, borderBottomWidth: 1, borderColor: '#f0f1f2', alignItems: 'center', paddingTop: 6 },
|
||||
dayCellSelected: { backgroundColor: '#eff9ea' },
|
||||
dayNumber: { color: '#111827', fontSize: 13, fontWeight: '600' },
|
||||
dayNumberOutside: { color: '#b6bbc3' },
|
||||
dayNumberToday: { color: '#3d7a30', fontWeight: '900' },
|
||||
eventDots: { flexDirection: 'row', gap: 2, marginTop: 5 },
|
||||
eventDot: { width: 5, height: 5, borderRadius: 3 },
|
||||
agendaCard: { backgroundColor: '#ffffff', borderRadius: 12, borderWidth: 1, borderColor: '#e5e7eb', padding: 12, gap: 8 },
|
||||
agendaHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
|
||||
agendaTitle: { color: '#111827', fontSize: 16, fontWeight: '700' },
|
||||
agendaCount: { color: '#6b7280', fontSize: 12, marginTop: 2 },
|
||||
addButton: { backgroundColor: PRIMARY, borderRadius: 8, minHeight: 36, paddingHorizontal: 11, alignItems: 'center', justifyContent: 'center' },
|
||||
addButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' },
|
||||
eventRow: { minHeight: 62, flexDirection: 'row', alignItems: 'stretch', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 9, overflow: 'hidden' },
|
||||
colorBar: { width: 5 },
|
||||
eventMain: { flex: 1, padding: 9 },
|
||||
eventTime: { color: '#3d7a30', fontSize: 12, fontWeight: '700' },
|
||||
eventName: { color: '#111827', fontSize: 14, fontWeight: '700' },
|
||||
eventProject: { color: '#6b7280', fontSize: 12, marginTop: 2 },
|
||||
arrow: { alignSelf: 'center', color: '#9ca3af', fontSize: 24, paddingRight: 9 },
|
||||
empty: { color: '#6b7280', fontSize: 13, paddingVertical: 8 },
|
||||
error: { color: '#dc2626', fontSize: 13 },
|
||||
loader: { paddingVertical: 12 },
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera';
|
||||
|
||||
import { HistorySection } from '@/components/history-section';
|
||||
import {
|
||||
createCustomerInventoryItem,
|
||||
CreatedDocument,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
uploadCustomerFile,
|
||||
} from '@/src/lib/api';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
import { getOpenableFileUrl } from '@/src/lib/file-opening';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
|
||||
@@ -117,10 +119,18 @@ export default function CustomerDetailScreen() {
|
||||
return [
|
||||
{ label: 'Name', value: String(customer.name || '-') },
|
||||
{ label: 'Kundennummer', value: String(customer.customerNumber || '-') },
|
||||
{ label: 'Typ', value: String(customer.type || '-') },
|
||||
{ label: 'Kundentyp', value: customer.isCompany ? 'Firma' : 'Privat' },
|
||||
{ label: 'Namenszusatz', value: String(customer.nameAddition || '-') },
|
||||
{ label: 'E-Mail', value: String(infoData.email || customer.email || '-') },
|
||||
{ label: 'Rechnungs-E-Mail', value: String(infoData.invoiceEmail || '-') },
|
||||
{ label: 'Telefon', value: String(infoData.tel || customer.phone || '-') },
|
||||
{ label: 'Mobilnummer', value: String(infoData.mobileTel || '-') },
|
||||
{ label: 'Straße', value: String(infoData.street || '-') },
|
||||
{ label: 'Adresszusatz', value: String(infoData.special || '-') },
|
||||
{ label: 'PLZ / Stadt', value: [infoData.zip, infoData.city].filter(Boolean).join(' ') || '-' },
|
||||
{ label: 'Land', value: String(infoData.country || '-') },
|
||||
{ label: 'Webseite', value: String(infoData.web || '-') },
|
||||
{ label: 'USt-Id', value: String(infoData.ustid || '-') },
|
||||
{ label: 'Erstellt', value: formatDateTime(customer.createdAt || customer.created_at) },
|
||||
{ label: 'Aktualisiert', value: formatDateTime(customer.updatedAt || customer.updated_at) },
|
||||
];
|
||||
@@ -267,7 +277,7 @@ export default function CustomerDetailScreen() {
|
||||
|
||||
async function onOpenFile(file: ProjectFile) {
|
||||
if (!file.url) return;
|
||||
await WebBrowser.openBrowserAsync(file.url, {
|
||||
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
|
||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
||||
controlsColor: PRIMARY,
|
||||
showTitle: true,
|
||||
@@ -373,6 +383,8 @@ export default function CustomerDetailScreen() {
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<HistorySection resource="customers" resourceId={customerId} />
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Kundeninventar ({inventoryItems.length})</Text>
|
||||
|
||||
@@ -34,8 +34,25 @@ export default function CustomersScreen() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [isCompanyInput, setIsCompanyInput] = useState(false);
|
||||
const [nameInput, setNameInput] = useState('');
|
||||
const [nameAdditionInput, setNameAdditionInput] = useState('');
|
||||
const [salutationInput, setSalutationInput] = useState('');
|
||||
const [titleInput, setTitleInput] = useState('');
|
||||
const [firstnameInput, setFirstnameInput] = useState('');
|
||||
const [lastnameInput, setLastnameInput] = useState('');
|
||||
const [numberInput, setNumberInput] = useState('');
|
||||
const [streetInput, setStreetInput] = useState('');
|
||||
const [addressAdditionInput, setAddressAdditionInput] = useState('');
|
||||
const [zipInput, setZipInput] = useState('');
|
||||
const [cityInput, setCityInput] = useState('');
|
||||
const [countryInput, setCountryInput] = useState('Deutschland');
|
||||
const [phoneInput, setPhoneInput] = useState('');
|
||||
const [mobileInput, setMobileInput] = useState('');
|
||||
const [emailInput, setEmailInput] = useState('');
|
||||
const [invoiceEmailInput, setInvoiceEmailInput] = useState('');
|
||||
const [websiteInput, setWebsiteInput] = useState('');
|
||||
const [vatIdInput, setVatIdInput] = useState('');
|
||||
const [notesInput, setNotesInput] = useState('');
|
||||
|
||||
const filtered = useMemo(
|
||||
@@ -72,17 +89,36 @@ export default function CustomersScreen() {
|
||||
function closeCreateModal() {
|
||||
setCreateOpen(false);
|
||||
setCreateError(null);
|
||||
setIsCompanyInput(false);
|
||||
setNameInput('');
|
||||
setNameAdditionInput('');
|
||||
setSalutationInput('');
|
||||
setTitleInput('');
|
||||
setFirstnameInput('');
|
||||
setLastnameInput('');
|
||||
setNumberInput('');
|
||||
setStreetInput('');
|
||||
setAddressAdditionInput('');
|
||||
setZipInput('');
|
||||
setCityInput('');
|
||||
setCountryInput('Deutschland');
|
||||
setPhoneInput('');
|
||||
setMobileInput('');
|
||||
setEmailInput('');
|
||||
setInvoiceEmailInput('');
|
||||
setWebsiteInput('');
|
||||
setVatIdInput('');
|
||||
setNotesInput('');
|
||||
}
|
||||
|
||||
async function onCreateCustomer() {
|
||||
if (!token) return;
|
||||
|
||||
const name = nameInput.trim();
|
||||
const name = isCompanyInput
|
||||
? nameInput.trim()
|
||||
: [salutationInput, titleInput, firstnameInput, lastnameInput].map((value) => value.trim()).filter(Boolean).join(' ');
|
||||
if (!name) {
|
||||
setCreateError('Bitte einen Namen eingeben.');
|
||||
setCreateError(isCompanyInput ? 'Bitte einen Firmennamen eingeben.' : 'Bitte mindestens Vor- oder Nachname eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,6 +129,25 @@ export default function CustomersScreen() {
|
||||
await createCustomer(token, {
|
||||
name,
|
||||
customerNumber: numberInput.trim() || null,
|
||||
isCompany: isCompanyInput,
|
||||
nameAddition: nameAdditionInput.trim() || null,
|
||||
salutation: salutationInput.trim() || null,
|
||||
title: titleInput.trim() || null,
|
||||
firstname: firstnameInput.trim() || null,
|
||||
lastname: lastnameInput.trim() || null,
|
||||
infoData: {
|
||||
street: streetInput.trim() || null,
|
||||
special: addressAdditionInput.trim() || null,
|
||||
zip: zipInput.trim() || null,
|
||||
city: cityInput.trim() || null,
|
||||
country: countryInput.trim() || null,
|
||||
tel: phoneInput.trim() || null,
|
||||
mobileTel: mobileInput.trim() || null,
|
||||
email: emailInput.trim() || null,
|
||||
invoiceEmail: invoiceEmailInput.trim() || null,
|
||||
web: websiteInput.trim() || null,
|
||||
ustid: vatIdInput.trim() || null,
|
||||
},
|
||||
notes: notesInput.trim() || null,
|
||||
});
|
||||
closeCreateModal();
|
||||
@@ -168,30 +223,187 @@ export default function CustomersScreen() {
|
||||
<View style={styles.modalCard}>
|
||||
<Text style={styles.modalTitle}>Neuer Kunde</Text>
|
||||
|
||||
<TextInput
|
||||
placeholder="Name"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={nameInput}
|
||||
onChangeText={setNameInput}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Kundennummer (optional)"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={numberInput}
|
||||
onChangeText={setNumberInput}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Notizen (optional)"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={[styles.searchInput, styles.multilineInput]}
|
||||
value={notesInput}
|
||||
onChangeText={setNotesInput}
|
||||
multiline
|
||||
/>
|
||||
<ScrollView
|
||||
style={styles.modalScroll}
|
||||
contentContainerStyle={styles.modalForm}
|
||||
keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.fieldLabel}>Kundentyp</Text>
|
||||
<View style={styles.segmentedControl}>
|
||||
<Pressable
|
||||
style={[styles.segment, !isCompanyInput ? styles.segmentActive : null]}
|
||||
onPress={() => setIsCompanyInput(false)}>
|
||||
<Text style={[styles.segmentText, !isCompanyInput ? styles.segmentTextActive : null]}>Privat</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.segment, isCompanyInput ? styles.segmentActive : null]}
|
||||
onPress={() => setIsCompanyInput(true)}>
|
||||
<Text style={[styles.segmentText, isCompanyInput ? styles.segmentTextActive : null]}>Firma</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{createError ? <Text style={styles.error}>{createError}</Text> : null}
|
||||
<Text style={styles.sectionLabel}>Allgemeines</Text>
|
||||
{isCompanyInput ? (
|
||||
<>
|
||||
<TextInput
|
||||
placeholder="Firmenname *"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={nameInput}
|
||||
onChangeText={setNameInput}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Firmenname Zusatz"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={nameAdditionInput}
|
||||
onChangeText={setNameAdditionInput}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
placeholder="Anrede"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={[styles.searchInput, styles.flexInput]}
|
||||
value={salutationInput}
|
||||
onChangeText={setSalutationInput}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Titel"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={[styles.searchInput, styles.flexInput]}
|
||||
value={titleInput}
|
||||
onChangeText={setTitleInput}
|
||||
/>
|
||||
</View>
|
||||
<TextInput
|
||||
placeholder="Vorname"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={firstnameInput}
|
||||
onChangeText={setFirstnameInput}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Nachname"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={lastnameInput}
|
||||
onChangeText={setLastnameInput}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<TextInput
|
||||
placeholder="Kundennummer (wird sonst automatisch vergeben)"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={numberInput}
|
||||
onChangeText={setNumberInput}
|
||||
/>
|
||||
|
||||
<Text style={styles.sectionLabel}>Adresse</Text>
|
||||
<TextInput
|
||||
placeholder="Straße + Hausnummer"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={streetInput}
|
||||
onChangeText={setStreetInput}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Adresszusatz"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={addressAdditionInput}
|
||||
onChangeText={setAddressAdditionInput}
|
||||
/>
|
||||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
placeholder="PLZ"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={[styles.searchInput, styles.zipInput]}
|
||||
value={zipInput}
|
||||
onChangeText={setZipInput}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Stadt"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={[styles.searchInput, styles.flexInput]}
|
||||
value={cityInput}
|
||||
onChangeText={setCityInput}
|
||||
/>
|
||||
</View>
|
||||
<TextInput
|
||||
placeholder="Land"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={countryInput}
|
||||
onChangeText={setCountryInput}
|
||||
/>
|
||||
|
||||
<Text style={styles.sectionLabel}>Kontaktdaten</Text>
|
||||
<TextInput
|
||||
placeholder="E-Mail"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={emailInput}
|
||||
onChangeText={setEmailInput}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="E-Mail für Rechnungen"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={invoiceEmailInput}
|
||||
onChangeText={setInvoiceEmailInput}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Telefon"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={phoneInput}
|
||||
onChangeText={setPhoneInput}
|
||||
keyboardType="phone-pad"
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Mobilnummer"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={mobileInput}
|
||||
onChangeText={setMobileInput}
|
||||
keyboardType="phone-pad"
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Webseite"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={websiteInput}
|
||||
onChangeText={setWebsiteInput}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="USt-Id"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={vatIdInput}
|
||||
onChangeText={setVatIdInput}
|
||||
autoCapitalize="characters"
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Notizen"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={[styles.searchInput, styles.multilineInput]}
|
||||
value={notesInput}
|
||||
onChangeText={setNotesInput}
|
||||
multiline
|
||||
/>
|
||||
|
||||
{createError ? <Text style={styles.formError}>{createError}</Text> : null}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}>
|
||||
@@ -313,6 +525,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
gap: 10,
|
||||
maxHeight: '92%',
|
||||
},
|
||||
modalTitle: {
|
||||
color: '#111827',
|
||||
@@ -323,6 +536,63 @@ const styles = StyleSheet.create({
|
||||
minHeight: 92,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
modalScroll: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
modalForm: {
|
||||
gap: 10,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
fieldLabel: {
|
||||
color: '#374151',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
sectionLabel: {
|
||||
color: '#111827',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
marginTop: 6,
|
||||
},
|
||||
segmentedControl: {
|
||||
flexDirection: 'row',
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 10,
|
||||
padding: 3,
|
||||
gap: 3,
|
||||
},
|
||||
segment: {
|
||||
flex: 1,
|
||||
minHeight: 36,
|
||||
borderRadius: 7,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
segmentActive: {
|
||||
backgroundColor: '#eff9ea',
|
||||
},
|
||||
segmentText: {
|
||||
color: '#6b7280',
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentTextActive: {
|
||||
color: '#3d7a30',
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
flexInput: {
|
||||
flex: 1,
|
||||
},
|
||||
zipInput: {
|
||||
width: 105,
|
||||
},
|
||||
formError: {
|
||||
color: '#dc2626',
|
||||
fontSize: 13,
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
|
||||
137
mobile/app/more/event/[id].tsx
Normal file
137
mobile/app/more/event/[id].tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
|
||||
import { HistorySection } from '@/components/history-section';
|
||||
import { CalendarEvent, fetchEventById } from '@/src/lib/api';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
|
||||
function formatDateTime(value: unknown): string {
|
||||
if (!value) return '-';
|
||||
const date = new Date(String(value));
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function relationName(value: unknown): string {
|
||||
if (!value) return '-';
|
||||
if (typeof value === 'object') {
|
||||
const relation = value as { id?: number | string; name?: string };
|
||||
return String(relation.name || relation.id || '-');
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default function EventDetailScreen() {
|
||||
const params = useLocalSearchParams<{ id?: string }>();
|
||||
const eventId = Number(params.id);
|
||||
const { token } = useAuth();
|
||||
const [event, setEvent] = useState<CalendarEvent | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const validId = useMemo(() => Number.isFinite(eventId) && eventId > 0, [eventId]);
|
||||
|
||||
const load = useCallback(async (showSpinner = true) => {
|
||||
if (!token || !validId) return;
|
||||
if (showSpinner) setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setEvent(await fetchEventById(token, eventId));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Der Termin konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [eventId, token, validId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load(true);
|
||||
}, [load]);
|
||||
|
||||
async function onRefresh() {
|
||||
setRefreshing(true);
|
||||
await load(false);
|
||||
}
|
||||
|
||||
async function openLink() {
|
||||
if (!event?.link) return;
|
||||
const raw = String(event.link).trim();
|
||||
const url = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||
await WebBrowser.openBrowserAsync(url, { controlsColor: PRIMARY, showTitle: true });
|
||||
}
|
||||
|
||||
const rows = event
|
||||
? [
|
||||
{ label: 'Beginn', value: formatDateTime(event.startDate) },
|
||||
{ label: 'Ende', value: formatDateTime(event.endDate) },
|
||||
{ label: 'Projekt', value: relationName(event.project) },
|
||||
{ label: 'Kunde', value: relationName(event.customer) },
|
||||
{ label: 'Status', value: String(event.state || '-') },
|
||||
{ label: 'Wiederholung', value: String(event.repeatInterval || '-') },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.container}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
{loading ? (
|
||||
<View style={styles.loadingBox}><ActivityIndicator /><Text style={styles.loadingText}>Termin wird geladen...</Text></View>
|
||||
) : null}
|
||||
{!loading && event ? (
|
||||
<>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.headingRow}>
|
||||
<View style={[styles.colorMark, { backgroundColor: event.color || PRIMARY }]} />
|
||||
<Text style={styles.title}>{event.name}</Text>
|
||||
</View>
|
||||
<View style={styles.table}>
|
||||
{rows.map((row) => (
|
||||
<View key={row.label} style={styles.row}>
|
||||
<Text style={styles.label}>{row.label}</Text>
|
||||
<Text style={styles.value}>{row.value}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{event.notes ? <View style={styles.notesBox}><Text style={styles.notesLabel}>Notizen</Text><Text style={styles.notes}>{event.notes}</Text></View> : null}
|
||||
{event.link ? <Pressable style={styles.linkButton} onPress={openLink}><Text style={styles.linkButtonText}>Link öffnen</Text></Pressable> : null}
|
||||
</View>
|
||||
<HistorySection resource="events" resourceId={eventId} />
|
||||
</>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 16, gap: 12, backgroundColor: '#f9fafb' },
|
||||
card: { backgroundColor: '#ffffff', borderRadius: 12, borderWidth: 1, borderColor: '#e5e7eb', padding: 12, gap: 10 },
|
||||
headingRow: { flexDirection: 'row', alignItems: 'center', gap: 9 },
|
||||
colorMark: { width: 6, height: 28, borderRadius: 3 },
|
||||
title: { flex: 1, color: '#111827', fontSize: 19, fontWeight: '700' },
|
||||
table: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, overflow: 'hidden' },
|
||||
row: { borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingHorizontal: 10, paddingVertical: 8, gap: 2 },
|
||||
label: { color: '#6b7280', fontSize: 12, textTransform: 'uppercase', fontWeight: '600' },
|
||||
value: { color: '#111827', fontSize: 14, fontWeight: '500' },
|
||||
notesBox: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, padding: 10, backgroundColor: '#fafafa', gap: 4 },
|
||||
notesLabel: { color: '#6b7280', fontSize: 12, fontWeight: '600', textTransform: 'uppercase' },
|
||||
notes: { color: '#374151', fontSize: 14 },
|
||||
linkButton: { backgroundColor: PRIMARY, borderRadius: 9, minHeight: 40, alignItems: 'center', justifyContent: 'center' },
|
||||
linkButtonText: { color: '#ffffff', fontWeight: '700' },
|
||||
loadingBox: { paddingVertical: 24, alignItems: 'center', gap: 8 },
|
||||
loadingText: { color: '#6b7280' },
|
||||
error: { color: '#dc2626', fontSize: 13 },
|
||||
});
|
||||
208
mobile/app/more/events.tsx
Normal file
208
mobile/app/more/events.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
import { EventCreateModal } from '@/components/event-create-modal';
|
||||
import { CalendarEvent, fetchEvents, fetchProjects, Project } from '@/src/lib/api';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
|
||||
function formatEventDate(event: CalendarEvent): string {
|
||||
const start = new Date(event.startDate);
|
||||
const end = event.endDate ? new Date(event.endDate) : null;
|
||||
if (Number.isNaN(start.getTime())) return String(event.startDate || '-');
|
||||
|
||||
const date = start.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const startTime = start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
const endTime = end && !Number.isNaN(end.getTime())
|
||||
? end.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
: null;
|
||||
return `${date} · ${startTime}${endTime ? `–${endTime}` : ''}`;
|
||||
}
|
||||
|
||||
function projectName(event: CalendarEvent): string | null {
|
||||
if (!event.project) return null;
|
||||
if (typeof event.project === 'object') return event.project.name || (event.project.id ? `Projekt #${event.project.id}` : null);
|
||||
return `Projekt #${event.project}`;
|
||||
}
|
||||
|
||||
export default function EventsScreen() {
|
||||
const { token } = useAuth();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (showSpinner = true) => {
|
||||
if (!token) return;
|
||||
if (showSpinner) setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [eventRows, projectRows] = await Promise.all([fetchEvents(token), fetchProjects(token)]);
|
||||
setEvents(eventRows);
|
||||
setProjects(projectRows);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Termine konnten nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
void load(true);
|
||||
}, [load]);
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
const now = Date.now();
|
||||
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return events
|
||||
.filter((event) => showPast || new Date(event.endDate || event.startDate).getTime() >= now)
|
||||
.filter((event) => {
|
||||
if (terms.length === 0) return true;
|
||||
const haystack = [event.name, event.notes, event.eventtype, projectName(event)]
|
||||
.map((value) => String(value || '').toLowerCase())
|
||||
.join(' ');
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
})
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
}, [events, search, showPast]);
|
||||
|
||||
async function onRefresh() {
|
||||
setRefreshing(true);
|
||||
await load(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.toolbar}>
|
||||
<TextInput
|
||||
placeholder="Termine suchen"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
/>
|
||||
<View style={styles.toolbarActions}>
|
||||
<Pressable
|
||||
style={[styles.filterButton, showPast ? styles.filterButtonActive : null]}
|
||||
onPress={() => setShowPast((value) => !value)}>
|
||||
<Text style={[styles.filterButtonText, showPast ? styles.filterButtonTextActive : null]}>Vergangene anzeigen</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.calendarButton} onPress={() => router.push('/more/calendar' as any)}>
|
||||
<Text style={styles.calendarButtonText}>Kalender</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
{loading ? (
|
||||
<View style={styles.loadingBox}>
|
||||
<ActivityIndicator />
|
||||
<Text style={styles.loadingText}>Termine werden geladen...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{!loading && visibleEvents.length === 0 ? <Text style={styles.empty}>Keine Termine gefunden.</Text> : null}
|
||||
{!loading
|
||||
? visibleEvents.map((event) => (
|
||||
<Pressable
|
||||
key={String(event.id)}
|
||||
style={({ pressed }) => [styles.eventRow, pressed ? styles.eventRowPressed : null]}
|
||||
onPress={() => router.push(`/more/event/${event.id}` as any)}>
|
||||
<View style={[styles.colorBar, { backgroundColor: event.color || PRIMARY }]} />
|
||||
<View style={styles.eventMain}>
|
||||
<Text style={styles.eventName}>{event.name}</Text>
|
||||
<Text style={styles.eventDate}>{formatEventDate(event)}</Text>
|
||||
{projectName(event) ? <Text style={styles.eventProject}>{projectName(event)}</Text> : null}
|
||||
{event.notes ? <Text style={styles.eventNotes} numberOfLines={2}>{String(event.notes)}</Text> : null}
|
||||
</View>
|
||||
<Text style={styles.arrow}>›</Text>
|
||||
</Pressable>
|
||||
))
|
||||
: null}
|
||||
</ScrollView>
|
||||
|
||||
<Pressable style={styles.fab} onPress={() => setCreateOpen(true)}>
|
||||
<Text style={styles.fabText}>+</Text>
|
||||
</Pressable>
|
||||
|
||||
<EventCreateModal
|
||||
visible={createOpen}
|
||||
projects={projects}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => load(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: '#ffffff' },
|
||||
toolbar: { padding: 14, gap: 10, borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
|
||||
searchInput: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 15,
|
||||
color: '#111827',
|
||||
},
|
||||
toolbarActions: { flexDirection: 'row', justifyContent: 'space-between', gap: 8 },
|
||||
filterButton: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 7 },
|
||||
filterButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
|
||||
filterButtonText: { color: '#374151', fontSize: 12, fontWeight: '600' },
|
||||
filterButtonTextActive: { color: '#3d7a30' },
|
||||
calendarButton: { borderRadius: 8, backgroundColor: PRIMARY, paddingHorizontal: 13, paddingVertical: 7 },
|
||||
calendarButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' },
|
||||
list: { flex: 1 },
|
||||
listContent: { paddingBottom: 96 },
|
||||
eventRow: { minHeight: 82, flexDirection: 'row', alignItems: 'stretch', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
|
||||
eventRowPressed: { backgroundColor: '#f3f4f6' },
|
||||
colorBar: { width: 5 },
|
||||
eventMain: { flex: 1, paddingHorizontal: 12, paddingVertical: 11, gap: 2 },
|
||||
eventName: { color: '#111827', fontSize: 15, fontWeight: '700' },
|
||||
eventDate: { color: '#374151', fontSize: 13 },
|
||||
eventProject: { color: '#3d7a30', fontSize: 12, fontWeight: '600' },
|
||||
eventNotes: { color: '#6b7280', fontSize: 12 },
|
||||
arrow: { alignSelf: 'center', color: '#9ca3af', fontSize: 25, paddingRight: 12 },
|
||||
loadingBox: { paddingVertical: 24, alignItems: 'center', gap: 8 },
|
||||
loadingText: { color: '#6b7280' },
|
||||
empty: { color: '#6b7280', textAlign: 'center', paddingVertical: 28 },
|
||||
error: { color: '#dc2626', fontSize: 13, padding: 14 },
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: 18,
|
||||
bottom: 20,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: PRIMARY,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#111827',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 5,
|
||||
},
|
||||
fabText: { color: '#ffffff', fontSize: 30, lineHeight: 30, marginTop: -2 },
|
||||
});
|
||||
@@ -5,8 +5,10 @@ import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
|
||||
import { HistorySection } from '@/components/history-section';
|
||||
import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
import { getOpenableFileUrl } from '@/src/lib/file-opening';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
|
||||
@@ -89,7 +91,7 @@ export default function PlantDetailScreen() {
|
||||
|
||||
async function onOpenFile(file: ProjectFile) {
|
||||
if (!file.url) return;
|
||||
await WebBrowser.openBrowserAsync(file.url, {
|
||||
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
|
||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
||||
controlsColor: PRIMARY,
|
||||
showTitle: true,
|
||||
@@ -233,6 +235,8 @@ export default function PlantDetailScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<HistorySection resource="plants" resourceId={plantId} />
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Dokumente ({files.length})</Text>
|
||||
|
||||
@@ -17,9 +17,13 @@ import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
|
||||
import { EventCreateModal } from '@/components/event-create-modal';
|
||||
import { HistorySection } from '@/components/history-section';
|
||||
import {
|
||||
CalendarEvent,
|
||||
createProjectTask,
|
||||
fetchProjectById,
|
||||
fetchProjectEvents,
|
||||
fetchProjectFiles,
|
||||
fetchProjectTasks,
|
||||
Project,
|
||||
@@ -30,6 +34,7 @@ import {
|
||||
updateTask,
|
||||
} from '@/src/lib/api';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
import { getOpenableFileUrl } from '@/src/lib/file-opening';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
|
||||
@@ -69,12 +74,14 @@ export default function ProjectDetailScreen() {
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [files, setFiles] = useState<ProjectFile[]>([]);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [creatingTask, setCreatingTask] = useState(false);
|
||||
const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null);
|
||||
const [createTaskModalOpen, setCreateTaskModalOpen] = useState(false);
|
||||
const [createEventModalOpen, setCreateEventModalOpen] = useState(false);
|
||||
const [createTaskError, setCreateTaskError] = useState<string | null>(null);
|
||||
const [newTaskName, setNewTaskName] = useState('');
|
||||
const [newTaskDescription, setNewTaskDescription] = useState('');
|
||||
@@ -121,14 +128,16 @@ export default function ProjectDetailScreen() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [projectData, fileData, taskData] = await Promise.all([
|
||||
const [projectData, fileData, taskData, eventData] = await Promise.all([
|
||||
fetchProjectById(token, projectId),
|
||||
fetchProjectFiles(token, projectId),
|
||||
fetchProjectTasks(token, projectId),
|
||||
fetchProjectEvents(token, projectId),
|
||||
]);
|
||||
setProject(projectData);
|
||||
setFiles(fileData);
|
||||
setTasks(taskData);
|
||||
setEvents(eventData.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Projektdaten konnten nicht geladen werden.');
|
||||
} finally {
|
||||
@@ -185,7 +194,7 @@ export default function ProjectDetailScreen() {
|
||||
|
||||
async function onOpenFile(file: ProjectFile) {
|
||||
if (!file.url) return;
|
||||
await WebBrowser.openBrowserAsync(file.url, {
|
||||
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
|
||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
||||
controlsColor: PRIMARY,
|
||||
showTitle: true,
|
||||
@@ -366,6 +375,37 @@ export default function ProjectDetailScreen() {
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<HistorySection resource="projects" resourceId={projectId} />
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Termine ({events.length})</Text>
|
||||
<Pressable style={styles.smallPrimaryButton} onPress={() => setCreateEventModalOpen(true)}>
|
||||
<Text style={styles.smallPrimaryButtonText}>Neuer Termin</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{events.length === 0 ? (
|
||||
<Text style={styles.empty}>Keine Termine für dieses Projekt vorhanden.</Text>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<Pressable
|
||||
key={String(event.id)}
|
||||
style={styles.eventRow}
|
||||
onPress={() => router.push(`/more/event/${event.id}` as any)}>
|
||||
<View style={[styles.eventColor, { backgroundColor: event.color || PRIMARY }]} />
|
||||
<View style={styles.eventMain}>
|
||||
<Text style={styles.eventName}>{event.name}</Text>
|
||||
<Text style={styles.eventDate}>
|
||||
{formatDateTime(event.startDate)}
|
||||
{event.endDate ? ` – ${formatDateTime(event.endDate)}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.eventArrow}>›</Text>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Aufgaben ({tasks.length})</Text>
|
||||
@@ -505,6 +545,14 @@ export default function ProjectDetailScreen() {
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<EventCreateModal
|
||||
visible={createEventModalOpen}
|
||||
projects={project ? [project] : []}
|
||||
initialProjectId={projectId}
|
||||
onClose={() => setCreateEventModalOpen(false)}
|
||||
onCreated={() => load(false)}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -588,6 +636,38 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
eventRow: {
|
||||
minHeight: 60,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'stretch',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
borderRadius: 9,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
eventColor: {
|
||||
width: 5,
|
||||
},
|
||||
eventMain: {
|
||||
flex: 1,
|
||||
padding: 9,
|
||||
gap: 2,
|
||||
},
|
||||
eventName: {
|
||||
color: '#111827',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
eventDate: {
|
||||
color: '#6b7280',
|
||||
fontSize: 12,
|
||||
},
|
||||
eventArrow: {
|
||||
alignSelf: 'center',
|
||||
color: '#9ca3af',
|
||||
fontSize: 24,
|
||||
paddingRight: 9,
|
||||
},
|
||||
sectionHeaderActions: {
|
||||
alignItems: 'flex-end',
|
||||
gap: 8,
|
||||
|
||||
375
mobile/components/event-create-modal.tsx
Normal file
375
mobile/components/event-create-modal.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { CalendarEvent, createEvent, Project } from '@/src/lib/api';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
|
||||
type EventCreateModalProps = {
|
||||
visible: boolean;
|
||||
projects?: Project[];
|
||||
initialProjectId?: number | null;
|
||||
initialDate?: Date | null;
|
||||
onClose: () => void;
|
||||
onCreated: (event: CalendarEvent) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function createDefaults(initialDate?: Date | null) {
|
||||
const start = initialDate ? new Date(initialDate) : new Date();
|
||||
if (!initialDate) {
|
||||
start.setMinutes(Math.ceil(start.getMinutes() / 15) * 15, 0, 0);
|
||||
} else {
|
||||
start.setHours(9, 0, 0, 0);
|
||||
}
|
||||
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function relationId(value: unknown): number | null {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object') {
|
||||
const id = (value as { id?: number | string }).id;
|
||||
return id ? Number(id) : null;
|
||||
}
|
||||
const id = Number(value);
|
||||
return Number.isFinite(id) ? id : null;
|
||||
}
|
||||
|
||||
export function EventCreateModal({
|
||||
visible,
|
||||
projects = [],
|
||||
initialProjectId = null,
|
||||
initialDate = null,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: EventCreateModalProps) {
|
||||
const { token } = useAuth();
|
||||
const [name, setName] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [link, setLink] = useState('');
|
||||
const [start, setStart] = useState(() => createDefaults(initialDate).start);
|
||||
const [end, setEnd] = useState(() => createDefaults(initialDate).end);
|
||||
const [activePicker, setActivePicker] = useState<'startDate' | 'startTime' | 'endDate' | 'endTime' | null>(null);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(initialProjectId);
|
||||
const [projectSearch, setProjectSearch] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const defaults = createDefaults(initialDate);
|
||||
setName('');
|
||||
setNotes('');
|
||||
setLink('');
|
||||
setStart(defaults.start);
|
||||
setEnd(defaults.end);
|
||||
setActivePicker(null);
|
||||
setSelectedProjectId(initialProjectId);
|
||||
setProjectSearch('');
|
||||
setError(null);
|
||||
}, [initialDate, initialProjectId, visible]);
|
||||
|
||||
const selectedProject = useMemo(
|
||||
() => projects.find((project) => Number(project.id) === selectedProjectId) || null,
|
||||
[projects, selectedProjectId]
|
||||
);
|
||||
|
||||
const projectOptions = useMemo(() => {
|
||||
const terms = projectSearch.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return projects
|
||||
.filter((project) => {
|
||||
if (terms.length === 0) return true;
|
||||
const haystack = `${project.name || ''} ${project.projectNumber || ''}`.toLowerCase();
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
})
|
||||
.slice(0, 8);
|
||||
}, [projectSearch, projects]);
|
||||
|
||||
function updatePicker(event: DateTimePickerEvent, selected?: Date) {
|
||||
if (Platform.OS === 'android') setActivePicker(null);
|
||||
if (event.type === 'dismissed' || !selected || !activePicker) return;
|
||||
|
||||
const isStart = activePicker.startsWith('start');
|
||||
const isDate = activePicker.endsWith('Date');
|
||||
const current = new Date(isStart ? start : end);
|
||||
if (isDate) {
|
||||
current.setFullYear(selected.getFullYear(), selected.getMonth(), selected.getDate());
|
||||
} else {
|
||||
current.setHours(selected.getHours(), selected.getMinutes(), 0, 0);
|
||||
}
|
||||
|
||||
if (isStart) {
|
||||
setStart(current);
|
||||
if (end <= current) setEnd(new Date(current.getTime() + 60 * 60 * 1000));
|
||||
} else {
|
||||
setEnd(current);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!token || saving) return;
|
||||
const title = name.trim();
|
||||
if (!title) {
|
||||
setError('Bitte einen Namen für den Termin eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (end <= start) {
|
||||
setError('Das Ende muss nach dem Beginn liegen.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const event = await createEvent(token, {
|
||||
name: title,
|
||||
startDate: start.toISOString(),
|
||||
endDate: end.toISOString(),
|
||||
project: selectedProjectId,
|
||||
customer: relationId(selectedProject?.customer),
|
||||
notes: notes.trim() || null,
|
||||
link: link.trim() || null,
|
||||
state: 'Final',
|
||||
repeatInterval: 'Keine Wiederholung',
|
||||
});
|
||||
await onCreated(event);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Der Termin konnte nicht gespeichert werden.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View style={styles.overlay}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardWrap}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Neuer Termin</Text>
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.form} keyboardShouldPersistTaps="handled">
|
||||
<TextInput
|
||||
autoFocus
|
||||
placeholder="Name *"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Beginn</Text>
|
||||
<View style={styles.row}>
|
||||
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('startDate')}>
|
||||
<Text style={styles.pickerButtonText}>{formatDate(start)}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('startTime')}>
|
||||
<Text style={styles.pickerButtonText}>{formatTime(start)}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={styles.label}>Ende</Text>
|
||||
<View style={styles.row}>
|
||||
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('endDate')}>
|
||||
<Text style={styles.pickerButtonText}>{formatDate(end)}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('endTime')}>
|
||||
<Text style={styles.pickerButtonText}>{formatTime(end)}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{activePicker ? (
|
||||
<View style={styles.pickerWrap}>
|
||||
<DateTimePicker
|
||||
value={activePicker.startsWith('start') ? start : end}
|
||||
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
|
||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||
locale="de-DE"
|
||||
minuteInterval={5}
|
||||
onChange={updatePicker}
|
||||
/>
|
||||
{Platform.OS === 'ios' ? (
|
||||
<Pressable style={styles.pickerDoneButton} onPress={() => setActivePicker(null)}>
|
||||
<Text style={styles.pickerDoneText}>Übernehmen</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text style={styles.label}>Projekt</Text>
|
||||
{selectedProject ? (
|
||||
<View style={styles.selectedProject}>
|
||||
<View style={styles.selectedProjectMain}>
|
||||
<Text style={styles.selectedProjectName}>{selectedProject.name}</Text>
|
||||
{selectedProject.projectNumber ? (
|
||||
<Text style={styles.selectedProjectNumber}>Nr. {selectedProject.projectNumber}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Pressable onPress={() => setSelectedProjectId(null)}>
|
||||
<Text style={styles.removeProject}>Ändern</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<TextInput
|
||||
placeholder="Projekt suchen (optional)"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.input}
|
||||
value={projectSearch}
|
||||
onChangeText={setProjectSearch}
|
||||
/>
|
||||
{projectOptions.map((project) => (
|
||||
<Pressable
|
||||
key={String(project.id)}
|
||||
style={styles.projectOption}
|
||||
onPress={() => setSelectedProjectId(Number(project.id))}>
|
||||
<Text style={styles.projectOptionName}>{project.name}</Text>
|
||||
<Text style={styles.projectOptionNumber}>{project.projectNumber || `#${project.id}`}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
placeholder="Link (optional)"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.input}
|
||||
value={link}
|
||||
onChangeText={setLink}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Notizen (optional)"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={[styles.input, styles.multiline]}
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
multiline
|
||||
/>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.secondaryButton} onPress={onClose} disabled={saving}>
|
||||
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.primaryButton, saving ? styles.disabled : null]} onPress={save} disabled={saving}>
|
||||
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, backgroundColor: 'rgba(17, 24, 39, 0.45)', justifyContent: 'center', padding: 20 },
|
||||
keyboardWrap: { width: '100%' },
|
||||
card: { maxHeight: '92%', backgroundColor: '#ffffff', borderRadius: 14, padding: 16, gap: 12 },
|
||||
title: { color: '#111827', fontSize: 18, fontWeight: '700' },
|
||||
scroll: { flexShrink: 1 },
|
||||
form: { gap: 10, paddingBottom: 4 },
|
||||
label: { color: '#374151', fontSize: 13, fontWeight: '700' },
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 15,
|
||||
color: '#111827',
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
row: { flexDirection: 'row', gap: 8 },
|
||||
pickerButton: {
|
||||
minHeight: 44,
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
pickerButtonText: { color: '#111827', fontSize: 15, fontWeight: '600' },
|
||||
dateInput: { flex: 1 },
|
||||
timeInput: { width: 92 },
|
||||
pickerWrap: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, padding: 8 },
|
||||
pickerDoneButton: { alignSelf: 'flex-end', paddingHorizontal: 10, paddingVertical: 7 },
|
||||
pickerDoneText: { color: '#3d7a30', fontSize: 14, fontWeight: '700' },
|
||||
multiline: { minHeight: 90, textAlignVertical: 'top' },
|
||||
selectedProject: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1,
|
||||
borderColor: '#b9e3ae',
|
||||
backgroundColor: '#eff9ea',
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
gap: 8,
|
||||
},
|
||||
selectedProjectMain: { flex: 1 },
|
||||
selectedProjectName: { color: '#111827', fontSize: 14, fontWeight: '700' },
|
||||
selectedProjectNumber: { color: '#6b7280', fontSize: 12 },
|
||||
removeProject: { color: '#3d7a30', fontSize: 13, fontWeight: '700' },
|
||||
projectOption: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
paddingVertical: 8,
|
||||
gap: 8,
|
||||
},
|
||||
projectOptionName: { flex: 1, color: '#111827', fontSize: 14, fontWeight: '600' },
|
||||
projectOptionNumber: { color: '#6b7280', fontSize: 12 },
|
||||
error: { color: '#dc2626', fontSize: 13 },
|
||||
actions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8 },
|
||||
secondaryButton: {
|
||||
minHeight: 40,
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
secondaryButtonText: { color: '#374151', fontWeight: '600' },
|
||||
primaryButton: {
|
||||
minHeight: 40,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: PRIMARY,
|
||||
},
|
||||
primaryButtonText: { color: '#ffffff', fontWeight: '700' },
|
||||
disabled: { opacity: 0.6 },
|
||||
});
|
||||
246
mobile/components/history-section.tsx
Normal file
246
mobile/components/history-section.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import {
|
||||
createResourceHistoryItem,
|
||||
fetchResourceHistory,
|
||||
HistoryItem,
|
||||
} from '@/src/lib/api';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
|
||||
type HistorySectionProps = {
|
||||
resource: string;
|
||||
resourceId: number | string;
|
||||
};
|
||||
|
||||
function formatDateTime(value: unknown): string {
|
||||
if (!value) return '-';
|
||||
const date = new Date(String(value));
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getAuthor(item: HistoryItem): string {
|
||||
if (!item.created_by && !item.createdBy) return 'FEDEO Bot';
|
||||
return String(
|
||||
item.created_by_profile?.full_name ||
|
||||
item.created_by_profile?.fullName ||
|
||||
item.created_by_profile?.email ||
|
||||
'Benutzer'
|
||||
);
|
||||
}
|
||||
|
||||
export function HistorySection({ resource, resourceId }: HistorySectionProps) {
|
||||
const { token } = useAuth();
|
||||
const [items, setItems] = useState<HistoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !resource || !resourceId) return;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const rows = await fetchResourceHistory(token, resource, resourceId);
|
||||
setItems((rows || []).slice().reverse());
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : 'Das Logbuch konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [resource, resourceId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function closeModal() {
|
||||
if (saving) return;
|
||||
setModalOpen(false);
|
||||
setText('');
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
async function addItem() {
|
||||
if (!token || saving) return;
|
||||
const value = text.trim();
|
||||
if (!value) {
|
||||
setFormError('Bitte einen Text für den Logbucheintrag eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
await createResourceHistoryItem(token, resource, resourceId, value);
|
||||
setModalOpen(false);
|
||||
setText('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : 'Der Logbucheintrag konnte nicht gespeichert werden.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Logbuch ({items.length})</Text>
|
||||
<Pressable style={styles.addButton} onPress={() => setModalOpen(true)}>
|
||||
<Text style={styles.addButtonText}>+ Eintrag</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{loading ? <ActivityIndicator /> : null}
|
||||
{!loading && loadError ? <Text style={styles.error}>{loadError}</Text> : null}
|
||||
{!loading && !loadError && items.length === 0 ? (
|
||||
<Text style={styles.empty}>Noch keine Logbucheinträge vorhanden.</Text>
|
||||
) : null}
|
||||
|
||||
{!loading && !loadError
|
||||
? items.map((item) => (
|
||||
<View key={String(item.id)} style={styles.item}>
|
||||
<Text style={styles.itemAuthor}>{getAuthor(item)}</Text>
|
||||
<Text style={styles.itemText}>{item.text}</Text>
|
||||
<Text style={styles.itemDate}>{formatDateTime(item.created_at || item.createdAt)}</Text>
|
||||
</View>
|
||||
))
|
||||
: null}
|
||||
|
||||
<Modal visible={modalOpen} transparent animationType="fade" onRequestClose={closeModal}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.modalKeyboardWrap}>
|
||||
<View style={styles.modalCard}>
|
||||
<Text style={styles.modalTitle}>Logbucheintrag hinzufügen</Text>
|
||||
<TextInput
|
||||
autoFocus
|
||||
multiline
|
||||
placeholder="Was soll im Logbuch festgehalten werden?"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.input}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
/>
|
||||
{formError ? <Text style={styles.error}>{formError}</Text> : null}
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable style={styles.secondaryButton} onPress={closeModal} disabled={saving}>
|
||||
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.primaryButton, saving ? styles.disabled : null]}
|
||||
onPress={addItem}
|
||||
disabled={saving}>
|
||||
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Speichern'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
padding: 12,
|
||||
gap: 8,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
},
|
||||
title: { color: '#111827', fontSize: 16, fontWeight: '700' },
|
||||
addButton: {
|
||||
minHeight: 36,
|
||||
borderRadius: 8,
|
||||
backgroundColor: PRIMARY,
|
||||
paddingHorizontal: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
addButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' },
|
||||
item: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e5e7eb',
|
||||
paddingTop: 9,
|
||||
gap: 2,
|
||||
},
|
||||
itemAuthor: { color: '#111827', fontSize: 13, fontWeight: '700' },
|
||||
itemText: { color: '#374151', fontSize: 14, lineHeight: 20 },
|
||||
itemDate: { color: '#6b7280', fontSize: 12 },
|
||||
empty: { color: '#6b7280', fontSize: 13, paddingVertical: 4 },
|
||||
error: { color: '#dc2626', fontSize: 13 },
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(17, 24, 39, 0.45)',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modalKeyboardWrap: { width: '100%' },
|
||||
modalCard: { backgroundColor: '#ffffff', borderRadius: 14, padding: 16, gap: 12 },
|
||||
modalTitle: { color: '#111827', fontSize: 18, fontWeight: '700' },
|
||||
input: {
|
||||
minHeight: 120,
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
color: '#111827',
|
||||
fontSize: 15,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8 },
|
||||
secondaryButton: {
|
||||
minHeight: 40,
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
secondaryButtonText: { color: '#374151', fontWeight: '600' },
|
||||
primaryButton: {
|
||||
minHeight: 40,
|
||||
borderRadius: 10,
|
||||
backgroundColor: PRIMARY,
|
||||
paddingHorizontal: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
primaryButtonText: { color: '#ffffff', fontWeight: '700' },
|
||||
disabled: { opacity: 0.6 },
|
||||
});
|
||||
125
mobile/package-lock.json
generated
125
mobile/package-lock.json
generated
@@ -9,14 +9,15 @@
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.36",
|
||||
"expo": "~54.0.37",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-constants": "~18.0.14",
|
||||
"expo-document-picker": "^14.0.8",
|
||||
"expo-file-system": "~19.0.23",
|
||||
"expo-file-system": "~19.0.24",
|
||||
"expo-font": "~14.0.12",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
@@ -54,12 +55,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@0no-co/graphql.web": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.3.tgz",
|
||||
"integrity": "sha512-4gFGBdyaFmQ6n9euhp5JtIGS4ZeivwDr1tCPENUxTvy5wyv532yOtFCr9zzYAJh1s6uibgC+TRXUcay+mxzCoQ==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.4.tgz",
|
||||
"integrity": "sha512-imSwulOeDQodRy/olQmVEo2PiY6ntjkZ9eiGdw6lMYylh/tay9b7MusyJBmEnkL8GiKRKr6ltr+D42mY5bd8Bg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0"
|
||||
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"graphql": {
|
||||
@@ -2222,9 +2223,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@expo/xcpretty": {
|
||||
"version": "4.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz",
|
||||
"integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==",
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz",
|
||||
"integrity": "sha512-J3eL4n4h5QTwfD0SIz8OIk6/+sOL/hFZAMacgCM07UNlxBQfJipEpIC2AQxvGkbYeStByJ0TVhQAJo+DeNgaSQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
@@ -2892,6 +2893,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/datetimepicker": {
|
||||
"version": "8.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz",
|
||||
"integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": ">=52.0.0",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-windows": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"expo": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-windows": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/assets-registry": {
|
||||
"version": "0.81.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||
@@ -4054,6 +4078,18 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-cli-detector": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.7.tgz",
|
||||
"integrity": "sha512-d8OWDVdZMgjhLUT9ZPgSv/BdFFF9pVuscC0JdUSz3bjwE15gcp6u/o0/JooM2yyAWC49KThFhXlgTXRa9B7yng==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"agent-cli-detector": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -6104,13 +6140,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo": {
|
||||
"version": "54.0.36",
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.36.tgz",
|
||||
"integrity": "sha512-HMHp1H+actmnX85NJE6lILKzSJV6pTDNkwghq9EMOP3zTynjvBYVqJGSLlm6sEVzJCC5Z2ZiKgLvtsHrqlY0dg==",
|
||||
"version": "54.0.37",
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.37.tgz",
|
||||
"integrity": "sha512-afzbtO4i/K9ZCrzyLgEOaXW56w/yuAK61JkCOMrw/S/VFCAp6YwKWA494X5mIcrVmRAmclv/ec7ga8ndTkzhxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@expo/cli": "54.0.26",
|
||||
"@expo/cli": "54.0.27",
|
||||
"@expo/config": "~12.0.14",
|
||||
"@expo/config-plugins": "~54.0.5",
|
||||
"@expo/devtools": "0.1.8",
|
||||
@@ -6121,11 +6157,11 @@
|
||||
"@ungap/structured-clone": "^1.3.0",
|
||||
"babel-preset-expo": "~54.0.12",
|
||||
"expo-asset": "~12.0.13",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-file-system": "~19.0.23",
|
||||
"expo-constants": "~18.0.14",
|
||||
"expo-file-system": "~19.0.24",
|
||||
"expo-font": "~14.0.12",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-modules-autolinking": "3.0.26",
|
||||
"expo-modules-autolinking": "3.0.27",
|
||||
"expo-modules-core": "3.0.30",
|
||||
"pretty-format": "^29.7.0",
|
||||
"react-refresh": "^0.14.2",
|
||||
@@ -6200,13 +6236,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||
"integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==",
|
||||
"version": "18.0.14",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.14.tgz",
|
||||
"integrity": "sha512-BUZm9mkl/TX7zNaN0N4C83ws7mEnesCFiKhqK+rMZwTD2+Q4wWB2kBltqnC/LrCRaMyRi40XxhCRVwuKnJkxXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/config": "~12.0.13",
|
||||
"@expo/env": "~2.0.8"
|
||||
"@expo/config": "~12.0.14",
|
||||
"@expo/env": "~2.0.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -6223,9 +6259,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-file-system": {
|
||||
"version": "19.0.23",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz",
|
||||
"integrity": "sha512-MeGkid9OeNILfT/qonaXHp4f2c15xaB28U/bcN7pqZej0Kx0+6+V7e9ZIXpPHm07zVatxA+QkMTPQEGfmvVOxA==",
|
||||
"version": "19.0.24",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.24.tgz",
|
||||
"integrity": "sha512-hZs0Ng+coNjx7YJ8MPwfJpoU1SRul6OZ9LZycFzozUeoR+denbyr944YrMP9NVn+Zf1tdC7p0K0zgah3ZySOYg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -6318,9 +6354,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "3.0.26",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz",
|
||||
"integrity": "sha512-WOaud6UKg16ciCOj8raKcMOoKFMHLXKI29U29yhgu1lf+Y7VxJyCktUcYo6AM+ccZ7zLD1uWZdMtgnpf+95OXA==",
|
||||
"version": "3.0.27",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.27.tgz",
|
||||
"integrity": "sha512-Ge216xmfXcba+aUQssfmUo9Hf92wXX3xcXJmOsC/wpuV5B5ozm4BTkRKpoCXEkdD8eJVmFy7ATS8yD3upFOPQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
@@ -6868,9 +6904,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/@expo/cli": {
|
||||
"version": "54.0.26",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.26.tgz",
|
||||
"integrity": "sha512-BjsAoKINLEo3LRE+sDC6FCgjxuOWsyfOFOKz0txrbEcxSatzIjJDVuX8XaTdmeicZdcoN524yl1sfwCWfxhYMw==",
|
||||
"version": "54.0.27",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.27.tgz",
|
||||
"integrity": "sha512-BxKOr6e8wmUwhGSyEVKY+CHWgS3Cre9ibgx1QWcdCenKTVKAz62qGmYL51q0lCcKg6vQY+4Usw9RK5Il4ZFaIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@0no-co/graphql.web": "^1.0.8",
|
||||
@@ -6884,7 +6920,7 @@
|
||||
"@expo/metro": "~54.2.0",
|
||||
"@expo/metro-config": "~54.0.17",
|
||||
"@expo/osascript": "^2.3.8",
|
||||
"@expo/package-manager": "^1.9.10",
|
||||
"@expo/package-manager": "^1.9.11",
|
||||
"@expo/plist": "^0.4.9",
|
||||
"@expo/prebuild-config": "^54.0.9",
|
||||
"@expo/schema-utils": "^0.1.9",
|
||||
@@ -6895,6 +6931,7 @@
|
||||
"@urql/core": "^5.0.6",
|
||||
"@urql/exchange-retry": "^1.3.0",
|
||||
"accepts": "^1.3.8",
|
||||
"agent-cli-detector": "^0.1.2",
|
||||
"arg": "^5.0.2",
|
||||
"better-opn": "~3.0.2",
|
||||
"bplist-creator": "0.1.0",
|
||||
@@ -7095,9 +7132,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -9040,6 +9077,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9060,6 +9100,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9080,6 +9123,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9100,6 +9146,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -12656,9 +12705,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"version": "6.28.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz",
|
||||
"integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
|
||||
@@ -22,14 +22,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.36",
|
||||
"expo": "~54.0.37",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-constants": "~18.0.14",
|
||||
"expo-document-picker": "^14.0.8",
|
||||
"expo-file-system": "~19.0.23",
|
||||
"expo-file-system": "~19.0.24",
|
||||
"expo-font": "~14.0.12",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
|
||||
@@ -54,6 +54,23 @@ export type Project = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type CalendarEvent = {
|
||||
id: number;
|
||||
name: string;
|
||||
startDate: string;
|
||||
endDate?: string | null;
|
||||
eventtype?: string | null;
|
||||
state?: string | null;
|
||||
repeatInterval?: string | null;
|
||||
project?: number | { id?: number; name?: string } | null;
|
||||
customer?: number | { id?: number; name?: string } | null;
|
||||
notes?: string | null;
|
||||
link?: string | null;
|
||||
color?: string | null;
|
||||
archived?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ProjectFile = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
@@ -76,8 +93,32 @@ export type Customer = {
|
||||
archived?: boolean;
|
||||
infoData?: {
|
||||
email?: string | null;
|
||||
invoiceEmail?: string | null;
|
||||
tel?: string | null;
|
||||
mobileTel?: string | null;
|
||||
street?: string | null;
|
||||
special?: string | null;
|
||||
zip?: string | null;
|
||||
city?: string | null;
|
||||
country?: string | null;
|
||||
web?: string | null;
|
||||
ustid?: string | null;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type HistoryItem = {
|
||||
id: number;
|
||||
text: string;
|
||||
created_at?: string | null;
|
||||
createdAt?: string | null;
|
||||
created_by?: string | null;
|
||||
createdBy?: string | null;
|
||||
created_by_profile?: {
|
||||
full_name?: string | null;
|
||||
fullName?: string | null;
|
||||
email?: string | null;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
[key: string]: unknown;
|
||||
@@ -218,27 +259,12 @@ export function isAuthenticationError(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 401;
|
||||
}
|
||||
|
||||
export type MatrixStatus = {
|
||||
enabled?: boolean;
|
||||
ready?: boolean;
|
||||
configured?: boolean;
|
||||
homeserverUrl?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixIdentity = {
|
||||
matrixUserId: string;
|
||||
displayName?: string | null;
|
||||
};
|
||||
|
||||
export type MatrixRoom = {
|
||||
export type ChatRoom = {
|
||||
key: string;
|
||||
name: string;
|
||||
topic?: string | null;
|
||||
type?: 'room' | 'project' | 'direct' | string;
|
||||
group?: string;
|
||||
roomId?: string | null;
|
||||
alias?: string | null;
|
||||
exists?: boolean;
|
||||
projectId?: number;
|
||||
projectNumber?: string | null;
|
||||
@@ -253,66 +279,31 @@ export type MatrixRoom = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixAttachment = {
|
||||
fileName?: string | null;
|
||||
mimeType?: string | null;
|
||||
size?: number | null;
|
||||
mxcUri?: string | null;
|
||||
previewUrl?: string | null;
|
||||
downloadUrl?: string | null;
|
||||
};
|
||||
|
||||
export type MatrixReaction = {
|
||||
key: string;
|
||||
count?: number;
|
||||
own?: boolean;
|
||||
senders?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixMessage = {
|
||||
id: string;
|
||||
export type ChatMessage = {
|
||||
id: number;
|
||||
sender: string;
|
||||
senderDisplayName?: string | null;
|
||||
body?: string | null;
|
||||
timestamp?: string | number | null;
|
||||
own?: boolean;
|
||||
edited?: boolean;
|
||||
redacted?: boolean;
|
||||
msgtype?: string;
|
||||
attachment?: MatrixAttachment | null;
|
||||
replyToEventId?: string | null;
|
||||
reactions?: MatrixReaction[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixMember = {
|
||||
matrixUserId: string;
|
||||
displayName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
membership?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixUser = {
|
||||
export type ChatMember = {
|
||||
userId: string;
|
||||
matrixUserId: string;
|
||||
displayName?: string | null;
|
||||
email?: string | null;
|
||||
own?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixSyncResponse = {
|
||||
nextBatch?: string;
|
||||
messages?: MatrixMessage[];
|
||||
replacements?: MatrixMessage[];
|
||||
reactions?: (MatrixReaction & { targetEventId?: string })[];
|
||||
redactions?: { redacts?: string; eventId?: string; targetEventId?: string }[];
|
||||
members?: MatrixMember[];
|
||||
export type ChatSyncResponse = {
|
||||
nextId?: number;
|
||||
messages?: ChatMessage[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixUnreadCounts = Record<string, { count?: number; mentions?: number }>;
|
||||
export type ChatUnreadCounts = Record<string, { count?: number; mentions?: number }>;
|
||||
|
||||
function buildUrl(path: string): string {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
@@ -388,77 +379,26 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
async function apiFormRequest<T>(path: string, token: string, formData: FormData): Promise<T> {
|
||||
const { signal, cleanup } = createTimeoutSignal();
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(buildUrl(path), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw new Error(`Zeitüberschreitung beim Hochladen zum FEDEO-Server (${path}).`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
const payload = await parseJson(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload as { message?: string; error?: string } | null)?.message ||
|
||||
(payload as { message?: string; error?: string } | null)?.error ||
|
||||
`Request failed (${response.status}) for ${path}`;
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function matrixRoomPath(roomKey: string, suffix = ''): string {
|
||||
return `/api/communication/matrix/rooms/${encodeURIComponent(roomKey)}${suffix}`;
|
||||
function chatRoomPath(roomKey: string, suffix = ''): string {
|
||||
return `/api/communication/chat/rooms/${encodeURIComponent(roomKey)}${suffix}`;
|
||||
}
|
||||
|
||||
export async function checkBackendHealth(): Promise<{ status: string; [key: string]: unknown }> {
|
||||
return apiRequest<{ status: string; [key: string]: unknown }>('/health');
|
||||
}
|
||||
|
||||
export async function fetchMatrixStatus(token: string): Promise<MatrixStatus> {
|
||||
return apiRequest<MatrixStatus>('/api/communication/matrix/status', { token });
|
||||
}
|
||||
|
||||
export async function fetchMatrixIdentity(token: string): Promise<MatrixIdentity> {
|
||||
return apiRequest<MatrixIdentity>('/api/communication/matrix/me', { token });
|
||||
}
|
||||
|
||||
export async function provisionMatrixUser(token: string): Promise<MatrixIdentity> {
|
||||
return apiRequest<MatrixIdentity>('/api/communication/matrix/me/provision', {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMatrixRooms(token: string): Promise<MatrixRoom[]> {
|
||||
export async function fetchChatRooms(token: string): Promise<ChatRoom[]> {
|
||||
const [rooms, projectRooms, directRooms, unread] = await Promise.all([
|
||||
apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/rooms', { token }),
|
||||
apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/project-rooms', { token }),
|
||||
apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/direct-rooms', { token }),
|
||||
apiRequest<{ rooms?: Record<string, { count?: number; mentions?: number }> }>('/api/communication/matrix/unread', {
|
||||
apiRequest<{ rooms?: ChatRoom[] }>('/api/communication/chat/rooms', { token }),
|
||||
apiRequest<{ rooms?: ChatRoom[] }>('/api/communication/chat/project-rooms', { token }),
|
||||
apiRequest<{ rooms?: ChatRoom[] }>('/api/communication/chat/direct-rooms', { token }),
|
||||
apiRequest<{ rooms?: ChatUnreadCounts }>('/api/communication/chat/unread', {
|
||||
token,
|
||||
}),
|
||||
]);
|
||||
|
||||
const unreadByRoom = unread.rooms || {};
|
||||
const decorate = (room: MatrixRoom, group: string): MatrixRoom => ({
|
||||
const decorate = (room: ChatRoom, group: string): ChatRoom => ({
|
||||
...room,
|
||||
group,
|
||||
unread: unreadByRoom[room.key]?.count || 0,
|
||||
@@ -472,165 +412,70 @@ export async function fetchMatrixRooms(token: string): Promise<MatrixRoom[]> {
|
||||
];
|
||||
}
|
||||
|
||||
export async function fetchMatrixUnreadCounts(token: string): Promise<MatrixUnreadCounts> {
|
||||
const response = await apiRequest<{ rooms?: MatrixUnreadCounts }>('/api/communication/matrix/unread', { token });
|
||||
export async function fetchChatUnreadCounts(token: string): Promise<ChatUnreadCounts> {
|
||||
const response = await apiRequest<{ rooms?: ChatUnreadCounts }>('/api/communication/chat/unread', { token });
|
||||
return response.rooms || {};
|
||||
}
|
||||
|
||||
export async function fetchMatrixUsers(token: string): Promise<MatrixUser[]> {
|
||||
const response = await apiRequest<{ users?: MatrixUser[] }>('/api/communication/matrix/users', { token });
|
||||
return response.users || [];
|
||||
}
|
||||
|
||||
export async function createMatrixRoom(
|
||||
export async function createChatRoom(
|
||||
token: string,
|
||||
payload: { key: string; name: string; topic?: string | null; type?: string }
|
||||
): Promise<MatrixRoom> {
|
||||
return apiRequest<MatrixRoom>('/api/communication/matrix/rooms', {
|
||||
): Promise<ChatRoom> {
|
||||
return apiRequest<ChatRoom>('/api/communication/chat/rooms', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function provisionMatrixRoom(token: string, room: MatrixRoom): Promise<MatrixRoom> {
|
||||
if (room.provisionEndpoint) {
|
||||
return apiRequest<MatrixRoom>(room.provisionEndpoint, { method: 'POST', token });
|
||||
}
|
||||
|
||||
export async function provisionChatRoom(token: string, room: ChatRoom): Promise<ChatRoom> {
|
||||
if (room.type === 'project' && room.projectId) {
|
||||
return apiRequest<MatrixRoom>(`/api/communication/matrix/project-rooms/${room.projectId}/provision`, {
|
||||
return apiRequest<ChatRoom>(`/api/communication/chat/project-rooms/${room.projectId}/provision`, {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
if (room.type === 'direct' && room.userId) {
|
||||
return apiRequest<MatrixRoom>(`/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`, {
|
||||
return apiRequest<ChatRoom>(`/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId)}/provision`, {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
return apiRequest<MatrixRoom>(matrixRoomPath(room.key, '/provision'), {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
key: room.key,
|
||||
name: room.name,
|
||||
topic: room.topic,
|
||||
type: room.type || 'room',
|
||||
entityType: room.entityType,
|
||||
entityId: room.entityId,
|
||||
entityUuid: room.entityUuid,
|
||||
},
|
||||
});
|
||||
return room;
|
||||
}
|
||||
|
||||
export async function fetchMatrixMessages(token: string, roomKey: string): Promise<MatrixMessage[]> {
|
||||
const response = await apiRequest<{ messages?: MatrixMessage[] }>(matrixRoomPath(roomKey, '/messages'), { token });
|
||||
export async function fetchChatMessages(token: string, roomKey: string): Promise<ChatMessage[]> {
|
||||
const response = await apiRequest<{ messages?: ChatMessage[] }>(chatRoomPath(roomKey, '/messages'), { token });
|
||||
return response.messages || [];
|
||||
}
|
||||
|
||||
export async function syncMatrixRoom(
|
||||
token: string,
|
||||
roomKey: string,
|
||||
since?: string,
|
||||
initial = false
|
||||
): Promise<MatrixSyncResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (since) query.set('since', since);
|
||||
if (initial) query.set('initial', '1');
|
||||
const suffix = query.toString() ? `/sync?${query.toString()}` : '/sync';
|
||||
return apiRequest<MatrixSyncResponse>(matrixRoomPath(roomKey, suffix), { token });
|
||||
export async function syncChatRoom(token: string, roomKey: string, afterId = 0): Promise<ChatSyncResponse> {
|
||||
return apiRequest<ChatSyncResponse>(chatRoomPath(roomKey, `/sync?afterId=${afterId}`), { token });
|
||||
}
|
||||
|
||||
export async function fetchMatrixMembers(token: string, roomKey: string): Promise<MatrixMember[]> {
|
||||
const response = await apiRequest<{ members?: MatrixMember[] }>(matrixRoomPath(roomKey, '/members'), { token });
|
||||
export async function fetchChatMembers(token: string, roomKey: string): Promise<ChatMember[]> {
|
||||
const response = await apiRequest<{ members?: ChatMember[] }>(chatRoomPath(roomKey, '/members'), { token });
|
||||
return response.members || [];
|
||||
}
|
||||
|
||||
export async function sendMatrixMessage(
|
||||
token: string,
|
||||
roomKey: string,
|
||||
text: string,
|
||||
replyToEventId?: string | null
|
||||
): Promise<MatrixMessage> {
|
||||
return apiRequest<MatrixMessage>(matrixRoomPath(roomKey, '/messages'), {
|
||||
export async function sendChatMessage(token: string, roomKey: string, text: string): Promise<ChatMessage> {
|
||||
return apiRequest<ChatMessage>(chatRoomPath(roomKey, '/messages'), {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { text, replyToEventId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMatrixMessage(token: string, roomKey: string, eventId: string, text: string): Promise<MatrixMessage> {
|
||||
return apiRequest<MatrixMessage>(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), {
|
||||
method: 'PUT',
|
||||
token,
|
||||
body: { text },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMatrixMessage(token: string, roomKey: string, eventId: string): Promise<void> {
|
||||
await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function reactToMatrixMessage(token: string, roomKey: string, eventId: string, key: string): Promise<void> {
|
||||
await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}/reactions`), {
|
||||
export async function markChatRoomRead(token: string, roomKey: string, messageId?: number): Promise<void> {
|
||||
await apiRequest(chatRoomPath(roomKey, '/read'), {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { key },
|
||||
body: { messageId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function markMatrixRoomRead(token: string, roomKey: string, eventId?: string): Promise<void> {
|
||||
await apiRequest(matrixRoomPath(roomKey, '/read'), {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { eventId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncMatrixMembers(token: string, roomKey: string): Promise<void> {
|
||||
await apiRequest(matrixRoomPath(roomKey, '/members/sync'), {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function inviteMatrixMember(token: string, roomKey: string, userId: string): Promise<void> {
|
||||
await apiRequest(matrixRoomPath(roomKey, '/members/invite'), {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeMatrixMember(token: string, roomKey: string, matrixUserId: string): Promise<void> {
|
||||
await apiRequest(matrixRoomPath(roomKey, `/members/${encodeURIComponent(matrixUserId)}`), {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadMatrixAttachment(
|
||||
token: string,
|
||||
roomKey: string,
|
||||
file: { uri: string; name: string; mimeType?: string | null }
|
||||
): Promise<MatrixMessage> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', {
|
||||
uri: file.uri,
|
||||
name: file.name,
|
||||
type: file.mimeType || 'application/octet-stream',
|
||||
} as unknown as Blob);
|
||||
|
||||
return apiFormRequest<MatrixMessage>(matrixRoomPath(roomKey, '/attachments'), token, formData);
|
||||
}
|
||||
|
||||
export async function renderPrintLabel(
|
||||
token: string,
|
||||
context: Record<string, unknown>,
|
||||
@@ -862,6 +707,49 @@ export async function createProject(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchEvents(token: string, includeArchived = false): Promise<CalendarEvent[]> {
|
||||
const events = await apiRequest<CalendarEvent[]>('/api/resource/events', { token });
|
||||
if (includeArchived) return events || [];
|
||||
return (events || []).filter((event) => !event.archived);
|
||||
}
|
||||
|
||||
export async function createEvent(
|
||||
token: string,
|
||||
payload: {
|
||||
name: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
project?: number | null;
|
||||
customer?: number | null;
|
||||
notes?: string | null;
|
||||
link?: string | null;
|
||||
eventtype?: string;
|
||||
state?: 'Entwurf' | 'Final';
|
||||
repeatInterval?: string;
|
||||
}
|
||||
): Promise<CalendarEvent> {
|
||||
return apiRequest<CalendarEvent>('/api/resource/events', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchEventById(token: string, eventId: number): Promise<CalendarEvent> {
|
||||
return apiRequest<CalendarEvent>(`/api/resource/events/${eventId}`, { token });
|
||||
}
|
||||
|
||||
function resolveEventProjectId(event: CalendarEvent): number | null {
|
||||
if (!event.project) return null;
|
||||
if (typeof event.project === 'object') return event.project.id ? Number(event.project.id) : null;
|
||||
return Number(event.project);
|
||||
}
|
||||
|
||||
export async function fetchProjectEvents(token: string, projectId: number): Promise<CalendarEvent[]> {
|
||||
const events = await fetchEvents(token);
|
||||
return events.filter((event) => resolveEventProjectId(event) === Number(projectId));
|
||||
}
|
||||
|
||||
export async function fetchCustomers(token: string, includeArchived = false): Promise<Customer[]> {
|
||||
const customers = await apiRequest<Customer[]>('/api/resource/customers', { token });
|
||||
if (includeArchived) return customers || [];
|
||||
@@ -874,6 +762,13 @@ export async function createCustomer(
|
||||
name: string;
|
||||
customerNumber?: string | null;
|
||||
notes?: string | null;
|
||||
isCompany?: boolean;
|
||||
salutation?: string | null;
|
||||
title?: string | null;
|
||||
firstname?: string | null;
|
||||
lastname?: string | null;
|
||||
nameAddition?: string | null;
|
||||
infoData?: Customer['infoData'];
|
||||
}
|
||||
): Promise<Customer> {
|
||||
return apiRequest<Customer>('/api/resource/customers', {
|
||||
@@ -887,6 +782,33 @@ export async function fetchCustomerById(token: string, customerId: number): Prom
|
||||
return apiRequest<Customer>(`/api/resource/customers/${customerId}`, { token });
|
||||
}
|
||||
|
||||
export async function fetchResourceHistory(
|
||||
token: string,
|
||||
resource: string,
|
||||
resourceId: number | string
|
||||
): Promise<HistoryItem[]> {
|
||||
return apiRequest<HistoryItem[]>(
|
||||
`/api/resource/${encodeURIComponent(resource)}/${encodeURIComponent(String(resourceId))}/history`,
|
||||
{ token }
|
||||
);
|
||||
}
|
||||
|
||||
export async function createResourceHistoryItem(
|
||||
token: string,
|
||||
resource: string,
|
||||
resourceId: number | string,
|
||||
text: string
|
||||
): Promise<HistoryItem> {
|
||||
return apiRequest<HistoryItem>(
|
||||
`/api/resource/${encodeURIComponent(resource)}/${encodeURIComponent(String(resourceId))}/history`,
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { text },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCustomerIdFromCustomerInventoryItem(item: CustomerInventoryItem): number | null {
|
||||
const rawCustomer = item.customer;
|
||||
if (!rawCustomer) return null;
|
||||
|
||||
9
mobile/src/lib/file-opening.ts
Normal file
9
mobile/src/lib/file-opening.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getApiBaseUrlSync } from '@/src/lib/server-config';
|
||||
|
||||
export function getOpenableFileUrl(rawUrl: string): string {
|
||||
if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
|
||||
|
||||
const apiBaseUrl = getApiBaseUrlSync().replace(/\/+$/, '');
|
||||
const path = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
|
||||
return `${apiBaseUrl}${path}`;
|
||||
}
|
||||
@@ -235,7 +235,7 @@ choose_mode() {
|
||||
|
||||
echo
|
||||
echo "Setup-Modus"
|
||||
echo " 1) einfach - Domain, Admin, lokale Datenbank, MinIO, Matrix"
|
||||
echo " 1) einfach - Domain, Admin, lokale Datenbank und MinIO"
|
||||
echo " 2) advanced - zusätzlich SMTP, externe Schlüssel und optionale Dienste"
|
||||
echo
|
||||
|
||||
@@ -266,12 +266,10 @@ FEDEO Selfhost Setup
|
||||
Dieses Script führt dich durch die lokale Betriebsstruktur:
|
||||
|
||||
$ROOT_DIR/
|
||||
$(basename "$COMPOSE_FILE") Docker Stack für FEDEO, Traefik, PostgreSQL, MinIO, Matrix und Monitoring
|
||||
$(basename "$COMPOSE_FILE") Docker Stack für FEDEO, Traefik, PostgreSQL, MinIO und Monitoring
|
||||
.env Zielkonfiguration, wird von diesem Script geschrieben
|
||||
postgres/ persistente FEDEO-Datenbank
|
||||
minio/ lokaler S3-kompatibler Dateispeicher
|
||||
matrix/postgres/ persistente Synapse-Datenbank
|
||||
matrix/synapse/ generierte Synapse-Konfiguration und Matrix-Daten
|
||||
traefik/letsencrypt/ Let's-Encrypt-Zertifikate
|
||||
traefik/logs/ Traefik-Logs
|
||||
|
||||
@@ -279,11 +277,6 @@ Dieses Script führt dich durch die lokale Betriebsstruktur:
|
||||
|
||||
https://DOMAIN/ FEDEO Frontend
|
||||
https://DOMAIN/backend FEDEO API
|
||||
https://DOMAIN/_matrix Matrix Homeserver
|
||||
https://DOMAIN/.well-known Matrix Discovery
|
||||
https://DOMAIN/livekit/sfu LiveKit
|
||||
https://DOMAIN/livekit/jwt LiveKit JWT-Service
|
||||
https://DOMAIN/element Element Web
|
||||
|
||||
EOF
|
||||
}
|
||||
@@ -303,32 +296,28 @@ write_env() {
|
||||
local admin_last_name="${12}"
|
||||
local tenant_name="${13}"
|
||||
local tenant_short="${14}"
|
||||
local matrix_db_password="${15}"
|
||||
local matrix_turn_secret="${16}"
|
||||
local matrix_registration_secret="${17}"
|
||||
local livekit_secret="${18}"
|
||||
local mailer_host="${19}"
|
||||
local mailer_port="${20}"
|
||||
local mailer_ssl="${21}"
|
||||
local mailer_user="${22}"
|
||||
local mailer_pass="${23}"
|
||||
local mailer_from="${24}"
|
||||
local web_push_public="${25}"
|
||||
local web_push_private="${26}"
|
||||
local pdf_license="${27}"
|
||||
local openai_key="${28}"
|
||||
local stirling_key="${29}"
|
||||
local gocardless_secret_id="${30}"
|
||||
local gocardless_secret_key="${31}"
|
||||
local dokubox_host="${32}"
|
||||
local dokubox_port="${33}"
|
||||
local dokubox_secure="${34}"
|
||||
local dokubox_user="${35}"
|
||||
local dokubox_password="${36}"
|
||||
local central_services_enabled="${37}"
|
||||
local central_services_url="${38}"
|
||||
local central_instance_id="${39}"
|
||||
local central_instance_secret="${40}"
|
||||
local mailer_host="${15}"
|
||||
local mailer_port="${16}"
|
||||
local mailer_ssl="${17}"
|
||||
local mailer_user="${18}"
|
||||
local mailer_pass="${19}"
|
||||
local mailer_from="${20}"
|
||||
local web_push_public="${21}"
|
||||
local web_push_private="${22}"
|
||||
local pdf_license="${23}"
|
||||
local openai_key="${24}"
|
||||
local stirling_key="${25}"
|
||||
local gocardless_secret_id="${26}"
|
||||
local gocardless_secret_key="${27}"
|
||||
local dokubox_host="${28}"
|
||||
local dokubox_port="${29}"
|
||||
local dokubox_secure="${30}"
|
||||
local dokubox_user="${31}"
|
||||
local dokubox_password="${32}"
|
||||
local central_services_enabled="${33}"
|
||||
local central_services_url="${34}"
|
||||
local central_instance_id="${35}"
|
||||
local central_instance_secret="${36}"
|
||||
|
||||
cat >"$ENV_FILE" <<EOF
|
||||
# FEDEO Selfhosting
|
||||
@@ -401,22 +390,6 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=$(env_quote "$admin_first_name")
|
||||
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=$(env_quote "$admin_last_name")
|
||||
FEDEO_BOOTSTRAP_TENANT_NAME=$(env_quote "$tenant_name")
|
||||
FEDEO_BOOTSTRAP_TENANT_SHORT=$(env_quote "$tenant_short")
|
||||
FEDEO_BOOTSTRAP_MATRIX=$(env_quote "true")
|
||||
|
||||
MATRIX_SERVER_NAME=$(env_quote "$domain")
|
||||
MATRIX_POSTGRES_DB=$(env_quote "synapse")
|
||||
MATRIX_POSTGRES_USER=$(env_quote "synapse")
|
||||
MATRIX_POSTGRES_PASSWORD=$(env_quote "$matrix_db_password")
|
||||
MATRIX_TURN_SHARED_SECRET=$(env_quote "$matrix_turn_secret")
|
||||
LIVEKIT_KEY=$(env_quote "fedeo-livekit")
|
||||
LIVEKIT_SECRET=$(env_quote "$livekit_secret")
|
||||
MATRIX_HOMESERVER_URL=$(env_quote "http://matrix-synapse:8008")
|
||||
MATRIX_RTC_HOST=$(env_quote "$domain")
|
||||
MATRIX_RTC_JWT_URL=$(env_quote "https://$domain/livekit/jwt")
|
||||
MATRIX_LIVEKIT_URL=$(env_quote "wss://$domain/livekit/sfu")
|
||||
MATRIX_REGISTRATION_SHARED_SECRET=$(env_quote "$matrix_registration_secret")
|
||||
MATRIX_SERVICE_USER_LOCALPART=$(env_quote "fedeo_service")
|
||||
NUXT_PUBLIC_MATRIX_ELEMENT_URL=$(env_quote "https://$domain/element")
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -425,9 +398,7 @@ prepare_directories() {
|
||||
"$ROOT_DIR/traefik/letsencrypt" \
|
||||
"$ROOT_DIR/traefik/logs" \
|
||||
"$ROOT_DIR/postgres" \
|
||||
"$ROOT_DIR/minio" \
|
||||
"$ROOT_DIR/matrix/postgres" \
|
||||
"$ROOT_DIR/matrix/synapse"
|
||||
"$ROOT_DIR/minio"
|
||||
|
||||
touch "$ROOT_DIR/traefik/letsencrypt/acme.json"
|
||||
chmod 600 "$ROOT_DIR/traefik/letsencrypt/acme.json"
|
||||
@@ -466,13 +437,11 @@ uninstall_stack() {
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Achtung: --purge entfernt lokale Datenbanken, Dateien, Matrix-Daten, Zertifikate und .env."
|
||||
echo "Achtung: --purge entfernt lokale Datenbanken, Dateien, Zertifikate und .env."
|
||||
echo "Betroffene Pfade:"
|
||||
echo " $ENV_FILE"
|
||||
echo " $ROOT_DIR/postgres"
|
||||
echo " $ROOT_DIR/minio"
|
||||
echo " $ROOT_DIR/matrix/postgres"
|
||||
echo " $ROOT_DIR/matrix/synapse"
|
||||
echo " $ROOT_DIR/traefik/letsencrypt"
|
||||
echo " $ROOT_DIR/traefik/logs"
|
||||
|
||||
@@ -487,8 +456,6 @@ uninstall_stack() {
|
||||
"$ENV_FILE" \
|
||||
"$ROOT_DIR/postgres" \
|
||||
"$ROOT_DIR/minio" \
|
||||
"$ROOT_DIR/matrix/postgres" \
|
||||
"$ROOT_DIR/matrix/synapse" \
|
||||
"$ROOT_DIR/traefik/letsencrypt" \
|
||||
"$ROOT_DIR/traefik/logs"
|
||||
|
||||
@@ -539,17 +506,12 @@ main() {
|
||||
echo
|
||||
echo "Secrets werden automatisch erzeugt."
|
||||
local db_password minio_password cookie_secret jwt_secret encryption_key m2m_key
|
||||
local matrix_db_password matrix_turn_secret matrix_registration_secret livekit_secret
|
||||
db_password="$(random_hex_secret)"
|
||||
minio_password="$(random_secret)"
|
||||
cookie_secret="$(random_secret)"
|
||||
jwt_secret="$(random_secret)"
|
||||
encryption_key="$(random_hex_secret)"
|
||||
m2m_key="$(random_secret)"
|
||||
matrix_db_password="$(random_secret)"
|
||||
matrix_turn_secret="$(random_secret)"
|
||||
matrix_registration_secret="$(random_secret)"
|
||||
livekit_secret="$(random_secret)"
|
||||
|
||||
local mailer_host="smtp.example.com"
|
||||
local mailer_port="587"
|
||||
@@ -613,8 +575,7 @@ main() {
|
||||
"$domain" "$contact_email" "$db_password" "$minio_password" \
|
||||
"$cookie_secret" "$jwt_secret" "$encryption_key" "$m2m_key" \
|
||||
"$admin_email" "$admin_password" "$admin_first_name" "$admin_last_name" \
|
||||
"$tenant_name" "$tenant_short" "$matrix_db_password" "$matrix_turn_secret" \
|
||||
"$matrix_registration_secret" "$livekit_secret" "$mailer_host" "$mailer_port" \
|
||||
"$tenant_name" "$tenant_short" "$mailer_host" "$mailer_port" \
|
||||
"$mailer_ssl" "$mailer_user" "$mailer_pass" "$mailer_from" "$web_push_public" \
|
||||
"$web_push_private" "$pdf_license" "$openai_key" "$stirling_key" \
|
||||
"$gocardless_secret_id" "$gocardless_secret_key" "$dokubox_host" \
|
||||
|
||||
@@ -286,7 +286,7 @@ h3 {
|
||||
.section,
|
||||
.workflow-section,
|
||||
.audience-teaser,
|
||||
.matrix-section,
|
||||
.chat-section,
|
||||
.open-source-section,
|
||||
.contact-section,
|
||||
footer {
|
||||
@@ -394,7 +394,7 @@ footer {
|
||||
|
||||
.workflow-section,
|
||||
.audience-teaser,
|
||||
.matrix-section,
|
||||
.chat-section,
|
||||
.open-source-section,
|
||||
.script-section,
|
||||
.contact-section {
|
||||
@@ -456,32 +456,32 @@ footer {
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
.matrix-section {
|
||||
.chat-section {
|
||||
border-top: 1px solid rgba(23, 33, 31, 0.12);
|
||||
grid-template-columns: 1fr;
|
||||
padding-top: 5rem;
|
||||
}
|
||||
|
||||
.matrix-layout {
|
||||
.chat-layout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(20rem, 0.82fr);
|
||||
}
|
||||
|
||||
.matrix-stack {
|
||||
.chat-stack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.matrix-stack article {
|
||||
.chat-stack article {
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid rgba(23, 33, 31, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
.matrix-stack span {
|
||||
.chat-stack span {
|
||||
color: var(--accent-dark);
|
||||
display: inline-block;
|
||||
font-size: 0.82rem;
|
||||
@@ -489,13 +489,13 @@ footer {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.matrix-stack p {
|
||||
.chat-stack p {
|
||||
color: #51605c;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.matrix-diagram {
|
||||
.chat-diagram {
|
||||
align-self: stretch;
|
||||
background: #17211f;
|
||||
border-radius: 0.5rem;
|
||||
@@ -505,7 +505,7 @@ footer {
|
||||
padding: clamp(1.25rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.matrix-diagram div {
|
||||
.chat-diagram div {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 0.45rem;
|
||||
@@ -514,12 +514,12 @@ footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.matrix-diagram small {
|
||||
.chat-diagram small {
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.matrix-diagram span {
|
||||
.chat-diagram span {
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
display: block;
|
||||
@@ -948,7 +948,7 @@ footer p {
|
||||
.hero-section,
|
||||
.workflow-section,
|
||||
.audience-teaser,
|
||||
.matrix-section,
|
||||
.chat-section,
|
||||
.open-source-section,
|
||||
.script-section,
|
||||
.contact-section {
|
||||
@@ -964,7 +964,7 @@ footer p {
|
||||
.form-grid,
|
||||
.audience-grid,
|
||||
.audience-fit,
|
||||
.matrix-layout,
|
||||
.chat-layout,
|
||||
.federation-panel,
|
||||
.legal-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1028,7 +1028,7 @@ footer p {
|
||||
.highlights,
|
||||
.feature-grid,
|
||||
.audience-grid,
|
||||
.matrix-stack,
|
||||
.chat-stack,
|
||||
.legal-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<NuxtLink to="/#funktionen">Funktionen</NuxtLink>
|
||||
<NuxtLink to="/#open-source">Open Source</NuxtLink>
|
||||
<NuxtLink to="/#selfhost">Selfhost</NuxtLink>
|
||||
<NuxtLink to="/#matrix">Matrix</NuxtLink>
|
||||
<NuxtLink to="/#chat">Chat</NuxtLink>
|
||||
<NuxtLink to="/kontakt">Kontakt</NuxtLink>
|
||||
<a class="login-link" href="https://app.fedeo.de">Einloggen</a>
|
||||
</nav>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<NuxtLink to="/#funktionen">Funktionen</NuxtLink>
|
||||
<NuxtLink to="/#open-source">Open Source</NuxtLink>
|
||||
<NuxtLink to="/#selfhost">Selfhost</NuxtLink>
|
||||
<NuxtLink to="/#matrix">Matrix</NuxtLink>
|
||||
<NuxtLink to="/#chat">Chat</NuxtLink>
|
||||
<NuxtLink to="/kontakt">Kontakt</NuxtLink>
|
||||
<a class="login-link" href="https://app.fedeo.de">Einloggen</a>
|
||||
</nav>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<a href="#funktionen">Funktionen</a>
|
||||
<a href="#open-source">Open Source</a>
|
||||
<a href="#selfhost">Selfhost</a>
|
||||
<a href="#matrix">Matrix</a>
|
||||
<a href="#chat">Chat</a>
|
||||
<NuxtLink to="/kontakt">Kontakt</NuxtLink>
|
||||
<a class="login-link" href="https://app.fedeo.de">Einloggen</a>
|
||||
</nav>
|
||||
@@ -167,42 +167,42 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="matrix" class="matrix-section">
|
||||
<section id="chat" class="chat-section">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Tief integriert</p>
|
||||
<h2>Matrix als Kommunikationsschicht für FEDEO.</h2>
|
||||
<h2>Chat direkt in FEDEO.</h2>
|
||||
<p>
|
||||
FEDEO verbindet Chat, Räume, Anrufe und Videokonferenzen mit Projekten, Vorgängen, Kontakten und Berechtigungen. Matrix läuft dabei nicht daneben, sondern wird durch FEDEO provisioniert, verknüpft und betrieben.
|
||||
FEDEO verbindet Nachrichten und Räume direkt mit Projekten, Kontakten und Berechtigungen. Identitäten, Zugriffe und Gesprächsverläufe bleiben dabei vollständig im FEDEO-Kontext.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="matrix-layout">
|
||||
<div class="matrix-stack">
|
||||
<article v-for="item in matrixStack" :key="item.title">
|
||||
<div class="chat-layout">
|
||||
<div class="chat-stack">
|
||||
<article v-for="item in chatStack" :key="item.title">
|
||||
<span>{{ item.tag }}</span>
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p>{{ item.description }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="matrix-diagram" aria-label="Matrix Stack Architektur">
|
||||
<div class="chat-diagram" aria-label="FEDEO Chat Architektur">
|
||||
<div>FEDEO Web & App</div>
|
||||
<span></span>
|
||||
<div>FEDEO Backend<br><small>SSO, Rechte, Objektkontext</small></div>
|
||||
<span></span>
|
||||
<div>Matrix Homeserver<br><small>Synapse, Räume, Events</small></div>
|
||||
<div>FEDEO Chat<br><small>Räume, Nachrichten, Lesestatus</small></div>
|
||||
<span></span>
|
||||
<div>FEDEO Kommunikation<br><small>Räume, Gäste, Föderation</small></div>
|
||||
<div>FEDEO Datenbank<br><small>Mandantengetrennte Speicherung</small></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="federation-panel">
|
||||
<div>
|
||||
<p class="eyebrow">Föderation</p>
|
||||
<h3>FEDEO-Instanzen können kontrolliert miteinander sprechen.</h3>
|
||||
<p class="eyebrow">Klare Zuständigkeit</p>
|
||||
<h3>Kommunikation folgt den FEDEO-Berechtigungen.</h3>
|
||||
</div>
|
||||
<p>
|
||||
Für sensible Unternehmenskommunikation bleibt die Föderation standardmäßig steuerbar: geschlossen für interne Installationen, per Allowlist für Partner und Kunden oder bewusst geöffnet für öffentliche Matrix-Szenarien.
|
||||
Allgemeine, projektbezogene und direkte Chats werden mandantengetrennt gespeichert. Nutzer sehen nur Räume, für die ihnen FEDEO einen Zugriff zuweist.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -257,10 +257,10 @@ const features = [
|
||||
{
|
||||
tag: 'Büro',
|
||||
title: 'Aufgaben und Kommunikation',
|
||||
description: 'Aufgaben, interne Nachrichten und Matrix-Räume laufen dort zusammen, wo die Arbeit entsteht.',
|
||||
description: 'Aufgaben und interne Nachrichten laufen dort zusammen, wo die Arbeit entsteht.',
|
||||
details: [
|
||||
'Aufgaben mit Bezug zu Projekten, Vorgängen und Teams',
|
||||
'Matrix-Räume für projektnahe Abstimmung',
|
||||
'Chaträume für projektnahe Abstimmung',
|
||||
'Benachrichtigungen und Lesestatus für laufende Arbeit'
|
||||
]
|
||||
},
|
||||
@@ -316,31 +316,31 @@ const features = [
|
||||
}
|
||||
]
|
||||
|
||||
const matrixStack = [
|
||||
const chatStack = [
|
||||
{
|
||||
tag: 'Chat',
|
||||
title: 'Projekt- und Vorgangsräume',
|
||||
description: 'Matrix-Räume werden aus FEDEO-Kontexten wie Projekten, Tickets, Teams und Kontakten heraus genutzt.'
|
||||
description: 'Chaträume werden aus FEDEO-Kontexten wie Projekten, Teams und Kontakten heraus genutzt.'
|
||||
},
|
||||
{
|
||||
tag: 'Identität',
|
||||
title: 'FEDEO bleibt führend',
|
||||
description: 'Nutzer, Rollen und Raumzugriffe werden aus FEDEO heraus provisioniert und bei Änderungen synchronisiert.'
|
||||
description: 'Nutzer, Rollen und Raumzugriffe werden direkt über FEDEO verwaltet.'
|
||||
},
|
||||
{
|
||||
tag: 'Kommunikation',
|
||||
title: 'Chat, Gäste und Räume',
|
||||
description: 'Teamräume, externe Gäste und projektnahe Abstimmung bleiben direkt mit FEDEO-Kontexten verbunden.'
|
||||
title: 'Nachrichten und Räume',
|
||||
description: 'Allgemeine, direkte und projektnahe Abstimmung bleibt direkt mit FEDEO-Kontexten verbunden.'
|
||||
},
|
||||
{
|
||||
tag: 'Betrieb',
|
||||
title: 'Selfhost-ready Stack',
|
||||
description: 'Synapse, PostgreSQL, Redis, .well-known, Element und TURN/STUN sind im Selfhost-Stack vorbereitet.'
|
||||
title: 'Selfhost-ready',
|
||||
description: 'Chatdaten werden mit der bestehenden FEDEO-Datenbank und ohne zusätzliche Kommunikationsdienste betrieben.'
|
||||
},
|
||||
{
|
||||
tag: 'Föderation',
|
||||
title: 'Instanzübergreifende Zusammenarbeit',
|
||||
description: 'Mehrere FEDEO- oder Matrix-Instanzen können über freigegebene Domains föderieren, ohne die lokale Kontrolle über Nutzer und Räume aufzugeben.'
|
||||
tag: 'Sicherheit',
|
||||
title: 'Mandantengetrennte Kommunikation',
|
||||
description: 'Teilnehmerrechte und Lesestatus werden serverseitig geprüft und innerhalb der jeweiligen FEDEO-Instanz gespeichert.'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<NuxtLink to="/#funktionen">Funktionen</NuxtLink>
|
||||
<NuxtLink to="/#open-source">Open Source</NuxtLink>
|
||||
<NuxtLink to="/#selfhost">Selfhost</NuxtLink>
|
||||
<NuxtLink to="/#matrix">Matrix</NuxtLink>
|
||||
<NuxtLink to="/#chat">Chat</NuxtLink>
|
||||
<NuxtLink to="/kontakt">Kontakt</NuxtLink>
|
||||
<a class="login-link" href="https://app.fedeo.de">Einloggen</a>
|
||||
</nav>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<NuxtLink to="/#funktionen">Funktionen</NuxtLink>
|
||||
<NuxtLink to="/#open-source">Open Source</NuxtLink>
|
||||
<NuxtLink to="/#selfhost">Selfhost</NuxtLink>
|
||||
<NuxtLink to="/#matrix">Matrix</NuxtLink>
|
||||
<NuxtLink to="/#chat">Chat</NuxtLink>
|
||||
<NuxtLink to="/kontakt">Kontakt</NuxtLink>
|
||||
<a class="login-link" href="https://app.fedeo.de">Einloggen</a>
|
||||
</nav>
|
||||
@@ -112,7 +112,7 @@ const audiences = [
|
||||
points: [
|
||||
'Vorgänge mit Historie, Dokumentation und Zuständigkeit',
|
||||
'Rollen und Berechtigungen für unterschiedliche Teams',
|
||||
'Nachvollziehbare Kommunikation über Matrix-Räume'
|
||||
'Nachvollziehbare Kommunikation in projektbezogenen Chats'
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -122,7 +122,7 @@ const audiences = [
|
||||
points: [
|
||||
'Offener Code und nachvollziehbare Architektur',
|
||||
'Docker-basierter Selfhost-Stack',
|
||||
'Matrix-Föderation und Integrationen kontrollierbar betreiben'
|
||||
'Mandanteninterne Kommunikation kontrolliert betreiben'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user