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,
|
"idx": 34,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
|
"when": 1777420800000,
|
||||||
|
"tag": "0034_events_color",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 35,
|
||||||
|
"version": "7",
|
||||||
"when": 1778191200000,
|
"when": 1778191200000,
|
||||||
"tag": "0035_contract_history",
|
"tag": "0035_contract_history",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 35,
|
"idx": 36,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778194800000,
|
"when": 1778194800000,
|
||||||
"tag": "0036_allowed_contracttypes",
|
"tag": "0036_allowed_contracttypes",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 36,
|
"idx": 37,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778840100000,
|
"when": 1778840100000,
|
||||||
"tag": "0037_outgoing_sepa_mandates",
|
"tag": "0037_outgoing_sepa_mandates",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 37,
|
"idx": 38,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778840200000,
|
"when": 1779158400000,
|
||||||
"tag": "0034_profile_availability_note",
|
"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
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const authProfiles = pgTable("auth_profiles", {
|
|||||||
|
|
||||||
email: text("email"),
|
email: text("email"),
|
||||||
token_id: text("token_id"),
|
token_id: text("token_id"),
|
||||||
|
calendar_subscription_token: text("calendar_subscription_token"),
|
||||||
|
|
||||||
weekly_working_days: doublePrecision("weekly_working_days"),
|
weekly_working_days: doublePrecision("weekly_working_days"),
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export const events = pgTable(
|
|||||||
|
|
||||||
eventtype: text("eventtype").default("Umsetzung"),
|
eventtype: text("eventtype").default("Umsetzung"),
|
||||||
quick: boolean("quick").notNull().default(false),
|
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
|
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const filetags = pgTable("filetags", {
|
|||||||
createdDocumentType: text("createddocumenttype").default(""),
|
createdDocumentType: text("createddocumenttype").default(""),
|
||||||
incomingDocumentType: text("incomingDocumentType"),
|
incomingDocumentType: text("incomingDocumentType"),
|
||||||
|
|
||||||
|
isSystemUsed: boolean("isSystemUsed").notNull().default(false),
|
||||||
|
|
||||||
archived: boolean("archived").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 timestamp = new Date()
|
||||||
|
|
||||||
const tagDefaults = [
|
const tagDefaults = [
|
||||||
{ name: "Rechnungen", color: "#16a34a", createdDocumentType: "invoices" },
|
{ name: "Rechnungen", color: "#16a34a", createdDocumentType: "invoices", isSystemUsed: true },
|
||||||
{ name: "Angebote", color: "#2563eb", createdDocumentType: "quotes" },
|
{ name: "Angebote", color: "#2563eb", createdDocumentType: "quotes", isSystemUsed: true },
|
||||||
{ name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders" },
|
{ name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders", isSystemUsed: true },
|
||||||
{ name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes" },
|
{ name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes", isSystemUsed: true },
|
||||||
{ name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices" },
|
{ name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices", isSystemUsed: true },
|
||||||
{ name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders" },
|
{ name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders", isSystemUsed: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const tag of tagDefaults) {
|
for (const tag of tagDefaults) {
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ type MatrixRoomEvent = {
|
|||||||
content?: {
|
content?: {
|
||||||
body?: string
|
body?: string
|
||||||
msgtype?: string
|
msgtype?: string
|
||||||
|
url?: string
|
||||||
|
info?: {
|
||||||
|
mimetype?: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +61,14 @@ type MatrixTenantRoomOptions = {
|
|||||||
entityType?: string | null
|
entityType?: string | null
|
||||||
entityId?: number | null
|
entityId?: number | null
|
||||||
entityUuid?: string | null
|
entityUuid?: string | null
|
||||||
|
inviteUserIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatrixAttachmentInput = {
|
||||||
|
buffer: Buffer
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type MatrixCachedValue<T = any> = {
|
type MatrixCachedValue<T = any> = {
|
||||||
@@ -247,6 +260,7 @@ export function matrixService(server: FastifyInstance) {
|
|||||||
entityType: options.entityType || null,
|
entityType: options.entityType || null,
|
||||||
entityId: options.entityId || null,
|
entityId: options.entityId || null,
|
||||||
entityUuid: options.entityUuid || 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 createAccessTokenForUser = async (userId: string, tenantId: number | null) => {
|
||||||
const matrixUserId = await matrixUserIdForUser(userId, tenantId)
|
const matrixUserId = await matrixUserIdForUser(userId, tenantId)
|
||||||
const cacheKey = `${tenantId || "global"}:${userId}`
|
const cacheKey = `${tenantId || "global"}:${userId}`
|
||||||
@@ -885,6 +934,7 @@ export function matrixService(server: FastifyInstance) {
|
|||||||
|
|
||||||
const existing = await getTenantRoomStatus(tenant.id, key, name)
|
const existing = await getTenantRoomStatus(tenant.id, key, name)
|
||||||
const userAccount = await provisionCurrentUser(userId, tenant.id)
|
const userAccount = await provisionCurrentUser(userId, tenant.id)
|
||||||
|
const invitedMatrixUserIds = await matrixUserIdsForInvitees(userId, tenant.id, normalizedOptions.inviteUserIds || [])
|
||||||
const tenantSpace = await provisionCurrentTenantSpace(userId, tenant.id)
|
const tenantSpace = await provisionCurrentTenantSpace(userId, tenant.id)
|
||||||
|
|
||||||
if (existing.exists) {
|
if (existing.exists) {
|
||||||
@@ -902,6 +952,8 @@ export function matrixService(server: FastifyInstance) {
|
|||||||
invitedUserId: userAccount.matrixUserId,
|
invitedUserId: userAccount.matrixUserId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await inviteUsersToRoom(existing.roomId, invitedMatrixUserIds)
|
||||||
|
|
||||||
matrixTenantRoomCache.set(cacheKey, {
|
matrixTenantRoomCache.set(cacheKey, {
|
||||||
exists: true,
|
exists: true,
|
||||||
cachedUntil: Date.now() + 30 * 60 * 1000,
|
cachedUntil: Date.now() + 30 * 60 * 1000,
|
||||||
@@ -923,7 +975,7 @@ export function matrixService(server: FastifyInstance) {
|
|||||||
preset: "private_chat",
|
preset: "private_chat",
|
||||||
visibility: "private",
|
visibility: "private",
|
||||||
room_alias_name: tenantRoomAliasLocalpart(tenant, key),
|
room_alias_name: tenantRoomAliasLocalpart(tenant, key),
|
||||||
invite: [userAccount.matrixUserId],
|
invite: Array.from(new Set([userAccount.matrixUserId, ...invitedMatrixUserIds])),
|
||||||
initial_state: [
|
initial_state: [
|
||||||
{
|
{
|
||||||
type: "m.room.history_visibility",
|
type: "m.room.history_visibility",
|
||||||
@@ -994,6 +1046,42 @@ export function matrixService(server: FastifyInstance) {
|
|||||||
return value
|
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 (
|
const ensureCurrentUserJoinedRoom = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
tenantId: number | null,
|
tenantId: number | null,
|
||||||
@@ -1077,12 +1165,16 @@ export function matrixService(server: FastifyInstance) {
|
|||||||
name: room.name,
|
name: room.name,
|
||||||
matrixUserId: session.matrixUserId,
|
matrixUserId: session.matrixUserId,
|
||||||
messages: response.chunk
|
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) => ({
|
.map((event) => ({
|
||||||
id: event.event_id,
|
id: event.event_id,
|
||||||
sender: event.sender,
|
sender: event.sender,
|
||||||
senderDisplayName: members.joined[event.sender]?.display_name || event.sender,
|
senderDisplayName: members.joined[event.sender]?.display_name || event.sender,
|
||||||
body: event.content?.body || "",
|
body: event.content?.body || "",
|
||||||
|
attachment: attachmentFromEvent(event),
|
||||||
timestamp: event.origin_server_ts,
|
timestamp: event.origin_server_ts,
|
||||||
own: event.sender === session.matrixUserId,
|
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 (
|
const createElementRoomSession = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
tenantId: number | null,
|
tenantId: number | null,
|
||||||
@@ -1401,6 +1590,17 @@ export function matrixService(server: FastifyInstance) {
|
|||||||
text
|
text
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const sendGeneralRoomAttachment = (userId: string, tenantId: number | null, attachment: MatrixAttachmentInput) =>
|
||||||
|
sendTenantRoomAttachment(
|
||||||
|
userId,
|
||||||
|
tenantId,
|
||||||
|
{
|
||||||
|
key: "allgemein",
|
||||||
|
name: "Allgemeiner Chat",
|
||||||
|
},
|
||||||
|
attachment
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getStatus,
|
getStatus,
|
||||||
matrixUserIdForUser,
|
matrixUserIdForUser,
|
||||||
@@ -1415,11 +1615,14 @@ export function matrixService(server: FastifyInstance) {
|
|||||||
getTenantRoomMessages,
|
getTenantRoomMessages,
|
||||||
getTenantRoomMembers,
|
getTenantRoomMembers,
|
||||||
sendTenantRoomMessage,
|
sendTenantRoomMessage,
|
||||||
|
sendTenantRoomAttachment,
|
||||||
|
getMediaContent,
|
||||||
createElementRoomSession,
|
createElementRoomSession,
|
||||||
createLiveKitRoomSession,
|
createLiveKitRoomSession,
|
||||||
syncTenantRoomMembers,
|
syncTenantRoomMembers,
|
||||||
getGeneralRoomMessages,
|
getGeneralRoomMessages,
|
||||||
getGeneralRoomMembers,
|
getGeneralRoomMembers,
|
||||||
sendGeneralRoomMessage,
|
sendGeneralRoomMessage,
|
||||||
|
sendGeneralRoomAttachment,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,36 +45,42 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
name: "Rechnungen",
|
name: "Rechnungen",
|
||||||
color: "#16a34a",
|
color: "#16a34a",
|
||||||
createdDocumentType: "invoices",
|
createdDocumentType: "invoices",
|
||||||
|
isSystemUsed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Angebote",
|
name: "Angebote",
|
||||||
color: "#2563eb",
|
color: "#2563eb",
|
||||||
createdDocumentType: "quotes",
|
createdDocumentType: "quotes",
|
||||||
|
isSystemUsed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Auftragsbestätigungen",
|
name: "Auftragsbestätigungen",
|
||||||
color: "#7c3aed",
|
color: "#7c3aed",
|
||||||
createdDocumentType: "confirmationOrders",
|
createdDocumentType: "confirmationOrders",
|
||||||
|
isSystemUsed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Lieferscheine",
|
name: "Lieferscheine",
|
||||||
color: "#ea580c",
|
color: "#ea580c",
|
||||||
createdDocumentType: "deliveryNotes",
|
createdDocumentType: "deliveryNotes",
|
||||||
|
isSystemUsed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Eingangsrechnungen",
|
name: "Eingangsrechnungen",
|
||||||
color: "#dc2626",
|
color: "#dc2626",
|
||||||
incomingDocumentType: "invoices",
|
incomingDocumentType: "invoices",
|
||||||
|
isSystemUsed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Mahnungen",
|
name: "Mahnungen",
|
||||||
color: "#b91c1c",
|
color: "#b91c1c",
|
||||||
incomingDocumentType: "reminders",
|
incomingDocumentType: "reminders",
|
||||||
|
isSystemUsed: true,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.returning({
|
.returning({
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { createHash } from "node:crypto"
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { and, eq, ne } from "drizzle-orm"
|
import multipart from "@fastify/multipart"
|
||||||
import { authTenantUsers, authUsers } from "../../db/schema"
|
import { and, eq, inArray, ne } from "drizzle-orm"
|
||||||
|
import { authProfiles, authTenantUsers, authUsers, notificationsItems, projects } from "../../db/schema"
|
||||||
import { matrixService } from "../modules/matrix.service"
|
import { matrixService } from "../modules/matrix.service"
|
||||||
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||||
|
|
||||||
@@ -14,7 +16,19 @@ const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId)
|
|||||||
return rows[0] || null
|
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) {
|
export default async function communicationRoutes(server: FastifyInstance) {
|
||||||
|
await server.register(multipart, {
|
||||||
|
limits: { fileSize: 25 * 1024 * 1024 },
|
||||||
|
})
|
||||||
|
|
||||||
const matrix = matrixService(server)
|
const matrix = matrixService(server)
|
||||||
const notifications = new NotificationService(server, getUserDirectory)
|
const notifications = new NotificationService(server, getUserDirectory)
|
||||||
const handleMatrixError = (req: any, reply: any, err: any, fallbackMessage: string) => {
|
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 callModeFromRequest = (req: any): "audio" | "video" => {
|
||||||
const body = (req.body || {}) as { mode?: string }
|
const body = (req.body || {}) as { mode?: string }
|
||||||
return body.mode === "audio" ? "audio" : "video"
|
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) => {
|
server.post("/communication/matrix/rooms", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
return await matrix.provisionTenantRoom(
|
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) => {
|
server.post("/communication/matrix/rooms/general/messages", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body as { text?: string }
|
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) {
|
} catch (err: any) {
|
||||||
return handleMatrixError(req, reply, err, "Matrix message send failed")
|
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) => {
|
server.get("/communication/matrix/rooms/:roomKey", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const params = req.params as { roomKey: string }
|
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) => {
|
server.get("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
return await matrix.getTenantRoomMessages(
|
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) => {
|
server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body as { text?: string }
|
const body = req.body as { text?: string }
|
||||||
return await matrix.sendTenantRoomMessage(
|
const message = await matrix.sendTenantRoomMessage(
|
||||||
req.user.user_id,
|
req.user.user_id,
|
||||||
req.user.tenant_id,
|
req.user.tenant_id,
|
||||||
roomOptionsFromRequest(req),
|
roomOptionsFromRequest(req),
|
||||||
body.text || ""
|
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) {
|
} catch (err: any) {
|
||||||
return handleMatrixError(req, reply, err, "Matrix message send failed")
|
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,
|
resolveTenantTeamIds,
|
||||||
syncProfileTeams,
|
syncProfileTeams,
|
||||||
} from "../utils/profileTeams";
|
} from "../utils/profileTeams";
|
||||||
|
import {
|
||||||
|
enrichProfileWithCalendarSubscription,
|
||||||
|
generateProfileCalendarSubscriptionToken,
|
||||||
|
} from "../utils/calendarSubscription";
|
||||||
|
|
||||||
export default async function authProfilesRoutes(server: FastifyInstance) {
|
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 reply.code(404).send({ error: "User not found or not in tenant" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return profile;
|
return enrichProfileWithCalendarSubscription(profile);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("GET /profiles/:id ERROR:", error);
|
console.error("GET /profiles/:id ERROR:", error);
|
||||||
@@ -50,10 +54,11 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
const cleaned: any = { ...body }
|
const cleaned: any = { ...body }
|
||||||
|
|
||||||
// ❌ Systemfelder entfernen
|
// ❌ Systemfelder entfernen
|
||||||
const forbidden = [
|
const forbidden = [
|
||||||
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
||||||
"updatedAt", "updatedBy", "old_profile_id", "full_name",
|
"updatedAt", "updatedBy", "old_profile_id", "full_name",
|
||||||
"branch"
|
"branch", "calendar_subscription_token",
|
||||||
|
"calendar_subscription_path", "calendar_subscription_url"
|
||||||
]
|
]
|
||||||
forbidden.forEach(f => delete cleaned[f])
|
forbidden.forEach(f => delete cleaned[f])
|
||||||
|
|
||||||
@@ -146,7 +151,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
const [profile] = profileWithBranches
|
const [profile] = profileWithBranches
|
||||||
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
||||||
: [null]
|
: [null]
|
||||||
return profile || updated[0]
|
return enrichProfileWithCalendarSubscription(profile || updated[0])
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("PUT /profiles/:id ERROR:", 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" })
|
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 { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||||
import { publicLinkService } from '../../modules/publiclinks.service';
|
import { publicLinkService } from '../../modules/publiclinks.service';
|
||||||
import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs
|
import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs
|
||||||
|
import { buildProfileCalendarSubscriptionFeed, loadProfileByCalendarSubscriptionToken } from '../../utils/calendarSubscription';
|
||||||
|
|
||||||
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
|
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) => {
|
server.get("/workflows/context/:token", async (req, reply) => {
|
||||||
const { token } = req.params as { token: string };
|
const { token } = req.params as { token: string };
|
||||||
const pin = req.headers['x-public-pin'] as string | undefined;
|
const pin = req.headers['x-public-pin'] as string | undefined;
|
||||||
@@ -49,4 +73,4 @@ export default async function publiclinksNonAuthenticatedRoutes(server: FastifyI
|
|||||||
return reply.code(500).send({ error: "Fehler beim Speichern", details: error.message });
|
return reply.code(500).send({ error: "Fehler beim Speichern", details: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,53 @@ function isDateLikeField(key: string) {
|
|||||||
return /(^|_|-)date($|_|-)/i.test(key)
|
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>) {
|
function normalizeMemberPayload(payload: Record<string, any>) {
|
||||||
const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
|
const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
|
||||||
const normalized = {
|
const normalized = {
|
||||||
@@ -324,16 +371,28 @@ function maskIban(iban: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function decryptEntityBankAccount(row: Record<string, any>) {
|
function decryptEntityBankAccount(row: Record<string, any>) {
|
||||||
const iban = row.ibanEncrypted ? decrypt(row.ibanEncrypted as any) : null
|
let iban = null
|
||||||
const bic = row.bicEncrypted ? decrypt(row.bicEncrypted as any) : null
|
let bic = null
|
||||||
const bankName = row.bankNameEncrypted ? decrypt(row.bankNameEncrypted as any) : 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 {
|
return {
|
||||||
...row,
|
...row,
|
||||||
iban,
|
iban,
|
||||||
bic,
|
bic,
|
||||||
bankName,
|
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]) {
|
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
||||||
const numberRangeResource = resource === "members" ? "customers" : resource
|
const numberRangeResource = resource === "members" ? "customers" : resource
|
||||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
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; }
|
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
|
||||||
Object.keys(createData).forEach((key) => {
|
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()
|
const [created] = await server.db.insert(table).values(createData).returning()
|
||||||
@@ -956,6 +1028,14 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
delete data.updatedBy; delete data.updatedAt;
|
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) {
|
if (portalCustomerId) {
|
||||||
data = {
|
data = {
|
||||||
...sanitizePortalCustomerUpdate(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) => {
|
Object.keys(data).forEach((key) => {
|
||||||
const value = data[key]
|
const value = data[key]
|
||||||
const shouldNormalize =
|
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"
|
import {secrets} from "./secrets"
|
||||||
const ALGORITHM = "aes-256-gcm";
|
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) {
|
export function encrypt(text) {
|
||||||
const ENCRYPTION_KEY = Buffer.from(secrets.ENCRYPTION_KEY, "hex");
|
const ENCRYPTION_KEY = getEncryptionKey();
|
||||||
const iv = crypto.randomBytes(16);
|
const iv = crypto.randomBytes(16);
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
||||||
|
|
||||||
@@ -21,7 +28,7 @@ export function encrypt(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function decrypt({ iv, content, tag }) {
|
export function decrypt({ iv, content, tag }) {
|
||||||
const ENCRYPTION_KEY = Buffer.from(secrets.ENCRYPTION_KEY, "hex");
|
const ENCRYPTION_KEY = getEncryptionKey();
|
||||||
const decipher = crypto.createDecipheriv(
|
const decipher = crypto.createDecipheriv(
|
||||||
ALGORITHM,
|
ALGORITHM,
|
||||||
ENCRYPTION_KEY,
|
ENCRYPTION_KEY,
|
||||||
|
|||||||
@@ -105,7 +105,22 @@ export const resourceConfig = {
|
|||||||
numberRangeHolder: "vendorNumber",
|
numberRangeHolder: "vendorNumber",
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
table: files
|
table: files,
|
||||||
|
mtoLoad: [
|
||||||
|
"project",
|
||||||
|
"customer",
|
||||||
|
"contract",
|
||||||
|
"vendor",
|
||||||
|
"incominginvoice",
|
||||||
|
"plant",
|
||||||
|
"createddocument",
|
||||||
|
"vehicle",
|
||||||
|
"product",
|
||||||
|
"check",
|
||||||
|
"inventoryitem",
|
||||||
|
"authProfile",
|
||||||
|
"type",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
folders: {
|
folders: {
|
||||||
table: folders
|
table: folders
|
||||||
@@ -113,6 +128,9 @@ export const resourceConfig = {
|
|||||||
filetags: {
|
filetags: {
|
||||||
table: filetags
|
table: filetags
|
||||||
},
|
},
|
||||||
|
type: {
|
||||||
|
table: filetags
|
||||||
|
},
|
||||||
inventoryitems: {
|
inventoryitems: {
|
||||||
table: inventoryitems,
|
table: inventoryitems,
|
||||||
numberRangeHolder: "articleNumber",
|
numberRangeHolder: "articleNumber",
|
||||||
@@ -201,6 +219,11 @@ export const resourceConfig = {
|
|||||||
tenantKey: "tenant_id",
|
tenantKey: "tenant_id",
|
||||||
searchColumns: ["first_name", "last_name", "full_name", "email", "employee_number"],
|
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: {
|
letterheads: {
|
||||||
table: letterheads,
|
table: letterheads,
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
|
|||||||
import { pool } from "../../db"
|
import { pool } from "../../db"
|
||||||
import { s3 } from "./s3"
|
import { s3 } from "./s3"
|
||||||
import { secrets } from "./secrets"
|
import { secrets } from "./secrets"
|
||||||
|
import { decrypt, encrypt } from "./crypt"
|
||||||
|
|
||||||
type TableRows = Record<string, Record<string, any>[]>
|
type TableRows = Record<string, Record<string, any>[]>
|
||||||
type TableMetadata = {
|
type TableMetadata = {
|
||||||
@@ -38,6 +39,12 @@ type ImportOptions = {
|
|||||||
targetTenantId?: number | null
|
targetTenantId?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ENTITY_BANKACCOUNT_PLAIN_FIELDS = {
|
||||||
|
iban: "__plainIban",
|
||||||
|
bic: "__plainBic",
|
||||||
|
bankName: "__plainBankName",
|
||||||
|
}
|
||||||
|
|
||||||
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`
|
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`
|
||||||
|
|
||||||
const tableColumns = async (client: any) => {
|
const tableColumns = async (client: any) => {
|
||||||
@@ -98,6 +105,22 @@ const addRows = (tables: TableRows, table: string, rows: Record<string, any>[])
|
|||||||
tables[table] = existingRows
|
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 loadObjectAsBase64 = async (path: string) => {
|
||||||
const { Body } = await s3.send(new GetObjectCommand({
|
const { Body } = await s3.send(new GetObjectCommand({
|
||||||
Bucket: secrets.S3_BUCKET,
|
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]))
|
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 fileRows = tables.files || []
|
||||||
const 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) => {
|
const prepareColumnValue = (value: any, isJsonColumn: boolean) => {
|
||||||
if (!isJsonColumn || value === null || typeof value === "undefined") return value
|
if (!isJsonColumn || value === null || typeof value === "undefined") return value
|
||||||
if (typeof value === "string") return value
|
if (typeof value === "string") return value
|
||||||
@@ -384,6 +431,7 @@ export const importTenantFullExport = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId)
|
const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId)
|
||||||
|
encryptEntityBankAccountRowsForImport(exportData)
|
||||||
const client = await pool.connect()
|
const client = await pool.connect()
|
||||||
const importOrder = [
|
const importOrder = [
|
||||||
"tenants",
|
"tenants",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const dataStore = useDataStore()
|
|
||||||
const modal = useModal()
|
const modal = useModal()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
documentData: {
|
documentData: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -15,353 +14,264 @@ const props = defineProps({
|
|||||||
returnEmit: {
|
returnEmit: {
|
||||||
type: Boolean
|
type: Boolean
|
||||||
},
|
},
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(["updateNeeded"])
|
const emit = defineEmits(["updateNeeded"])
|
||||||
|
|
||||||
const folders = ref([])
|
|
||||||
|
|
||||||
const filetypes = 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 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()
|
filetypes.value = await useEntities("filetags").select()
|
||||||
//documentboxes.value = await useEntities("documentboxes").select()
|
selectedFiletype.value = getFiletypeId()
|
||||||
|
await getItemsBySelectedResource()
|
||||||
}
|
}
|
||||||
|
|
||||||
setup()
|
const updateDocument = async (payload, closeAfterUpdate = false) => {
|
||||||
|
try {
|
||||||
const updateDocument = async () => {
|
await useEntities("files").update(props.documentData.id, payload, true)
|
||||||
const {url, ...objData} = props.documentData
|
Object.assign(props.documentData, payload)
|
||||||
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)
|
|
||||||
toast.add({title: "Datei aktualisiert"})
|
toast.add({title: "Datei aktualisiert"})
|
||||||
modal.close()
|
|
||||||
emit("updateNeeded")
|
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 () => {
|
const archiveDocument = async () => {
|
||||||
props.documentData.archived = true
|
await updateDocument({archived: true}, true)
|
||||||
await updateDocument()
|
|
||||||
|
|
||||||
modal.close()
|
|
||||||
emit("update")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 () => {
|
const getItemsBySelectedResource = async () => {
|
||||||
if(resourceToAssign.value === "project") {
|
idToAssign.value = null
|
||||||
itemOptions.value = await useEntities("projects").select()
|
itemOptions.value = selectedResource.value?.entity
|
||||||
} else if(resourceToAssign.value === "customer") {
|
? await useEntities(selectedResource.value.entity).select()
|
||||||
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 = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
getItemsBySelectedResource()
|
|
||||||
|
|
||||||
const updateDocumentAssignment = async () => {
|
const updateDocumentAssignment = async () => {
|
||||||
props.documentData[resourceToAssign.value] = idToAssign.value
|
if (!selectedResource.value || !idToAssign.value) return
|
||||||
await updateDocument()
|
|
||||||
|
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 removeAssignment = async (assignment) => {
|
||||||
const moveFile = async () => {
|
await updateDocument({[assignment.value]: null})
|
||||||
|
props.documentData[assignment.value] = null
|
||||||
const res = await useEntities("files").update(props.documentData.id, {folder: folderToMoveTo.value})
|
|
||||||
|
|
||||||
modal.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UModal fullscreen >
|
<UModal fullscreen>
|
||||||
<template #content>
|
<template #content>
|
||||||
<UCard :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }" class="h-full">
|
<UCard :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }" class="h-full">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<UBadge
|
<UBadge
|
||||||
v-for="tag in props.documentData.filetags"
|
v-for="tag in displayedFileTags"
|
||||||
>
|
:key="tag.id"
|
||||||
{{tag.name}}
|
>
|
||||||
</UBadge>
|
{{tag.name}}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="modal.close()" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-row">
|
||||||
|
<div class="w-1/3">
|
||||||
|
<PDFViewer
|
||||||
|
v-if="props.documentData.id && props.documentData.path.toLowerCase().includes('pdf')"
|
||||||
|
:file-id="props.documentData.id" />
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
class="w-full"
|
||||||
|
:src="props.documentData.url"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-2/3 p-5">
|
||||||
|
<UButtonGroup>
|
||||||
|
<ArchiveButton
|
||||||
|
color="error"
|
||||||
|
variant="outline"
|
||||||
|
type="files"
|
||||||
|
@confirmed="archiveDocument"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
:to="props.documentData.url"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-heroicons-arrow-top-right-on-square"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Öffnen
|
||||||
|
</UButton>
|
||||||
|
</UButtonGroup>
|
||||||
|
|
||||||
|
<USeparator class="my-3" label="Zuweisungen"/>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="assignment in currentAssignments"
|
||||||
|
:key="assignment.value"
|
||||||
|
class="flex items-center justify-between gap-3 rounded-md border border-gray-200 p-2 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-xs text-gray-500">{{ assignment.label }}</div>
|
||||||
|
<nuxt-link
|
||||||
|
v-if="assignment.item"
|
||||||
|
:to="assignment.route(assignment.item)"
|
||||||
|
class="block truncate font-medium text-primary"
|
||||||
|
>
|
||||||
|
{{ assignment.display }}
|
||||||
|
</nuxt-link>
|
||||||
|
<span v-else class="font-medium">{{ assignment.display }}</span>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-x-mark"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeAssignment(assignment)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<UAlert
|
||||||
|
v-if="currentAssignments.length === 0"
|
||||||
|
icon="i-heroicons-link"
|
||||||
|
title="Noch keine Zuweisungen vorhanden"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<USeparator class="my-3" label="Datei zuweisen"/>
|
||||||
|
|
||||||
|
<UFormField label="Bereich auswählen">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="resourceToAssign"
|
||||||
|
:items="resourceOptions"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
@update:model-value="getItemsBySelectedResource"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField class="mt-3" label="Eintrag auswählen">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="idToAssign"
|
||||||
|
:items="itemOptions"
|
||||||
|
:label-key="selectedResource ? selectedResource.optionAttr : 'name'"
|
||||||
|
value-key="id"
|
||||||
|
:search-input="{ placeholder: 'Eintrag suchen...' }"
|
||||||
|
:filter-fields="[selectedResource ? selectedResource.optionAttr : 'name']"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="mt-2 flex justify-end">
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-link"
|
||||||
|
:disabled="!idToAssign"
|
||||||
|
@click="updateDocumentAssignment"
|
||||||
|
>
|
||||||
|
Zuweisen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<USeparator class="my-5" label="Dateityp"/>
|
||||||
|
|
||||||
|
<InputGroup class="w-full">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="selectedFiletype"
|
||||||
|
class="flex-auto"
|
||||||
|
value-key="id"
|
||||||
|
label-key="name"
|
||||||
|
:items="filetypes"
|
||||||
|
@update:model-value="updateFiletype"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="modal.close()" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
|
||||||
<div class="flex flex-row">
|
|
||||||
<div :class="false ? ['w-full'] : ['w-1/3']">
|
|
||||||
<PDFViewer
|
|
||||||
v-if="props.documentData.id && props.documentData.path.toLowerCase().includes('pdf')"
|
|
||||||
:file-id="props.documentData.id" />
|
|
||||||
|
|
||||||
<img
|
|
||||||
class=" w-full"
|
|
||||||
:src="props.documentData.url"
|
|
||||||
alt=""
|
|
||||||
v-else
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="w-2/3 p-5" v-if="!false">
|
|
||||||
<UButtonGroup>
|
|
||||||
<ArchiveButton
|
|
||||||
color="error"
|
|
||||||
variant="outline"
|
|
||||||
type="files"
|
|
||||||
@confirmed="archiveDocument"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
:to="props.documentData.url"
|
|
||||||
variant="outline"
|
|
||||||
icon="i-heroicons-arrow-top-right-on-square"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Öffnen
|
|
||||||
</UButton>
|
|
||||||
</UButtonGroup>
|
|
||||||
|
|
||||||
<USeparator label="Zuweisungen"/>
|
|
||||||
<table class="w-full">
|
|
||||||
<tr v-if="props.documentData.project">
|
|
||||||
<td>Projekt</td>
|
|
||||||
<td>
|
|
||||||
<nuxt-link :to="`/standardEntity/projects/show/${props.documentData.project.id}`">{{props.documentData.project.name}}</nuxt-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="props.documentData.customer">
|
|
||||||
<td>Kunde</td>
|
|
||||||
<td>
|
|
||||||
<nuxt-link :to="`/standardEntity/customers/show/${props.documentData.customer.id}`">{{props.documentData.customer.name}}</nuxt-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="props.documentData.vendor">
|
|
||||||
<td>Lieferant</td>
|
|
||||||
<td>
|
|
||||||
<nuxt-link :to="`/standardEntity/vendors/show/${props.documentData.vendor.id}`">{{props.documentData.vendor.name}}</nuxt-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="props.documentData.createddocument">
|
|
||||||
<td>Ausgangsbeleg</td>
|
|
||||||
<td>
|
|
||||||
<nuxt-link :to="`/createDocument/show/${props.documentData.createddocument.id}`">{{props.documentData.createddocument.documentNumber}}</nuxt-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="props.documentData.plant">
|
|
||||||
<td>Objekt</td>
|
|
||||||
<td>
|
|
||||||
<nuxt-link :to="`/standardEntity/plants/show/${props.documentData.plant.id}`">{{props.documentData.plant.name}}</nuxt-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="props.documentData.contract">
|
|
||||||
<td>Vertrag</td>
|
|
||||||
<td>
|
|
||||||
<nuxt-link :to="`/standardEntity/contracts/show/${props.documentData.contract.id}`">{{props.documentData.contract.name}}</nuxt-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="props.documentData.vehicle">
|
|
||||||
<td>Fahrzeug</td>
|
|
||||||
<td>
|
|
||||||
<nuxt-link :to="`/standardEntity/vehicles/show/${props.documentData.vehicle.id}`">{{props.documentData.vehicle.licensePlate}}</nuxt-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="props.documentData.product">
|
|
||||||
<td>Artikel</td>
|
|
||||||
<td>
|
|
||||||
<nuxt-link :to="`/standardEntity/products/show/${props.documentData.product.id}`">{{props.documentData.product.name}}</nuxt-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="props.documentData.inventoryitem">
|
|
||||||
<td>Inventarartikel</td>
|
|
||||||
<td>
|
|
||||||
<nuxt-link :to="`/standardEntity/inventoryitem/show/${props.documentData.inventoryitem.id}`">{{props.documentData.inventoryitem.name}}</nuxt-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="props.documentData.check">
|
|
||||||
<td>Überprüfung</td>
|
|
||||||
<td>
|
|
||||||
<nuxt-link :to="`/standardEntity/checks/show/${props.documentData.check.id}`">{{props.documentData.check.name}}</nuxt-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="props.documentData.profile">
|
|
||||||
<td>Mitarbeiter</td>
|
|
||||||
<td>
|
|
||||||
<nuxt-link :to="`/profiles/show/${props.documentData.profile.id}`">{{props.documentData.profile.fullName}}</nuxt-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="props.documentData.incominginvoice">
|
|
||||||
<td>Eingangsrechnung</td>
|
|
||||||
<td>
|
|
||||||
<nuxt-link :to="`/incomingInvoices/show/${props.documentData.incominginvoice.id}`">{{props.documentData.incominginvoice.reference}}</nuxt-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<USeparator class="my-3" label="Datei zuweisen"/>
|
|
||||||
|
|
||||||
<UFormField
|
|
||||||
label="Resource auswählen"
|
|
||||||
>
|
|
||||||
<USelectMenu
|
|
||||||
:items="resourceOptions"
|
|
||||||
v-model="resourceToAssign"
|
|
||||||
value-key="value"
|
|
||||||
label-key="label"
|
|
||||||
@change="getItemsBySelectedResource"
|
|
||||||
>
|
|
||||||
|
|
||||||
</USelectMenu>
|
|
||||||
</UFormField>
|
|
||||||
<UFormField
|
|
||||||
label="Eintrag auswählen:"
|
|
||||||
>
|
|
||||||
<USelectMenu
|
|
||||||
:items="itemOptions"
|
|
||||||
v-model="idToAssign"
|
|
||||||
:label-key="resourceOptions.find(i => i.value === resourceToAssign)? resourceOptions.find(i => i.value === resourceToAssign).optionAttr : 'name'"
|
|
||||||
value-key="id"
|
|
||||||
@change="updateDocumentAssignment"
|
|
||||||
></USelectMenu>
|
|
||||||
</UFormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<USeparator class="my-5" label="Datei verschieben"/>
|
|
||||||
|
|
||||||
<InputGroup class="w-full">
|
|
||||||
<USelectMenu
|
|
||||||
class="flex-auto"
|
|
||||||
v-model="folderToMoveTo"
|
|
||||||
value-key="id"
|
|
||||||
label-key="name"
|
|
||||||
:items="folders"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
@click="moveFile"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="!folderToMoveTo"
|
|
||||||
>Verschieben</UButton>
|
|
||||||
</InputGroup>
|
|
||||||
|
|
||||||
<USeparator class="my-5" label="Dateityp"/>
|
|
||||||
|
|
||||||
<InputGroup class="w-full">
|
|
||||||
<USelectMenu
|
|
||||||
class="flex-auto"
|
|
||||||
v-model="props.documentData.type"
|
|
||||||
value-key="id"
|
|
||||||
label-key="name"
|
|
||||||
:items="filetypes"
|
|
||||||
@change="updateDocument"
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
<USeparator class="my-5" label="Dokumentenbox" />
|
|
||||||
|
|
||||||
<InputGroup class="w-full">
|
|
||||||
<USelectMenu
|
|
||||||
class="flex-auto"
|
|
||||||
v-model="props.documentData.documentbox"
|
|
||||||
value-key="id"
|
|
||||||
label-key="key"
|
|
||||||
:items="documentboxes"
|
|
||||||
@change="updateDocument"
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.bigPreview {
|
.bigPreview {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1/ 1.414;
|
aspect-ratio: 1/ 1.414;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -378,6 +378,27 @@ const getEntityModalCreateQuery = (datapoint) => {
|
|||||||
return datapoint.entityModalCreateQuery || {}
|
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 () => {
|
const createItem = async () => {
|
||||||
let ret = null
|
let ret = null
|
||||||
@@ -386,7 +407,14 @@ const createItem = async () => {
|
|||||||
ret = await useEntities(type).create(item.value, true)
|
ret = await useEntities(type).create(item.value, true)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
ret = await useEntities(type).create(item.value)//dataStore.createNewItem(type,item.value)
|
const postSaveRoute = getPostSaveRoute()
|
||||||
|
|
||||||
|
if (postSaveRoute) {
|
||||||
|
ret = await useEntities(type).create(item.value, true)
|
||||||
|
await router.push(postSaveRoute)
|
||||||
|
} else {
|
||||||
|
ret = await useEntities(type).create(item.value)//dataStore.createNewItem(type,item.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('returnData', ret)
|
emit('returnData', ret)
|
||||||
@@ -401,7 +429,14 @@ const updateItem = async () => {
|
|||||||
emit('returnData', ret)
|
emit('returnData', ret)
|
||||||
modal.close()
|
modal.close()
|
||||||
} else {
|
} else {
|
||||||
ret = await useEntities(type).update(item.value.id, item.value)
|
const postSaveRoute = getPostSaveRoute()
|
||||||
|
|
||||||
|
if (postSaveRoute) {
|
||||||
|
ret = await useEntities(type).update(item.value.id, item.value, true)
|
||||||
|
await router.push(postSaveRoute)
|
||||||
|
} else {
|
||||||
|
ret = await useEntities(type).update(item.value.id, item.value)
|
||||||
|
}
|
||||||
emit('returnData', ret)
|
emit('returnData', ret)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,7 +467,7 @@ const updateItem = async () => {
|
|||||||
<template #right>
|
<template #right>
|
||||||
<ArchiveButton
|
<ArchiveButton
|
||||||
color="error"
|
color="error"
|
||||||
v-if="platform !== 'mobile'"
|
v-if="platform !== 'mobile' && canArchiveItem"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:type="type"
|
:type="type"
|
||||||
@confirmed="useEntities(type).archive(item.id)"
|
@confirmed="useEntities(type).archive(item.id)"
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ const renderDatapointValue = (datapoint) => {
|
|||||||
const value = getDatapointValue(datapoint)
|
const value = getDatapointValue(datapoint)
|
||||||
if (value === null || value === undefined || value === "") return "-"
|
if (value === null || value === undefined || value === "") return "-"
|
||||||
|
|
||||||
|
if (datapoint.displayFunction) {
|
||||||
|
return datapoint.displayFunction(value, props.item)
|
||||||
|
}
|
||||||
|
|
||||||
if (datapoint.inputType === "date") {
|
if (datapoint.inputType === "date") {
|
||||||
return dayjs(value).isValid() ? dayjs(value).format("DD.MM.YYYY") : String(value)
|
return dayjs(value).isValid() ? dayjs(value).format("DD.MM.YYYY") : String(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,11 @@
|
|||||||
|
|
||||||
return `${stringValue.substring(0, 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 handleSortChange = (value) => {
|
const handleSortChange = (value) => {
|
||||||
const nextSort = Array.isArray(value) ? value[0] : undefined
|
const nextSort = Array.isArray(value) ? value[0] : undefined
|
||||||
|
|
||||||
@@ -148,9 +153,9 @@
|
|||||||
v-slot:[`${column.key}-cell`]="{ row }">
|
v-slot:[`${column.key}-cell`]="{ row }">
|
||||||
<component v-if="column.component" :is="column.component" :row="row.original"></component>
|
<component v-if="column.component" :is="column.component" :row="row.original"></component>
|
||||||
<span v-else-if="row.original[column.key]" class="block truncate">
|
<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">
|
<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>
|
</span>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -80,11 +80,6 @@ const links = computed(() => {
|
|||||||
to: "/communication/chat",
|
to: "/communication/chat",
|
||||||
icon: "i-heroicons-chat-bubble-left-right"
|
icon: "i-heroicons-chat-bubble-left-right"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Matrix-Setup",
|
|
||||||
to: "/communication",
|
|
||||||
icon: "i-heroicons-cog-6-tooth"
|
|
||||||
},
|
|
||||||
featureEnabled("helpdesk") ? {
|
featureEnabled("helpdesk") ? {
|
||||||
label: "Helpdesk",
|
label: "Helpdesk",
|
||||||
to: "/helpdesk",
|
to: "/helpdesk",
|
||||||
@@ -294,6 +289,11 @@ const links = computed(() => {
|
|||||||
to: "/standardEntity/contracttypes",
|
to: "/standardEntity/contracttypes",
|
||||||
icon: "i-heroicons-document-duplicate",
|
icon: "i-heroicons-document-duplicate",
|
||||||
} : null,
|
} : null,
|
||||||
|
featureEnabled("files") ? {
|
||||||
|
label: "Dateitypen",
|
||||||
|
to: "/standardEntity/filetags",
|
||||||
|
icon: "i-heroicons-tag",
|
||||||
|
} : null,
|
||||||
has("vehicles") && featureEnabled("vehicles") ? {
|
has("vehicles") && featureEnabled("vehicles") ? {
|
||||||
label: "Fahrzeuge",
|
label: "Fahrzeuge",
|
||||||
to: "/standardEntity/vehicles",
|
to: "/standardEntity/vehicles",
|
||||||
@@ -332,6 +332,11 @@ const links = computed(() => {
|
|||||||
to: "/settings/tenant",
|
to: "/settings/tenant",
|
||||||
icon: "i-heroicons-building-office",
|
icon: "i-heroicons-building-office",
|
||||||
} : null,
|
} : null,
|
||||||
|
{
|
||||||
|
label: "Matrix-Setup",
|
||||||
|
to: "/communication",
|
||||||
|
icon: "i-heroicons-chat-bubble-left-right",
|
||||||
|
},
|
||||||
featureEnabled("export") ? {
|
featureEnabled("export") ? {
|
||||||
label: "Export",
|
label: "Export",
|
||||||
to: "/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) => {
|
const selectSomeDocuments = async (documentIds, sortColumn = null, folder = null) => {
|
||||||
@@ -73,6 +77,7 @@ export const useFiles = () => {
|
|||||||
const selectDocument = async (id) => {
|
const selectDocument = async (id) => {
|
||||||
let documentIds = [id]
|
let documentIds = [id]
|
||||||
if(documentIds.length === 0) return []
|
if(documentIds.length === 0) return []
|
||||||
|
const fileData = await useEntities("files").selectSingle(id)
|
||||||
const res = await useNuxtApp().$api("/api/files/presigned",{
|
const res = await useNuxtApp().$api("/api/files/presigned",{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
@@ -80,9 +85,8 @@ export const useFiles = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(res)
|
const file = res.files?.[0] || null
|
||||||
|
return file ? {...file, ...(fileData || {}), url: file.url} : null
|
||||||
return res.files[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadFile = async (id?: string, ids?: string[], returnAsBlob: Boolean = false) => {
|
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 resourceTimelinePlugin from "@fullcalendar/resource-timeline";
|
||||||
import interactionPlugin from "@fullcalendar/interaction";
|
import interactionPlugin from "@fullcalendar/interaction";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { expandRecurringEvent } from "~/utils/eventRecurrence"
|
||||||
|
|
||||||
//TODO BACKEND CHANGE COLOR IN TENANT FOR RENDERING
|
//TODO BACKEND CHANGE COLOR IN TENANT FOR RENDERING
|
||||||
|
|
||||||
@@ -33,6 +34,124 @@ const selectedEvent = ref({})
|
|||||||
const selectedResources = ref([])
|
const selectedResources = ref([])
|
||||||
|
|
||||||
const events = 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(() => {
|
const calendarOptionsGrid = computed(() => {
|
||||||
return {
|
return {
|
||||||
locale: deLocale,
|
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}`)
|
router.push(`/standardEntity/events/show/${info.event.extendedProps.eventId}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
datesSet: function(info) {
|
||||||
|
expandEventsForRange(info.startStr, info.endStr)
|
||||||
|
},
|
||||||
resourceGroupField: "type",
|
resourceGroupField: "type",
|
||||||
resourceOrder: "-type",
|
resourceOrder: "-type",
|
||||||
resources: [],
|
resources: [],
|
||||||
@@ -127,75 +252,37 @@ const calendarOptionsTimeline = ref({
|
|||||||
|
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
let tempData = (await useEntities("events").select()).filter(i => !i.archived)
|
sourceEvents.value = (await useEntities("events").select()).filter(i => !i.archived)
|
||||||
let absencerequests = (await useEntities("absencerequests").select("*, profile(*)")).filter(i => !i.archived)
|
sourceAbsenceRequests.value = (await useEntities("absencerequests").select("*, profile(*)")).filter(i => !i.archived)
|
||||||
let projects = (await useEntities("projects").select( "*")).filter(i => !i.archived)
|
sourceProjects.value = (await useEntities("projects").select( "*")).filter(i => !i.archived)
|
||||||
let inventoryitems = (await useEntities("inventoryitems").select()).filter(i => !i.archived)
|
sourceInventoryItems.value = (await useEntities("inventoryitems").select()).filter(i => !i.archived)
|
||||||
let inventoryitemgroups = (await useEntities("inventoryitemgroups").select()).filter(i => !i.archived)
|
sourceInventoryItemGroups.value = (await useEntities("inventoryitemgroups").select()).filter(i => !i.archived)
|
||||||
let profiles = (await useEntities("profiles").select()).filter(i => !i.archived)
|
sourceProfiles.value = (await useEntities("profiles").select()).filter(i => !i.archived)
|
||||||
let vehicles = (await useEntities("vehicles").select()).filter(i => !i.archived)
|
sourceVehicles.value = (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",
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]
|
|
||||||
|
|
||||||
calendarOptionsTimeline.value.resources = [
|
calendarOptionsTimeline.value.resources = [
|
||||||
...profiles.filter(i => i.tenant === profileStore.currentTenant).map(profile => {
|
...sourceProfiles.value.filter(i => i.tenant === profileStore.currentTenant).map(profile => {
|
||||||
return {
|
return {
|
||||||
type: 'Mitarbeiter',
|
type: 'Mitarbeiter',
|
||||||
title: profile.fullName,
|
title: profile.fullName,
|
||||||
id: `P-${profile.id}`
|
id: `P-${profile.id}`
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
...vehicles.map(vehicle => {
|
...sourceVehicles.value.map(vehicle => {
|
||||||
return {
|
return {
|
||||||
type: 'Fahrzeug',
|
type: 'Fahrzeug',
|
||||||
title: vehicle.licensePlate,
|
title: vehicle.licensePlate,
|
||||||
id: `F-${vehicle.id}`
|
id: `F-${vehicle.id}`
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
...inventoryitems.filter(i=> i.usePlanning).map(item => {
|
...sourceInventoryItems.value.filter(i=> i.usePlanning).map(item => {
|
||||||
return {
|
return {
|
||||||
type: 'Inventar',
|
type: 'Inventar',
|
||||||
title: item.name,
|
title: item.name,
|
||||||
id: `I-${item.id}`
|
id: `I-${item.id}`
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
...inventoryitemgroups.filter(i=> i.usePlanning).map(item => {
|
...sourceInventoryItemGroups.value.filter(i=> i.usePlanning).map(item => {
|
||||||
return {
|
return {
|
||||||
type: 'Inventargruppen',
|
type: 'Inventargruppen',
|
||||||
title: item.name,
|
title: item.name,
|
||||||
@@ -234,83 +321,7 @@ const setupPage = async () => {
|
|||||||
]
|
]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let tempEvents = []
|
expandEventsForRange(dayjs().startOf("month").toISOString(), dayjs().endOf("month").toISOString())
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
|
|
||||||
@@ -385,4 +396,4 @@ const convertResourceIds = () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,16 +3,23 @@ import { ConnectionState, Room, RoomEvent, Track } from "livekit-client"
|
|||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { $api } = useNuxtApp()
|
const { $api } = useNuxtApp()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const status = ref(null)
|
const status = ref(null)
|
||||||
const identity = ref(null)
|
const identity = ref(null)
|
||||||
const matrixRooms = ref([])
|
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 matrixMessages = ref([])
|
||||||
const matrixMembers = ref([])
|
const matrixMembers = ref([])
|
||||||
const matrixMessageDraft = ref("")
|
const matrixMessageDraft = ref("")
|
||||||
const matrixMessagesViewport = ref(null)
|
const matrixMessagesViewport = ref(null)
|
||||||
|
const matrixAttachmentInput = ref(null)
|
||||||
|
const matrixAttachmentObjectUrls = ref({})
|
||||||
const roomCreateOpen = ref(false)
|
const roomCreateOpen = ref(false)
|
||||||
|
const collapsedRoomGroups = ref({})
|
||||||
const matrixCallOpen = ref(false)
|
const matrixCallOpen = ref(false)
|
||||||
const matrixCallMode = ref("video")
|
const matrixCallMode = ref("video")
|
||||||
const matrixCallLoading = ref(false)
|
const matrixCallLoading = ref(false)
|
||||||
@@ -35,11 +42,13 @@ const roomCreateForm = ref({
|
|||||||
})
|
})
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const roomProvisioning = ref(false)
|
const roomProvisioning = ref(false)
|
||||||
|
const roomProvisioningKey = ref("")
|
||||||
const roomCreating = ref(false)
|
const roomCreating = ref(false)
|
||||||
const roomMembersSyncing = ref(false)
|
const roomMembersSyncing = ref(false)
|
||||||
const matrixMessagesLoading = ref(false)
|
const matrixMessagesLoading = ref(false)
|
||||||
const matrixMembersLoading = ref(false)
|
const matrixMembersLoading = ref(false)
|
||||||
const matrixMessageSending = ref(false)
|
const matrixMessageSending = ref(false)
|
||||||
|
const matrixAttachmentUploading = ref(false)
|
||||||
const matrixAutoRefreshActive = ref(false)
|
const matrixAutoRefreshActive = ref(false)
|
||||||
const lastUpdated = ref(null)
|
const lastUpdated = ref(null)
|
||||||
let matrixRefreshInterval = null
|
let matrixRefreshInterval = null
|
||||||
@@ -48,13 +57,14 @@ let matrixMessagesRequestActive = false
|
|||||||
let matrixMembersRequestActive = false
|
let matrixMembersRequestActive = false
|
||||||
let matrixLiveKitRoom = null
|
let matrixLiveKitRoom = null
|
||||||
const matrixCallVideoElements = new Map()
|
const matrixCallVideoElements = new Map()
|
||||||
|
const matrixAttachmentPreviewRequests = new Set()
|
||||||
|
|
||||||
const canUseMatrixChat = computed(() =>
|
const canUseMatrixChat = computed(() =>
|
||||||
Boolean(status.value?.reachable && status.value?.provisioningConfigured)
|
Boolean(status.value?.reachable && status.value?.provisioningConfigured)
|
||||||
)
|
)
|
||||||
|
|
||||||
const activeRoom = computed(() =>
|
const activeRoom = computed(() =>
|
||||||
matrixRooms.value.find((room) => room.key === activeRoomKey.value) || {
|
rooms.value.find((room) => room.key === activeRoomKey.value) || {
|
||||||
key: activeRoomKey.value,
|
key: activeRoomKey.value,
|
||||||
name: activeRoomKey.value,
|
name: activeRoomKey.value,
|
||||||
description: "Mandantenweiter Austausch",
|
description: "Mandantenweiter Austausch",
|
||||||
@@ -109,26 +119,75 @@ const roomCreateKeyPreview = computed(() =>
|
|||||||
normalizeRoomKey(roomCreateForm.value.key || roomCreateForm.value.name)
|
normalizeRoomKey(roomCreateForm.value.key || roomCreateForm.value.name)
|
||||||
)
|
)
|
||||||
|
|
||||||
const rooms = computed(() => [
|
const sortRoomsByName = (roomList) => [...roomList].sort((first, second) =>
|
||||||
...matrixRooms.value.map((room) => ({
|
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,
|
...room,
|
||||||
description: room.alias || room.roomId || "Mandantenweiter Austausch"
|
group: "Projekte",
|
||||||
})),
|
icon: "i-heroicons-briefcase",
|
||||||
|
unread: unreadRooms.value[room.key]?.count || 0,
|
||||||
|
mentions: unreadRooms.value[room.key]?.mentions || 0,
|
||||||
|
description: room.projectNumber || room.topic || "Projektkommunikation",
|
||||||
|
provisionEndpoint: `/api/communication/matrix/project-rooms/${encodeURIComponent(room.projectId)}/provision`
|
||||||
|
}))
|
||||||
|
|
||||||
|
const directRooms = matrixDirectRooms.value.map((room) => ({
|
||||||
|
...room,
|
||||||
|
group: "Direkt",
|
||||||
|
icon: "i-heroicons-user-circle",
|
||||||
|
unread: unreadRooms.value[room.key]?.count || 0,
|
||||||
|
mentions: unreadRooms.value[room.key]?.mentions || 0,
|
||||||
|
description: room.email || room.topic || "Direktnachricht",
|
||||||
|
provisionEndpoint: `/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [
|
||||||
|
...sortRoomsByName(baseRooms),
|
||||||
|
...sortRoomsByName(projectRooms),
|
||||||
|
...sortRoomsByName(directRooms)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedRooms = computed(() => [
|
||||||
|
{
|
||||||
|
key: "rooms",
|
||||||
|
label: "Räume",
|
||||||
|
rooms: rooms.value.filter((room) => room.group === "Räume")
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "projects",
|
key: "projects",
|
||||||
name: "Projekt-Chats",
|
label: "Projekte",
|
||||||
description: "Nächste Ausbaustufe",
|
rooms: rooms.value.filter((room) => room.group === "Projekte")
|
||||||
exists: false,
|
|
||||||
disabled: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "direct",
|
key: "direct",
|
||||||
name: "Direktnachrichten",
|
label: "Direktnachrichten",
|
||||||
description: "Nächste Ausbaustufe",
|
rooms: rooms.value.filter((room) => room.group === "Direkt")
|
||||||
exists: false,
|
|
||||||
disabled: true
|
|
||||||
}
|
}
|
||||||
])
|
].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 normalizeRoomKey = (value) => {
|
||||||
const normalized = String(value || "")
|
const normalized = String(value || "")
|
||||||
@@ -149,12 +208,61 @@ const normalizeRoomKey = (value) => {
|
|||||||
const setActiveRoom = async (room) => {
|
const setActiveRoom = async (room) => {
|
||||||
if (room.disabled || room.key === activeRoomKey.value) return
|
if (room.disabled || room.key === activeRoomKey.value) return
|
||||||
|
|
||||||
|
if (!room.exists && room.provisionEndpoint) {
|
||||||
|
await provisionRoomFromList(room)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
activeRoomKey.value = room.key
|
activeRoomKey.value = room.key
|
||||||
matrixMessages.value = []
|
matrixMessages.value = []
|
||||||
matrixMembers.value = []
|
matrixMembers.value = []
|
||||||
await loadRoomChat({ includeMembers: true })
|
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 upsertRoom = (room) => {
|
||||||
const roomWasKnown = matrixRooms.value.some((item) => item.key === room.key)
|
const roomWasKnown = matrixRooms.value.some((item) => item.key === room.key)
|
||||||
matrixRooms.value = roomWasKnown
|
matrixRooms.value = roomWasKnown
|
||||||
@@ -180,6 +288,7 @@ const mergeMatrixMessages = (incomingMessages) => {
|
|||||||
|
|
||||||
matrixMessages.value = Array.from(byId.values())
|
matrixMessages.value = Array.from(byId.values())
|
||||||
.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0))
|
.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0))
|
||||||
|
loadAttachmentPreviews()
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollMessagesToBottom = async () => {
|
const scrollMessagesToBottom = async () => {
|
||||||
@@ -189,19 +298,51 @@ const scrollMessagesToBottom = async () => {
|
|||||||
matrixMessagesViewport.value.scrollTop = matrixMessagesViewport.value.scrollHeight
|
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 () => {
|
const loadChatInfo = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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/status"),
|
||||||
$api("/api/communication/matrix/me"),
|
$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
|
status.value = statusRes
|
||||||
identity.value = identityRes
|
identity.value = identityRes
|
||||||
matrixRooms.value = roomsRes.rooms || []
|
matrixRooms.value = roomsRes.rooms || []
|
||||||
|
matrixProjectRooms.value = projectRoomsRes.rooms || []
|
||||||
|
matrixDirectRooms.value = directRoomsRes.rooms || []
|
||||||
lastUpdated.value = new Date()
|
lastUpdated.value = new Date()
|
||||||
|
await loadUnreadCounts()
|
||||||
|
|
||||||
if (activeRoom.value?.exists && canUseMatrixChat.value) {
|
if (activeRoom.value?.exists && canUseMatrixChat.value) {
|
||||||
await loadRoomChat({ silent: true, includeMembers: true })
|
await loadRoomChat({ silent: true, includeMembers: true })
|
||||||
@@ -303,6 +444,7 @@ const loadRoomMessages = async ({ silent = false } = {}) => {
|
|||||||
try {
|
try {
|
||||||
const res = await $api(`${activeRoomEndpoint.value}/messages`)
|
const res = await $api(`${activeRoomEndpoint.value}/messages`)
|
||||||
mergeMatrixMessages(res.messages || [])
|
mergeMatrixMessages(res.messages || [])
|
||||||
|
await markActiveRoomRead()
|
||||||
matrixRooms.value = matrixRooms.value.map((room) => room.key === activeRoomKey.value ? {
|
matrixRooms.value = matrixRooms.value.map((room) => room.key === activeRoomKey.value ? {
|
||||||
...room,
|
...room,
|
||||||
alias: res.alias || room.alias,
|
alias: res.alias || room.alias,
|
||||||
@@ -706,6 +848,7 @@ const sendMatrixMessage = async () => {
|
|||||||
matrixMessages.value = matrixMessages.value.map((item) =>
|
matrixMessages.value = matrixMessages.value.map((item) =>
|
||||||
item.id === optimisticId ? message : item
|
item.id === optimisticId ? message : item
|
||||||
)
|
)
|
||||||
|
loadAttachmentPreviews()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
matrixMessages.value = matrixMessages.value.map((item) =>
|
matrixMessages.value = matrixMessages.value.map((item) =>
|
||||||
item.id === optimisticId ? { ...item, pending: false, failed: true } : 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 = () => {
|
const startMatrixAutoRefresh = () => {
|
||||||
if (matrixRefreshInterval) return
|
if (matrixRefreshInterval) return
|
||||||
|
|
||||||
@@ -726,6 +930,7 @@ const startMatrixAutoRefresh = () => {
|
|||||||
matrixRefreshInterval = window.setInterval(() => {
|
matrixRefreshInterval = window.setInterval(() => {
|
||||||
if (!document.hidden && canUseMatrixChat.value && activeRoom.value?.exists) {
|
if (!document.hidden && canUseMatrixChat.value && activeRoom.value?.exists) {
|
||||||
loadRoomChat({ silent: true })
|
loadRoomChat({ silent: true })
|
||||||
|
loadUnreadCounts()
|
||||||
}
|
}
|
||||||
}, 15000)
|
}, 15000)
|
||||||
}
|
}
|
||||||
@@ -747,6 +952,56 @@ const formatMessageTime = (timestamp) => {
|
|||||||
}).format(new Date(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(() => {
|
const formatLastUpdated = computed(() => {
|
||||||
if (!lastUpdated.value) return "Noch nicht aktualisiert"
|
if (!lastUpdated.value) return "Noch nicht aktualisiert"
|
||||||
|
|
||||||
@@ -770,6 +1025,7 @@ onBeforeUnmount(() => {
|
|||||||
stopMatrixAutoRefresh()
|
stopMatrixAutoRefresh()
|
||||||
stopMatrixCallDurationTimer()
|
stopMatrixCallDurationTimer()
|
||||||
leaveMatrixCall()
|
leaveMatrixCall()
|
||||||
|
Object.values(matrixAttachmentObjectUrls.value).forEach((objectUrl) => URL.revokeObjectURL(objectUrl))
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -855,55 +1111,88 @@ onBeforeUnmount(() => {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-3">
|
<div class="flex-1 space-y-4 overflow-y-auto p-3">
|
||||||
<button
|
<section
|
||||||
v-for="room in rooms"
|
v-for="group in groupedRooms"
|
||||||
:key="room.key"
|
:key="group.key"
|
||||||
type="button"
|
class="space-y-1"
|
||||||
class="mb-1 flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition"
|
|
||||||
:class="[
|
|
||||||
room.disabled ? 'cursor-not-allowed opacity-50' : 'text-highlighted hover:bg-muted',
|
|
||||||
room.key === activeRoomKey ? 'bg-muted ring-1 ring-primary/20' : ''
|
|
||||||
]"
|
|
||||||
:disabled="room.disabled"
|
|
||||||
@click="setActiveRoom(room)"
|
|
||||||
>
|
>
|
||||||
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
<button
|
||||||
<UIcon name="i-heroicons-chat-bubble-left-right" class="size-5" />
|
type="button"
|
||||||
</span>
|
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"
|
||||||
<span class="min-w-0 flex-1">
|
:aria-expanded="!isRoomGroupCollapsed(group.key)"
|
||||||
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
|
@click="toggleRoomGroup(group.key)"
|
||||||
<span class="block truncate text-xs text-muted">{{ room.description }}</span>
|
|
||||||
</span>
|
|
||||||
<UBadge
|
|
||||||
v-if="room.exists"
|
|
||||||
color="success"
|
|
||||||
variant="soft"
|
|
||||||
size="xs"
|
|
||||||
>
|
>
|
||||||
aktiv
|
<span class="flex min-w-0 items-center gap-2">
|
||||||
</UBadge>
|
<UIcon
|
||||||
<UBadge
|
name="i-heroicons-chevron-right"
|
||||||
v-else-if="!room.disabled"
|
class="size-3.5 shrink-0 transition-transform"
|
||||||
color="neutral"
|
:class="!isRoomGroupCollapsed(group.key) ? 'rotate-90' : ''"
|
||||||
variant="soft"
|
/>
|
||||||
size="xs"
|
<span class="truncate text-[11px] font-semibold uppercase tracking-wide">
|
||||||
|
{{ group.label }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px]">{{ group.rooms.length }}</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="!isRoomGroupCollapsed(group.key)"
|
||||||
|
class="space-y-1"
|
||||||
>
|
>
|
||||||
offen
|
<button
|
||||||
</UBadge>
|
v-for="room in group.rooms"
|
||||||
</button>
|
:key="room.key"
|
||||||
</div>
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition"
|
||||||
<div class="border-t border-default p-3">
|
:class="[
|
||||||
<UButton
|
room.disabled ? 'cursor-not-allowed opacity-50' : 'text-highlighted hover:bg-muted',
|
||||||
to="/communication"
|
room.key === activeRoomKey ? 'bg-muted ring-1 ring-primary/20' : ''
|
||||||
icon="i-heroicons-cog-6-tooth"
|
]"
|
||||||
color="neutral"
|
:disabled="room.disabled"
|
||||||
variant="outline"
|
@click="setActiveRoom(room)"
|
||||||
block
|
>
|
||||||
>
|
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
Matrix-Setup
|
<UIcon :name="room.icon || 'i-heroicons-chat-bubble-left-right'" class="size-5" />
|
||||||
</UButton>
|
</span>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
|
||||||
|
<span class="block truncate text-xs text-muted">{{ room.description }}</span>
|
||||||
|
</span>
|
||||||
|
<UBadge
|
||||||
|
v-if="roomProvisioningKey === room.key"
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
lädt
|
||||||
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-else-if="room.unread"
|
||||||
|
:color="room.mentions ? 'error' : 'primary'"
|
||||||
|
variant="solid"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{{ room.mentions ? `@${room.mentions}` : room.unread }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-else-if="room.exists"
|
||||||
|
color="success"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
aktiv
|
||||||
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-else-if="!room.disabled"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
offen
|
||||||
|
</UBadge>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -948,13 +1237,6 @@ onBeforeUnmount(() => {
|
|||||||
:disabled="!canStartMatrixCall"
|
:disabled="!canStartMatrixCall"
|
||||||
@click="openMatrixCall('video')"
|
@click="openMatrixCall('video')"
|
||||||
/>
|
/>
|
||||||
<UButton
|
|
||||||
class="lg:hidden"
|
|
||||||
to="/communication"
|
|
||||||
icon="i-heroicons-cog-6-tooth"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
/>
|
|
||||||
<UButton
|
<UButton
|
||||||
class="lg:hidden"
|
class="lg:hidden"
|
||||||
icon="i-heroicons-plus"
|
icon="i-heroicons-plus"
|
||||||
@@ -987,7 +1269,16 @@ onBeforeUnmount(() => {
|
|||||||
:disabled="room.disabled"
|
:disabled="room.disabled"
|
||||||
@click="setActiveRoom(room)"
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1143,6 +1434,45 @@ onBeforeUnmount(() => {
|
|||||||
<p class="whitespace-pre-wrap break-words text-sm">
|
<p class="whitespace-pre-wrap break-words text-sm">
|
||||||
{{ message.body }}
|
{{ message.body }}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1151,18 +1481,33 @@ onBeforeUnmount(() => {
|
|||||||
class="flex shrink-0 gap-2 border-t border-default bg-default p-3"
|
class="flex shrink-0 gap-2 border-t border-default bg-default p-3"
|
||||||
@submit.prevent="sendMatrixMessage"
|
@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
|
<UInput
|
||||||
v-model="matrixMessageDraft"
|
v-model="matrixMessageDraft"
|
||||||
class="min-w-0 flex-1"
|
class="min-w-0 flex-1"
|
||||||
placeholder="Nachricht schreiben"
|
placeholder="Nachricht schreiben"
|
||||||
:disabled="matrixMessageSending || !canUseMatrixChat || !activeRoom?.exists"
|
:disabled="matrixMessageSending || matrixAttachmentUploading || !canUseMatrixChat || !activeRoom?.exists"
|
||||||
@keydown.enter.exact.prevent="sendMatrixMessage"
|
@keydown.enter.exact.prevent="sendMatrixMessage"
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
type="submit"
|
type="submit"
|
||||||
icon="i-heroicons-paper-airplane"
|
icon="i-heroicons-paper-airplane"
|
||||||
:loading="matrixMessageSending"
|
:loading="matrixMessageSending"
|
||||||
:disabled="!matrixMessageDraft.trim() || !canUseMatrixChat || !activeRoom?.exists"
|
:disabled="!matrixMessageDraft.trim() || matrixAttachmentUploading || !canUseMatrixChat || !activeRoom?.exists"
|
||||||
>
|
>
|
||||||
Senden
|
Senden
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|||||||
@@ -303,6 +303,11 @@ watch(
|
|||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const normalizeEntityId = (value) => {
|
const normalizeEntityId = (value) => {
|
||||||
if (value === null || typeof value === "undefined") return null
|
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
|
return typeof value === "object" ? (value.id ?? null) : value
|
||||||
}
|
}
|
||||||
const normalizeCreatedDocumentRow = (row) => {
|
const normalizeCreatedDocumentRow = (row) => {
|
||||||
@@ -1567,14 +1572,14 @@ const saveSerialInvoice = async () => {
|
|||||||
let createData = {
|
let createData = {
|
||||||
type: itemInfo.value.type,
|
type: itemInfo.value.type,
|
||||||
state: 'Erstellt',
|
state: 'Erstellt',
|
||||||
customer: itemInfo.value.customer,
|
customer: normalizeEntityId(itemInfo.value.customer),
|
||||||
contact: itemInfo.value.contact,
|
contact: normalizeEntityId(itemInfo.value.contact),
|
||||||
contract: itemInfo.value.contract,
|
contract: normalizeEntityId(itemInfo.value.contract),
|
||||||
address: itemInfo.value.address,
|
address: itemInfo.value.address,
|
||||||
project: itemInfo.value.project,
|
project: normalizeEntityId(itemInfo.value.project),
|
||||||
paymentDays: itemInfo.value.paymentDays,
|
paymentDays: itemInfo.value.paymentDays,
|
||||||
payment_type: itemInfo.value.payment_type,
|
payment_type: itemInfo.value.payment_type,
|
||||||
outgoingsepamandate: itemInfo.value.outgoingsepamandate,
|
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
|
||||||
deliveryDateType: "Leistungszeitraum",
|
deliveryDateType: "Leistungszeitraum",
|
||||||
createdBy: itemInfo.value.createdBy,
|
createdBy: itemInfo.value.createdBy,
|
||||||
created_by: itemInfo.value.created_by,
|
created_by: itemInfo.value.created_by,
|
||||||
@@ -1650,19 +1655,19 @@ const saveDocument = async (state, resetup = false) => {
|
|||||||
type: itemInfo.value.type,
|
type: itemInfo.value.type,
|
||||||
taxType: ['invoices', 'cancellationInvoices', 'advanceInvoices', 'confirmationOrders', ...quoteLikeDocumentTypes].includes(itemInfo.value.type) ? normalizeTaxTypeValue(itemInfo.value.taxType) : null,
|
taxType: ['invoices', 'cancellationInvoices', 'advanceInvoices', 'confirmationOrders', ...quoteLikeDocumentTypes].includes(itemInfo.value.type) ? normalizeTaxTypeValue(itemInfo.value.taxType) : null,
|
||||||
state: itemInfo.value.state || "Entwurf",
|
state: itemInfo.value.state || "Entwurf",
|
||||||
customer: itemInfo.value.customer,
|
customer: normalizeEntityId(itemInfo.value.customer),
|
||||||
contact: itemInfo.value.contact,
|
contact: normalizeEntityId(itemInfo.value.contact),
|
||||||
contract: itemInfo.value.contract,
|
contract: normalizeEntityId(itemInfo.value.contract),
|
||||||
address: itemInfo.value.address,
|
address: itemInfo.value.address,
|
||||||
project: itemInfo.value.project,
|
project: normalizeEntityId(itemInfo.value.project),
|
||||||
plant: itemInfo.value.plant,
|
plant: normalizeEntityId(itemInfo.value.plant),
|
||||||
documentNumber: itemInfo.value.documentNumber,
|
documentNumber: itemInfo.value.documentNumber,
|
||||||
documentDate: itemInfo.value.documentDate,
|
documentDate: itemInfo.value.documentDate,
|
||||||
deliveryDate: itemInfo.value.deliveryDate,
|
deliveryDate: itemInfo.value.deliveryDate,
|
||||||
deliveryDateEnd: itemInfo.value.deliveryDateEnd,
|
deliveryDateEnd: itemInfo.value.deliveryDateEnd,
|
||||||
paymentDays: itemInfo.value.paymentDays,
|
paymentDays: itemInfo.value.paymentDays,
|
||||||
payment_type: itemInfo.value.payment_type,
|
payment_type: itemInfo.value.payment_type,
|
||||||
outgoingsepamandate: itemInfo.value.outgoingsepamandate,
|
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
|
||||||
deliveryDateType: itemInfo.value.deliveryDateType,
|
deliveryDateType: itemInfo.value.deliveryDateType,
|
||||||
info: {},
|
info: {},
|
||||||
createdBy: itemInfo.value.createdBy,
|
createdBy: itemInfo.value.createdBy,
|
||||||
@@ -1673,9 +1678,9 @@ const saveDocument = async (state, resetup = false) => {
|
|||||||
endText: itemInfo.value.endText,
|
endText: itemInfo.value.endText,
|
||||||
rows: itemInfo.value.rows,
|
rows: itemInfo.value.rows,
|
||||||
contactPerson: itemInfo.value.contactPerson,
|
contactPerson: itemInfo.value.contactPerson,
|
||||||
createddocument: itemInfo.value.createddocument,
|
createddocument: normalizeEntityId(itemInfo.value.createddocument),
|
||||||
agriculture: itemInfo.value.agriculture,
|
agriculture: itemInfo.value.agriculture,
|
||||||
letterhead: itemInfo.value.letterhead,
|
letterhead: normalizeEntityId(itemInfo.value.letterhead),
|
||||||
usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices,
|
usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices,
|
||||||
availableInPortal: itemInfo.value.availableInPortal,
|
availableInPortal: itemInfo.value.availableInPortal,
|
||||||
customSurchargePercentage: itemInfo.value.customSurchargePercentage,
|
customSurchargePercentage: itemInfo.value.customSurchargePercentage,
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import interactionPlugin from "@fullcalendar/interaction"
|
|||||||
import resourceTimelinePlugin from "@fullcalendar/resource-timeline"
|
import resourceTimelinePlugin from "@fullcalendar/resource-timeline"
|
||||||
import { parseDate } from "@internationalized/date"
|
import { parseDate } from "@internationalized/date"
|
||||||
import { useDraggable } from "@vueuse/core"
|
import { useDraggable } from "@vueuse/core"
|
||||||
|
import { expandRecurringEvent } from "~/utils/eventRecurrence"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -14,12 +16,16 @@ const { $api, $dayjs } = useNuxtApp()
|
|||||||
const { create: createEvent } = useEntities("events")
|
const { create: createEvent } = useEntities("events")
|
||||||
const { list: listStaffTimeSpans, createEntry, update: updateStaffTimeEntry } = useStaffTime()
|
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 loading = ref(true)
|
||||||
const savingAbsence = ref(false)
|
const savingAbsence = ref(false)
|
||||||
const selectedType = ref("all")
|
const selectedType = ref("all")
|
||||||
const calendarRef = ref(null)
|
const calendarRef = ref(null)
|
||||||
const calendarView = ref("resourceTimelineWeek")
|
const calendarView = ref(initialRouteView || "resourceTimelineWeek")
|
||||||
const calendarCurrentDate = ref($dayjs().format("YYYY-MM-DD"))
|
const calendarCurrentDate = ref(initialRouteDate || $dayjs().format("YYYY-MM-DD"))
|
||||||
const calendarTitle = ref("")
|
const calendarTitle = ref("")
|
||||||
const visibleRange = ref({
|
const visibleRange = ref({
|
||||||
from: $dayjs().startOf("month").format("YYYY-MM-DD"),
|
from: $dayjs().startOf("month").format("YYYY-MM-DD"),
|
||||||
@@ -30,6 +36,9 @@ const resources = ref([])
|
|||||||
const events = ref([])
|
const events = ref([])
|
||||||
const profiles = ref([])
|
const profiles = ref([])
|
||||||
const inventoryitems = ref([])
|
const inventoryitems = ref([])
|
||||||
|
const isDraftModeActive = ref(false)
|
||||||
|
const isFinalizeDraftsModalOpen = ref(false)
|
||||||
|
const finalizingDrafts = ref(false)
|
||||||
const savingQuickConfig = ref(false)
|
const savingQuickConfig = ref(false)
|
||||||
const isQuickConfigModalOpen = ref(false)
|
const isQuickConfigModalOpen = ref(false)
|
||||||
const quickConfigWindowEl = ref(null)
|
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(() => ({
|
const calendarOptions = computed(() => ({
|
||||||
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
|
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
|
||||||
locale: deLocale,
|
locale: deLocale,
|
||||||
@@ -358,7 +375,14 @@ const calendarOptions = computed(() => ({
|
|||||||
return
|
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) {
|
datesSet(info) {
|
||||||
const nextFrom = $dayjs(info.start).format("YYYY-MM-DD")
|
const nextFrom = $dayjs(info.start).format("YYYY-MM-DD")
|
||||||
@@ -388,10 +412,15 @@ function resolveEventTitle(event, projectsById) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveRenderedEventColor(event) {
|
function resolveRenderedEventColor(event) {
|
||||||
if (event?.quick) return activeQuickEntryConfig.value.color
|
if (event?.quick) return event?.color || activeQuickEntryConfig.value.color
|
||||||
return resolveEventColor(event.eventtype)
|
return resolveEventColor(event.eventtype)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDisplayedEventTitle(event, projectsById) {
|
||||||
|
const baseTitle = resolveEventTitle(event, projectsById)
|
||||||
|
return event?.state === "Entwurf" ? `[Entwurf] ${baseTitle}` : baseTitle
|
||||||
|
}
|
||||||
|
|
||||||
function getProfileLabel(profile) {
|
function getProfileLabel(profile) {
|
||||||
return profile?.full_name || profile?.fullName || [profile?.first_name, profile?.last_name].filter(Boolean).join(" ") || profile?.email || `Profil ${profile?.id || ""}`.trim()
|
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 }) {
|
function buildEvents({ rawEvents, projectsById }) {
|
||||||
const mappedEvents = rawEvents
|
const mappedEvents = rawEvents
|
||||||
.filter((event) => !event.archived)
|
.filter((event) => !event.archived)
|
||||||
.map((event) => {
|
.flatMap((event) => {
|
||||||
const resourceIds = [
|
const resourceIds = [
|
||||||
...(profiles.value
|
...(profiles.value
|
||||||
.filter((profile) => (event.profiles || []).includes(profile.id))
|
.filter((profile) => (event.profiles || []).includes(profile.id))
|
||||||
@@ -526,17 +555,26 @@ function buildEvents({ rawEvents, projectsById }) {
|
|||||||
...(event.inventoryitems || []).map((itemId) => `I-${itemId}`)
|
...(event.inventoryitems || []).map((itemId) => `I-${itemId}`)
|
||||||
]
|
]
|
||||||
|
|
||||||
return {
|
return expandRecurringEvent(
|
||||||
title: resolveEventTitle(event, projectsById),
|
event,
|
||||||
start: event.startDate,
|
`${visibleRange.value.from}T00:00:00`,
|
||||||
end: event.endDate,
|
`${visibleRange.value.to}T23:59:59`,
|
||||||
resourceIds,
|
(occurrenceStart, occurrenceEnd, occurrenceIndex) => ({
|
||||||
backgroundColor: resolveRenderedEventColor(event),
|
title: resolveDisplayedEventTitle(event, projectsById),
|
||||||
borderColor: resolveRenderedEventColor(event),
|
start: occurrenceStart.toISOString(),
|
||||||
textColor: "#ffffff",
|
end: occurrenceEnd ? occurrenceEnd.toISOString() : null,
|
||||||
entrytype: "event",
|
resourceIds,
|
||||||
eventId: event.id
|
color: event.color || null,
|
||||||
}
|
state: event.state || "Final",
|
||||||
|
backgroundColor: resolveRenderedEventColor(event),
|
||||||
|
borderColor: resolveRenderedEventColor(event),
|
||||||
|
textColor: "#ffffff",
|
||||||
|
classNames: event.state === "Entwurf" ? ["planning-board-draft-event"] : [],
|
||||||
|
entrytype: "event",
|
||||||
|
eventId: event.id,
|
||||||
|
occurrenceIndex
|
||||||
|
})
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.filter((event) => event.resourceIds.length > 0)
|
.filter((event) => event.resourceIds.length > 0)
|
||||||
|
|
||||||
@@ -755,6 +793,8 @@ async function createQuickEvent(info) {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name: activeQuickEntryConfig.value.name,
|
name: activeQuickEntryConfig.value.name,
|
||||||
quick: true,
|
quick: true,
|
||||||
|
state: isDraftModeActive.value ? "Entwurf" : "Final",
|
||||||
|
color: activeQuickEntryConfig.value.color,
|
||||||
startDate: info.startStr,
|
startDate: info.startStr,
|
||||||
endDate: info.endStr,
|
endDate: info.endStr,
|
||||||
profiles: resourceIds
|
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 = {}) {
|
function openAbsenceModal(type = "vacation", preset = {}) {
|
||||||
absenceForm.mode = preset.entry ? "edit" : "create"
|
absenceForm.mode = preset.entry ? "edit" : "create"
|
||||||
absenceForm.entry = preset.entry || null
|
absenceForm.entry = preset.entry || null
|
||||||
@@ -983,6 +1071,14 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
Quick-Einträge
|
Quick-Einträge
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
:color="isDraftModeActive ? 'amber' : 'neutral'"
|
||||||
|
:variant="isDraftModeActive ? 'solid' : 'outline'"
|
||||||
|
icon="i-heroicons-document-duplicate"
|
||||||
|
@click="toggleDraftMode"
|
||||||
|
>
|
||||||
|
{{ isDraftModeActive ? "Entwurfsmodus aktiv" : "Entwurfsmodus" }}
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
color="amber"
|
color="amber"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
@@ -1211,6 +1307,53 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<UModal v-model:open="isAbsenceModalOpen">
|
||||||
<template #content>
|
<template #content>
|
||||||
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
|
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
|
||||||
@@ -1506,3 +1649,13 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 auth = useAuthStore()
|
||||||
const admin = useAdmin()
|
const admin = useAdmin()
|
||||||
const { $api } = useNuxtApp()
|
const { $api } = useNuxtApp()
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
|
||||||
const id = route.params.id as string
|
const id = route.params.id as string
|
||||||
const profile = ref<any>(null)
|
const profile = ref<any>(null)
|
||||||
@@ -15,6 +16,7 @@ const saving = ref(false)
|
|||||||
const creatingLinkedUser = ref(false)
|
const creatingLinkedUser = ref(false)
|
||||||
const createLinkedUserModalOpen = ref(false)
|
const createLinkedUserModalOpen = ref(false)
|
||||||
const createdLinkedUserPassword = ref("")
|
const createdLinkedUserPassword = ref("")
|
||||||
|
const generatingCalendarSubscription = ref(false)
|
||||||
const createLinkedUserForm = reactive({
|
const createLinkedUserForm = reactive({
|
||||||
email: "",
|
email: "",
|
||||||
})
|
})
|
||||||
@@ -26,6 +28,30 @@ const selectMenuUi = {
|
|||||||
const canCreateLinkedUser = computed(() => Boolean(auth.user?.is_admin && profile.value && !profile.value.user_id))
|
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 linkedUserStatusLabel = computed(() => profile.value?.user_id ? "Benutzer verknüpft" : "Kein Benutzer verknüpft")
|
||||||
const linkedUserStatusColor = computed(() => profile.value?.user_id ? "green" : "orange")
|
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() {
|
async function fetchBranches() {
|
||||||
try {
|
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 = [
|
const weekdays = [
|
||||||
{ key: '1', label: 'Montag' },
|
{ key: '1', label: 'Montag' },
|
||||||
{ key: '2', label: 'Dienstag' },
|
{ key: '2', label: 'Dienstag' },
|
||||||
@@ -471,6 +546,57 @@ onMounted(async () => {
|
|||||||
</UForm>
|
</UForm>
|
||||||
</UCard>
|
</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">
|
<UCard v-if="!pending && profile" class="mt-3">
|
||||||
<USeparator label="Adresse & Standort" />
|
<USeparator label="Adresse & Standort" />
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ const type = route.params.type
|
|||||||
|
|
||||||
const dataType = dataStore.dataTypes[type]
|
const dataType = dataStore.dataTypes[type]
|
||||||
const canCreate = computed(() => {
|
const canCreate = computed(() => {
|
||||||
|
if (type === "filetags") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "members") {
|
if (type === "members") {
|
||||||
return has("members-create") || has("customers-create")
|
return has("members-create") || has("customers-create")
|
||||||
}
|
}
|
||||||
@@ -313,9 +317,17 @@ const truncateValue = (value, maxLength) => {
|
|||||||
return `${stringValue.substring(0, 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 getDistinctFilterItems = (columnKey) => {
|
||||||
|
const column = dataType.templateColumns.find((item) => item.key === columnKey)
|
||||||
|
|
||||||
return (itemsMeta.value?.distinctValues?.[columnKey] || []).map((value) => ({
|
return (itemsMeta.value?.distinctValues?.[columnKey] || []).map((value) => ({
|
||||||
label: String(value),
|
label: String(column?.displayFunction ? column.displayFunction(value) : value),
|
||||||
value
|
value
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -546,9 +558,9 @@ const isDistinctFilterActive = (columnKey) => {
|
|||||||
v-slot:[`${column.key}-cell`]="{row}">
|
v-slot:[`${column.key}-cell`]="{row}">
|
||||||
<component v-if="column.component" :is="column.component" :row="row.original"></component>
|
<component v-if="column.component" :is="column.component" :row="row.original"></component>
|
||||||
<span v-else-if="row.original[column.key]" class="block truncate">
|
<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">
|
<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>
|
</span>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
</span>
|
</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 = {
|
const dataTypes = {
|
||||||
tasks: {
|
tasks: {
|
||||||
isArchivable: true,
|
isArchivable: true,
|
||||||
@@ -2337,6 +2360,78 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
labelSingle: "Datei",
|
labelSingle: "Datei",
|
||||||
selectWithInformation: "*",
|
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: {
|
folders: {
|
||||||
isArchivable: true,
|
isArchivable: true,
|
||||||
label: "Ordner",
|
label: "Ordner",
|
||||||
@@ -3089,6 +3184,26 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
inputType: "bool",
|
inputType: "bool",
|
||||||
sortable: true
|
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",
|
key: "startDate",
|
||||||
label: "Start",
|
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