Compare commits
20 Commits
817d0e814b
...
6455be81bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 6455be81bd | |||
| 9cde630562 | |||
| 48d101e139 | |||
| 167e9a40c3 | |||
| f9d3f10eae | |||
| 6d9bceb63f | |||
| e29e84898b | |||
| 1ccabbedcd | |||
| 24febf4c95 | |||
| 5fc7cc9604 | |||
| 941f1d819b | |||
| 58c47fa8f7 | |||
| ea392af094 | |||
| 0ac22d346f | |||
| 26ffc4421a | |||
| 7caa37378b | |||
| 227a88b24b | |||
| 0fb469c9b0 | |||
| 5b3445c2dc | |||
| 716de8a503 |
8
backend/db/migrations/0034_events_color.sql
Normal file
8
backend/db/migrations/0034_events_color.sql
Normal 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;
|
||||
5
backend/db/migrations/0038_events_state.sql
Normal file
5
backend/db/migrations/0038_events_state.sql
Normal 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;
|
||||
1
backend/db/migrations/0039_events_repeat_interval.sql
Normal file
1
backend/db/migrations/0039_events_repeat_interval.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "events" ADD COLUMN "repeatInterval" text DEFAULT 'Keine Wiederholung' NOT NULL;
|
||||
6
backend/db/migrations/0040_filetag_system_types.sql
Normal file
6
backend/db/migrations/0040_filetag_system_types.sql
Normal 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", '') <> '';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "auth_profiles"
|
||||
ADD COLUMN "calendar_subscription_token" text;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -53,7 +57,8 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
const forbidden = [
|
||||
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
||||
"updatedAt", "updatedBy", "old_profile_id", "full_name",
|
||||
"branch"
|
||||
"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" })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
193
backend/src/utils/calendarSubscription.ts
Normal file
193
backend/src/utils/calendarSubscription.ts
Normal 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`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
const toast = useToast()
|
||||
const dataStore = useDataStore()
|
||||
const modal = useModal()
|
||||
|
||||
const props = defineProps({
|
||||
documentData: {
|
||||
type: Object,
|
||||
@@ -15,176 +14,156 @@ 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"
|
||||
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="false ? ['w-full'] : ['w-1/3']">
|
||||
<div class="w-1/3">
|
||||
<PDFViewer
|
||||
v-if="props.documentData.id && props.documentData.path.toLowerCase().includes('pdf')"
|
||||
:file-id="props.documentData.id" />
|
||||
|
||||
<img
|
||||
class=" w-full"
|
||||
v-else
|
||||
class="w-full"
|
||||
:src="props.documentData.url"
|
||||
alt=""
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
<div class="w-2/3 p-5" v-if="!false">
|
||||
|
||||
<div class="w-2/3 p-5">
|
||||
<UButtonGroup>
|
||||
<ArchiveButton
|
||||
color="error"
|
||||
@@ -203,153 +182,86 @@ const moveFile = async () => {
|
||||
</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="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="Resource auswählen"
|
||||
>
|
||||
<UFormField label="Bereich auswählen">
|
||||
<USelectMenu
|
||||
:items="resourceOptions"
|
||||
v-model="resourceToAssign"
|
||||
:items="resourceOptions"
|
||||
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"
|
||||
@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
|
||||
@click="moveFile"
|
||||
variant="outline"
|
||||
:disabled="!folderToMoveTo"
|
||||
>Verschieben</UButton>
|
||||
</InputGroup>
|
||||
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"
|
||||
v-model="props.documentData.type"
|
||||
value-key="id"
|
||||
label-key="name"
|
||||
:items="filetypes"
|
||||
@change="updateDocument"
|
||||
@update:model-value="updateFiletype"
|
||||
/>
|
||||
</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>
|
||||
@@ -358,10 +270,8 @@ const moveFile = async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.bigPreview {
|
||||
width: 100%;
|
||||
aspect-ratio: 1/ 1.414;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
@@ -385,9 +406,16 @@ const createItem = async () => {
|
||||
if (props.inModal) {
|
||||
ret = await useEntities(type).create(item.value, true)
|
||||
|
||||
} else {
|
||||
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)
|
||||
modal.close()
|
||||
@@ -400,8 +428,15 @@ const updateItem = async () => {
|
||||
ret = await useEntities(type).update(item.value.id, item.value, true)
|
||||
emit('returnData', ret)
|
||||
modal.close()
|
||||
} else {
|
||||
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)"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
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,12 +1111,39 @@ onBeforeUnmount(() => {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<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"
|
||||
>
|
||||
<button
|
||||
v-for="room in rooms"
|
||||
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)"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<button
|
||||
v-for="room in group.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="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' : ''
|
||||
@@ -869,14 +1152,30 @@ onBeforeUnmount(() => {
|
||||
@click="setActiveRoom(room)"
|
||||
>
|
||||
<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" />
|
||||
<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="room.exists"
|
||||
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"
|
||||
@@ -893,17 +1192,7 @@ onBeforeUnmount(() => {
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
78
frontend/utils/eventRecurrence.ts
Normal file
78
frontend/utils/eventRecurrence.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user