Compare commits

...

20 Commits

Author SHA1 Message Date
6455be81bd KI-AGENT: Beleg-Relationswerte vor dem Speichern normalisieren
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 51s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-05-19 16:53:53 +02:00
9cde630562 KI-AGENT: Datumswerte in Beleg-Relationen bereinigen 2026-05-19 16:48:22 +02:00
48d101e139 KI-AGENT: Fehlerhafte Serienausführung bei Belegen abfangen 2026-05-19 16:31:57 +02:00
167e9a40c3 KI-AGENT: Ergänze Kalender-Abo für Mitarbeiterprofile 2026-05-19 16:26:49 +02:00
f9d3f10eae Dateityp-Belegarten in Detailinfo anzeigen 2026-05-19 15:26:44 +02:00
6d9bceb63f Doppelte Abschlagsrechnung in Auswahl entfernen 2026-05-19 15:25:04 +02:00
e29e84898b Abschlagsrechnung als Dateityp ergänzen 2026-05-19 15:21:44 +02:00
1ccabbedcd Dateityp-Belegarten in Listen anzeigen 2026-05-19 15:20:38 +02:00
24febf4c95 Dateityp-Belegarten als Auswahl pflegen 2026-05-19 15:12:27 +02:00
5fc7cc9604 Dateimodal überarbeiten und Dateitypen pflegen 2026-05-19 12:47:51 +02:00
941f1d819b KI-AGENT: Leite Termine nach dem Speichern zurück zur Plantafel 2026-05-19 12:31:39 +02:00
58c47fa8f7 KI-AGENT: Ergänze wiederholende Termine für Kalender und Plantafel 2026-05-19 12:27:17 +02:00
ea392af094 Ergänze Entwurfsstatus für Termine und Plantafel 2026-05-19 12:18:30 +02:00
0ac22d346f KI-AGENT: Bildvorschau im Chat authentifiziert laden 2026-05-19 10:55:36 +02:00
26ffc4421a KI-AGENT: Anhänge im Chat über Matrix unterstützen 2026-05-19 10:51:33 +02:00
7caa37378b KI-AGENT: Chat Benachrichtigungen und Ungelesen-Zähler umsetzen 2026-05-19 08:39:26 +02:00
227a88b24b KI-AGENT: Chatgruppen einklappbar machen 2026-05-19 08:33:17 +02:00
0fb469c9b0 KI-AGENT: Chaträume sortieren und Matrix-Setup verschieben 2026-05-19 08:31:40 +02:00
5b3445c2dc KI-AGENT: Projekträume und Direktnachrichten integrieren 2026-05-19 08:27:39 +02:00
716de8a503 KI-AGENT: Verschlüssele Bankverbindungen beim Import neu 2026-05-19 08:19:25 +02:00
34 changed files with 2539 additions and 585 deletions

View File

@@ -0,0 +1,8 @@
ALTER TABLE "events" ADD COLUMN "color" text;
UPDATE "events" AS e
SET "color" = COALESCE(t."calendarConfig"->'quickEntry'->>'color', '#2563eb')
FROM "tenants" AS t
WHERE e."tenant" = t."id"
AND e."quick" = true
AND e."color" IS NULL;

View File

@@ -0,0 +1,5 @@
ALTER TABLE "events" ADD COLUMN "state" text DEFAULT 'Final' NOT NULL;
UPDATE "events"
SET "state" = 'Final'
WHERE "state" IS NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "events" ADD COLUMN "repeatInterval" text DEFAULT 'Keine Wiederholung' NOT NULL;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "filetags" ADD COLUMN "isSystemUsed" boolean DEFAULT false NOT NULL;
UPDATE "filetags"
SET "isSystemUsed" = true
WHERE COALESCE("createddocumenttype", '') <> ''
OR COALESCE("incomingDocumentType", '') <> '';

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth_profiles"
ADD COLUMN "calendar_subscription_token" text;

View File

@@ -243,29 +243,57 @@
{
"idx": 34,
"version": "7",
"when": 1777420800000,
"tag": "0034_events_color",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1778191200000,
"tag": "0035_contract_history",
"breakpoints": true
},
{
"idx": 35,
"idx": 36,
"version": "7",
"when": 1778194800000,
"tag": "0036_allowed_contracttypes",
"breakpoints": true
},
{
"idx": 36,
"idx": 37,
"version": "7",
"when": 1778840100000,
"tag": "0037_outgoing_sepa_mandates",
"breakpoints": true
},
{
"idx": 37,
"idx": 38,
"version": "7",
"when": 1778840200000,
"tag": "0034_profile_availability_note",
"when": 1779158400000,
"tag": "0038_events_state",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1779840000000,
"tag": "0039_events_repeat_interval",
"breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1779141600000,
"tag": "0040_filetag_system_types",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1780149600000,
"tag": "0041_profile_calendar_subscription",
"breakpoints": true
}
]

View File

@@ -63,6 +63,7 @@ export const authProfiles = pgTable("auth_profiles", {
email: text("email"),
token_id: text("token_id"),
calendar_subscription_token: text("calendar_subscription_token"),
weekly_working_days: doublePrecision("weekly_working_days"),

View File

@@ -32,6 +32,9 @@ export const events = pgTable(
eventtype: text("eventtype").default("Umsetzung"),
quick: boolean("quick").notNull().default(false),
state: text("state").notNull().default("Final"),
color: text("color"),
repeatInterval: text("repeatInterval").notNull().default("Keine Wiederholung"),
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists

View File

@@ -26,6 +26,8 @@ export const filetags = pgTable("filetags", {
createdDocumentType: text("createddocumenttype").default(""),
incomingDocumentType: text("incomingDocumentType"),
isSystemUsed: boolean("isSystemUsed").notNull().default(false),
archived: boolean("archived").notNull().default(false),
})

View File

@@ -116,12 +116,12 @@ async function ensureTenantFileDefaults(server: FastifyInstance, tenantId: numbe
const timestamp = new Date()
const tagDefaults = [
{ name: "Rechnungen", color: "#16a34a", createdDocumentType: "invoices" },
{ name: "Angebote", color: "#2563eb", createdDocumentType: "quotes" },
{ name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders" },
{ name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes" },
{ name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices" },
{ name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders" },
{ name: "Rechnungen", color: "#16a34a", createdDocumentType: "invoices", isSystemUsed: true },
{ name: "Angebote", color: "#2563eb", createdDocumentType: "quotes", isSystemUsed: true },
{ name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders", isSystemUsed: true },
{ name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes", isSystemUsed: true },
{ name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices", isSystemUsed: true },
{ name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders", isSystemUsed: true },
]
for (const tag of tagDefaults) {

View File

@@ -20,6 +20,11 @@ type MatrixRoomEvent = {
content?: {
body?: string
msgtype?: string
url?: string
info?: {
mimetype?: string
size?: number
}
}
}
@@ -56,6 +61,14 @@ type MatrixTenantRoomOptions = {
entityType?: string | null
entityId?: number | null
entityUuid?: string | null
inviteUserIds?: string[]
}
type MatrixAttachmentInput = {
buffer: Buffer
filename: string
mimeType: string
size: number
}
type MatrixCachedValue<T = any> = {
@@ -247,6 +260,7 @@ export function matrixService(server: FastifyInstance) {
entityType: options.entityType || null,
entityId: options.entityId || null,
entityUuid: options.entityUuid || null,
inviteUserIds: options.inviteUserIds || [],
}
}
@@ -402,6 +416,41 @@ export function matrixService(server: FastifyInstance) {
})
}
const mxcToMediaPath = (mxcUri: string) => {
const match = mxcUri.match(/^mxc:\/\/([^/]+)\/(.+)$/)
if (!match) {
throw Object.assign(
new Error("Ungültige Matrix-Media-URI"),
{ statusCode: 400 }
)
}
return `/_matrix/media/v3/download/${encodeURIComponent(match[1])}/${encodeURIComponent(match[2])}`
}
const matrixMediaUrl = (mxcUri: string) => `${homeserverUrl()}${mxcToMediaPath(mxcUri)}`
const attachmentFromEvent = (event: MatrixRoomEvent) => {
const msgtype = event.content?.msgtype || "m.text"
if (!["m.file", "m.image"].includes(msgtype) || !event.content?.url) {
return null
}
const mimeType = event.content.info?.mimetype || "application/octet-stream"
return {
type: msgtype === "m.image" ? "image" : "file",
url: event.content.url,
fileName: event.content.body || "Anhang",
mimeType,
size: event.content.info?.size || 0,
previewUrl: msgtype === "m.image" ? matrixMediaUrl(event.content.url) : null,
downloadUrl: matrixMediaUrl(event.content.url),
isImage: msgtype === "m.image" || mimeType.startsWith("image/"),
}
}
const createAccessTokenForUser = async (userId: string, tenantId: number | null) => {
const matrixUserId = await matrixUserIdForUser(userId, tenantId)
const cacheKey = `${tenantId || "global"}:${userId}`
@@ -885,6 +934,7 @@ export function matrixService(server: FastifyInstance) {
const existing = await getTenantRoomStatus(tenant.id, key, name)
const userAccount = await provisionCurrentUser(userId, tenant.id)
const invitedMatrixUserIds = await matrixUserIdsForInvitees(userId, tenant.id, normalizedOptions.inviteUserIds || [])
const tenantSpace = await provisionCurrentTenantSpace(userId, tenant.id)
if (existing.exists) {
@@ -902,6 +952,8 @@ export function matrixService(server: FastifyInstance) {
invitedUserId: userAccount.matrixUserId,
}
await inviteUsersToRoom(existing.roomId, invitedMatrixUserIds)
matrixTenantRoomCache.set(cacheKey, {
exists: true,
cachedUntil: Date.now() + 30 * 60 * 1000,
@@ -923,7 +975,7 @@ export function matrixService(server: FastifyInstance) {
preset: "private_chat",
visibility: "private",
room_alias_name: tenantRoomAliasLocalpart(tenant, key),
invite: [userAccount.matrixUserId],
invite: Array.from(new Set([userAccount.matrixUserId, ...invitedMatrixUserIds])),
initial_state: [
{
type: "m.room.history_visibility",
@@ -994,6 +1046,42 @@ export function matrixService(server: FastifyInstance) {
return value
}
const matrixUserIdsForInvitees = async (
currentUserId: string,
tenantId: number,
inviteUserIds: string[]
) => {
const uniqueUserIds = Array.from(new Set(inviteUserIds.filter((id) => id && id !== currentUserId)))
return await Promise.all(uniqueUserIds.map(async (inviteUserId) => {
const account = await provisionCurrentUser(inviteUserId, tenantId)
return account.matrixUserId
}))
}
const inviteUsersToRoom = async (roomId: string | null, matrixUserIds: string[]) => {
if (!roomId || !matrixUserIds.length) return
const serviceLogin = await ensureServiceAccessToken()
for (const matrixUserId of matrixUserIds) {
try {
await requestMatrixJson(
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`,
serviceLogin.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: matrixUserId }),
}
)
} catch (err: any) {
if (err.statusCode === 403 || err.statusCode === 400) continue
throw err
}
}
}
const ensureCurrentUserJoinedRoom = async (
userId: string,
tenantId: number | null,
@@ -1077,12 +1165,16 @@ export function matrixService(server: FastifyInstance) {
name: room.name,
matrixUserId: session.matrixUserId,
messages: response.chunk
.filter((event) => event.type === "m.room.message" && event.content?.msgtype === "m.text")
.filter((event) =>
event.type === "m.room.message" &&
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "")
)
.map((event) => ({
id: event.event_id,
sender: event.sender,
senderDisplayName: members.joined[event.sender]?.display_name || event.sender,
body: event.content?.body || "",
attachment: attachmentFromEvent(event),
timestamp: event.origin_server_ts,
own: event.sender === session.matrixUserId,
}))
@@ -1169,6 +1261,103 @@ export function matrixService(server: FastifyInstance) {
}
}
const sendTenantRoomAttachment = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
attachment: MatrixAttachmentInput
) => {
if (!attachment.buffer?.length) {
throw Object.assign(
new Error("Attachment file is required"),
{ statusCode: 400 }
)
}
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const upload = await requestMatrixJson<{ content_uri: string }>(
`/_matrix/media/v3/upload?filename=${encodeURIComponent(attachment.filename)}`,
session.accessToken,
{
method: "POST",
headers: { "Content-Type": attachment.mimeType || "application/octet-stream" },
body: attachment.buffer as any,
}
)
const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}`
const isImage = (attachment.mimeType || "").startsWith("image/")
const messageContent = {
msgtype: isImage ? "m.image" : "m.file",
body: attachment.filename,
url: upload.content_uri,
info: {
mimetype: attachment.mimeType || "application/octet-stream",
size: attachment.size,
},
}
const response = await requestMatrixJson<{ event_id: string }>(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
session.accessToken,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(messageContent),
}
)
return {
id: response.event_id,
sender: session.matrixUserId,
senderDisplayName: await getCurrentUserDisplayName(userId, tenantId),
body: attachment.filename,
attachment: {
type: isImage ? "image" : "file",
url: upload.content_uri,
fileName: attachment.filename,
mimeType: attachment.mimeType || "application/octet-stream",
size: attachment.size,
previewUrl: isImage ? matrixMediaUrl(upload.content_uri) : null,
downloadUrl: matrixMediaUrl(upload.content_uri),
isImage,
},
timestamp: Date.now(),
own: true,
roomId: room.roomId,
alias: room.alias,
key: room.key,
}
}
const getMediaContent = async (
userId: string,
tenantId: number | null,
mxcUri: string
) => {
const session = await createAccessTokenForUser(userId, tenantId)
const response = await fetch(matrixMediaUrl(mxcUri), {
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
})
if (!response.ok) {
throw Object.assign(
new Error(`Matrix media request failed with ${response.status}`),
{ statusCode: response.status }
)
}
return {
buffer: Buffer.from(await response.arrayBuffer()),
contentType: response.headers.get("content-type") || "application/octet-stream",
contentLength: response.headers.get("content-length"),
}
}
const createElementRoomSession = async (
userId: string,
tenantId: number | null,
@@ -1401,6 +1590,17 @@ export function matrixService(server: FastifyInstance) {
text
)
const sendGeneralRoomAttachment = (userId: string, tenantId: number | null, attachment: MatrixAttachmentInput) =>
sendTenantRoomAttachment(
userId,
tenantId,
{
key: "allgemein",
name: "Allgemeiner Chat",
},
attachment
)
return {
getStatus,
matrixUserIdForUser,
@@ -1415,11 +1615,14 @@ export function matrixService(server: FastifyInstance) {
getTenantRoomMessages,
getTenantRoomMembers,
sendTenantRoomMessage,
sendTenantRoomAttachment,
getMediaContent,
createElementRoomSession,
createLiveKitRoomSession,
syncTenantRoomMembers,
getGeneralRoomMessages,
getGeneralRoomMembers,
sendGeneralRoomMessage,
sendGeneralRoomAttachment,
}
}

View File

@@ -45,36 +45,42 @@ export default async function adminRoutes(server: FastifyInstance) {
name: "Rechnungen",
color: "#16a34a",
createdDocumentType: "invoices",
isSystemUsed: true,
},
{
tenant: tenantId,
name: "Angebote",
color: "#2563eb",
createdDocumentType: "quotes",
isSystemUsed: true,
},
{
tenant: tenantId,
name: "Auftragsbestätigungen",
color: "#7c3aed",
createdDocumentType: "confirmationOrders",
isSystemUsed: true,
},
{
tenant: tenantId,
name: "Lieferscheine",
color: "#ea580c",
createdDocumentType: "deliveryNotes",
isSystemUsed: true,
},
{
tenant: tenantId,
name: "Eingangsrechnungen",
color: "#dc2626",
incomingDocumentType: "invoices",
isSystemUsed: true,
},
{
tenant: tenantId,
name: "Mahnungen",
color: "#b91c1c",
incomingDocumentType: "reminders",
isSystemUsed: true,
},
])
.returning({

View File

@@ -1,6 +1,8 @@
import { createHash } from "node:crypto"
import { FastifyInstance } from "fastify"
import { and, eq, ne } from "drizzle-orm"
import { authTenantUsers, authUsers } from "../../db/schema"
import multipart from "@fastify/multipart"
import { and, eq, inArray, ne } from "drizzle-orm"
import { authProfiles, authTenantUsers, authUsers, notificationsItems, projects } from "../../db/schema"
import { matrixService } from "../modules/matrix.service"
import { NotificationService, UserDirectory } from "../modules/notification.service"
@@ -14,7 +16,19 @@ const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId)
return rows[0] || null
}
type ChatRecipient = {
userId: string
email?: string | null
firstName?: string | null
lastName?: string | null
fullName?: string | null
}
export default async function communicationRoutes(server: FastifyInstance) {
await server.register(multipart, {
limits: { fileSize: 25 * 1024 * 1024 },
})
const matrix = matrixService(server)
const notifications = new NotificationService(server, getUserDirectory)
const handleMatrixError = (req: any, reply: any, err: any, fallbackMessage: string) => {
@@ -47,6 +61,194 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
}
const projectRoomKey = (projectId: number) => `project_${projectId}`
const directRoomKey = (firstUserId: string, secondUserId: string) => {
const hash = createHash("sha256")
.update([firstUserId, secondUserId].sort().join(":"))
.digest("hex")
.slice(0, 16)
return `direct_${hash}`
}
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 getTenantRecipients = async (tenantId: number, senderUserId: string) => {
return 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(and(
eq(authTenantUsers.tenant_id, tenantId),
ne(authTenantUsers.user_id, senderUserId)
))
}
const getSenderName = async (tenantId: number, senderUserId: string) => {
const [sender] = await server.db
.select({
email: authUsers.email,
firstName: authProfiles.first_name,
lastName: authProfiles.last_name,
fullName: authProfiles.full_name,
})
.from(authUsers)
.leftJoin(authProfiles, and(
eq(authProfiles.user_id, authUsers.id),
eq(authProfiles.tenant_id, tenantId)
))
.where(eq(authUsers.id, senderUserId))
.limit(1)
return sender ? displayUserName(sender) : "FEDEO"
}
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)
}
const chatMessageRecipients = async (
tenantId: number,
senderUserId: string,
room: any,
text: string
) => {
const recipients = await getTenantRecipients(tenantId, senderUserId)
const mentioned = new Set(mentionedRecipientIds(text, recipients))
const directRecipients = new Set<string>()
if (room?.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) {
directRecipients.add(room.entityUuid)
}
return recipients
.filter((recipient) => directRecipients.has(recipient.userId) || mentioned.has(recipient.userId))
.map((recipient) => ({
...recipient,
mentioned: mentioned.has(recipient.userId),
direct: directRecipients.has(recipient.userId),
}))
}
const notifyUsersAboutChatMessage = async (req: any, room: any, message: any, text: string) => {
if (!req.user.tenant_id) return
try {
const recipients = await chatMessageRecipients(req.user.tenant_id, req.user.user_id, room, text)
if (!recipients.length) return
const senderName = await getSenderName(req.user.tenant_id, req.user.user_id)
const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text
for (const recipient of recipients) {
await notifications.trigger({
tenantId: req.user.tenant_id,
userId: recipient.userId,
eventType: "communication.message.new",
title: recipient.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,
mentioned: recipient.mentioned,
direct: recipient.direct,
},
channels: ["inapp", "push"],
})
}
} catch (err) {
req.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden")
}
}
const unreadChatNotifications = async (tenantId: number, userId: string) => {
return await server.db
.select({
id: notificationsItems.id,
payload: notificationsItems.payload,
})
.from(notificationsItems)
.where(and(
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId),
eq(notificationsItems.eventType, "communication.message.new"),
eq(notificationsItems.channel, "inapp"),
ne(notificationsItems.status, "read")
))
}
const markRoomNotificationsRead = async (tenantId: number, userId: string, roomKey: string) => {
const rows = await unreadChatNotifications(tenantId, userId)
const ids = rows
.filter((row) => (row.payload as any)?.roomKey === roomKey)
.map((row) => row.id)
if (!ids.length) return { read: 0 }
await server.db
.update(notificationsItems)
.set({ readAt: new Date(), status: "read" })
.where(and(
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId),
inArray(notificationsItems.id, ids)
))
return { read: ids.length }
}
const uploadedAttachmentFromRequest = async (req: any) => {
const data = await req.file()
if (!data?.file) {
throw Object.assign(
new Error("Keine Datei hochgeladen"),
{ statusCode: 400 }
)
}
const buffer = await data.toBuffer()
return {
buffer,
filename: data.filename || "Anhang",
mimeType: data.mimetype || "application/octet-stream",
size: buffer.length,
}
}
const callModeFromRequest = (req: any): "audio" | "video" => {
const body = (req.body || {}) as { mode?: string }
return body.mode === "audio" ? "audio" : "video"
@@ -131,6 +333,229 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
})
server.get("/communication/matrix/unread", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const rows = await unreadChatNotifications(req.user.tenant_id, req.user.user_id)
const rooms = rows.reduce((acc: Record<string, { count: number; mentions: number }>, row) => {
const payload = row.payload as any
const roomKey = payload?.roomKey
if (!roomKey) return acc
acc[roomKey] = acc[roomKey] || { count: 0, mentions: 0 }
acc[roomKey].count += 1
if (payload.mentioned) {
acc[roomKey].mentions += 1
}
return acc
}, {})
return { rooms }
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix unread state failed")
}
})
server.get("/communication/matrix/media", async (req, reply) => {
try {
const query = req.query as { uri?: string; name?: string }
if (!query.uri) return reply.code(400).send({ error: "Matrix-Media-URI fehlt" })
const media = await matrix.getMediaContent(req.user.user_id, req.user.tenant_id, query.uri)
reply.header("Content-Type", media.contentType)
if (media.contentLength) reply.header("Content-Length", media.contentLength)
if (query.name) {
reply.header("Content-Disposition", `inline; filename="${query.name.replace(/"/g, "")}"`)
}
return reply.send(media.buffer)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix media failed")
}
})
server.get("/communication/matrix/project-rooms", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const [roomsRes, projectRows] = await Promise.all([
matrix.listTenantRooms(req.user.tenant_id),
server.db
.select({
id: projects.id,
name: projects.name,
projectNumber: projects.projectNumber,
profiles: projects.profiles,
})
.from(projects)
.where(and(
eq(projects.tenant, req.user.tenant_id),
eq(projects.archived, false)
))
])
const roomsByKey = new Map((roomsRes.rooms || []).map((room: any) => [room.key, room]))
return {
rooms: projectRows.map((project) => {
const key = projectRoomKey(project.id)
const existing = roomsByKey.get(key) as any
return {
...(existing || {}),
key,
name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name,
topic: `Projektkommunikation zu ${project.name}`,
type: "project",
entityType: "project",
entityId: project.id,
exists: Boolean(existing?.exists),
projectId: project.id,
projectNumber: project.projectNumber,
}
})
}
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix project rooms failed")
}
})
server.post("/communication/matrix/project-rooms/:projectId/provision", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const params = req.params as { projectId: string }
const projectId = Number(params.projectId)
const [project] = await server.db
.select()
.from(projects)
.where(and(
eq(projects.tenant, req.user.tenant_id),
eq(projects.id, projectId)
))
.limit(1)
if (!project) return reply.code(404).send({ error: "Projekt nicht gefunden" })
const profileIds = (project.profiles || []) as string[]
const profileRows = profileIds.length
? await server.db
.select({ userId: authProfiles.user_id })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, req.user.tenant_id),
inArray(authProfiles.id, profileIds)
))
: []
const inviteUserIds = profileRows.map((profile) => profile.userId).filter(Boolean) as string[]
return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, {
key: projectRoomKey(project.id),
name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name,
topic: `Projektkommunikation zu ${project.name}`,
type: "project",
entityType: "project",
entityId: project.id,
inviteUserIds,
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix project room provisioning failed")
}
})
server.get("/communication/matrix/direct-rooms", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const [roomsRes, userRows] = await Promise.all([
matrix.listTenantRooms(req.user.tenant_id),
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, req.user.tenant_id)
))
.where(and(
eq(authTenantUsers.tenant_id, req.user.tenant_id),
ne(authTenantUsers.user_id, req.user.user_id)
))
])
const roomsByKey = new Map((roomsRes.rooms || []).map((room: any) => [room.key, room]))
return {
rooms: userRows.map((user) => {
const key = directRoomKey(req.user.user_id, user.userId)
const existing = roomsByKey.get(key) as any
const name = displayUserName(user)
return {
...(existing || {}),
key,
name,
topic: `Direktnachricht mit ${name}`,
type: "direct",
entityType: "user",
entityUuid: user.userId,
exists: Boolean(existing?.exists),
userId: user.userId,
email: user.email,
}
})
}
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix direct rooms failed")
}
})
server.post("/communication/matrix/direct-rooms/:userId/provision", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const params = req.params as { userId: string }
const [target] = 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, req.user.tenant_id)
))
.where(and(
eq(authTenantUsers.tenant_id, req.user.tenant_id),
eq(authTenantUsers.user_id, params.userId)
))
.limit(1)
if (!target || target.userId === req.user.user_id) {
return reply.code(404).send({ error: "Benutzer nicht gefunden" })
}
const targetName = displayUserName(target)
return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, {
key: directRoomKey(req.user.user_id, target.userId),
name: targetName,
topic: `Direktnachricht mit ${targetName}`,
type: "direct",
entityType: "user",
entityUuid: target.userId,
inviteUserIds: [target.userId],
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix direct room provisioning failed")
}
})
server.post("/communication/matrix/rooms", async (req, reply) => {
try {
return await matrix.provisionTenantRoom(
@@ -217,12 +642,27 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.post("/communication/matrix/rooms/general/messages", async (req, reply) => {
try {
const body = req.body as { text?: string }
return await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "")
const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "")
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat")
await notifyUsersAboutChatMessage(req, room, message, body.text || "")
return message
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix message send failed")
}
})
server.post("/communication/matrix/rooms/general/attachments", async (req, reply) => {
try {
const attachment = await uploadedAttachmentFromRequest(req)
const message = await matrix.sendGeneralRoomAttachment(req.user.user_id, req.user.tenant_id, attachment)
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat")
await notifyUsersAboutChatMessage(req, room, message, `Anhang: ${attachment.filename}`)
return message
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix attachment send failed")
}
})
server.get("/communication/matrix/rooms/:roomKey", async (req, reply) => {
try {
const params = req.params as { roomKey: string }
@@ -244,6 +684,16 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
})
server.post("/communication/matrix/rooms/:roomKey/read", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const params = req.params as { roomKey: string }
return await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room read state failed")
}
})
server.get("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
try {
return await matrix.getTenantRoomMessages(
@@ -310,14 +760,34 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
try {
const body = req.body as { text?: string }
return await matrix.sendTenantRoomMessage(
const message = await matrix.sendTenantRoomMessage(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
body.text || ""
)
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key)
await notifyUsersAboutChatMessage(req, room, message, body.text || "")
return message
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix message send failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => {
try {
const attachment = await uploadedAttachmentFromRequest(req)
const message = await matrix.sendTenantRoomAttachment(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
attachment
)
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key)
await notifyUsersAboutChatMessage(req, room, message, `Anhang: ${attachment.filename}`)
return message
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix attachment send failed")
}
})
}

View File

@@ -14,6 +14,10 @@ import {
resolveTenantTeamIds,
syncProfileTeams,
} from "../utils/profileTeams";
import {
enrichProfileWithCalendarSubscription,
generateProfileCalendarSubscriptionToken,
} from "../utils/calendarSubscription";
export default async function authProfilesRoutes(server: FastifyInstance) {
@@ -38,7 +42,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
return reply.code(404).send({ error: "User not found or not in tenant" });
}
return profile;
return enrichProfileWithCalendarSubscription(profile);
} catch (error) {
console.error("GET /profiles/:id ERROR:", error);
@@ -50,10 +54,11 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
const cleaned: any = { ...body }
// ❌ Systemfelder entfernen
const forbidden = [
"id", "user_id", "tenant_id", "created_at", "updated_at",
"updatedAt", "updatedBy", "old_profile_id", "full_name",
"branch"
const forbidden = [
"id", "user_id", "tenant_id", "created_at", "updated_at",
"updatedAt", "updatedBy", "old_profile_id", "full_name",
"branch", "calendar_subscription_token",
"calendar_subscription_path", "calendar_subscription_url"
]
forbidden.forEach(f => delete cleaned[f])
@@ -146,7 +151,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
const [profile] = profileWithBranches
? await enrichProfilesWithTeams(server, [profileWithBranches])
: [null]
return profile || updated[0]
return enrichProfileWithCalendarSubscription(profile || updated[0])
} catch (err) {
console.error("PUT /profiles/:id ERROR:", err)
@@ -159,4 +164,31 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
return reply.code(500).send({ error: "Internal Server Error" })
}
})
server.post("/profiles/:id/calendar-subscription-token", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const updatedProfile = await generateProfileCalendarSubscriptionToken(server, id, tenantId)
if (!updatedProfile) {
return reply.code(404).send({ error: "User not found or not in tenant" })
}
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
const [profile] = profileWithBranches
? await enrichProfilesWithTeams(server, [profileWithBranches])
: [updatedProfile]
return enrichProfileWithCalendarSubscription(profile)
} catch (err) {
console.error("POST /profiles/:id/calendar-subscription-token ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
}

View File

@@ -1,8 +1,32 @@
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
import { publicLinkService } from '../../modules/publiclinks.service';
import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs
import { buildProfileCalendarSubscriptionFeed, loadProfileByCalendarSubscriptionToken } from '../../utils/calendarSubscription';
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
server.get("/api/public/calendar/subscriptions/:token.ics", async (req, reply) => {
const { token } = req.params as { token: string }
try {
const profile = await loadProfileByCalendarSubscriptionToken(server, token)
if (!profile || !profile.active) {
return reply.code(404).send({ error: "Kalender-Abo nicht gefunden" })
}
const icsFeed = await buildProfileCalendarSubscriptionFeed(server, profile)
reply.header("Content-Type", "text/calendar; charset=utf-8")
reply.header("Content-Disposition", `inline; filename="fedeo-${profile.id}.ics"`)
reply.header("Cache-Control", "private, max-age=300")
return reply.send(icsFeed)
} catch (error: any) {
server.log.error(error)
return reply.code(500).send({ error: "Interner Server Fehler" })
}
})
server.get("/workflows/context/:token", async (req, reply) => {
const { token } = req.params as { token: string };
const pin = req.headers['x-public-pin'] as string | undefined;

View File

@@ -230,6 +230,53 @@ function isDateLikeField(key: string) {
return /(^|_|-)date($|_|-)/i.test(key)
}
function isDateValue(value: any) {
if (value instanceof Date) return !Number.isNaN(value.getTime())
if (typeof value !== "string") return false
const normalized = value.trim()
if (/^\d+$/.test(normalized)) return false
return !Number.isNaN(new Date(normalized).getTime())
}
function normalizeCreatedDocumentPayload(payload: Record<string, any>) {
const numberRelationFields = [
"customer",
"contact",
"project",
"createddocument",
"letterhead",
"plant",
"contract",
"outgoingsepamandate",
]
for (const field of numberRelationFields) {
const value = payload[field]
if (value instanceof Date || (typeof value === "string" && isDateValue(value))) {
payload[field] = null
}
}
const serialexecution = payload.serialexecution
if (serialexecution === undefined || serialexecution === null || serialexecution === "") return payload
if (isDateValue(serialexecution)) {
payload.serialexecution = null
return payload
}
if (typeof serialexecution === "string") {
const normalized = serialexecution.trim()
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(normalized)
if (!isUuid && isDateValue(normalized)) {
payload.serialexecution = null
return payload
}
}
return payload
}
function normalizeMemberPayload(payload: Record<string, any>) {
const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
const normalized = {
@@ -324,16 +371,28 @@ function maskIban(iban: string) {
}
function decryptEntityBankAccount(row: Record<string, any>) {
const iban = row.ibanEncrypted ? decrypt(row.ibanEncrypted as any) : null
const bic = row.bicEncrypted ? decrypt(row.bicEncrypted as any) : null
const bankName = row.bankNameEncrypted ? decrypt(row.bankNameEncrypted as any) : null
let iban = null
let bic = null
let bankName = null
let decryptError = null
try {
iban = row.ibanEncrypted ? decrypt(row.ibanEncrypted as any) : null
bic = row.bicEncrypted ? decrypt(row.bicEncrypted as any) : null
bankName = row.bankNameEncrypted ? decrypt(row.bankNameEncrypted as any) : null
} catch (err: any) {
decryptError = err?.message || "Bankverbindung konnte nicht entschlüsselt werden."
}
return {
...row,
iban,
bic,
bankName,
displayLabel: `${maskIban(iban || "")}${bankName ? ` | ${bankName}` : ""}${row.description ? ` (${row.description})` : ""}`.trim(),
decryptError,
displayLabel: decryptError
? `Bankverbindung nicht lesbar${row.description ? ` (${row.description})` : ""}`
: `${maskIban(iban || "")}${bankName ? ` | ${bankName}` : ""}${row.description ? ` (${row.description})` : ""}`.trim(),
}
}
@@ -865,6 +924,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
}
if (resource === "createddocuments") {
createData = normalizeCreatedDocumentPayload(createData)
}
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
const numberRangeResource = resource === "members" ? "customers" : resource
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
@@ -873,7 +936,16 @@ export default async function resourceRoutes(server: FastifyInstance) {
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
Object.keys(createData).forEach((key) => {
if (key.toLowerCase().includes("date") && key !== "deliveryDateType") createData[key] = normalizeDate(createData[key])
const value = createData[key]
const shouldNormalize =
isDateLikeField(key) &&
value !== null &&
value !== undefined &&
(typeof value === "string" || typeof value === "number" || value instanceof Date)
if (shouldNormalize) {
createData[key] = normalizeDate(value)
}
})
const [created] = await server.db.insert(table).values(createData).returning()
@@ -956,6 +1028,14 @@ export default async function resourceRoutes(server: FastifyInstance) {
//@ts-ignore
delete data.updatedBy; delete data.updatedAt;
if (resource === "filetags") {
delete data.isSystemUsed
if (oldRecord.isSystemUsed && data.archived === true) {
return reply.code(400).send({ error: "System-Dateitypen können nicht archiviert werden" })
}
}
if (portalCustomerId) {
data = {
...sanitizePortalCustomerUpdate(data),
@@ -1004,6 +1084,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
}
if (resource === "createddocuments") {
data = normalizeCreatedDocumentPayload(data)
}
Object.keys(data).forEach((key) => {
const value = data[key]
const shouldNormalize =

View File

@@ -0,0 +1,193 @@
import crypto from "crypto"
import { and, asc, eq, sql } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { authProfiles, events } from "../../db/schema"
import { secrets } from "./secrets"
const CALENDAR_FEED_PREFIX = "/api/public/calendar/subscriptions"
function escapeIcsText(value: string) {
return value
.replace(/\\/g, "\\\\")
.replace(/\r?\n/g, "\\n")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
}
function foldIcsLine(line: string) {
const maxLength = 73
if (line.length <= maxLength) return line
const parts: string[] = []
for (let index = 0; index < line.length; index += maxLength) {
parts.push(index === 0 ? line.slice(index, index + maxLength) : ` ${line.slice(index, index + maxLength)}`)
}
return parts.join("\r\n")
}
function formatUtcDate(value: string | Date | null | undefined) {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}Z$/, "Z")
}
function buildRecurrenceRule(repeatInterval?: string | null) {
switch (repeatInterval) {
case "Täglich":
return "FREQ=DAILY"
case "Wöchentlich":
return "FREQ=WEEKLY"
case "2-wöchentlich":
return "FREQ=WEEKLY;INTERVAL=2"
case "Monatlich":
return "FREQ=MONTHLY"
case "Jährlich":
return "FREQ=YEARLY"
default:
return null
}
}
function normalizeApiBaseUrl() {
const rawBase = secrets.API_BASE_URL?.trim()
if (!rawBase) return null
return rawBase.replace(/\/+$/, "")
}
export function buildCalendarSubscriptionPath(token: string) {
return `${CALENDAR_FEED_PREFIX}/${token}.ics`
}
export function buildCalendarSubscriptionUrl(token: string) {
const apiBaseUrl = normalizeApiBaseUrl()
const subscriptionPath = buildCalendarSubscriptionPath(token)
if (!apiBaseUrl) return subscriptionPath
if (apiBaseUrl.endsWith("/api")) {
return `${apiBaseUrl}${subscriptionPath.replace(/^\/api/, "")}`
}
return `${apiBaseUrl}${subscriptionPath}`
}
export function enrichProfileWithCalendarSubscription(profile: Record<string, any> | null) {
if (!profile) return profile
return {
...profile,
calendar_subscription_path: profile.calendar_subscription_token
? buildCalendarSubscriptionPath(profile.calendar_subscription_token)
: null,
calendar_subscription_url: profile.calendar_subscription_token
? buildCalendarSubscriptionUrl(profile.calendar_subscription_token)
: null,
}
}
export async function generateProfileCalendarSubscriptionToken(server: FastifyInstance, profileId: string, tenantId: number) {
const token = crypto.randomBytes(24).toString("hex")
const [profile] = await server.db
.update(authProfiles)
.set({
calendar_subscription_token: token,
})
.where(
and(
eq(authProfiles.id, profileId),
eq(authProfiles.tenant_id, tenantId)
)
)
.returning()
return profile || null
}
export async function loadProfileByCalendarSubscriptionToken(server: FastifyInstance, token: string) {
return server.db.query.authProfiles.findFirst({
where: eq(authProfiles.calendar_subscription_token, token)
})
}
export async function buildProfileCalendarSubscriptionFeed(server: FastifyInstance, profile: typeof authProfiles.$inferSelect) {
const assignedEvents = await server.db
.select({
id: events.id,
name: events.name,
startDate: events.startDate,
endDate: events.endDate,
repeatInterval: events.repeatInterval,
notes: events.notes,
link: events.link,
state: events.state,
color: events.color,
createdAt: events.createdAt,
updatedAt: events.updatedAt
})
.from(events)
.where(
and(
eq(events.tenant, profile.tenant_id),
eq(events.archived, false),
sql`${events.profiles} ? ${profile.id}`
)
)
.orderBy(asc(events.startDate))
const nowStamp = formatUtcDate(new Date()) || ""
const calendarLines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//FEDEO//Kalender-Abo//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
foldIcsLine(`X-WR-CALNAME:${escapeIcsText(`FEDEO - ${profile.full_name}`)}`),
foldIcsLine(`X-WR-CALDESC:${escapeIcsText(`Kalender-Abo für ${profile.full_name}`)}`),
]
assignedEvents.forEach((event) => {
const startDate = formatUtcDate(event.startDate)
if (!startDate) return
const endDate = formatUtcDate(event.endDate)
const lastModified = formatUtcDate(event.updatedAt || event.createdAt) || nowStamp
const recurrenceRule = buildRecurrenceRule(event.repeatInterval)
const descriptionParts = [event.notes, event.link].filter(Boolean)
const summary = event.state === "Entwurf" ? `[Entwurf] ${event.name}` : event.name
calendarLines.push("BEGIN:VEVENT")
calendarLines.push(foldIcsLine(`UID:fedeo-event-${event.id}@fedeo.local`))
calendarLines.push(`DTSTAMP:${nowStamp}`)
calendarLines.push(`DTSTART:${startDate}`)
if (endDate) {
calendarLines.push(`DTEND:${endDate}`)
}
calendarLines.push(`LAST-MODIFIED:${lastModified}`)
calendarLines.push(foldIcsLine(`SUMMARY:${escapeIcsText(summary)}`))
calendarLines.push(`STATUS:${event.state === "Entwurf" ? "TENTATIVE" : "CONFIRMED"}`)
if (event.color) {
calendarLines.push(foldIcsLine(`COLOR:${escapeIcsText(event.color)}`))
}
if (descriptionParts.length) {
calendarLines.push(foldIcsLine(`DESCRIPTION:${escapeIcsText(descriptionParts.join("\n\n"))}`))
}
if (recurrenceRule) {
calendarLines.push(`RRULE:${recurrenceRule}`)
}
calendarLines.push("END:VEVENT")
})
calendarLines.push("END:VCALENDAR")
return `${calendarLines.join("\r\n")}\r\n`
}

View File

@@ -2,11 +2,18 @@ import crypto from "crypto";
import {secrets} from "./secrets"
const ALGORITHM = "aes-256-gcm";
function getEncryptionKey() {
const key = secrets.ENCRYPTION_KEY || ""
if (!/^[a-f0-9]{64}$/i.test(key)) {
throw new Error("ENCRYPTION_KEY muss ein 64 Zeichen langer Hex-String sein. Beispiel: openssl rand -hex 32")
}
return Buffer.from(key, "hex")
}
export function encrypt(text) {
const ENCRYPTION_KEY = Buffer.from(secrets.ENCRYPTION_KEY, "hex");
const ENCRYPTION_KEY = getEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
@@ -21,7 +28,7 @@ export function encrypt(text) {
}
export function decrypt({ iv, content, tag }) {
const ENCRYPTION_KEY = Buffer.from(secrets.ENCRYPTION_KEY, "hex");
const ENCRYPTION_KEY = getEncryptionKey();
const decipher = crypto.createDecipheriv(
ALGORITHM,
ENCRYPTION_KEY,

View File

@@ -105,7 +105,22 @@ export const resourceConfig = {
numberRangeHolder: "vendorNumber",
},
files: {
table: files
table: files,
mtoLoad: [
"project",
"customer",
"contract",
"vendor",
"incominginvoice",
"plant",
"createddocument",
"vehicle",
"product",
"check",
"inventoryitem",
"authProfile",
"type",
],
},
folders: {
table: folders
@@ -113,6 +128,9 @@ export const resourceConfig = {
filetags: {
table: filetags
},
type: {
table: filetags
},
inventoryitems: {
table: inventoryitems,
numberRangeHolder: "articleNumber",
@@ -201,6 +219,11 @@ export const resourceConfig = {
tenantKey: "tenant_id",
searchColumns: ["first_name", "last_name", "full_name", "email", "employee_number"],
},
authProfile: {
table: authProfiles,
tenantKey: "tenant_id",
searchColumns: ["first_name", "last_name", "full_name", "email", "employee_number"],
},
letterheads: {
table: letterheads,

View File

@@ -4,6 +4,7 @@ import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
import { pool } from "../../db"
import { s3 } from "./s3"
import { secrets } from "./secrets"
import { decrypt, encrypt } from "./crypt"
type TableRows = Record<string, Record<string, any>[]>
type TableMetadata = {
@@ -38,6 +39,12 @@ type ImportOptions = {
targetTenantId?: number | null
}
const ENTITY_BANKACCOUNT_PLAIN_FIELDS = {
iban: "__plainIban",
bic: "__plainBic",
bankName: "__plainBankName",
}
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`
const tableColumns = async (client: any) => {
@@ -98,6 +105,22 @@ const addRows = (tables: TableRows, table: string, rows: Record<string, any>[])
tables[table] = existingRows
}
const decryptEntityBankAccountsForExport = (rows: Record<string, any>[] = []) => {
return rows.map((row) => {
const nextRow = { ...row }
try {
nextRow[ENTITY_BANKACCOUNT_PLAIN_FIELDS.iban] = row.iban_encrypted ? decrypt(row.iban_encrypted) : null
nextRow[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bic] = row.bic_encrypted ? decrypt(row.bic_encrypted) : null
nextRow[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bankName] = row.bank_name_encrypted ? decrypt(row.bank_name_encrypted) : null
} catch (err: any) {
throw new Error(`Bankverbindung ${row.id || ""} konnte für den Export nicht entschlüsselt werden: ${err?.message || err}`)
}
return nextRow
})
}
const loadObjectAsBase64 = async (path: string) => {
const { Body } = await s3.send(new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
@@ -165,6 +188,10 @@ export const buildTenantFullExport = async (server: FastifyInstance, tenantId: n
addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds]))
}
if (tables.entitybankaccounts?.length) {
tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts)
}
const fileRows = tables.files || []
const files = []
@@ -266,6 +293,26 @@ const remapTenantScopedExport = (
}
}
const encryptEntityBankAccountRowsForImport = (exportData: TenantFullExport) => {
const rows = exportData.tables.entitybankaccounts || []
for (const row of rows) {
const plainIban = row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.iban]
const plainBic = row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bic]
const plainBankName = row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bankName]
if (typeof plainIban === "string" && typeof plainBic === "string" && typeof plainBankName === "string") {
row.iban_encrypted = encrypt(plainIban)
row.bic_encrypted = encrypt(plainBic)
row.bank_name_encrypted = encrypt(plainBankName)
}
delete row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.iban]
delete row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bic]
delete row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bankName]
}
}
const prepareColumnValue = (value: any, isJsonColumn: boolean) => {
if (!isJsonColumn || value === null || typeof value === "undefined") return value
if (typeof value === "string") return value
@@ -384,6 +431,7 @@ export const importTenantFullExport = async (
}
const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId)
encryptEntityBankAccountRowsForImport(exportData)
const client = await pool.connect()
const importOrder = [
"tenants",

View File

@@ -1,8 +1,7 @@
<script setup>
const toast = useToast()
const dataStore = useDataStore()
const modal = useModal()
const props = defineProps({
documentData: {
type: Object,
@@ -15,353 +14,264 @@ const props = defineProps({
returnEmit: {
type: Boolean
},
})
const emit = defineEmits(["updateNeeded"])
const folders = ref([])
const filetypes = ref([])
const documentboxes = ref([])
const getFiletypeId = () => props.documentData.type && typeof props.documentData.type === "object"
? props.documentData.type.id
: props.documentData.type || null
const selectedFiletype = ref(getFiletypeId())
const resourceOptions = ref([
{label: "Projekt", value: "project", entity: "projects", optionAttr: "name", route: (item) => `/standardEntity/projects/show/${item.id}`},
{label: "Kunde", value: "customer", entity: "customers", optionAttr: "name", route: (item) => `/standardEntity/customers/show/${item.id}`},
{label: "Lieferant", value: "vendor", entity: "vendors", optionAttr: "name", route: (item) => `/standardEntity/vendors/show/${item.id}`},
{label: "Fahrzeug", value: "vehicle", entity: "vehicles", optionAttr: "licensePlate", route: (item) => `/standardEntity/vehicles/show/${item.id}`},
{label: "Objekt", value: "plant", entity: "plants", optionAttr: "name", route: (item) => `/standardEntity/plants/show/${item.id}`},
{label: "Vertrag", value: "contract", entity: "contracts", optionAttr: "name", route: (item) => `/standardEntity/contracts/show/${item.id}`},
{label: "Produkt", value: "product", entity: "products", optionAttr: "name", route: (item) => `/standardEntity/products/show/${item.id}`},
{label: "Ausgangsbeleg", value: "createddocument", entity: "createddocuments", optionAttr: "documentNumber", route: (item) => `/createDocument/show/${item.id}`},
{label: "Eingangsrechnung", value: "incominginvoice", entity: "incominginvoices", optionAttr: "reference", route: (item) => `/incomingInvoices/show/${item.id}`},
{label: "Inventarartikel", value: "inventoryitem", entity: "inventoryitems", optionAttr: "name", route: (item) => `/standardEntity/inventoryitems/show/${item.id}`},
{label: "Überprüfung", value: "check", entity: "checks", optionAttr: "name", route: (item) => `/standardEntity/checks/show/${item.id}`},
{label: "Mitarbeiter", value: "authProfile", entity: "profiles", optionAttr: "fullName", route: (item) => `/staff/profiles/${item.id}`}
])
const resourceToAssign = ref("project")
const itemOptions = ref([])
const idToAssign = ref(null)
const selectedResource = computed(() => resourceOptions.value.find((option) => option.value === resourceToAssign.value))
const setup = async () => {
const data = await useEntities("folders").select()
data.forEach(folder => {
let name = folder.name
const addParent = (item) => {
name = `${item.name} > ${name}`
if(item.parent){
addParent(data.find(i => i.id === item.parent))
} else {
folders.value.push({
id: folder.id,
name: name,
})
}
}
if(folder.parent) {
addParent(data.find(i => i.id === folder.parent))
} else {
folders.value.push({
id: folder.id,
name: folder.name,
})
}
})
filetypes.value = await useEntities("filetags").select()
//documentboxes.value = await useEntities("documentboxes").select()
selectedFiletype.value = getFiletypeId()
await getItemsBySelectedResource()
}
setup()
const updateDocument = async () => {
const {url, ...objData} = props.documentData
delete objData.url
delete objData.filetags
/*console.log(objData)
if(objData.project) objData.project = objData.project.id
if(objData.customer) objData.customer = objData.customer.id
if(objData.contract) objData.contract = objData.contract.id
if(objData.vendor) objData.vendor = objData.vendor.id
if(objData.plant) objData.plant = objData.plant.id
if(objData.createddocument) objData.createddocument = objData.createddocument.id
if(objData.vehicle) objData.vehicle = objData.vehicle.id
if(objData.product) objData.product = objData.product.id
if(objData.profile) objData.profile = objData.profile.id
if(objData.check) objData.check = objData.check.id
if(objData.inventoryitem) objData.inventoryitem = objData.inventoryitem.id
if(objData.incominginvoice) objData.incominginvoice = objData.incominginvoice.id*/
console.log(objData)
const {data,error} = await useEntities("files").update(objData.id, objData)
if(error) {
console.log(error)
} else {
console.log(data)
const updateDocument = async (payload, closeAfterUpdate = false) => {
try {
await useEntities("files").update(props.documentData.id, payload, true)
Object.assign(props.documentData, payload)
toast.add({title: "Datei aktualisiert"})
modal.close()
emit("updateNeeded")
//openShowModal.value = false
if (closeAfterUpdate) modal.close()
} catch (error) {
console.error(error)
toast.add({title: "Datei konnte nicht aktualisiert werden", color: "error"})
}
}
const archiveDocument = async () => {
props.documentData.archived = true
await updateDocument()
modal.close()
emit("update")
await updateDocument({archived: true}, true)
}
const resourceOptions = ref([
{label: 'Projekt', value: 'project', optionAttr: "name"},
{label: 'Kunde', value: 'customer', optionAttr: "name"},
{label: 'Lieferant', value: 'vendor', optionAttr: "name"},
{label: 'Fahrzeug', value: 'vehicle', optionAttr: "licensePlate"},
{label: 'Objekt', value: 'plant', optionAttr: "name"},
{label: 'Vertrag', value: 'contract', optionAttr: "name"},
{label: 'Produkt', value: 'product', optionAttr: "name"}
])
const resourceToAssign = ref("project")
const itemOptions = ref([])
const idToAssign = ref(null)
const getItemsBySelectedResource = async () => {
if(resourceToAssign.value === "project") {
itemOptions.value = await useEntities("projects").select()
} else if(resourceToAssign.value === "customer") {
itemOptions.value = await useEntities("customers").select()
} else if(resourceToAssign.value === "vendor") {
itemOptions.value = await useEntities("vendors").select()
} else if(resourceToAssign.value === "vehicle") {
itemOptions.value = await useEntities("vehicles").select()
} else if(resourceToAssign.value === "product") {
itemOptions.value = await useEntities("products").select()
} else if(resourceToAssign.value === "plant") {
itemOptions.value = await useEntities("plants").select()
} else if(resourceToAssign.value === "contract") {
itemOptions.value = await useEntities("contracts").select()
} else {
itemOptions.value = []
}
idToAssign.value = null
itemOptions.value = selectedResource.value?.entity
? await useEntities(selectedResource.value.entity).select()
: []
}
getItemsBySelectedResource()
const updateDocumentAssignment = async () => {
props.documentData[resourceToAssign.value] = idToAssign.value
await updateDocument()
if (!selectedResource.value || !idToAssign.value) return
await updateDocument({[selectedResource.value.value]: idToAssign.value})
props.documentData[selectedResource.value.value] = itemOptions.value.find((item) => item.id === idToAssign.value) || idToAssign.value
idToAssign.value = null
}
const folderToMoveTo = ref(null)
const moveFile = async () => {
const res = await useEntities("files").update(props.documentData.id, {folder: folderToMoveTo.value})
modal.close()
const removeAssignment = async (assignment) => {
await updateDocument({[assignment.value]: null})
props.documentData[assignment.value] = null
}
const getAssignmentItem = (assignment) => {
const value = props.documentData[assignment.value]
return value && typeof value === "object" ? value : null
}
const getAssignmentLabel = (assignment) => {
const value = props.documentData[assignment.value]
if (!value) return ""
if (typeof value === "object") return value[assignment.optionAttr] || value.name || value.id
return value
}
const currentAssignments = computed(() =>
resourceOptions.value
.filter((assignment) => Boolean(props.documentData[assignment.value]))
.map((assignment) => ({
...assignment,
item: getAssignmentItem(assignment),
display: getAssignmentLabel(assignment)
}))
)
const displayedFileTags = computed(() => {
if (Array.isArray(props.documentData.filetags) && props.documentData.filetags.length) {
return props.documentData.filetags
}
if (props.documentData.type && typeof props.documentData.type === "object") {
return [props.documentData.type]
}
const selected = filetypes.value.find((filetype) => filetype.id === props.documentData.type)
return selected ? [selected] : []
})
const updateFiletype = async () => {
await updateDocument({type: selectedFiletype.value || null})
props.documentData.type = selectedFiletype.value || null
}
setup()
</script>
<template>
<UModal fullscreen >
<UModal fullscreen>
<template #content>
<UCard :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }" class="h-full">
<template #header>
<div class="flex flex-row justify-between">
<div class="flex items-center gap-2">
<UBadge
v-for="tag in props.documentData.filetags"
>
{{tag.name}}
</UBadge>
<template #header>
<div class="flex flex-row justify-between">
<div class="flex items-center gap-2">
<UBadge
v-for="tag in displayedFileTags"
:key="tag.id"
>
{{tag.name}}
</UBadge>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="modal.close()" />
</div>
</template>
<div class="flex flex-row">
<div class="w-1/3">
<PDFViewer
v-if="props.documentData.id && props.documentData.path.toLowerCase().includes('pdf')"
:file-id="props.documentData.id" />
<img
v-else
class="w-full"
:src="props.documentData.url"
alt=""
/>
</div>
<div class="w-2/3 p-5">
<UButtonGroup>
<ArchiveButton
color="error"
variant="outline"
type="files"
@confirmed="archiveDocument"
/>
<UButton
:to="props.documentData.url"
variant="outline"
icon="i-heroicons-arrow-top-right-on-square"
target="_blank"
>
Öffnen
</UButton>
</UButtonGroup>
<USeparator class="my-3" label="Zuweisungen"/>
<div class="space-y-2">
<div
v-for="assignment in currentAssignments"
:key="assignment.value"
class="flex items-center justify-between gap-3 rounded-md border border-gray-200 p-2 dark:border-gray-800"
>
<div class="min-w-0">
<div class="text-xs text-gray-500">{{ assignment.label }}</div>
<nuxt-link
v-if="assignment.item"
:to="assignment.route(assignment.item)"
class="block truncate font-medium text-primary"
>
{{ assignment.display }}
</nuxt-link>
<span v-else class="font-medium">{{ assignment.display }}</span>
</div>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
@click="removeAssignment(assignment)"
/>
</div>
<UAlert
v-if="currentAssignments.length === 0"
icon="i-heroicons-link"
title="Noch keine Zuweisungen vorhanden"
color="neutral"
variant="soft"
/>
</div>
<USeparator class="my-3" label="Datei zuweisen"/>
<UFormField label="Bereich auswählen">
<USelectMenu
v-model="resourceToAssign"
:items="resourceOptions"
value-key="value"
label-key="label"
@update:model-value="getItemsBySelectedResource"
/>
</UFormField>
<UFormField class="mt-3" label="Eintrag auswählen">
<USelectMenu
v-model="idToAssign"
:items="itemOptions"
:label-key="selectedResource ? selectedResource.optionAttr : 'name'"
value-key="id"
:search-input="{ placeholder: 'Eintrag suchen...' }"
:filter-fields="[selectedResource ? selectedResource.optionAttr : 'name']"
/>
</UFormField>
<div class="mt-2 flex justify-end">
<UButton
icon="i-heroicons-link"
:disabled="!idToAssign"
@click="updateDocumentAssignment"
>
Zuweisen
</UButton>
</div>
<USeparator class="my-5" label="Dateityp"/>
<InputGroup class="w-full">
<USelectMenu
v-model="selectedFiletype"
class="flex-auto"
value-key="id"
label-key="name"
:items="filetypes"
@update:model-value="updateFiletype"
/>
</InputGroup>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="modal.close()" />
</div>
</template>
<div class="flex flex-row">
<div :class="false ? ['w-full'] : ['w-1/3']">
<PDFViewer
v-if="props.documentData.id && props.documentData.path.toLowerCase().includes('pdf')"
:file-id="props.documentData.id" />
<img
class=" w-full"
:src="props.documentData.url"
alt=""
v-else
/>
</div>
<div class="w-2/3 p-5" v-if="!false">
<UButtonGroup>
<ArchiveButton
color="error"
variant="outline"
type="files"
@confirmed="archiveDocument"
/>
<UButton
:to="props.documentData.url"
variant="outline"
icon="i-heroicons-arrow-top-right-on-square"
target="_blank"
>
Öffnen
</UButton>
</UButtonGroup>
<USeparator label="Zuweisungen"/>
<table class="w-full">
<tr v-if="props.documentData.project">
<td>Projekt</td>
<td>
<nuxt-link :to="`/standardEntity/projects/show/${props.documentData.project.id}`">{{props.documentData.project.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.customer">
<td>Kunde</td>
<td>
<nuxt-link :to="`/standardEntity/customers/show/${props.documentData.customer.id}`">{{props.documentData.customer.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.vendor">
<td>Lieferant</td>
<td>
<nuxt-link :to="`/standardEntity/vendors/show/${props.documentData.vendor.id}`">{{props.documentData.vendor.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.createddocument">
<td>Ausgangsbeleg</td>
<td>
<nuxt-link :to="`/createDocument/show/${props.documentData.createddocument.id}`">{{props.documentData.createddocument.documentNumber}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.plant">
<td>Objekt</td>
<td>
<nuxt-link :to="`/standardEntity/plants/show/${props.documentData.plant.id}`">{{props.documentData.plant.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.contract">
<td>Vertrag</td>
<td>
<nuxt-link :to="`/standardEntity/contracts/show/${props.documentData.contract.id}`">{{props.documentData.contract.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.vehicle">
<td>Fahrzeug</td>
<td>
<nuxt-link :to="`/standardEntity/vehicles/show/${props.documentData.vehicle.id}`">{{props.documentData.vehicle.licensePlate}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.product">
<td>Artikel</td>
<td>
<nuxt-link :to="`/standardEntity/products/show/${props.documentData.product.id}`">{{props.documentData.product.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.inventoryitem">
<td>Inventarartikel</td>
<td>
<nuxt-link :to="`/standardEntity/inventoryitem/show/${props.documentData.inventoryitem.id}`">{{props.documentData.inventoryitem.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.check">
<td>Überprüfung</td>
<td>
<nuxt-link :to="`/standardEntity/checks/show/${props.documentData.check.id}`">{{props.documentData.check.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.profile">
<td>Mitarbeiter</td>
<td>
<nuxt-link :to="`/profiles/show/${props.documentData.profile.id}`">{{props.documentData.profile.fullName}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.incominginvoice">
<td>Eingangsrechnung</td>
<td>
<nuxt-link :to="`/incomingInvoices/show/${props.documentData.incominginvoice.id}`">{{props.documentData.incominginvoice.reference}}</nuxt-link>
</td>
</tr>
</table>
<USeparator class="my-3" label="Datei zuweisen"/>
<UFormField
label="Resource auswählen"
>
<USelectMenu
:items="resourceOptions"
v-model="resourceToAssign"
value-key="value"
label-key="label"
@change="getItemsBySelectedResource"
>
</USelectMenu>
</UFormField>
<UFormField
label="Eintrag auswählen:"
>
<USelectMenu
:items="itemOptions"
v-model="idToAssign"
:label-key="resourceOptions.find(i => i.value === resourceToAssign)? resourceOptions.find(i => i.value === resourceToAssign).optionAttr : 'name'"
value-key="id"
@change="updateDocumentAssignment"
></USelectMenu>
</UFormField>
<USeparator class="my-5" label="Datei verschieben"/>
<InputGroup class="w-full">
<USelectMenu
class="flex-auto"
v-model="folderToMoveTo"
value-key="id"
label-key="name"
:items="folders"
/>
<UButton
@click="moveFile"
variant="outline"
:disabled="!folderToMoveTo"
>Verschieben</UButton>
</InputGroup>
<USeparator class="my-5" label="Dateityp"/>
<InputGroup class="w-full">
<USelectMenu
class="flex-auto"
v-model="props.documentData.type"
value-key="id"
label-key="name"
:items="filetypes"
@change="updateDocument"
/>
</InputGroup>
<USeparator class="my-5" label="Dokumentenbox" />
<InputGroup class="w-full">
<USelectMenu
class="flex-auto"
v-model="props.documentData.documentbox"
value-key="id"
label-key="key"
:items="documentboxes"
@change="updateDocument"
/>
</InputGroup>
</div>
</div>
</UCard>
</template>
</UModal>
</template>
<style scoped>
.bigPreview {
width: 100%;
aspect-ratio: 1/ 1.414;
}
</style>

View File

@@ -378,6 +378,27 @@ const getEntityModalCreateQuery = (datapoint) => {
return datapoint.entityModalCreateQuery || {}
}
const getPostSaveRoute = () => {
if (type !== "events") return null
if (route.query.returnTo === "plantafel") {
return {
path: "/organisation/plantafel",
query: {
date: route.query.returnDate || undefined,
view: route.query.returnView || undefined
}
}
}
return null
}
const canArchiveItem = computed(() => {
if (!item.value?.id) return false
if (dataType.canArchiveFunction) return dataType.canArchiveFunction(item.value)
return true
})
const createItem = async () => {
let ret = null
@@ -386,7 +407,14 @@ const createItem = async () => {
ret = await useEntities(type).create(item.value, true)
} else {
ret = await useEntities(type).create(item.value)//dataStore.createNewItem(type,item.value)
const postSaveRoute = getPostSaveRoute()
if (postSaveRoute) {
ret = await useEntities(type).create(item.value, true)
await router.push(postSaveRoute)
} else {
ret = await useEntities(type).create(item.value)//dataStore.createNewItem(type,item.value)
}
}
emit('returnData', ret)
@@ -401,7 +429,14 @@ const updateItem = async () => {
emit('returnData', ret)
modal.close()
} else {
ret = await useEntities(type).update(item.value.id, item.value)
const postSaveRoute = getPostSaveRoute()
if (postSaveRoute) {
ret = await useEntities(type).update(item.value.id, item.value, true)
await router.push(postSaveRoute)
} else {
ret = await useEntities(type).update(item.value.id, item.value)
}
emit('returnData', ret)
}
}
@@ -432,7 +467,7 @@ const updateItem = async () => {
<template #right>
<ArchiveButton
color="error"
v-if="platform !== 'mobile'"
v-if="platform !== 'mobile' && canArchiveItem"
variant="outline"
:type="type"
@confirmed="useEntities(type).archive(item.id)"

View File

@@ -41,6 +41,10 @@ const renderDatapointValue = (datapoint) => {
const value = getDatapointValue(datapoint)
if (value === null || value === undefined || value === "") return "-"
if (datapoint.displayFunction) {
return datapoint.displayFunction(value, props.item)
}
if (datapoint.inputType === "date") {
return dayjs(value).isValid() ? dayjs(value).format("DD.MM.YYYY") : String(value)
}

View File

@@ -75,6 +75,11 @@
return `${stringValue.substring(0, maxLength)}...`
}
const getColumnDisplayValue = (column, row) => {
const value = row[column.key]
if (column.displayFunction) return column.displayFunction(value, row)
return value
}
const handleSortChange = (value) => {
const nextSort = Array.isArray(value) ? value[0] : undefined
@@ -148,9 +153,9 @@
v-slot:[`${column.key}-cell`]="{ row }">
<component v-if="column.component" :is="column.component" :row="row.original"></component>
<span v-else-if="row.original[column.key]" class="block truncate">
<UTooltip :text="String(row.original[column.key])">
<UTooltip :text="String(getColumnDisplayValue(column, row.original))">
<span class="block truncate">
{{ `${truncateValue(row.original[column.key], column.maxLength)}${column.unit ? ` ${column.unit}` : ''}` }}
{{ `${truncateValue(getColumnDisplayValue(column, row.original), column.maxLength)}${column.unit ? ` ${column.unit}` : ''}` }}
</span>
</UTooltip>
</span>

View File

@@ -80,11 +80,6 @@ const links = computed(() => {
to: "/communication/chat",
icon: "i-heroicons-chat-bubble-left-right"
},
{
label: "Matrix-Setup",
to: "/communication",
icon: "i-heroicons-cog-6-tooth"
},
featureEnabled("helpdesk") ? {
label: "Helpdesk",
to: "/helpdesk",
@@ -294,6 +289,11 @@ const links = computed(() => {
to: "/standardEntity/contracttypes",
icon: "i-heroicons-document-duplicate",
} : null,
featureEnabled("files") ? {
label: "Dateitypen",
to: "/standardEntity/filetags",
icon: "i-heroicons-tag",
} : null,
has("vehicles") && featureEnabled("vehicles") ? {
label: "Fahrzeuge",
to: "/standardEntity/vehicles",
@@ -332,6 +332,11 @@ const links = computed(() => {
to: "/settings/tenant",
icon: "i-heroicons-building-office",
} : null,
{
label: "Matrix-Setup",
to: "/communication",
icon: "i-heroicons-chat-bubble-left-right",
},
featureEnabled("export") ? {
label: "Export",
to: "/export",

View File

@@ -48,9 +48,13 @@ export const useFiles = () => {
}
})
console.log(res)
const fileDataById = new Map(data.map((file) => [file.id, file]))
return res.files
return (res.files || []).map((file) => ({
...file,
...(fileDataById.get(file.id) || {}),
url: file.url
}))
}
const selectSomeDocuments = async (documentIds, sortColumn = null, folder = null) => {
@@ -73,6 +77,7 @@ export const useFiles = () => {
const selectDocument = async (id) => {
let documentIds = [id]
if(documentIds.length === 0) return []
const fileData = await useEntities("files").selectSingle(id)
const res = await useNuxtApp().$api("/api/files/presigned",{
method: "POST",
body: {
@@ -80,9 +85,8 @@ export const useFiles = () => {
}
})
console.log(res)
return res.files[0]
const file = res.files?.[0] || null
return file ? {...file, ...(fileData || {}), url: file.url} : null
}
const downloadFile = async (id?: string, ids?: string[], returnAsBlob: Boolean = false) => {

View File

@@ -6,6 +6,7 @@ import timeGridPlugin from "@fullcalendar/timegrid"
import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
import interactionPlugin from "@fullcalendar/interaction";
import dayjs from "dayjs";
import { expandRecurringEvent } from "~/utils/eventRecurrence"
//TODO BACKEND CHANGE COLOR IN TENANT FOR RENDERING
@@ -33,6 +34,124 @@ const selectedEvent = ref({})
const selectedResources = ref([])
const events = ref([])
const sourceEvents = ref([])
const sourceAbsenceRequests = ref([])
const sourceProjects = ref([])
const sourceProfiles = ref([])
const sourceVehicles = ref([])
const sourceInventoryItems = ref([])
const sourceInventoryItemGroups = ref([])
const buildEventTitle = (event) => {
if (event.name) return event.name
if (event.project) {
const project = sourceProjects.value.find((item) => item.id === event.project)
return project?.name || ""
}
return ""
}
const buildEventColor = (event) =>
profileStore.ownTenant.calendarConfig.eventTypes.find(type => type.label === event.eventtype)?.color || "black"
const expandEventsForRange = (rangeStart, rangeEnd) => {
const gridEvents = sourceEvents.value.flatMap((event) =>
expandRecurringEvent(event, rangeStart, rangeEnd, (occurrenceStart, occurrenceEnd, occurrenceIndex) => {
const eventColor = buildEventColor(event)
return {
...event,
start: occurrenceStart.toISOString(),
end: occurrenceEnd ? occurrenceEnd.toISOString() : null,
title: buildEventTitle(event),
borderColor: eventColor,
textColor: eventColor,
backgroundColor: "black",
entrytype: "event",
eventId: event.id,
occurrenceIndex
}
})
)
const absenceEvents = sourceAbsenceRequests.value.map(absence => ({
id: absence.id,
resourceId: absence.user,
resourceType: "person",
title: `${absence.reason} - ${absence.profile.fullName}`,
start: dayjs(absence.startDate).toDate(),
end: dayjs(absence.endDate).add(1, 'day').toDate(),
allDay: true,
absencerequestId: absence.id,
entrytype: "absencerequest",
}))
calendarOptionsGrid.value.initialEvents = [
...gridEvents,
...absenceEvents
]
const timelineEvents = sourceEvents.value.flatMap((event) => {
const eventColor = buildEventColor(event)
const title = buildEventTitle(event)
return expandRecurringEvent(event, rangeStart, rangeEnd, (occurrenceStart, occurrenceEnd) => {
const returnData = {
title,
borderColor: eventColor,
textColor: "white",
backgroundColor: eventColor,
start: occurrenceStart.toISOString(),
end: occurrenceEnd ? occurrenceEnd.toISOString() : null,
resourceIds: [],
entrytype: "event",
eventId: event.id
}
if(event.profiles.length > 0) {
event.profiles.forEach(profile => {
returnData.resourceIds.push(`P-${profile}`)
})
}
if(event.vehicles.length > 0) {
event.vehicles.forEach(vehicle => {
returnData.resourceIds.push(`F-${vehicle}`)
})
}
if(event.inventoryitems.length > 0) {
event.inventoryitems.forEach(inventoryitem => {
returnData.resourceIds.push(`I-${inventoryitem}`)
})
}
if(event.inventoryitemgroups.length > 0) {
event.inventoryitemgroups.forEach(inventoryitemgroup => {
returnData.resourceIds.push(`G-${inventoryitemgroup}`)
})
}
return returnData
})
})
sourceAbsenceRequests.value.forEach(absencerequest => {
timelineEvents.push({
title: `${absencerequest.reason}`,
backgroundColor: "red",
borderColor: "red",
start: absencerequest.startDate,
end: absencerequest.endDate,
resourceIds: [`P-${absencerequest.profile.id}`],
entrytype: "absencerequest",
allDay: true,
absencerequestId: absencerequest.id
})
})
calendarOptionsTimeline.value.initialEvents = timelineEvents
}
const calendarOptionsGrid = computed(() => {
return {
locale: deLocale,
@@ -62,6 +181,9 @@ const calendarOptionsGrid = computed(() => {
}
},
datesSet: function(info) {
expandEventsForRange(info.startStr, info.endStr)
}
}
})
@@ -93,6 +215,9 @@ const calendarOptionsTimeline = ref({
router.push(`/standardEntity/events/show/${info.event.extendedProps.eventId}`)
}
},
datesSet: function(info) {
expandEventsForRange(info.startStr, info.endStr)
},
resourceGroupField: "type",
resourceOrder: "-type",
resources: [],
@@ -127,75 +252,37 @@ const calendarOptionsTimeline = ref({
const loaded = ref(false)
const setupPage = async () => {
let tempData = (await useEntities("events").select()).filter(i => !i.archived)
let absencerequests = (await useEntities("absencerequests").select("*, profile(*)")).filter(i => !i.archived)
let projects = (await useEntities("projects").select( "*")).filter(i => !i.archived)
let inventoryitems = (await useEntities("inventoryitems").select()).filter(i => !i.archived)
let inventoryitemgroups = (await useEntities("inventoryitemgroups").select()).filter(i => !i.archived)
let profiles = (await useEntities("profiles").select()).filter(i => !i.archived)
let vehicles = (await useEntities("vehicles").select()).filter(i => !i.archived)
calendarOptionsGrid.value.initialEvents = [
...tempData.map(event => {
let eventColor = profileStore.ownTenant.calendarConfig.eventTypes.find(type => type.label === event.eventtype).color
let title = ""
if (event.name) {
title = event.name
} else if (event.project) {
title = projects.find(i => i.id === event.project) ? projects.find(i => i.id === event.project).name : ""
}
return {
...event,
start: event.startDate,
end: event.endDate,
title: title,
borderColor: eventColor,
textColor: eventColor,
backgroundColor: "black",
entrytype: "event",
eventId: event.id
}
}),
...absencerequests.map(absence => {
return {
id: absence.id,
resourceId: absence.user,
resourceType: "person",
title: `${absence.reason} - ${absence.profile.fullName}`,
start: dayjs(absence.startDate).toDate(),
end: dayjs(absence.endDate).add(1, 'day').toDate(),
allDay: true,
absencerequestId: absence.id,
entrytype: "absencerequest",
}
})
]
sourceEvents.value = (await useEntities("events").select()).filter(i => !i.archived)
sourceAbsenceRequests.value = (await useEntities("absencerequests").select("*, profile(*)")).filter(i => !i.archived)
sourceProjects.value = (await useEntities("projects").select( "*")).filter(i => !i.archived)
sourceInventoryItems.value = (await useEntities("inventoryitems").select()).filter(i => !i.archived)
sourceInventoryItemGroups.value = (await useEntities("inventoryitemgroups").select()).filter(i => !i.archived)
sourceProfiles.value = (await useEntities("profiles").select()).filter(i => !i.archived)
sourceVehicles.value = (await useEntities("vehicles").select()).filter(i => !i.archived)
calendarOptionsTimeline.value.resources = [
...profiles.filter(i => i.tenant === profileStore.currentTenant).map(profile => {
...sourceProfiles.value.filter(i => i.tenant === profileStore.currentTenant).map(profile => {
return {
type: 'Mitarbeiter',
title: profile.fullName,
id: `P-${profile.id}`
}
}),
...vehicles.map(vehicle => {
...sourceVehicles.value.map(vehicle => {
return {
type: 'Fahrzeug',
title: vehicle.licensePlate,
id: `F-${vehicle.id}`
}
}),
...inventoryitems.filter(i=> i.usePlanning).map(item => {
...sourceInventoryItems.value.filter(i=> i.usePlanning).map(item => {
return {
type: 'Inventar',
title: item.name,
id: `I-${item.id}`
}
}),
...inventoryitemgroups.filter(i=> i.usePlanning).map(item => {
...sourceInventoryItemGroups.value.filter(i=> i.usePlanning).map(item => {
return {
type: 'Inventargruppen',
title: item.name,
@@ -234,83 +321,7 @@ const setupPage = async () => {
]
*/
let tempEvents = []
tempData.forEach(event => {
console.log(event)
let eventColor = profileStore.ownTenant.calendarConfig.eventTypes.find(type => type.label === event.eventtype).color
let title = ""
if (event.name) {
title = event.name
} else if (event.project) {
projects.find(i => i.id === event.project) ? projects.find(i => i.id === event.project).name : ""
}
let returnData = {
title: title,
borderColor: eventColor,
textColor: "white",
backgroundColor: eventColor,
start: event.startDate,
end: event.endDate,
resourceIds: [],
entrytype: "event",
eventId: event.id
}
if(event.profiles.length > 0) {
event.profiles.forEach(profile => {
returnData.resourceIds.push(`P-${profile}`)
})
}
if(event.vehicles.length > 0) {
event.vehicles.forEach(vehicle => {
returnData.resourceIds.push(`F-${vehicle}`)
})
}
if(event.inventoryitems.length > 0) {
event.inventoryitems.forEach(inventoryitem => {
returnData.resourceIds.push(`I-${inventoryitem}`)
})
}
if(event.inventoryitemgroups.length > 0) {
event.inventoryitemgroups.forEach(inventoryitemgroup => {
returnData.resourceIds.push(`G-${inventoryitemgroup}`)
})
}
console.log(returnData)
tempEvents.push(returnData)
})
absencerequests.forEach(absencerequest => {
let returnData = {
title: `${absencerequest.reason}`,
backgroundColor: "red",
borderColor: "red",
start: absencerequest.startDate,
end: absencerequest.endDate,
resourceIds: [`P-${absencerequest.profile.id}`],
entrytype: "absencerequest",
allDay: true,
absencerequestId: absencerequest.id
}
tempEvents.push(returnData)
})
console.log(tempEvents)
calendarOptionsTimeline.value.initialEvents = tempEvents
console.log(calendarOptionsTimeline.value)
expandEventsForRange(dayjs().startOf("month").toISOString(), dayjs().endOf("month").toISOString())
loaded.value = true

View File

@@ -3,16 +3,23 @@ import { ConnectionState, Room, RoomEvent, Track } from "livekit-client"
const toast = useToast()
const { $api } = useNuxtApp()
const route = useRoute()
const status = ref(null)
const identity = ref(null)
const matrixRooms = ref([])
const activeRoomKey = ref("allgemein")
const matrixProjectRooms = ref([])
const matrixDirectRooms = ref([])
const unreadRooms = ref({})
const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein")
const matrixMessages = ref([])
const matrixMembers = ref([])
const matrixMessageDraft = ref("")
const matrixMessagesViewport = ref(null)
const matrixAttachmentInput = ref(null)
const matrixAttachmentObjectUrls = ref({})
const roomCreateOpen = ref(false)
const collapsedRoomGroups = ref({})
const matrixCallOpen = ref(false)
const matrixCallMode = ref("video")
const matrixCallLoading = ref(false)
@@ -35,11 +42,13 @@ const roomCreateForm = ref({
})
const loading = ref(false)
const roomProvisioning = ref(false)
const roomProvisioningKey = ref("")
const roomCreating = ref(false)
const roomMembersSyncing = ref(false)
const matrixMessagesLoading = ref(false)
const matrixMembersLoading = ref(false)
const matrixMessageSending = ref(false)
const matrixAttachmentUploading = ref(false)
const matrixAutoRefreshActive = ref(false)
const lastUpdated = ref(null)
let matrixRefreshInterval = null
@@ -48,13 +57,14 @@ let matrixMessagesRequestActive = false
let matrixMembersRequestActive = false
let matrixLiveKitRoom = null
const matrixCallVideoElements = new Map()
const matrixAttachmentPreviewRequests = new Set()
const canUseMatrixChat = computed(() =>
Boolean(status.value?.reachable && status.value?.provisioningConfigured)
)
const activeRoom = computed(() =>
matrixRooms.value.find((room) => room.key === activeRoomKey.value) || {
rooms.value.find((room) => room.key === activeRoomKey.value) || {
key: activeRoomKey.value,
name: activeRoomKey.value,
description: "Mandantenweiter Austausch",
@@ -109,26 +119,75 @@ const roomCreateKeyPreview = computed(() =>
normalizeRoomKey(roomCreateForm.value.key || roomCreateForm.value.name)
)
const rooms = computed(() => [
...matrixRooms.value.map((room) => ({
const sortRoomsByName = (roomList) => [...roomList].sort((first, second) =>
String(first.name || "").localeCompare(String(second.name || ""), "de", { sensitivity: "base" })
)
const rooms = computed(() => {
const baseRooms = matrixRooms.value
.filter((room) => !["project", "direct"].includes(room.type))
.map((room) => ({
...room,
group: "Räume",
icon: "i-heroicons-chat-bubble-left-right",
unread: unreadRooms.value[room.key]?.count || 0,
mentions: unreadRooms.value[room.key]?.mentions || 0,
description: room.alias || room.roomId || "Mandantenweiter Austausch"
}))
const projectRooms = matrixProjectRooms.value.map((room) => ({
...room,
description: room.alias || room.roomId || "Mandantenweiter Austausch"
})),
group: "Projekte",
icon: "i-heroicons-briefcase",
unread: unreadRooms.value[room.key]?.count || 0,
mentions: unreadRooms.value[room.key]?.mentions || 0,
description: room.projectNumber || room.topic || "Projektkommunikation",
provisionEndpoint: `/api/communication/matrix/project-rooms/${encodeURIComponent(room.projectId)}/provision`
}))
const directRooms = matrixDirectRooms.value.map((room) => ({
...room,
group: "Direkt",
icon: "i-heroicons-user-circle",
unread: unreadRooms.value[room.key]?.count || 0,
mentions: unreadRooms.value[room.key]?.mentions || 0,
description: room.email || room.topic || "Direktnachricht",
provisionEndpoint: `/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`
}))
return [
...sortRoomsByName(baseRooms),
...sortRoomsByName(projectRooms),
...sortRoomsByName(directRooms)
]
})
const groupedRooms = computed(() => [
{
key: "rooms",
label: "Räume",
rooms: rooms.value.filter((room) => room.group === "Räume")
},
{
key: "projects",
name: "Projekt-Chats",
description: "Nächste Ausbaustufe",
exists: false,
disabled: true
label: "Projekte",
rooms: rooms.value.filter((room) => room.group === "Projekte")
},
{
key: "direct",
name: "Direktnachrichten",
description: "Nächste Ausbaustufe",
exists: false,
disabled: true
label: "Direktnachrichten",
rooms: rooms.value.filter((room) => room.group === "Direkt")
}
])
].filter((group) => group.rooms.length > 0))
const isRoomGroupCollapsed = (groupKey) => Boolean(collapsedRoomGroups.value[groupKey])
const toggleRoomGroup = (groupKey) => {
collapsedRoomGroups.value = {
...collapsedRoomGroups.value,
[groupKey]: !isRoomGroupCollapsed(groupKey)
}
}
const normalizeRoomKey = (value) => {
const normalized = String(value || "")
@@ -149,12 +208,61 @@ const normalizeRoomKey = (value) => {
const setActiveRoom = async (room) => {
if (room.disabled || room.key === activeRoomKey.value) return
if (!room.exists && room.provisionEndpoint) {
await provisionRoomFromList(room)
return
}
activeRoomKey.value = room.key
matrixMessages.value = []
matrixMembers.value = []
await loadRoomChat({ includeMembers: true })
}
const mergeRoomIntoLists = (room) => {
upsertRoom({ ...room, exists: true })
if (room.type === "project") {
matrixProjectRooms.value = matrixProjectRooms.value.map((item) =>
item.key === room.key ? { ...item, ...room, exists: true } : item
)
}
if (room.type === "direct") {
matrixDirectRooms.value = matrixDirectRooms.value.map((item) =>
item.key === room.key ? { ...item, ...room, exists: true } : item
)
}
}
const provisionRoomFromList = async (room) => {
roomProvisioningKey.value = room.key
try {
const createdRoom = await $api(room.provisionEndpoint, {
method: "POST"
})
mergeRoomIntoLists(createdRoom)
activeRoomKey.value = createdRoom.key
matrixMessages.value = []
matrixMembers.value = []
toast.add({
title: "Chatraum ist bereit",
color: "success"
})
await loadRoomChat({ includeMembers: true })
} catch (error) {
toast.add({
title: "Chatraum konnte nicht erstellt werden",
color: "error"
})
} finally {
roomProvisioningKey.value = ""
}
}
const upsertRoom = (room) => {
const roomWasKnown = matrixRooms.value.some((item) => item.key === room.key)
matrixRooms.value = roomWasKnown
@@ -180,6 +288,7 @@ const mergeMatrixMessages = (incomingMessages) => {
matrixMessages.value = Array.from(byId.values())
.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0))
loadAttachmentPreviews()
}
const scrollMessagesToBottom = async () => {
@@ -189,19 +298,51 @@ const scrollMessagesToBottom = async () => {
matrixMessagesViewport.value.scrollTop = matrixMessagesViewport.value.scrollHeight
}
const loadUnreadCounts = async () => {
if (!canUseMatrixChat.value) return
try {
const res = await $api("/api/communication/matrix/unread")
unreadRooms.value = res.rooms || {}
} catch (error) {
unreadRooms.value = {}
}
}
const markActiveRoomRead = async () => {
if (!canUseMatrixChat.value || !activeRoom.value?.exists) return
try {
await $api(`${activeRoomEndpoint.value}/read`, {
method: "POST"
})
unreadRooms.value = {
...unreadRooms.value,
[activeRoomKey.value]: { count: 0, mentions: 0 }
}
} catch (error) {
// Lesestatus ist Komfortfunktion; Chat selbst soll dadurch nicht blockieren.
}
}
const loadChatInfo = async () => {
loading.value = true
try {
const [statusRes, identityRes, roomsRes] = await Promise.all([
const [statusRes, identityRes, roomsRes, projectRoomsRes, directRoomsRes] = await Promise.all([
$api("/api/communication/matrix/status"),
$api("/api/communication/matrix/me"),
$api("/api/communication/matrix/rooms")
$api("/api/communication/matrix/rooms"),
$api("/api/communication/matrix/project-rooms"),
$api("/api/communication/matrix/direct-rooms")
])
status.value = statusRes
identity.value = identityRes
matrixRooms.value = roomsRes.rooms || []
matrixProjectRooms.value = projectRoomsRes.rooms || []
matrixDirectRooms.value = directRoomsRes.rooms || []
lastUpdated.value = new Date()
await loadUnreadCounts()
if (activeRoom.value?.exists && canUseMatrixChat.value) {
await loadRoomChat({ silent: true, includeMembers: true })
@@ -303,6 +444,7 @@ const loadRoomMessages = async ({ silent = false } = {}) => {
try {
const res = await $api(`${activeRoomEndpoint.value}/messages`)
mergeMatrixMessages(res.messages || [])
await markActiveRoomRead()
matrixRooms.value = matrixRooms.value.map((room) => room.key === activeRoomKey.value ? {
...room,
alias: res.alias || room.alias,
@@ -706,6 +848,7 @@ const sendMatrixMessage = async () => {
matrixMessages.value = matrixMessages.value.map((item) =>
item.id === optimisticId ? message : item
)
loadAttachmentPreviews()
} catch (error) {
matrixMessages.value = matrixMessages.value.map((item) =>
item.id === optimisticId ? { ...item, pending: false, failed: true } : item
@@ -719,6 +862,67 @@ const sendMatrixMessage = async () => {
}
}
const openAttachmentPicker = () => {
if (!canUseMatrixChat.value || !activeRoom.value?.exists || matrixAttachmentUploading.value) return
matrixAttachmentInput.value?.click()
}
const uploadMatrixAttachment = async (event) => {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || !canUseMatrixChat.value || !activeRoom.value?.exists) return
const optimisticId = `attachment-${Date.now()}`
const optimisticMessage = {
id: optimisticId,
sender: identity.value?.matrixUserId || "Du",
senderDisplayName: identity.value?.displayName || "Du",
body: file.name,
attachment: {
fileName: file.name,
mimeType: file.type || "application/octet-stream",
size: file.size,
isImage: file.type?.startsWith("image/"),
},
timestamp: Date.now(),
own: true,
pending: true,
failed: false
}
matrixMessages.value = [
...matrixMessages.value,
optimisticMessage
]
await scrollMessagesToBottom()
const formData = new FormData()
formData.append("file", file)
matrixAttachmentUploading.value = true
try {
const message = await $api(`${activeRoomEndpoint.value}/attachments`, {
method: "POST",
body: formData
})
matrixMessages.value = matrixMessages.value.map((item) =>
item.id === optimisticId ? message : item
)
} catch (error) {
matrixMessages.value = matrixMessages.value.map((item) =>
item.id === optimisticId ? { ...item, pending: false, failed: true } : item
)
toast.add({
title: "Anhang konnte nicht gesendet werden",
color: "error"
})
} finally {
matrixAttachmentUploading.value = false
}
}
const startMatrixAutoRefresh = () => {
if (matrixRefreshInterval) return
@@ -726,6 +930,7 @@ const startMatrixAutoRefresh = () => {
matrixRefreshInterval = window.setInterval(() => {
if (!document.hidden && canUseMatrixChat.value && activeRoom.value?.exists) {
loadRoomChat({ silent: true })
loadUnreadCounts()
}
}, 15000)
}
@@ -747,6 +952,56 @@ const formatMessageTime = (timestamp) => {
}).format(new Date(timestamp))
}
const formatAttachmentSize = (size) => {
const bytes = Number(size || 0)
if (!bytes) return ""
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const matrixMediaProxyUrl = (attachment) => {
if (!attachment?.url) return ""
const params = new URLSearchParams({
uri: attachment.url,
name: attachment.fileName || "Anhang"
})
return `/api/communication/matrix/media?${params.toString()}`
}
const attachmentObjectUrl = (attachment) =>
attachment?.url ? matrixAttachmentObjectUrls.value[attachment.url] || "" : ""
const ensureAttachmentObjectUrl = async (attachment) => {
if (!attachment?.url || matrixAttachmentObjectUrls.value[attachment.url] || matrixAttachmentPreviewRequests.has(attachment.url)) return
matrixAttachmentPreviewRequests.add(attachment.url)
try {
const blob = await $api(matrixMediaProxyUrl(attachment), {
responseType: "blob"
})
const objectUrl = URL.createObjectURL(blob)
matrixAttachmentObjectUrls.value = {
...matrixAttachmentObjectUrls.value,
[attachment.url]: objectUrl
}
} catch (error) {
// Fällt nur auf den Link zurück; der Chat selbst soll weiter funktionieren.
} finally {
matrixAttachmentPreviewRequests.delete(attachment.url)
}
}
const loadAttachmentPreviews = () => {
for (const message of matrixMessages.value) {
if (message.attachment?.isImage && message.attachment.url) {
ensureAttachmentObjectUrl(message.attachment)
}
}
}
const formatLastUpdated = computed(() => {
if (!lastUpdated.value) return "Noch nicht aktualisiert"
@@ -770,6 +1025,7 @@ onBeforeUnmount(() => {
stopMatrixAutoRefresh()
stopMatrixCallDurationTimer()
leaveMatrixCall()
Object.values(matrixAttachmentObjectUrls.value).forEach((objectUrl) => URL.revokeObjectURL(objectUrl))
})
</script>
@@ -855,55 +1111,88 @@ onBeforeUnmount(() => {
</form>
</div>
<div class="flex-1 overflow-y-auto p-3">
<button
v-for="room in rooms"
:key="room.key"
type="button"
class="mb-1 flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition"
:class="[
room.disabled ? 'cursor-not-allowed opacity-50' : 'text-highlighted hover:bg-muted',
room.key === activeRoomKey ? 'bg-muted ring-1 ring-primary/20' : ''
]"
:disabled="room.disabled"
@click="setActiveRoom(room)"
<div class="flex-1 space-y-4 overflow-y-auto p-3">
<section
v-for="group in groupedRooms"
:key="group.key"
class="space-y-1"
>
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<UIcon name="i-heroicons-chat-bubble-left-right" class="size-5" />
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
<span class="block truncate text-xs text-muted">{{ room.description }}</span>
</span>
<UBadge
v-if="room.exists"
color="success"
variant="soft"
size="xs"
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left text-muted transition hover:bg-muted hover:text-highlighted"
:aria-expanded="!isRoomGroupCollapsed(group.key)"
@click="toggleRoomGroup(group.key)"
>
aktiv
</UBadge>
<UBadge
v-else-if="!room.disabled"
color="neutral"
variant="soft"
size="xs"
<span class="flex min-w-0 items-center gap-2">
<UIcon
name="i-heroicons-chevron-right"
class="size-3.5 shrink-0 transition-transform"
:class="!isRoomGroupCollapsed(group.key) ? 'rotate-90' : ''"
/>
<span class="truncate text-[11px] font-semibold uppercase tracking-wide">
{{ group.label }}
</span>
</span>
<span class="text-[11px]">{{ group.rooms.length }}</span>
</button>
<div
v-if="!isRoomGroupCollapsed(group.key)"
class="space-y-1"
>
offen
</UBadge>
</button>
</div>
<div class="border-t border-default p-3">
<UButton
to="/communication"
icon="i-heroicons-cog-6-tooth"
color="neutral"
variant="outline"
block
>
Matrix-Setup
</UButton>
<button
v-for="room in group.rooms"
:key="room.key"
type="button"
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition"
:class="[
room.disabled ? 'cursor-not-allowed opacity-50' : 'text-highlighted hover:bg-muted',
room.key === activeRoomKey ? 'bg-muted ring-1 ring-primary/20' : ''
]"
:disabled="room.disabled"
@click="setActiveRoom(room)"
>
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<UIcon :name="room.icon || 'i-heroicons-chat-bubble-left-right'" class="size-5" />
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
<span class="block truncate text-xs text-muted">{{ room.description }}</span>
</span>
<UBadge
v-if="roomProvisioningKey === room.key"
color="primary"
variant="soft"
size="xs"
>
lädt
</UBadge>
<UBadge
v-else-if="room.unread"
:color="room.mentions ? 'error' : 'primary'"
variant="solid"
size="xs"
>
{{ room.mentions ? `@${room.mentions}` : room.unread }}
</UBadge>
<UBadge
v-else-if="room.exists"
color="success"
variant="soft"
size="xs"
>
aktiv
</UBadge>
<UBadge
v-else-if="!room.disabled"
color="neutral"
variant="soft"
size="xs"
>
offen
</UBadge>
</button>
</div>
</section>
</div>
</aside>
@@ -948,13 +1237,6 @@ onBeforeUnmount(() => {
:disabled="!canStartMatrixCall"
@click="openMatrixCall('video')"
/>
<UButton
class="lg:hidden"
to="/communication"
icon="i-heroicons-cog-6-tooth"
color="neutral"
variant="outline"
/>
<UButton
class="lg:hidden"
icon="i-heroicons-plus"
@@ -987,7 +1269,16 @@ onBeforeUnmount(() => {
:disabled="room.disabled"
@click="setActiveRoom(room)"
>
{{ room.name }}
<span>{{ room.name }}</span>
<UBadge
v-if="room.unread"
class="ml-2"
:color="room.mentions ? 'error' : 'primary'"
variant="solid"
size="xs"
>
{{ room.mentions ? `@${room.mentions}` : room.unread }}
</UBadge>
</button>
</div>
@@ -1143,6 +1434,45 @@ onBeforeUnmount(() => {
<p class="whitespace-pre-wrap break-words text-sm">
{{ message.body }}
</p>
<div
v-if="message.attachment"
class="mt-2 overflow-hidden rounded-md border"
:class="message.own ? 'border-white/20' : 'border-default'"
>
<img
v-if="message.attachment.isImage && attachmentObjectUrl(message.attachment)"
:src="attachmentObjectUrl(message.attachment)"
:alt="message.attachment.fileName || message.body"
class="max-h-72 w-full object-contain"
>
<a
v-if="message.attachment.url"
:href="matrixMediaProxyUrl(message.attachment)"
target="_blank"
rel="noopener"
class="flex items-center gap-2 px-3 py-2 text-sm"
>
<UIcon name="i-heroicons-paper-clip" class="size-4 shrink-0" />
<span class="min-w-0 flex-1 truncate">
{{ message.attachment.fileName || message.body }}
</span>
<span class="shrink-0 text-xs opacity-70">
{{ formatAttachmentSize(message.attachment.size) }}
</span>
</a>
<div
v-else
class="flex items-center gap-2 px-3 py-2 text-sm"
>
<UIcon name="i-heroicons-paper-clip" class="size-4 shrink-0" />
<span class="min-w-0 flex-1 truncate">
{{ message.attachment.fileName || message.body }}
</span>
<span class="shrink-0 text-xs opacity-70">
{{ formatAttachmentSize(message.attachment.size) }}
</span>
</div>
</div>
</div>
</div>
</div>
@@ -1151,18 +1481,33 @@ onBeforeUnmount(() => {
class="flex shrink-0 gap-2 border-t border-default bg-default p-3"
@submit.prevent="sendMatrixMessage"
>
<input
ref="matrixAttachmentInput"
type="file"
class="hidden"
@change="uploadMatrixAttachment"
>
<UButton
type="button"
icon="i-heroicons-paper-clip"
color="neutral"
variant="outline"
:loading="matrixAttachmentUploading"
:disabled="matrixAttachmentUploading || !canUseMatrixChat || !activeRoom?.exists"
@click="openAttachmentPicker"
/>
<UInput
v-model="matrixMessageDraft"
class="min-w-0 flex-1"
placeholder="Nachricht schreiben"
:disabled="matrixMessageSending || !canUseMatrixChat || !activeRoom?.exists"
:disabled="matrixMessageSending || matrixAttachmentUploading || !canUseMatrixChat || !activeRoom?.exists"
@keydown.enter.exact.prevent="sendMatrixMessage"
/>
<UButton
type="submit"
icon="i-heroicons-paper-airplane"
:loading="matrixMessageSending"
:disabled="!matrixMessageDraft.trim() || !canUseMatrixChat || !activeRoom?.exists"
:disabled="!matrixMessageDraft.trim() || matrixAttachmentUploading || !canUseMatrixChat || !activeRoom?.exists"
>
Senden
</UButton>

View File

@@ -303,6 +303,11 @@ watch(
const loaded = ref(false)
const normalizeEntityId = (value) => {
if (value === null || typeof value === "undefined") return null
if (value instanceof Date) return null
if (typeof value === "string") {
const normalized = value.trim()
if (!/^\d+$/.test(normalized) && dayjs(normalized).isValid()) return null
}
return typeof value === "object" ? (value.id ?? null) : value
}
const normalizeCreatedDocumentRow = (row) => {
@@ -1567,14 +1572,14 @@ const saveSerialInvoice = async () => {
let createData = {
type: itemInfo.value.type,
state: 'Erstellt',
customer: itemInfo.value.customer,
contact: itemInfo.value.contact,
contract: itemInfo.value.contract,
customer: normalizeEntityId(itemInfo.value.customer),
contact: normalizeEntityId(itemInfo.value.contact),
contract: normalizeEntityId(itemInfo.value.contract),
address: itemInfo.value.address,
project: itemInfo.value.project,
project: normalizeEntityId(itemInfo.value.project),
paymentDays: itemInfo.value.paymentDays,
payment_type: itemInfo.value.payment_type,
outgoingsepamandate: itemInfo.value.outgoingsepamandate,
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
deliveryDateType: "Leistungszeitraum",
createdBy: itemInfo.value.createdBy,
created_by: itemInfo.value.created_by,
@@ -1650,19 +1655,19 @@ const saveDocument = async (state, resetup = false) => {
type: itemInfo.value.type,
taxType: ['invoices', 'cancellationInvoices', 'advanceInvoices', 'confirmationOrders', ...quoteLikeDocumentTypes].includes(itemInfo.value.type) ? normalizeTaxTypeValue(itemInfo.value.taxType) : null,
state: itemInfo.value.state || "Entwurf",
customer: itemInfo.value.customer,
contact: itemInfo.value.contact,
contract: itemInfo.value.contract,
customer: normalizeEntityId(itemInfo.value.customer),
contact: normalizeEntityId(itemInfo.value.contact),
contract: normalizeEntityId(itemInfo.value.contract),
address: itemInfo.value.address,
project: itemInfo.value.project,
plant: itemInfo.value.plant,
project: normalizeEntityId(itemInfo.value.project),
plant: normalizeEntityId(itemInfo.value.plant),
documentNumber: itemInfo.value.documentNumber,
documentDate: itemInfo.value.documentDate,
deliveryDate: itemInfo.value.deliveryDate,
deliveryDateEnd: itemInfo.value.deliveryDateEnd,
paymentDays: itemInfo.value.paymentDays,
payment_type: itemInfo.value.payment_type,
outgoingsepamandate: itemInfo.value.outgoingsepamandate,
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
deliveryDateType: itemInfo.value.deliveryDateType,
info: {},
createdBy: itemInfo.value.createdBy,
@@ -1673,9 +1678,9 @@ const saveDocument = async (state, resetup = false) => {
endText: itemInfo.value.endText,
rows: itemInfo.value.rows,
contactPerson: itemInfo.value.contactPerson,
createddocument: itemInfo.value.createddocument,
createddocument: normalizeEntityId(itemInfo.value.createddocument),
agriculture: itemInfo.value.agriculture,
letterhead: itemInfo.value.letterhead,
letterhead: normalizeEntityId(itemInfo.value.letterhead),
usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices,
availableInPortal: itemInfo.value.availableInPortal,
customSurchargePercentage: itemInfo.value.customSurchargePercentage,

View File

@@ -5,8 +5,10 @@ import interactionPlugin from "@fullcalendar/interaction"
import resourceTimelinePlugin from "@fullcalendar/resource-timeline"
import { parseDate } from "@internationalized/date"
import { useDraggable } from "@vueuse/core"
import { expandRecurringEvent } from "~/utils/eventRecurrence"
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const profileStore = useProfileStore()
const toast = useToast()
@@ -14,12 +16,16 @@ const { $api, $dayjs } = useNuxtApp()
const { create: createEvent } = useEntities("events")
const { list: listStaffTimeSpans, createEntry, update: updateStaffTimeEntry } = useStaffTime()
const allowedCalendarViews = ["resourceTimelineDay", "resourceTimelineWeek", "resourceTimelineMonth"]
const initialRouteDate = /^\d{4}-\d{2}-\d{2}$/.test(String(route.query.date || "")) ? String(route.query.date) : null
const initialRouteView = allowedCalendarViews.includes(String(route.query.view || "")) ? String(route.query.view) : null
const loading = ref(true)
const savingAbsence = ref(false)
const selectedType = ref("all")
const calendarRef = ref(null)
const calendarView = ref("resourceTimelineWeek")
const calendarCurrentDate = ref($dayjs().format("YYYY-MM-DD"))
const calendarView = ref(initialRouteView || "resourceTimelineWeek")
const calendarCurrentDate = ref(initialRouteDate || $dayjs().format("YYYY-MM-DD"))
const calendarTitle = ref("")
const visibleRange = ref({
from: $dayjs().startOf("month").format("YYYY-MM-DD"),
@@ -30,6 +36,9 @@ const resources = ref([])
const events = ref([])
const profiles = ref([])
const inventoryitems = ref([])
const isDraftModeActive = ref(false)
const isFinalizeDraftsModalOpen = ref(false)
const finalizingDrafts = ref(false)
const savingQuickConfig = ref(false)
const isQuickConfigModalOpen = ref(false)
const quickConfigWindowEl = ref(null)
@@ -273,6 +282,14 @@ const visibleEvents = computed(() => {
)
})
const visibleDraftEventIds = computed(() =>
[...new Set(
visibleEvents.value
.filter((event) => event.state === "Entwurf" && event.entrytype === "event" && event.eventId)
.map((event) => event.eventId)
)]
)
const calendarOptions = computed(() => ({
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
locale: deLocale,
@@ -358,7 +375,14 @@ const calendarOptions = computed(() => ({
return
}
router.push(`/standardEntity/events/edit/${info.event.extendedProps.eventId}`)
router.push({
path: `/standardEntity/events/edit/${info.event.extendedProps.eventId}`,
query: {
returnTo: "plantafel",
returnDate: calendarCurrentDate.value,
returnView: calendarView.value
}
})
},
datesSet(info) {
const nextFrom = $dayjs(info.start).format("YYYY-MM-DD")
@@ -388,10 +412,15 @@ function resolveEventTitle(event, projectsById) {
}
function resolveRenderedEventColor(event) {
if (event?.quick) return activeQuickEntryConfig.value.color
if (event?.quick) return event?.color || activeQuickEntryConfig.value.color
return resolveEventColor(event.eventtype)
}
function resolveDisplayedEventTitle(event, projectsById) {
const baseTitle = resolveEventTitle(event, projectsById)
return event?.state === "Entwurf" ? `[Entwurf] ${baseTitle}` : baseTitle
}
function getProfileLabel(profile) {
return profile?.full_name || profile?.fullName || [profile?.first_name, profile?.last_name].filter(Boolean).join(" ") || profile?.email || `Profil ${profile?.id || ""}`.trim()
}
@@ -518,7 +547,7 @@ function buildResources({ profiles, inventoryitems }) {
function buildEvents({ rawEvents, projectsById }) {
const mappedEvents = rawEvents
.filter((event) => !event.archived)
.map((event) => {
.flatMap((event) => {
const resourceIds = [
...(profiles.value
.filter((profile) => (event.profiles || []).includes(profile.id))
@@ -526,17 +555,26 @@ function buildEvents({ rawEvents, projectsById }) {
...(event.inventoryitems || []).map((itemId) => `I-${itemId}`)
]
return {
title: resolveEventTitle(event, projectsById),
start: event.startDate,
end: event.endDate,
resourceIds,
backgroundColor: resolveRenderedEventColor(event),
borderColor: resolveRenderedEventColor(event),
textColor: "#ffffff",
entrytype: "event",
eventId: event.id
}
return expandRecurringEvent(
event,
`${visibleRange.value.from}T00:00:00`,
`${visibleRange.value.to}T23:59:59`,
(occurrenceStart, occurrenceEnd, occurrenceIndex) => ({
title: resolveDisplayedEventTitle(event, projectsById),
start: occurrenceStart.toISOString(),
end: occurrenceEnd ? occurrenceEnd.toISOString() : null,
resourceIds,
color: event.color || null,
state: event.state || "Final",
backgroundColor: resolveRenderedEventColor(event),
borderColor: resolveRenderedEventColor(event),
textColor: "#ffffff",
classNames: event.state === "Entwurf" ? ["planning-board-draft-event"] : [],
entrytype: "event",
eventId: event.id,
occurrenceIndex
})
)
})
.filter((event) => event.resourceIds.length > 0)
@@ -755,6 +793,8 @@ async function createQuickEvent(info) {
const payload = {
name: activeQuickEntryConfig.value.name,
quick: true,
state: isDraftModeActive.value ? "Entwurf" : "Final",
color: activeQuickEntryConfig.value.color,
startDate: info.startStr,
endDate: info.endStr,
profiles: resourceIds
@@ -786,6 +826,54 @@ async function createQuickEvent(info) {
}
}
function toggleDraftMode() {
if (isDraftModeActive.value) {
if (!visibleDraftEventIds.value.length) {
isDraftModeActive.value = false
toast.add({ title: "Entwurfsmodus deaktiviert", color: "green" })
return
}
isFinalizeDraftsModalOpen.value = true
return
}
isDraftModeActive.value = true
toast.add({ title: "Entwurfsmodus aktiviert", description: "Neue Schichten werden als Entwurf angelegt.", color: "green" })
}
async function finalizeVisibleDrafts() {
if (finalizingDrafts.value || !visibleDraftEventIds.value.length) return
finalizingDrafts.value = true
const draftCount = visibleDraftEventIds.value.length
try {
await Promise.all(
visibleDraftEventIds.value.map((eventId) =>
$api(`/api/resource/events/${eventId}`, {
method: "PUT",
body: { state: "Final" }
})
)
)
isDraftModeActive.value = false
isFinalizeDraftsModalOpen.value = false
toast.add({ title: "Entwürfe finalisiert", description: `${draftCount} Termine wurden finalisiert.`, color: "green" })
await loadPlanningBoard()
} catch (error) {
console.error("finalizeVisibleDrafts failed", error)
toast.add({
title: "Entwürfe konnten nicht finalisiert werden",
description: error?.message || "Bitte erneut versuchen.",
color: "red"
})
} finally {
finalizingDrafts.value = false
}
}
function openAbsenceModal(type = "vacation", preset = {}) {
absenceForm.mode = preset.entry ? "edit" : "create"
absenceForm.entry = preset.entry || null
@@ -983,6 +1071,14 @@ onMounted(() => {
>
Quick-Einträge
</UButton>
<UButton
:color="isDraftModeActive ? 'amber' : 'neutral'"
:variant="isDraftModeActive ? 'solid' : 'outline'"
icon="i-heroicons-document-duplicate"
@click="toggleDraftMode"
>
{{ isDraftModeActive ? "Entwurfsmodus aktiv" : "Entwurfsmodus" }}
</UButton>
<UButton
color="amber"
variant="soft"
@@ -1211,6 +1307,53 @@ onMounted(() => {
</div>
</div>
<UModal v-model:open="isFinalizeDraftsModalOpen">
<template #content>
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
<template #header>
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Entwürfe finalisieren
</h3>
<p class="text-sm text-muted">
Beim Beenden des Entwurfsmodus können die aktuell sichtbaren Entwürfe finalisiert werden.
</p>
</div>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="isFinalizeDraftsModalOpen = false"
/>
</div>
</template>
<div class="space-y-4">
<UAlert
color="amber"
variant="soft"
icon="i-heroicons-exclamation-triangle"
:title="`${visibleDraftEventIds.length} sichtbare Entwürfe gefunden`"
description="Diese Termine werden beim Finalisieren auf den Status Final gesetzt."
/>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="soft" @click="isFinalizeDraftsModalOpen = false">
Abbrechen
</UButton>
<UButton color="primary" :loading="finalizingDrafts" @click="finalizeVisibleDrafts">
Entwürfe finalisieren
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
<UModal v-model:open="isAbsenceModalOpen">
<template #content>
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
@@ -1506,3 +1649,13 @@ onMounted(() => {
</div>
</div>
</template>
<style scoped>
:deep(.planning-board-draft-event) {
opacity: 0.7;
}
:deep(.planning-board-draft-event .fc-timeline-event) {
border-style: dashed;
}
</style>

View File

@@ -5,6 +5,7 @@ const toast = useToast()
const auth = useAuthStore()
const admin = useAdmin()
const { $api } = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const id = route.params.id as string
const profile = ref<any>(null)
@@ -15,6 +16,7 @@ const saving = ref(false)
const creatingLinkedUser = ref(false)
const createLinkedUserModalOpen = ref(false)
const createdLinkedUserPassword = ref("")
const generatingCalendarSubscription = ref(false)
const createLinkedUserForm = reactive({
email: "",
})
@@ -26,6 +28,30 @@ const selectMenuUi = {
const canCreateLinkedUser = computed(() => Boolean(auth.user?.is_admin && profile.value && !profile.value.user_id))
const linkedUserStatusLabel = computed(() => profile.value?.user_id ? "Benutzer verknüpft" : "Kein Benutzer verknüpft")
const linkedUserStatusColor = computed(() => profile.value?.user_id ? "green" : "orange")
const calendarSubscriptionHttpUrl = computed(() => {
const token = profile.value?.calendar_subscription_token
if (!token) return ""
const path = profile.value?.calendar_subscription_path || `/api/public/calendar/subscriptions/${token}.ics`
const apiBase = String(runtimeConfig.public.apiBase || "")
if (/^https?:\/\//i.test(apiBase)) {
const apiUrl = new URL(apiBase)
return new URL(path, `${apiUrl.protocol}//${apiUrl.host}`).toString()
}
if (process.client) {
return `${window.location.origin}${path}`
}
return path
})
const calendarSubscriptionWebcalUrl = computed(() =>
calendarSubscriptionHttpUrl.value
? calendarSubscriptionHttpUrl.value.replace(/^https?/i, "webcal")
: ""
)
async function fetchBranches() {
try {
@@ -195,6 +221,55 @@ async function createLinkedUser() {
}
}
async function generateCalendarSubscription() {
if (!profile.value || generatingCalendarSubscription.value) return
generatingCalendarSubscription.value = true
try {
profile.value = await $api(`/api/profiles/${id}/calendar-subscription-token`, {
method: "POST"
})
ensureWorkingHoursStructure()
ensureBranchStructure()
ensureTeamStructure()
toast.add({
title: "Kalender-Abo erstellt",
description: "Der abonnierbare Kalender-Link wurde generiert.",
color: "green"
})
} catch (err: any) {
console.error("[generateCalendarSubscription]", err)
toast.add({
title: "Kalender-Abo konnte nicht erstellt werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "red"
})
} finally {
generatingCalendarSubscription.value = false
}
}
async function copyCalendarSubscriptionUrl(value: string, successTitle: string) {
if (!value) return
try {
await navigator.clipboard.writeText(value)
toast.add({
title: successTitle,
color: "green"
})
} catch (err) {
console.error("[copyCalendarSubscriptionUrl]", err)
toast.add({
title: "Link konnte nicht kopiert werden",
color: "red"
})
}
}
const weekdays = [
{ key: '1', label: 'Montag' },
{ key: '2', label: 'Dienstag' },
@@ -471,6 +546,57 @@ onMounted(async () => {
</UForm>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<USeparator label="Kalender-Abo" />
<div class="mt-4 space-y-4">
<p class="text-sm text-gray-500">
Hier kann ein abonnierbarer Kalender-Link für Handy-Kalender erzeugt werden. Das Abo nutzt einen persönlichen Backend-Link und kann in vielen Kalender-Apps direkt als `webcal` oder `ics` eingebunden werden.
</p>
<div class="flex flex-wrap gap-2">
<UButton
icon="i-heroicons-link"
color="primary"
:loading="generatingCalendarSubscription"
@click="generateCalendarSubscription"
>
{{ profile.calendar_subscription_token ? 'Link neu generieren' : 'Link generieren' }}
</UButton>
<UButton
v-if="calendarSubscriptionHttpUrl"
icon="i-heroicons-clipboard-document"
color="neutral"
variant="outline"
@click="copyCalendarSubscriptionUrl(calendarSubscriptionHttpUrl, 'ICS-Link kopiert')"
>
ICS kopieren
</UButton>
<UButton
v-if="calendarSubscriptionWebcalUrl"
icon="i-heroicons-device-phone-mobile"
color="neutral"
variant="outline"
@click="copyCalendarSubscriptionUrl(calendarSubscriptionWebcalUrl, 'Webcal-Link kopiert')"
>
Webcal kopieren
</UButton>
</div>
<UForm :state="profile" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="ICS-Link" class="w-full">
<UInput :model-value="calendarSubscriptionHttpUrl" readonly class="w-full" />
</UFormField>
<UFormField label="Webcal-Link" class="w-full">
<UInput :model-value="calendarSubscriptionWebcalUrl" readonly class="w-full" />
</UFormField>
</UForm>
</div>
</UCard>
<UCard v-if="!pending && profile" class="mt-3">
<USeparator label="Adresse & Standort" />

View File

@@ -50,6 +50,10 @@ const type = route.params.type
const dataType = dataStore.dataTypes[type]
const canCreate = computed(() => {
if (type === "filetags") {
return true
}
if (type === "members") {
return has("members-create") || has("customers-create")
}
@@ -313,9 +317,17 @@ const truncateValue = (value, maxLength) => {
return `${stringValue.substring(0, maxLength)}...`
}
const getColumnDisplayValue = (column, row) => {
const value = row[column.key]
if (column.displayFunction) return column.displayFunction(value, row)
return value
}
const getDistinctFilterItems = (columnKey) => {
const column = dataType.templateColumns.find((item) => item.key === columnKey)
return (itemsMeta.value?.distinctValues?.[columnKey] || []).map((value) => ({
label: String(value),
label: String(column?.displayFunction ? column.displayFunction(value) : value),
value
}))
}
@@ -546,9 +558,9 @@ const isDistinctFilterActive = (columnKey) => {
v-slot:[`${column.key}-cell`]="{row}">
<component v-if="column.component" :is="column.component" :row="row.original"></component>
<span v-else-if="row.original[column.key]" class="block truncate">
<UTooltip :text="String(row.original[column.key])">
<UTooltip :text="String(getColumnDisplayValue(column, row.original))">
<span class="block truncate">
{{ `${truncateValue(row.original[column.key], column.maxLength)}${column.unit ? ` ${column.unit}` : ''}` }}
{{ `${truncateValue(getColumnDisplayValue(column, row.original), column.maxLength)}${column.unit ? ` ${column.unit}` : ''}` }}
</span>
</UTooltip>
</span>

View File

@@ -70,6 +70,29 @@ export const useDataStore = defineStore('data', () => {
const filetagCreatedDocumentTypeOptions = [
{ key: "invoices", label: "Rechnung" },
{ key: "serialInvoices", label: "Serienrechnung" },
{ key: "advanceInvoices", label: "Abschlagsrechnung" },
{ key: "cancellationInvoices", label: "Storno" },
{ key: "quotes", label: "Angebot" },
{ key: "costEstimates", label: "Kostenschätzung" },
{ key: "confirmationOrders", label: "Auftragsbestätigung" },
{ key: "deliveryNotes", label: "Lieferschein" },
{ key: "packingSlips", label: "Packschein" },
]
const filetagIncomingDocumentTypeOptions = [
{ key: "invoices", label: "Eingangsrechnung" },
{ key: "reminders", label: "Mahnung" },
]
const getFiletagDocumentTypeLabel = (options, value) => {
if (value === "advanceInvoice") return "Abschlagsrechnung"
return options.find((option) => option.key === value)?.label || value
}
const dataTypes = {
tasks: {
isArchivable: true,
@@ -2337,6 +2360,78 @@ export const useDataStore = defineStore('data', () => {
labelSingle: "Datei",
selectWithInformation: "*",
},
filetags: {
isArchivable: true,
label: "Dateitypen",
labelSingle: "Dateityp",
isStandardEntity: true,
redirect: true,
inputColumns: [
"Allgemeines",
"Automatik",
],
filters: [{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
return !row.archived
}
}],
canArchiveFunction: function (row) {
return !row.isSystemUsed
},
templateColumns: [
{
key: "name",
label: "Name",
title: true,
required: true,
inputType: "text",
inputColumn: "Allgemeines",
sortable: true,
},
{
key: "color",
label: "Farbe",
inputType: "text",
inputColumn: "Allgemeines",
},
{
key: "createdDocumentType",
label: "Ausgangsbeleg-Typ",
inputType: "select",
selectValueAttribute: "key",
selectOptionAttribute: "label",
selectManualOptions: filetagCreatedDocumentTypeOptions,
displayFunction: function (value) {
return getFiletagDocumentTypeLabel(filetagCreatedDocumentTypeOptions, value)
},
inputColumn: "Automatik",
},
{
key: "incomingDocumentType",
label: "Eingangsbeleg-Typ",
inputType: "select",
selectValueAttribute: "key",
selectOptionAttribute: "label",
selectManualOptions: filetagIncomingDocumentTypeOptions,
displayFunction: function (value) {
return getFiletagDocumentTypeLabel(filetagIncomingDocumentTypeOptions, value)
},
inputColumn: "Automatik",
},
{
key: "isSystemUsed",
label: "Systemtyp",
inputType: "bool",
inputColumn: "Automatik",
disabledFunction: function () {
return true
}
},
],
showTabs: [{label: "Informationen"}]
},
folders: {
isArchivable: true,
label: "Ordner",
@@ -3089,6 +3184,26 @@ export const useDataStore = defineStore('data', () => {
inputType: "bool",
sortable: true
},
{
key: "state",
label: "Status",
inputType: "select",
selectManualOptions: ["Entwurf", "Final"],
sortable: true
},
{
key: "repeatInterval",
label: "Wiederholung",
inputType: "select",
selectManualOptions: ["Keine Wiederholung", "Täglich", "Wöchentlich", "2-wöchentlich", "Monatlich", "Jährlich"],
sortable: true
},
{
key: "color",
label: "Farbe",
inputType: "text",
sortable: true
},
{
key: "startDate",
label: "Start",

View File

@@ -0,0 +1,78 @@
import dayjs from "dayjs"
export const EVENT_REPEAT_INTERVALS = [
"Keine Wiederholung",
"Täglich",
"Wöchentlich",
"2-wöchentlich",
"Monatlich",
"Jährlich"
] as const
const DEFAULT_REPEAT_INTERVAL = "Keine Wiederholung"
const MAX_OCCURRENCES = 500
const addInterval = (date: dayjs.Dayjs, repeatInterval: string) => {
switch (repeatInterval) {
case "Täglich":
return date.add(1, "day")
case "Wöchentlich":
return date.add(1, "week")
case "2-wöchentlich":
return date.add(2, "week")
case "Monatlich":
return date.add(1, "month")
case "Jährlich":
return date.add(1, "year")
default:
return null
}
}
export const normalizeRepeatInterval = (value?: string | null) =>
EVENT_REPEAT_INTERVALS.includes(value as typeof EVENT_REPEAT_INTERVALS[number])
? value as typeof EVENT_REPEAT_INTERVALS[number]
: DEFAULT_REPEAT_INTERVAL
export const expandRecurringEvent = <T>(
event: {
startDate: string | Date
endDate?: string | Date | null
repeatInterval?: string | null
},
rangeStart: string | Date,
rangeEnd: string | Date,
buildOccurrence: (occurrenceStart: dayjs.Dayjs, occurrenceEnd: dayjs.Dayjs | null, occurrenceIndex: number) => T
) => {
const repeatInterval = normalizeRepeatInterval(event.repeatInterval)
const baseStart = dayjs(event.startDate)
const baseEnd = event.endDate ? dayjs(event.endDate) : null
const visibleStart = dayjs(rangeStart)
const visibleEnd = dayjs(rangeEnd)
const durationMs = baseEnd ? Math.max(baseEnd.diff(baseStart), 0) : 0
if (repeatInterval === DEFAULT_REPEAT_INTERVAL) {
const occurrenceEnd = baseEnd ? baseStart.add(durationMs, "millisecond") : null
if (baseStart.isAfter(visibleEnd) || (occurrenceEnd && occurrenceEnd.isBefore(visibleStart))) return []
return [buildOccurrence(baseStart, occurrenceEnd, 0)]
}
const occurrences: T[] = []
let occurrenceStart = baseStart
let occurrenceIndex = 0
while (occurrenceIndex < MAX_OCCURRENCES && occurrenceStart.isBefore(visibleEnd.add(1, "day"))) {
const occurrenceEnd = baseEnd ? occurrenceStart.add(durationMs, "millisecond") : null
if (!occurrenceStart.isAfter(visibleEnd) && !(occurrenceEnd && occurrenceEnd.isBefore(visibleStart))) {
occurrences.push(buildOccurrence(occurrenceStart, occurrenceEnd, occurrenceIndex))
}
const nextStart = addInterval(occurrenceStart, repeatInterval)
if (!nextStart || nextStart.isSame(occurrenceStart)) break
occurrenceStart = nextStart
occurrenceIndex += 1
}
return occurrences
}