Compare commits

...

69 Commits

Author SHA1 Message Date
f793d4cce6 Supabase Removals Frontend
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 3m2s
Build and Push Docker Images / build-frontend (push) Successful in 3m38s
2026-02-14 13:35:21 +01:00
c3f46cd184 Supabase Removals Frontend 2026-02-14 13:15:01 +01:00
6bf336356d Supabase Removals Frontend 2026-02-14 13:14:22 +01:00
55699da42c Supabase Removals Frontend 2026-02-14 12:48:59 +01:00
053f184a33 Supabase Removals Backend 2026-02-14 12:29:59 +01:00
6541cb2adf Supabase Removals Backend 2026-02-14 12:27:44 +01:00
7dca84947e Supabase Removals 2026-02-14 12:16:50 +01:00
45fd6fda08 Remove Supa 2026-02-14 12:03:12 +01:00
31e80fb386 Fix #96 2026-02-14 11:59:07 +01:00
7ea28cc6c0 Neue E-Mail Sending Seite 2026-02-14 11:50:58 +01:00
c0faa398b8 Fix #103
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 33s
Build and Push Docker Images / build-frontend (push) Successful in 1m11s
2026-02-05 18:49:10 +01:00
19be1f0d03 Fix #102 2026-02-05 18:44:32 +01:00
c43d3225e3 Fix dev output #98 2026-02-05 18:30:39 +01:00
7125d15b3f PWA
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 3m56s
2026-02-04 16:33:13 +01:00
4b7cf171c8 PWA
Some checks failed
Build and Push Docker Images / build-backend (push) Successful in 31s
Build and Push Docker Images / build-frontend (push) Failing after 20s
2026-02-04 16:02:47 +01:00
59fdedfaa0 Fix for Rendering in Bank Booking
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 18s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-02-02 18:03:41 +01:00
71d249d8bf #15
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m9s
2026-01-30 16:49:40 +01:00
e496a62b36 Fix for #74 and #72
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-01-30 16:36:58 +01:00
0bfef0806b Fix for #73
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-01-30 16:23:57 +01:00
5c69388f1c Fix for #2
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-01-30 16:20:03 +01:00
7ed0388acb Fix for #34
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 19s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-30 16:15:31 +01:00
3aa0c7d77a Change for #34 2026-01-30 16:12:55 +01:00
77aa277347 Fix for Times Edit
Some checks failed
Build and Push Docker Images / build-backend (push) Successful in 3m7s
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-01-30 16:11:30 +01:00
2fff1ca8a8 Added Entity Wiki
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 32s
Build and Push Docker Images / build-frontend (push) Successful in 3m27s
2026-01-27 15:17:37 +01:00
e58929d9a0 Added Entity Wiki 2026-01-27 15:01:56 +01:00
90560ecd2c Added Internal Links #84 2026-01-27 14:07:36 +01:00
b07953fb7d Fix Cursor, Fix Task Item #87
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m5s
2026-01-27 13:07:40 +01:00
01ef3c5a42 Change for Agrar
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 31s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-27 08:58:44 +01:00
2aed851224 Change for Agrar
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 32s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
2026-01-26 22:04:27 +01:00
c56fcfbd14 #84
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 33s
Build and Push Docker Images / build-frontend (push) Successful in 3m25s
2026-01-26 21:52:24 +01:00
ca2020b9c6 Added more text functions 2026-01-26 21:11:16 +01:00
c87212d54a Folders in Wiki 2026-01-26 20:43:35 +01:00
db22d47900 Changes More Functions to wiki 2026-01-26 20:31:17 +01:00
143485e107 Changes More Functions to wiki 2026-01-26 20:18:33 +01:00
c1d4b24418 Added Wiki 2026-01-26 19:44:08 +01:00
9655d4fa05 Fix Ownaccount Booking
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m9s
2026-01-22 19:45:30 +01:00
4efe452f1c Redone Layouts
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 30s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-22 19:38:09 +01:00
cb21a85736 Added Dokubox Sync Service and Button Fix #12 2026-01-22 19:35:45 +01:00
d2b70e5883 Added Dokubox Sync Service and Button Fix #12
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 30s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
2026-01-22 17:05:22 +01:00
1a065b649c Fixed #71
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
2026-01-22 14:48:35 +01:00
34c58c3755 Fixed #71
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-01-22 11:28:39 +01:00
37d8a414d3 Fixed DB
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 30s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-21 15:16:23 +01:00
7f4f232c32 Added Health Ednpoint for Devices
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 31s
Build and Push Docker Images / build-frontend (push) Successful in 17s
Added Offline Sync for times
2026-01-21 12:38:36 +01:00
d6f257bcc6 Fix für #71
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-21 10:52:56 +01:00
3109f4d5ff Fix for Object Create from Customer
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-20 16:16:08 +01:00
235b33ae08 Fix for #46
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m11s
2026-01-20 15:16:47 +01:00
2d135b7068 Fix for #65
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
2026-01-20 15:08:58 +01:00
8831320a4c Fix ts
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m2s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-20 14:14:58 +01:00
000d409e4d fix for no createddocuments
Some checks failed
Build and Push Docker Images / build-backend (push) Failing after 31s
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-01-20 14:14:09 +01:00
160124a184 fix for #68
Some checks failed
Build and Push Docker Images / build-backend (push) Failing after 1m28s
Build and Push Docker Images / build-frontend (push) Successful in 1m12s
2026-01-20 13:55:10 +01:00
26dad422ec added size column 2026-01-17 16:04:39 +01:00
e59cbade53 Webdav 2026-01-17 16:04:32 +01:00
6423886930 added webdav server 2026-01-17 15:15:34 +01:00
6adf09faa0 DB Change con string 2026-01-17 15:15:26 +01:00
d7f3920763 Cors Change 2026-01-17 15:15:06 +01:00
3af92ebf71 #16 Added Move Up 2026-01-17 12:55:39 +01:00
5ab90830a0 Redone Files Index, #16 Added Drag and Drop for Files 2026-01-17 12:36:28 +01:00
4f72919269 #64 Fix
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-16 13:14:09 +01:00
f2c9dcc900 #64 Fix
Some checks failed
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Failing after 35s
2026-01-16 13:11:54 +01:00
b4ec792cc0 Diasbled Label Test Card
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-01-15 19:09:26 +01:00
9b3f48defe Added Calculator 2026-01-15 19:08:26 +01:00
5edc90bd4d Fix #8
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-15 18:45:35 +01:00
d140251aa0 Fix #7 Added Month Markings, Range Select 2026-01-15 18:45:25 +01:00
e7fb2df5c7 Added Debouncing #36
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-15 18:19:05 +01:00
f27fd3f6da Fix TS
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 29s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-15 18:07:58 +01:00
d3e2b106af Storno Fix createddocument link. Added Disable and Tooltip for Storno Button
Some checks failed
Build and Push Docker Images / build-backend (push) Failing after 29s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-15 18:05:44 +01:00
769d2059ca Redone Search to inluce more Columns #36
TODO: Spalten nachpflegen
2026-01-15 18:05:14 +01:00
53349fae83 Fix Link Buttons Added New link buttons
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-01-15 13:38:01 +01:00
d8eb1559c8 Update Problem bei #54
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 31s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-15 13:18:58 +01:00
116 changed files with 5789 additions and 4643 deletions

View File

@@ -0,0 +1,3 @@
{
"rules": []
}

View File

@@ -1,13 +1,26 @@
import { drizzle } from "drizzle-orm/node-postgres"
import { Pool } from "pg"
import {secrets} from "../src/utils/secrets";
import * as schema from "./schema"
// src/db/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
console.log("[DB INIT] 1. Suche Connection String...");
// Checken woher die URL kommt
let connectionString = process.env.DATABASE_URL;
if (connectionString) {
console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL");
} else {
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
}
export const pool = new Pool({
connectionString: secrets.DATABASE_URL,
max: 10, // je nach Last
})
connectionString,
max: 10,
});
export const db = drizzle(pool , {schema})
// TEST: Ist die DB wirklich da?
pool.query('SELECT NOW()')
.then(res => console.log(`[DB INIT] ✅ VERBINDUNG ERFOLGREICH! Zeit auf DB: ${res.rows[0].now}`))
.catch(err => console.error(`[DB INIT] ❌ VERBINDUNGSFEHLER:`, err.message));
export const db = drizzle(pool, { schema });

View File

@@ -3,7 +3,7 @@ import {
uuid,
timestamp,
text,
bigint,
bigint, jsonb,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
@@ -23,6 +23,11 @@ export const devices = pgTable("devices", {
password: text("password"),
externalId: text("externalId"),
lastSeen: timestamp("last_seen", { withTimezone: true }),
// Hier speichern wir den ganzen Payload (RSSI, Heap, IP, etc.)
lastDebugInfo: jsonb("last_debug_info"),
})
export type Device = typeof devices.$inferSelect

View File

@@ -73,6 +73,7 @@ export const files = pgTable("files", {
createdBy: uuid("created_by").references(() => authUsers.id),
authProfile: uuid("auth_profile").references(() => authProfiles.id),
size: bigint("size", { mode: "number" }),
})
export type File = typeof files.$inferSelect

View File

@@ -14,7 +14,7 @@ export const hourrates = pgTable("hourrates", {
name: text("name").notNull(),
purchasePrice: doublePrecision("purchasePrice").notNull(),
purchase_price: doublePrecision("purchasePrice").notNull(),
sellingPrice: doublePrecision("sellingPrice").notNull(),
archived: boolean("archived").notNull().default(false),

View File

@@ -72,3 +72,4 @@ export * from "./staff_time_events"
export * from "./serialtypes"
export * from "./serialexecutions"
export * from "./public_links"
export * from "./wikipages"

View File

@@ -0,0 +1,99 @@
import {
pgTable,
bigint,
text,
timestamp,
boolean,
jsonb,
integer,
index,
uuid,
AnyPgColumn
} from "drizzle-orm/pg-core"
import { relations } from "drizzle-orm"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const wikiPages = pgTable(
"wiki_pages",
{
// ID des Wiki-Eintrags selbst (neu = UUID)
id: uuid("id")
.primaryKey()
.defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
parentId: uuid("parent_id")
.references((): AnyPgColumn => wikiPages.id, { onDelete: "cascade" }),
title: text("title").notNull(),
content: jsonb("content"),
isFolder: boolean("is_folder").notNull().default(false),
sortOrder: integer("sort_order").notNull().default(0),
// --- POLYMORPHE BEZIEHUNG (Split) ---
// Art der Entität (z.B. 'customer', 'invoice', 'iot_device')
entityType: text("entity_type"),
// SPALTE 1: Für Legacy-Tabellen (BigInt)
// Nutzung: Wenn entityType='customer', wird hier die ID 1050 gespeichert
entityId: bigint("entity_id", { mode: "number" }),
// SPALTE 2: Für neue Tabellen (UUID)
// Nutzung: Wenn entityType='iot_device', wird hier die UUID gespeichert
entityUuid: uuid("entity_uuid"),
// ------------------------------------
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
createdBy: uuid("created_by").references(() => authUsers.id),
updatedBy: uuid("updated_by").references(() => authUsers.id),
},
(table) => ({
tenantIdx: index("wiki_pages_tenant_idx").on(table.tenantId),
parentIdx: index("wiki_pages_parent_idx").on(table.parentId),
// ZWEI separate Indexe für schnelle Lookups, je nachdem welche ID genutzt wird
// Fall 1: Suche nach Notizen für Kunde 1050
entityIntIdx: index("wiki_pages_entity_int_idx")
.on(table.tenantId, table.entityType, table.entityId),
// Fall 2: Suche nach Notizen für IoT-Device 550e84...
entityUuidIdx: index("wiki_pages_entity_uuid_idx")
.on(table.tenantId, table.entityType, table.entityUuid),
})
)
export const wikiPagesRelations = relations(wikiPages, ({ one, many }) => ({
tenant: one(tenants, {
fields: [wikiPages.tenantId],
references: [tenants.id],
}),
parent: one(wikiPages, {
fields: [wikiPages.parentId],
references: [wikiPages.id],
relationName: "parent_child",
}),
children: many(wikiPages, {
relationName: "parent_child",
}),
author: one(authUsers, {
fields: [wikiPages.createdBy],
references: [authUsers.id],
}),
}))
export type WikiPage = typeof wikiPages.$inferSelect
export type NewWikiPage = typeof wikiPages.$inferInsert

View File

@@ -5,6 +5,8 @@
"main": "index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"fill": "ts-node src/webdav/fill-file-sizes.ts",
"dev:dav": "tsx watch src/webdav/server.ts",
"build": "tsc",
"start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts"
@@ -27,7 +29,6 @@
"@infisical/sdk": "^4.0.6",
"@mmote/niimbluelib": "^0.0.1-alpha.29",
"@prisma/client": "^6.15.0",
"@supabase/supabase-js": "^2.56.1",
"@zip.js/zip.js": "^2.7.73",
"archiver": "^7.0.1",
"axios": "^1.12.1",
@@ -48,6 +49,7 @@
"pg": "^8.16.3",
"pngjs": "^7.0.0",
"sharp": "^0.34.5",
"webdav-server": "^2.6.2",
"xmlbuilder": "^15.1.1",
"zpl-image": "^0.2.0",
"zpl-renderer-js": "^2.0.2"

View File

@@ -1,6 +1,5 @@
import Fastify from "fastify";
import swaggerPlugin from "./plugins/swagger"
import supabasePlugin from "./plugins/supabase";
import dayjsPlugin from "./plugins/dayjs";
import healthRoutes from "./routes/health";
import meRoutes from "./routes/auth/me";
@@ -29,6 +28,7 @@ import staffTimeRoutes from "./routes/staff/time";
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
import userRoutes from "./routes/auth/user";
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
import wikiRoutes from "./routes/wiki";
//Public Links
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
@@ -45,6 +45,7 @@ import staffTimeRoutesInternal from "./routes/internal/time";
//Devices
import devicesRFIDRoutes from "./routes/devices/rfid";
import devicesManagementRoutes from "./routes/devices/management";
import {sendMail} from "./utils/mailer";
@@ -52,6 +53,7 @@ import {loadSecrets, secrets} from "./utils/secrets";
import {initMailer} from "./utils/mailer"
import {initS3} from "./utils/s3";
//Services
import servicesPlugin from "./plugins/services";
@@ -70,8 +72,6 @@ async function main() {
// Plugins Global verfügbar
await app.register(swaggerPlugin);
await app.register(corsPlugin);
await app.register(supabasePlugin);
await app.register(tenantPlugin);
await app.register(dayjsPlugin);
await app.register(dbPlugin);
@@ -115,8 +115,10 @@ async function main() {
await app.register(async (devicesApp) => {
await devicesApp.register(devicesRFIDRoutes)
await devicesApp.register(devicesManagementRoutes)
},{prefix: "/devices"})
await app.register(corsPlugin);
//Geschützte Routes
@@ -141,6 +143,7 @@ async function main() {
await subApp.register(userRoutes);
await subApp.register(publiclinksAuthenticatedRoutes);
await subApp.register(resourceRoutes);
await subApp.register(wikiRoutes);
},{prefix: "/api"})

View File

@@ -19,15 +19,14 @@ import {
and,
} from "drizzle-orm"
export function syncDokuboxService (server: FastifyInstance) {
let badMessageDetected = false
let badMessageMessageSent = false
let client: ImapFlow | null = null
// -------------------------------------------------------------
// IMAP CLIENT INITIALIZEN
// -------------------------------------------------------------
export async function initDokuboxClient() {
async function initDokuboxClient() {
client = new ImapFlow({
host: secrets.DOKUBOX_IMAP_HOST,
port: secrets.DOKUBOX_IMAP_PORT,
@@ -44,13 +43,7 @@ export async function initDokuboxClient() {
await client.connect()
}
// -------------------------------------------------------------
// MAIN SYNC FUNCTION (DRIZZLE VERSION)
// -------------------------------------------------------------
export const syncDokubox = (server: FastifyInstance) =>
async () => {
const syncDokubox = async () => {
console.log("Perform Dokubox Sync")
@@ -130,11 +123,6 @@ export const syncDokubox = (server: FastifyInstance) =>
}
}
// -------------------------------------------------------------
// TENANT ERKENNEN + FOLDER/FILETYPES (DRIZZLE VERSION)
// -------------------------------------------------------------
const getMessageConfigDrizzle = async (
server: FastifyInstance,
message,
@@ -257,3 +245,12 @@ const getMessageConfigDrizzle = async (
filetype: filetypeId
}
}
return {
run: async () => {
await initDokuboxClient()
await syncDokubox()
console.log("Service: Dokubox sync finished")
}
}
}

View File

@@ -1,5 +1,7 @@
// modules/helpdesk/helpdesk.contact.service.ts
import { FastifyInstance } from 'fastify'
import { and, eq, or } from "drizzle-orm";
import { helpdesk_contacts } from "../../../db/schema";
export async function getOrCreateContact(
server: FastifyInstance,
@@ -9,30 +11,35 @@ export async function getOrCreateContact(
if (!email && !phone) throw new Error('Contact must have at least an email or phone')
// Bestehenden Kontakt prüfen
const { data: existing, error: findError } = await server.supabase
.from('helpdesk_contacts')
.select('*')
.eq('tenant_id', tenant_id)
.or(`email.eq.${email || ''},phone.eq.${phone || ''}`)
.maybeSingle()
const matchConditions = []
if (email) matchConditions.push(eq(helpdesk_contacts.email, email))
if (phone) matchConditions.push(eq(helpdesk_contacts.phone, phone))
if (findError) throw findError
if (existing) return existing
const existing = await server.db
.select()
.from(helpdesk_contacts)
.where(
and(
eq(helpdesk_contacts.tenantId, tenant_id),
or(...matchConditions)
)
)
.limit(1)
if (existing[0]) return existing[0]
// Anlegen
const { data: created, error: insertError } = await server.supabase
.from('helpdesk_contacts')
.insert({
tenant_id,
const created = await server.db
.insert(helpdesk_contacts)
.values({
tenantId: tenant_id,
email,
phone,
display_name,
customer_id,
contact_id
displayName: display_name,
customerId: customer_id,
contactId: contact_id
})
.select()
.single()
.returning()
if (insertError) throw insertError
return created
return created[0]
}

View File

@@ -2,6 +2,8 @@
import { FastifyInstance } from 'fastify'
import { getOrCreateContact } from './helpdesk.contact.service.js'
import {useNextNumberRangeNumber} from "../../utils/functions";
import { and, desc, eq } from "drizzle-orm";
import { customers, helpdesk_contacts, helpdesk_conversations } from "../../../db/schema";
export async function createConversation(
server: FastifyInstance,
@@ -25,24 +27,34 @@ export async function createConversation(
const {usedNumber } = await useNextNumberRangeNumber(server, tenant_id, "tickets")
const { data, error } = await server.supabase
.from('helpdesk_conversations')
.insert({
tenant_id,
contact_id: contactRecord.id,
channel_instance_id,
const inserted = await server.db
.insert(helpdesk_conversations)
.values({
tenantId: tenant_id,
contactId: contactRecord.id,
channelInstanceId: channel_instance_id,
subject: subject || null,
status: 'open',
created_at: new Date().toISOString(),
customer_id,
contact_person_id,
ticket_number: usedNumber
createdAt: new Date(),
customerId: customer_id,
contactPersonId: contact_person_id,
ticketNumber: usedNumber
})
.select()
.single()
.returning()
if (error) throw error
return data
const data = inserted[0]
return {
...data,
channel_instance_id: data.channelInstanceId,
contact_id: data.contactId,
contact_person_id: data.contactPersonId,
created_at: data.createdAt,
customer_id: data.customerId,
last_message_at: data.lastMessageAt,
tenant_id: data.tenantId,
ticket_number: data.ticketNumber,
}
}
export async function getConversations(
@@ -52,22 +64,34 @@ export async function getConversations(
) {
const { status, limit = 50 } = opts || {}
let query = server.supabase.from('helpdesk_conversations').select('*, customer_id(*)').eq('tenant_id', tenant_id)
const filters = [eq(helpdesk_conversations.tenantId, tenant_id)]
if (status) filters.push(eq(helpdesk_conversations.status, status))
if (status) query = query.eq('status', status)
query = query.order('last_message_at', { ascending: false }).limit(limit)
const { data, error } = await query
if (error) throw error
const mappedData = data.map(entry => {
return {
...entry,
customer: entry.customer_id
}
const data = await server.db
.select({
conversation: helpdesk_conversations,
contact: helpdesk_contacts,
customer: customers,
})
.from(helpdesk_conversations)
.leftJoin(helpdesk_contacts, eq(helpdesk_contacts.id, helpdesk_conversations.contactId))
.leftJoin(customers, eq(customers.id, helpdesk_conversations.customerId))
.where(and(...filters))
.orderBy(desc(helpdesk_conversations.lastMessageAt))
.limit(limit)
return mappedData
return data.map((entry) => ({
...entry.conversation,
helpdesk_contacts: entry.contact,
channel_instance_id: entry.conversation.channelInstanceId,
contact_id: entry.conversation.contactId,
contact_person_id: entry.conversation.contactPersonId,
created_at: entry.conversation.createdAt,
customer_id: entry.customer,
last_message_at: entry.conversation.lastMessageAt,
tenant_id: entry.conversation.tenantId,
ticket_number: entry.conversation.ticketNumber,
}))
}
export async function updateConversationStatus(
@@ -78,13 +102,22 @@ export async function updateConversationStatus(
const valid = ['open', 'in_progress', 'waiting_for_customer', 'answered', 'closed']
if (!valid.includes(status)) throw new Error('Invalid status')
const { data, error } = await server.supabase
.from('helpdesk_conversations')
.update({ status })
.eq('id', conversation_id)
.select()
.single()
const updated = await server.db
.update(helpdesk_conversations)
.set({ status })
.where(eq(helpdesk_conversations.id, conversation_id))
.returning()
if (error) throw error
return data
const data = updated[0]
return {
...data,
channel_instance_id: data.channelInstanceId,
contact_id: data.contactId,
contact_person_id: data.contactPersonId,
created_at: data.createdAt,
customer_id: data.customerId,
last_message_at: data.lastMessageAt,
tenant_id: data.tenantId,
ticket_number: data.ticketNumber,
}
}

View File

@@ -1,5 +1,7 @@
// modules/helpdesk/helpdesk.message.service.ts
import { FastifyInstance } from 'fastify'
import { asc, eq } from "drizzle-orm";
import { helpdesk_conversations, helpdesk_messages } from "../../../db/schema";
export async function addMessage(
server: FastifyInstance,
@@ -23,38 +25,53 @@ export async function addMessage(
) {
if (!payload?.text) throw new Error('Message payload requires text content')
const { data: message, error } = await server.supabase
.from('helpdesk_messages')
.insert({
tenant_id,
conversation_id,
author_user_id,
const inserted = await server.db
.insert(helpdesk_messages)
.values({
tenantId: tenant_id,
conversationId: conversation_id,
authorUserId: author_user_id,
direction,
payload,
raw_meta,
created_at: new Date().toISOString(),
rawMeta: raw_meta,
externalMessageId: external_message_id,
receivedAt: new Date(),
})
.select()
.single()
.returning()
if (error) throw error
const message = inserted[0]
// Letzte Nachricht aktualisieren
await server.supabase
.from('helpdesk_conversations')
.update({ last_message_at: new Date().toISOString() })
.eq('id', conversation_id)
await server.db
.update(helpdesk_conversations)
.set({ lastMessageAt: new Date() })
.where(eq(helpdesk_conversations.id, conversation_id))
return message
return {
...message,
author_user_id: message.authorUserId,
conversation_id: message.conversationId,
created_at: message.createdAt,
external_message_id: message.externalMessageId,
raw_meta: message.rawMeta,
tenant_id: message.tenantId,
}
}
export async function getMessages(server: FastifyInstance, conversation_id: string) {
const { data, error } = await server.supabase
.from('helpdesk_messages')
.select('*')
.eq('conversation_id', conversation_id)
.order('created_at', { ascending: true })
const data = await server.db
.select()
.from(helpdesk_messages)
.where(eq(helpdesk_messages.conversationId, conversation_id))
.orderBy(asc(helpdesk_messages.createdAt))
if (error) throw error
return data
return data.map((message) => ({
...message,
author_user_id: message.authorUserId,
conversation_id: message.conversationId,
created_at: message.createdAt,
external_message_id: message.externalMessageId,
raw_meta: message.rawMeta,
tenant_id: message.tenantId,
}))
}

View File

@@ -1,6 +1,8 @@
// services/notification.service.ts
import type { FastifyInstance } from 'fastify';
import {secrets} from "../utils/secrets";
import { eq } from "drizzle-orm";
import { notificationsEventTypes, notificationsItems } from "../../db/schema";
export type NotificationStatus = 'queued' | 'sent' | 'failed';
@@ -34,16 +36,16 @@ export class NotificationService {
*/
async trigger(input: TriggerInput) {
const { tenantId, userId, eventType, title, message, payload } = input;
const supabase = this.server.supabase;
// 1) Event-Typ prüfen (aktiv?)
const { data: eventTypeRow, error: etErr } = await supabase
.from('notifications_event_types')
.select('event_key,is_active')
.eq('event_key', eventType)
.maybeSingle();
const eventTypeRows = await this.server.db
.select()
.from(notificationsEventTypes)
.where(eq(notificationsEventTypes.eventKey, eventType))
.limit(1)
const eventTypeRow = eventTypeRows[0]
if (etErr || !eventTypeRow || eventTypeRow.is_active !== true) {
if (!eventTypeRow || eventTypeRow.isActive !== true) {
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`);
}
@@ -54,40 +56,40 @@ export class NotificationService {
}
// 3) Notification anlegen (status: queued)
const { data: inserted, error: insErr } = await supabase
.from('notifications_items')
.insert({
tenant_id: tenantId,
user_id: userId,
event_type: eventType,
const insertedRows = await this.server.db
.insert(notificationsItems)
.values({
tenantId,
userId,
eventType,
title,
message,
payload: payload ?? null,
channel: 'email',
status: 'queued'
})
.select('id')
.single();
.returning({ id: notificationsItems.id })
const inserted = insertedRows[0]
if (insErr || !inserted) {
throw new Error(`Fehler beim Einfügen der Notification: ${insErr?.message}`);
if (!inserted) {
throw new Error("Fehler beim Einfügen der Notification");
}
// 4) E-Mail versenden
try {
await this.sendEmail(user.email, title, message);
await supabase
.from('notifications_items')
.update({ status: 'sent', sent_at: new Date().toISOString() })
.eq('id', inserted.id);
await this.server.db
.update(notificationsItems)
.set({ status: 'sent', sentAt: new Date() })
.where(eq(notificationsItems.id, inserted.id));
return { success: true, id: inserted.id };
} catch (err: any) {
await supabase
.from('notifications_items')
.update({ status: 'failed', error: String(err?.message || err) })
.eq('id', inserted.id);
await this.server.db
.update(notificationsItems)
.set({ status: 'failed', error: String(err?.message || err) })
.where(eq(notificationsItems.id, inserted.id));
this.server.log.error({ err, notificationId: inserted.id }, 'E-Mail Versand fehlgeschlagen');
return { success: false, error: err?.message || 'E-Mail Versand fehlgeschlagen' };

View File

@@ -9,13 +9,15 @@ export default fp(async (server: FastifyInstance) => {
"http://localhost:3001", // dein Nuxt-Frontend
"http://127.0.0.1:3000", // dein Nuxt-Frontend
"http://192.168.1.227:3001", // dein Nuxt-Frontend
"http://192.168.1.234:3000", // dein Nuxt-Frontend
"http://192.168.1.113:3000", // dein Nuxt-Frontend
"https://beta.fedeo.de", // dein Nuxt-Frontend
"https://app.fedeo.de", // dein Nuxt-Frontend
"capacitor://localhost", // dein Nuxt-Frontend
],
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin"],
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS","PATCH",
"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"],
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin","Depth", "Overwrite", "Destination", "Lock-Token", "If"],
exposedHeaders: ["Authorization", "Content-Disposition", "Content-Type", "Content-Length"], // optional, falls du ihn auch auslesen willst
credentials: true, // wichtig, falls du Cookies nutzt
});

View File

@@ -1,7 +1,7 @@
// /plugins/services.ts
import fp from "fastify-plugin";
import { bankStatementService } from "../modules/cron/bankstatementsync.service";
//import {initDokuboxClient, syncDokubox} from "../modules/cron/dokuboximport.service";
import {syncDokuboxService} from "../modules/cron/dokuboximport.service";
import { FastifyInstance } from "fastify";
import {prepareIncomingInvoices} from "../modules/cron/prepareIncomingInvoices";
@@ -9,7 +9,7 @@ declare module "fastify" {
interface FastifyInstance {
services: {
bankStatements: ReturnType<typeof bankStatementService>;
//dokuboxSync: ReturnType<typeof syncDokubox>;
dokuboxSync: ReturnType<typeof syncDokuboxService>;
prepareIncomingInvoices: ReturnType<typeof prepareIncomingInvoices>;
};
}
@@ -18,7 +18,7 @@ declare module "fastify" {
export default fp(async function servicePlugin(server: FastifyInstance) {
server.decorate("services", {
bankStatements: bankStatementService(server),
//dokuboxSync: syncDokubox(server),
dokuboxSync: syncDokuboxService(server),
prepareIncomingInvoices: prepareIncomingInvoices(server),
});
});

View File

@@ -1,19 +0,0 @@
import { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
import { createClient, SupabaseClient } from "@supabase/supabase-js";
import {secrets} from "../utils/secrets";
export default fp(async (server: FastifyInstance) => {
const supabaseUrl = secrets.SUPABASE_URL
const supabaseServiceKey = secrets.SUPABASE_SERVICE_ROLE_KEY
const supabase: SupabaseClient = createClient(supabaseUrl, supabaseServiceKey);
// Fastify um supabase erweitern
server.decorate("supabase", supabase);
});
declare module "fastify" {
interface FastifyInstance {
supabase: SupabaseClient;
}
}

View File

@@ -1,5 +1,7 @@
import { FastifyInstance, FastifyRequest } from "fastify";
import fp from "fastify-plugin";
import { eq } from "drizzle-orm";
import { tenants } from "../../db/schema";
export default fp(async (server: FastifyInstance) => {
server.addHook("preHandler", async (req, reply) => {
@@ -9,11 +11,12 @@ export default fp(async (server: FastifyInstance) => {
return;
}
// Tenant aus DB laden
const { data: tenant } = await server.supabase
.from("tenants")
.select("*")
.eq("portalDomain", host)
.single();
const rows = await server.db
.select()
.from(tenants)
.where(eq(tenants.portalDomain, host))
.limit(1);
const tenant = rows[0];
if(!tenant) {

View File

@@ -0,0 +1,58 @@
import { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import { db } from "../../../db"; // <--- PFAD ZUR DB INSTANZ ANPASSEN
import { devices } from "../../../db/schema";
// Definition, was wir vom ESP32 erwarten
interface HealthBody {
terminal_id: string;
ip_address?: string;
wifi_rssi?: number;
uptime_seconds?: number;
heap_free?: number;
[key: string]: any; // Erlaubt weitere Felder
}
export default async function devicesManagementRoutes(server: FastifyInstance) {
server.post<{ Body: HealthBody }>(
"/health",
async (req, reply) => {
try {
const data = req.body;
// 1. Validierung: Haben wir eine ID?
if (!data.terminal_id) {
console.warn("Health Check ohne terminal_id empfangen:", data);
return reply.code(400).send({ error: "terminal_id missing" });
}
console.log(`Health Ping von Device ${data.terminal_id}`, data);
// 2. Datenbank Update
// Wir suchen das Gerät mit der passenden externalId
const result = await server.db
.update(devices)
.set({
lastSeen: new Date(), // Setzt Zeit auf JETZT
lastDebugInfo: data // Speichert das ganze JSON
})
.where(eq(devices.externalId, data.terminal_id))
.returning({ id: devices.id }); // Gibt ID zurück, falls gefunden
// 3. Checken ob Gerät gefunden wurde
if (result.length === 0) {
console.warn(`Unbekanntes Terminal versucht Health Check: ${data.terminal_id}`);
// Optional: 404 senden oder ignorieren (Sicherheit)
return reply.code(404).send({ error: "Device not found" });
}
// Alles OK
return reply.code(200).send({ status: "ok" });
} catch (err: any) {
console.error("Health Check Error:", err);
return reply.code(500).send({ error: err.message });
}
}
);
}

View File

@@ -7,31 +7,33 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
"/rfid/createevent/:terminal_id",
async (req, reply) => {
try {
// 1. Timestamp aus dem Body holen (optional)
const { rfid_id, timestamp } = req.body as {
rfid_id: string,
timestamp?: number // Kann undefined sein (Live) oder Zahl (Offline)
};
const {rfid_id} = req.body as {rfid_id: string};
const { terminal_id } = req.params as { terminal_id: string };
if (!rfid_id || !terminal_id) {
console.log(`Missing Params`);
return reply.code(400).send(`Missing Params`)
return reply.code(400).send(`Missing Params`);
}
// 2. Gerät suchen
const device = await server.db
.select()
.from(devices)
.where(
eq(devices.externalId, terminal_id)
)
.where(eq(devices.externalId, terminal_id))
.limit(1)
.then(rows => rows[0]);
if (!device) {
console.log(`Device ${terminal_id} not found`);
return reply.code(400).send(`Device ${terminal_id} not found`)
return reply.code(400).send(`Device ${terminal_id} not found`);
}
// 3. User-Profil suchen
const profile = await server.db
.select()
.from(authProfiles)
@@ -46,53 +48,54 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
if (!profile) {
console.log(`Profile for Token ${rfid_id} not found`);
return reply.code(400).send(`Profile for Token ${rfid_id} not found`)
return reply.code(400).send(`Profile for Token ${rfid_id} not found`);
}
// 4. Letztes Event suchen (für Status-Toggle Work Start/End)
const lastEvent = await server.db
.select()
.from(stafftimeevents)
.where(
eq(stafftimeevents.user_id, profile.user_id)
)
.orderBy(desc(stafftimeevents.eventtime)) // <-- Sortierung: Neuestes zuerst
.where(eq(stafftimeevents.user_id, profile.user_id))
.orderBy(desc(stafftimeevents.eventtime))
.limit(1)
.then(rows => rows[0]);
console.log(lastEvent)
// 5. Zeitstempel Logik (WICHTIG!)
// Der ESP32 sendet Unix-Timestamp in SEKUNDEN. JS braucht MILLISEKUNDEN.
// Wenn kein Timestamp kommt (0 oder undefined), nehmen wir JETZT.
const actualEventTime = (timestamp && timestamp > 0)
? new Date(timestamp * 1000)
: new Date();
// 6. Event Typ bestimmen (Toggle Logik)
// Falls noch nie gestempelt wurde (lastEvent undefined), fangen wir mit start an.
const nextEventType = (lastEvent?.eventtype === "work_start")
? "work_end"
: "work_start";
const dataToInsert = {
tenant_id: device.tenant,
user_id: profile.user_id,
actortype: "system",
eventtime: new Date(),
eventtype: lastEvent.eventtype === "work_start" ? "work_end" : "work_start",
source: "WEB"
}
eventtime: actualEventTime, // Hier nutzen wir die berechnete Zeit
eventtype: nextEventType,
source: "TERMINAL" // Habe ich von WEB auf TERMINAL geändert (optional)
};
console.log(dataToInsert)
console.log(`New Event for ${profile.user_id}: ${nextEventType} @ ${actualEventTime.toISOString()}`);
const [created] = await server.db
.insert(stafftimeevents)
//@ts-ignore
.values(dataToInsert)
.returning()
.returning();
return created;
return created
} catch (err: any) {
console.error(err)
return reply.code(400).send({ error: err.message })
console.error(err);
return reply.code(400).send({ error: err.message });
}
console.log(req.body)
return
}
);
}

View File

@@ -1,6 +1,4 @@
import { FastifyInstance } from "fastify";
import jwt from "jsonwebtoken";
import {insertHistoryItem} from "../utils/history";
import {buildExportZip} from "../utils/export/datev";
import {s3} from "../utils/s3";
import {GetObjectCommand, PutObjectCommand} from "@aws-sdk/client-s3"
@@ -9,6 +7,8 @@ import dayjs from "dayjs";
import {randomUUID} from "node:crypto";
import {secrets} from "../utils/secrets";
import {createSEPAExport} from "../utils/export/sepa";
import {generatedexports} from "../../db/schema";
import {eq} from "drizzle-orm";
const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDate,beraternr,mandantennr) => {
try {
@@ -45,25 +45,21 @@ const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDat
console.log(url)
// 5) In Supabase-DB speichern
const { data, error } = await server.supabase
.from("exports")
.insert([
{
tenant_id: req.user.tenant_id,
start_date: startDate,
end_date: endDate,
valid_until: dayjs().add(24,"hours").toISOString(),
file_path: fileKey,
url: url,
created_at: new Date().toISOString(),
},
])
.select()
.single()
// 5) In Haupt-DB speichern
const inserted = await server.db
.insert(generatedexports)
.values({
tenantId: req.user.tenant_id,
startDate: new Date(startDate),
endDate: new Date(endDate),
validUntil: dayjs().add(24, "hours").toDate(),
filePath: fileKey,
url,
type: "datev",
})
.returning()
console.log(data)
console.log(error)
console.log(inserted[0])
} catch (error) {
console.log(error)
}
@@ -120,9 +116,22 @@ export default async function exportRoutes(server: FastifyInstance) {
//List Exports Available for Download
server.get("/exports", async (req,reply) => {
const {data,error} = await server.supabase.from("exports").select().eq("tenant_id",req.user.tenant_id)
const data = await server.db
.select({
id: generatedexports.id,
created_at: generatedexports.createdAt,
tenant_id: generatedexports.tenantId,
start_date: generatedexports.startDate,
end_date: generatedexports.endDate,
valid_until: generatedexports.validUntil,
type: generatedexports.type,
url: generatedexports.url,
file_path: generatedexports.filePath,
})
.from(generatedexports)
.where(eq(generatedexports.tenantId, req.user.tenant_id))
console.log(data,error)
console.log(data)
reply.send(data)
})

View File

@@ -110,17 +110,6 @@ export default async function functionRoutes(server: FastifyInstance) {
const data = await server.db.select().from(citys).where(eq(citys.zip,zip))
/*const { data, error } = await server.supabase
.from('citys')
.select()
.eq('zip', zip)
.maybeSingle()
if (error) {
console.log(error)
return reply.code(500).send({ error: 'Database error' })
}*/
if (!data) {
return reply.code(404).send({ error: 'ZIP not found' })
}
@@ -179,6 +168,11 @@ export default async function functionRoutes(server: FastifyInstance) {
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
})
server.post('/functions/services/syncdokubox', async (req, reply) => {
await server.services.dokuboxSync.run()
})
/*server.post('/print/zpl/preview', async (req, reply) => {
const { zpl, widthMm = 50, heightMm = 30, dpmm = 8, asBase64 = false } = req.body as {zpl:string,widthMm:number,heightMm:number,dpmm:number,asBase64:string}

View File

@@ -3,12 +3,11 @@ import { FastifyInstance } from "fastify";
export default async function routes(server: FastifyInstance) {
server.get("/ping", async () => {
// Testquery gegen DB
const { data, error } = await server.supabase.from("tenants").select("id").limit(1);
const result = await server.db.execute("SELECT NOW()");
return {
status: "ok",
db: error ? "not connected" : "connected",
tenant_count: data?.length ?? 0
db: JSON.stringify(result.rows[0]),
};
});
}

View File

@@ -3,8 +3,9 @@ import { FastifyPluginAsync } from 'fastify'
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
import {extractDomain, findCustomerOrContactByEmailOrDomain} from "../utils/helpers";
import {useNextNumberRangeNumber} from "../utils/functions";
import { findCustomerOrContactByEmailOrDomain } from "../utils/helpers";
import { eq } from "drizzle-orm";
import { helpdesk_conversations, helpdesk_messages } from "../../db/schema";
// -------------------------------------------------------------
// 📧 Interne M2M-Route für eingehende E-Mails
@@ -52,12 +53,12 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
// 3⃣ Konversation anhand In-Reply-To suchen
let conversationId: string | null = null
if (in_reply_to) {
const { data: msg } = await server.supabase
.from('helpdesk_messages')
.select('conversation_id')
.eq('external_message_id', in_reply_to)
.maybeSingle()
conversationId = msg?.conversation_id || null
const msg = await server.db
.select({ conversationId: helpdesk_messages.conversationId })
.from(helpdesk_messages)
.where(eq(helpdesk_messages.externalMessageId, in_reply_to))
.limit(1)
conversationId = msg[0]?.conversationId || null
}
// 4⃣ Neue Konversation anlegen falls keine existiert
@@ -73,12 +74,12 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
})
conversationId = conversation.id
} else {
const { data } = await server.supabase
.from('helpdesk_conversations')
.select('*')
.eq('id', conversationId)
.single()
conversation = data
const rows = await server.db
.select()
.from(helpdesk_conversations)
.where(eq(helpdesk_conversations.id, conversationId))
.limit(1)
conversation = rows[0]
}
// 5⃣ Nachricht speichern
@@ -96,7 +97,7 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
return res.status(201).send({
success: true,
conversation_id: conversationId,
ticket_number: conversation.ticket_number,
ticket_number: conversation?.ticket_number || conversation?.ticketNumber,
})
})
}

View File

@@ -3,70 +3,9 @@ import { FastifyPluginAsync } from 'fastify'
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
/**
* Öffentliche Route zum Empfang eingehender Kontaktformular-Nachrichten.
* Authentifizierung: über `public_token` aus helpdesk_channel_instances
*/
function extractDomain(email) {
if (!email) return null
const parts = email.split("@")
return parts.length === 2 ? parts[1].toLowerCase() : null
}
async function findCustomerOrContactByEmailOrDomain(server,fromMail, tenantId) {
const sender = fromMail
const senderDomain = extractDomain(sender)
if (!senderDomain) return null
// 1⃣ Direkter Match über contacts
const { data: contactMatch } = await server.supabase
.from("contacts")
.select("id, customer")
.eq("email", sender)
.eq("tenant", tenantId)
.maybeSingle()
if (contactMatch?.customer_id) return {
customer: contactMatch.customer,
contact: contactMatch.id
}
// 2⃣ Kunden laden, bei denen E-Mail oder Rechnungsmail passt
const { data: customers, error } = await server.supabase
.from("customers")
.select("id, infoData")
.eq("tenant", tenantId)
if (error) {
console.error(`[Helpdesk] Fehler beim Laden der Kunden:`, error.message)
return null
}
// 3⃣ Durch Kunden iterieren und prüfen
for (const c of customers || []) {
const info = c.infoData || {}
const email = info.email?.toLowerCase()
const invoiceEmail = info.invoiceEmail?.toLowerCase()
const emailDomain = extractDomain(email)
const invoiceDomain = extractDomain(invoiceEmail)
// exakter Match oder Domain-Match
if (
sender === email ||
sender === invoiceEmail ||
senderDomain === emailDomain ||
senderDomain === invoiceDomain
) {
return {customer: c.id, contact:null}
}
}
return null
}
import { findCustomerOrContactByEmailOrDomain } from "../utils/helpers";
import { eq } from "drizzle-orm";
import { helpdesk_channel_instances } from "../../db/schema";
const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
// Öffentliche POST-Route
@@ -85,17 +24,18 @@ const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
}
// 1⃣ Kanalinstanz anhand des Tokens ermitteln
const { data: channel, error: channelError } = await server.supabase
.from('helpdesk_channel_instances')
.select('*')
.eq('public_token', public_token)
.single()
const channels = await server.db
.select()
.from(helpdesk_channel_instances)
.where(eq(helpdesk_channel_instances.publicToken, public_token))
.limit(1)
const channel = channels[0]
if (channelError || !channel) {
if (!channel) {
return res.status(404).send({ error: 'Invalid channel token' })
}
const tenant_id = channel.tenant_id
const tenant_id = channel.tenantId
const channel_instance_id = channel.id
// @ts-ignore

View File

@@ -5,6 +5,13 @@ import { addMessage, getMessages } from '../modules/helpdesk/helpdesk.message.se
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
import {decrypt, encrypt} from "../utils/crypt";
import nodemailer from "nodemailer"
import { eq } from "drizzle-orm";
import {
helpdesk_channel_instances,
helpdesk_contacts,
helpdesk_conversations,
helpdesk_messages,
} from "../../db/schema";
const helpdeskRoutes: FastifyPluginAsync = async (server) => {
// 📩 1. Liste aller Konversationen
@@ -58,15 +65,30 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
const tenant_id = req.user?.tenant_id
const {id: conversation_id} = req.params as {id: string}
const { data, error } = await server.supabase
.from('helpdesk_conversations')
.select('*, helpdesk_contacts(*)')
.eq('tenant_id', tenant_id)
.eq('id', conversation_id)
.single()
const rows = await server.db
.select({
conversation: helpdesk_conversations,
contact: helpdesk_contacts
})
.from(helpdesk_conversations)
.leftJoin(helpdesk_contacts, eq(helpdesk_contacts.id, helpdesk_conversations.contactId))
.where(eq(helpdesk_conversations.id, conversation_id))
if (error) return res.status(404).send({ error: 'Conversation not found' })
return res.send(data)
const data = rows[0]
if (!data || data.conversation.tenantId !== tenant_id) return res.status(404).send({ error: 'Conversation not found' })
return res.send({
...data.conversation,
channel_instance_id: data.conversation.channelInstanceId,
contact_id: data.conversation.contactId,
contact_person_id: data.conversation.contactPersonId,
created_at: data.conversation.createdAt,
customer_id: data.conversation.customerId,
last_message_at: data.conversation.lastMessageAt,
tenant_id: data.conversation.tenantId,
ticket_number: data.conversation.ticketNumber,
helpdesk_contacts: data.contact,
})
})
// 🔄 4. Konversation Status ändern
@@ -181,36 +203,39 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
safeConfig.smtp.pass = encrypt(safeConfig.smtp.pass)
}
// Speichern in Supabase
const { data, error } = await server.supabase
.from("helpdesk_channel_instances")
.insert({
tenant_id,
type_id,
const inserted = await server.db
.insert(helpdesk_channel_instances)
.values({
tenantId: tenant_id,
typeId: type_id,
name,
config: safeConfig,
is_active,
isActive: is_active,
})
.select()
.single()
.returning()
if (error) throw error
const data = inserted[0]
if (!data) throw new Error("Konnte Channel nicht erstellen")
const responseConfig: any = data.config
// sensible Felder aus Response entfernen
if (data.config?.imap) {
delete data.config.imap.host
delete data.config.imap.user
delete data.config.imap.pass
if (responseConfig?.imap) {
delete responseConfig.imap.host
delete responseConfig.imap.user
delete responseConfig.imap.pass
}
if (data.config?.smtp) {
delete data.config.smtp.host
delete data.config.smtp.user
delete data.config.smtp.pass
if (responseConfig?.smtp) {
delete responseConfig.smtp.host
delete responseConfig.smtp.user
delete responseConfig.smtp.pass
}
reply.send({
message: "E-Mail-Channel erfolgreich erstellt",
channel: data,
channel: {
...data,
config: responseConfig
},
})
} catch (err) {
console.error("Fehler bei Channel-Erstellung:", err)
@@ -234,29 +259,29 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
const { text } = req.body as { text: string }
// 🔹 Konversation inkl. Channel + Kontakt laden
const { data: conv, error: convErr } = await server.supabase
.from("helpdesk_conversations")
.select(`
id,
tenant_id,
subject,
channel_instance_id,
helpdesk_contacts(email),
helpdesk_channel_instances(config, name),
ticket_number
`)
.eq("id", conversationId)
.single()
const rows = await server.db
.select({
conversation: helpdesk_conversations,
contact: helpdesk_contacts,
channel: helpdesk_channel_instances,
})
.from(helpdesk_conversations)
.leftJoin(helpdesk_contacts, eq(helpdesk_contacts.id, helpdesk_conversations.contactId))
.leftJoin(helpdesk_channel_instances, eq(helpdesk_channel_instances.id, helpdesk_conversations.channelInstanceId))
.where(eq(helpdesk_conversations.id, conversationId))
.limit(1)
const conv = rows[0]
console.log(conv)
if (convErr || !conv) {
if (!conv) {
reply.status(404).send({ error: "Konversation nicht gefunden" })
return
}
const contact = conv.helpdesk_contacts as unknown as {email: string}
const channel = conv.helpdesk_channel_instances as unknown as {name: string}
const contact = conv.contact as unknown as {email: string}
const channel = conv.channel as unknown as {name: string, config: any}
console.log(contact)
if (!contact?.email) {
@@ -288,7 +313,7 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
const mailOptions = {
from: `"${channel?.name}" <${user}>`,
to: contact.email,
subject: `${conv.ticket_number} | ${conv.subject}` || `${conv.ticket_number} | Antwort vom FEDEO Helpdesk`,
subject: `${conv.conversation.ticketNumber} | ${conv.conversation.subject}` || `${conv.conversation.ticketNumber} | Antwort vom FEDEO Helpdesk`,
text,
}
@@ -296,24 +321,22 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
console.log(`[Helpdesk SMTP] Gesendet an ${contact.email}: ${info.messageId}`)
// 💾 Nachricht speichern
const { error: insertErr } = await server.supabase
.from("helpdesk_messages")
.insert({
tenant_id: conv.tenant_id,
conversation_id: conversationId,
await server.db
.insert(helpdesk_messages)
.values({
tenantId: conv.conversation.tenantId,
conversationId: conversationId,
direction: "outgoing",
payload: { type: "text", text },
external_message_id: info.messageId,
received_at: new Date().toISOString(),
externalMessageId: info.messageId,
receivedAt: new Date(),
})
if (insertErr) throw insertErr
// 🔁 Konversation aktualisieren
await server.supabase
.from("helpdesk_conversations")
.update({ last_message_at: new Date().toISOString() })
.eq("id", conversationId)
await server.db
.update(helpdesk_conversations)
.set({ lastMessageAt: new Date() })
.where(eq(helpdesk_conversations.id, conversationId))
reply.send({
message: "E-Mail erfolgreich gesendet",

View File

@@ -1,12 +1,34 @@
// src/routes/resources/history.ts
import { FastifyInstance } from "fastify";
import { and, asc, eq, inArray } from "drizzle-orm";
import { authProfiles, historyitems } from "../../db/schema";
const columnMap: Record<string, string> = {
const columnMap: Record<string, any> = {
customers: historyitems.customer,
vendors: historyitems.vendor,
projects: historyitems.project,
plants: historyitems.plant,
contacts: historyitems.contact,
tasks: historyitems.task,
vehicles: historyitems.vehicle,
events: historyitems.event,
files: historyitems.file,
products: historyitems.product,
inventoryitems: historyitems.inventoryitem,
inventoryitemgroups: historyitems.inventoryitemgroup,
checks: historyitems.check,
costcentres: historyitems.costcentre,
ownaccounts: historyitems.ownaccount,
documentboxes: historyitems.documentbox,
hourrates: historyitems.hourrate,
services: historyitems.service,
};
const insertFieldMap: Record<string, string> = {
customers: "customer",
vendors: "vendor",
projects: "project",
plants: "plant",
contracts: "contract",
contacts: "contact",
tasks: "task",
vehicles: "vehicle",
@@ -15,15 +37,18 @@ const columnMap: Record<string, string> = {
products: "product",
inventoryitems: "inventoryitem",
inventoryitemgroups: "inventoryitemgroup",
absencerequests: "absencerequest",
checks: "check",
costcentres: "costcentre",
ownaccounts: "ownaccount",
documentboxes: "documentbox",
hourrates: "hourrate",
services: "service",
roles: "role",
};
}
const parseId = (value: string) => {
if (/^\d+$/.test(value)) return Number(value)
return value
}
export default async function resourceHistoryRoutes(server: FastifyInstance) {
server.get<{
@@ -49,29 +74,36 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
return reply.code(400).send({ error: `History not supported for resource '${resource}'` });
}
const { data, error } = await server.supabase
.from("historyitems")
.select("*")
.eq(column, id)
.order("created_at", { ascending: true });
const data = await server.db
.select()
.from(historyitems)
.where(eq(column, parseId(id)))
.orderBy(asc(historyitems.createdAt));
if (error) {
server.log.error(error);
return reply.code(500).send({ error: "Failed to fetch history" });
}
const userIds = Array.from(
new Set(data.map((item) => item.createdBy).filter(Boolean))
) as string[]
const {data:users, error:usersError} = await server.supabase
.from("auth_users")
.select("*, auth_profiles(*), tenants!auth_tenant_users(*)")
const profiles = userIds.length > 0
? await server.db
.select()
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, req.user?.tenant_id),
inArray(authProfiles.user_id, userIds)
))
: []
const filteredUsers = (users ||[]).filter(i => i.tenants.find((t:any) => t.id === req.user?.tenant_id))
const profileByUserId = new Map(
profiles.map((profile) => [profile.user_id, profile])
)
const dataCombined = data.map(historyitem => {
return {
const dataCombined = data.map((historyitem) => ({
...historyitem,
created_by_profile: filteredUsers.find(i => i.id === historyitem.created_by) ? filteredUsers.find(i => i.id === historyitem.created_by).auth_profiles[0] : null
}
})
created_at: historyitem.createdAt,
created_by: historyitem.createdBy,
created_by_profile: historyitem.createdBy ? profileByUserId.get(historyitem.createdBy) || null : null,
}))
@@ -128,29 +160,33 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
const userId = (req.user as any)?.user_id;
const fkField = columnMap[resource];
const fkField = insertFieldMap[resource];
if (!fkField) {
return reply.code(400).send({ error: `Unknown resource: ${resource}` });
}
const { data, error } = await server.supabase
.from("historyitems")
.insert({
const inserted = await server.db
.insert(historyitems)
.values({
text,
[fkField]: id,
[fkField]: parseId(id),
oldVal: old_val || null,
newVal: new_val || null,
config: config || null,
tenant: (req.user as any)?.tenant_id,
created_by: userId
createdBy: userId
})
.select()
.single();
.returning()
if (error) {
return reply.code(500).send({ error: error.message });
const data = inserted[0]
if (!data) {
return reply.code(500).send({ error: "Failed to create history entry" });
}
return reply.code(201).send(data);
return reply.code(201).send({
...data,
created_at: data.createdAt,
created_by: data.createdBy
});
});
}

View File

@@ -1,21 +1,22 @@
// routes/notifications.routes.ts
import { FastifyInstance } from 'fastify';
import { NotificationService, UserDirectory } from '../modules/notification.service';
import { eq } from "drizzle-orm";
import { authUsers } from "../../db/schema";
// Beispiel: E-Mail aus eigener User-Tabelle laden
const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => {
const { data, error } = await server.supabase
.from('auth_users')
.select('email')
.eq('id', userId)
.maybeSingle();
if (error || !data) return null;
const rows = await server.db
.select({ email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
const data = rows[0]
if (!data) return null;
return { email: data.email };
};
export default async function notificationsRoutes(server: FastifyInstance) {
// wichtig: server.supabase ist über app verfügbar
const svc = new NotificationService(server, getUserDirectory);
server.post('/notifications/trigger', async (req, reply) => {

View File

@@ -1,40 +1,19 @@
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
import { publicLinkService } from '../../modules/publiclinks.service';
import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
server.get("/workflows/context/:token", async (req, reply) => {
const { token } = req.params as { token: string };
// Wir lesen die PIN aus dem Header (Best Practice für Security)
const pin = req.headers['x-public-pin'] as string | undefined;
try {
const context = await publicLinkService.getLinkContext(server, token, pin);
return reply.send(context);
} catch (error: any) {
// Spezifische Fehlercodes für das Frontend
if (error.message === "Link_NotFound") {
return reply.code(404).send({ error: "Link nicht gefunden oder abgelaufen" });
}
if (error.message === "Pin_Required") {
return reply.code(401).send({
error: "PIN erforderlich",
code: "PIN_REQUIRED",
requirePin: true
});
}
if (error.message === "Pin_Invalid") {
return reply.code(403).send({
error: "PIN falsch",
code: "PIN_INVALID",
requirePin: true
});
}
if (error.message === "Link_NotFound") return reply.code(404).send({ error: "Link nicht gefunden" });
if (error.message === "Pin_Required") return reply.code(401).send({ error: "PIN erforderlich", requirePin: true });
if (error.message === "Pin_Invalid") return reply.code(403).send({ error: "PIN falsch", requirePin: true });
server.log.error(error);
return reply.code(500).send({ error: "Interner Server Fehler" });
@@ -43,49 +22,31 @@ export default async function publiclinksNonAuthenticatedRoutes(server: FastifyI
server.post("/workflows/submit/:token", async (req, reply) => {
const { token } = req.params as { token: string };
// PIN sicher aus dem Header lesen
const pin = req.headers['x-public-pin'] as string | undefined;
// Der Body enthält { profile, project, service, ... }
const payload = req.body;
console.log(payload)
const body = req.body as any;
try {
// Service aufrufen (führt die 3 Schritte aus: Lieferschein -> Zeit -> History)
const result = await publicLinkService.submitFormData(server, token, payload, pin);
const quantity = parseFloat(body.quantity) || 0;
// 201 Created zurückgeben
// Wir nutzen das vom User gewählte deliveryDate
// Falls kein Datum geschickt wurde, Fallback auf Heute
const baseDate = body.deliveryDate ? dayjs(body.deliveryDate) : dayjs();
const payload = {
...body,
// Wir mappen das deliveryDate auf die Zeitstempel
// Start ist z.B. 08:00 Uhr am gewählten Tag, Ende ist Start + Menge
startDate: baseDate.hour(8).minute(0).toDate(),
endDate: baseDate.hour(8).add(quantity, 'hour').toDate(),
deliveryDate: baseDate.format('YYYY-MM-DD')
};
const result = await publicLinkService.submitFormData(server, token, payload, pin);
return reply.code(201).send(result);
} catch (error: any) {
console.log(error);
// Fehler-Mapping für saubere HTTP Codes
if (error.message === "Link_NotFound") {
return reply.code(404).send({ error: "Link ungültig oder nicht aktiv" });
}
if (error.message === "Pin_Required") {
return reply.code(401).send({ error: "PIN erforderlich" });
}
if (error.message === "Pin_Invalid") {
return reply.code(403).send({ error: "PIN ist falsch" });
}
if (error.message === "Profile_Missing") {
return reply.code(400).send({ error: "Kein Mitarbeiter-Profil gefunden (weder im Link noch in der Eingabe)" });
}
if (error.message === "Project not found" || error.message === "Service not found") {
return reply.code(400).send({ error: "Ausgewähltes Projekt oder Leistung existiert nicht mehr." });
}
// Fallback für alle anderen Fehler (z.B. DB Constraints)
return reply.code(500).send({
error: "Interner Fehler beim Speichern",
details: error.message
});
server.log.error(error);
return reply.code(500).send({ error: "Fehler beim Speichern", details: error.message });
}
});
}

View File

@@ -10,30 +10,23 @@ import {
or
} from "drizzle-orm"
import { resourceConfig } from "../../utils/resource.config";
import { useNextNumberRangeNumber } from "../../utils/functions";
import {stafftimeentries} from "../../../db/schema";
// -------------------------------------------------------------
// SQL Volltextsuche auf mehreren Feldern
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
// -------------------------------------------------------------
function buildSearchCondition(table: any, columns: string[], search: string) {
function buildSearchCondition(columns: any[], search: string) {
if (!search || !columns.length) return null
const term = `%${search.toLowerCase()}%`
const conditions = columns
.map((colName) => table[colName])
.filter(Boolean)
.map((col) => ilike(col, term))
if (conditions.length === 0) return null
// @ts-ignore
return or(...conditions)
}
@@ -55,95 +48,85 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
const { resource } = req.params as { resource: string }
const table = resourceConfig[resource].table
const config = resourceConfig[resource]
const table = config.table
// WHERE-Basis
let whereCond: any = eq(table.tenant, tenantId)
let q = server.db.select().from(table).$dynamic()
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel]
if (relConfig) {
const relTable = relConfig.table
// FIX: Nur joinen, wenn es keine Self-Reference ist (verhindert ERROR 42712)
if (relTable !== table) {
// @ts-ignore
q = q.leftJoin(relTable, eq(table[rel], relTable.id))
if (relConfig.searchColumns) {
relConfig.searchColumns.forEach(c => {
if (relTable[c]) searchCols.push(relTable[c])
})
}
}
}
})
}
// 🔍 SQL Search
if (search) {
const searchCond = buildSearchCondition(
table,
resourceConfig[resource].searchColumns,
search.trim()
)
if (searchCond) {
whereCond = and(whereCond, searchCond)
}
const searchCond = buildSearchCondition(searchCols, search.trim())
if (searchCond) whereCond = and(whereCond, searchCond)
}
// Base Query
let q = server.db.select().from(table).where(whereCond)
q = q.where(whereCond)
// Sortierung
if (sort) {
const col = (table as any)[sort]
if (col) {
//@ts-ignore
q = ascQuery === "true"
? q.orderBy(asc(col))
: q.orderBy(desc(col))
q = ascQuery === "true" ? q.orderBy(asc(col)) : q.orderBy(desc(col))
}
}
const queryData = await q
// Transformation: Falls Joins genutzt wurden, das Hauptobjekt extrahieren
const rows = queryData.map(r => r[resource] || r.table || r);
// RELATION LOADING (MANY-TO-ONE)
let ids = {}
let lists = {}
let maps = {}
let data = [...queryData]
if(resourceConfig[resource].mtoLoad) {
resourceConfig[resource].mtoLoad.forEach(relation => {
ids[relation] = [...new Set(queryData.map(r => r[relation]).filter(Boolean))];
// RELATION LOADING
let data = [...rows]
if(config.mtoLoad) {
let ids: any = {}
let lists: any = {}
let maps: any = {}
config.mtoLoad.forEach(rel => {
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
})
for await (const relation of resourceConfig[resource].mtoLoad ) {
console.log(relation)
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
for await (const rel of config.mtoLoad) {
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
const relTab = relConf.table
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
}
resourceConfig[resource].mtoLoad.forEach(relation => {
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
data = rows.map(row => {
let toReturn = { ...row }
config.mtoLoad.forEach(rel => {
toReturn[rel] = row[rel] ? maps[rel][row[rel]] : null
})
data = queryData.map(row => {
let toReturn = {
...row
}
resourceConfig[resource].mtoLoad.forEach(relation => {
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
})
return toReturn
});
}
if(resourceConfig[resource].mtmListLoad) {
for await (const relation of resourceConfig[resource].mtmListLoad) {
console.log(relation)
console.log(resource.substring(0,resource.length-1))
const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id)))
console.log(relationRows.length)
data = data.map(row => {
let toReturn = {
...row
}
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
return toReturn
})
if(config.mtmListLoad) {
for await (const relation of config.mtmListLoad) {
const relTable = resourceConfig[relation].table
const parentKey = resource.substring(0, resource.length - 1)
const relationRows = await server.db.select().from(relTable).where(inArray(relTable[parentKey], data.map(i => i.id)))
data = data.map(row => ({
...row,
[relation]: relationRows.filter(i => i[parentKey] === row.id)
}))
}
}
@@ -155,212 +138,130 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
})
// -------------------------------------------------------------
// PAGINATED LIST
// -------------------------------------------------------------
server.get("/resource/:resource/paginated", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id;
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" });
}
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" });
const { resource } = req.params as { resource: string };
const config = resourceConfig[resource];
const table = config.table;
const { queryConfig } = req;
const {
pagination,
sort,
filters,
paginationDisabled
} = queryConfig;
const { search, distinctColumns } = req.query as {
search?: string;
distinctColumns?: string;
};
let table = resourceConfig[resource].table
const { pagination, sort, filters } = queryConfig;
const { search, distinctColumns } = req.query as { search?: string; distinctColumns?: string; };
let whereCond: any = eq(table.tenant, tenantId);
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
let countQuery = server.db.select({ value: count(table.id) }).from(table).$dynamic();
let mainQuery = server.db.select().from(table).$dynamic();
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
if (relConfig) {
const relTable = relConfig.table;
// FIX: Self-Reference Check
if (relTable !== table) {
countQuery = countQuery.leftJoin(relTable, eq(table[rel], relTable.id));
// @ts-ignore
mainQuery = mainQuery.leftJoin(relTable, eq(table[rel], relTable.id));
if (relConfig.searchColumns) {
relConfig.searchColumns.forEach(c => {
if (relTable[c]) searchCols.push(relTable[c]);
});
}
}
}
});
}
if (search) {
const searchCond = buildSearchCondition(
table,
resourceConfig[resource].searchColumns,
search.trim()
)
if (searchCond) {
whereCond = and(whereCond, searchCond)
}
const searchCond = buildSearchCondition(searchCols, search.trim());
if (searchCond) whereCond = and(whereCond, searchCond);
}
if (filters) {
for (const [key, val] of Object.entries(filters)) {
const col = (table as any)[key];
if (!col) continue;
if (Array.isArray(val)) {
whereCond = and(whereCond, inArray(col, val));
} else {
whereCond = and(whereCond, eq(col, val as any));
}
whereCond = Array.isArray(val) ? and(whereCond, inArray(col, val)) : and(whereCond, eq(col, val as any));
}
}
// -----------------------------------------------
// COUNT (for pagination)
// -----------------------------------------------
const totalRes = await server.db
.select({ value: count(table.id) })
.from(table)
.where(whereCond);
const totalRes = await countQuery.where(whereCond);
const total = Number(totalRes[0]?.value ?? 0);
// -----------------------------------------------
// DISTINCT VALUES (regardless of pagination)
// -----------------------------------------------
const distinctValues: Record<string, any[]> = {};
if (distinctColumns) {
for (const colName of distinctColumns.split(",").map(c => c.trim())) {
const col = (table as any)[colName];
if (!col) continue;
const rows = await server.db
.select({ v: col })
.from(table)
.where(eq(table.tenant, tenantId));
const values = rows
.map(r => r.v)
.filter(v => v != null && v !== "");
distinctValues[colName] = [...new Set(values)].sort();
}
}
// PAGINATION
const offset = pagination?.offset ?? 0;
const limit = pagination?.limit ?? 100;
// SORTING
let orderField: any = null;
let direction: "asc" | "desc" = "asc";
mainQuery = mainQuery.where(whereCond).offset(offset).limit(limit);
if (sort?.length > 0) {
const s = sort[0];
const col = (table as any)[s.field];
if (col) {
orderField = col;
direction = s.direction === "asc" ? "asc" : "desc";
mainQuery = s.direction === "asc" ? mainQuery.orderBy(asc(col)) : mainQuery.orderBy(desc(col));
}
}
// MAIN QUERY (Paginated)
let q = server.db
.select()
.from(table)
.where(whereCond)
.offset(offset)
.limit(limit);
const rawRows = await mainQuery;
// Transformation für Drizzle Joins
let rows = rawRows.map(r => r[resource] || r.table || r);
if (orderField) {
//@ts-ignore
q = direction === "asc"
? q.orderBy(asc(orderField))
: q.orderBy(desc(orderField));
const distinctValues: Record<string, any[]> = {};
if (distinctColumns) {
for (const colName of distinctColumns.split(",").map(c => c.trim())) {
const col = (table as any)[colName];
if (!col) continue;
const dRows = await server.db.select({ v: col }).from(table).where(eq(table.tenant, tenantId));
distinctValues[colName] = [...new Set(dRows.map(r => r.v).filter(v => v != null && v !== ""))].sort();
}
}
const rows = await q;
if (!rows.length) {
return {
data: [],
queryConfig: {
...queryConfig,
total,
totalPages: 0,
distinctValues
let data = [...rows];
if (config.mtoLoad) {
let ids: any = {};
let lists: any = {};
let maps: any = {};
config.mtoLoad.forEach(rel => {
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
});
for await (const rel of config.mtoLoad) {
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
const relTab = relConf.table;
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : [];
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
}
};
}
let data = [...rows]
//Many to One
if(resourceConfig[resource].mtoLoad) {
let ids = {}
let lists = {}
let maps = {}
resourceConfig[resource].mtoLoad.forEach(relation => {
ids[relation] = [...new Set(rows.map(r => r[relation]).filter(Boolean))];
})
for await (const relation of resourceConfig[resource].mtoLoad ) {
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
}
resourceConfig[resource].mtoLoad.forEach(relation => {
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
})
data = rows.map(row => {
let toReturn = {
...row
}
resourceConfig[resource].mtoLoad.forEach(relation => {
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
})
return toReturn
let toReturn = { ...row };
config.mtoLoad.forEach(rel => {
toReturn[rel] = row[rel] ? maps[rel][row[rel]] : null;
});
return toReturn;
});
}
if(resourceConfig[resource].mtmListLoad) {
for await (const relation of resourceConfig[resource].mtmListLoad) {
console.log(relation)
const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id)))
console.log(relationRows)
data = data.map(row => {
let toReturn = {
...row
}
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
return toReturn
})
if (config.mtmListLoad) {
for await (const relation of config.mtmListLoad) {
const relTable = resourceConfig[relation].table;
const parentKey = resource.substring(0, resource.length - 1);
const relationRows = await server.db.select().from(relTable).where(inArray(relTable[parentKey], data.map(i => i.id)));
data = data.map(row => ({
...row,
[relation]: relationRows.filter(i => i[parentKey] === row.id)
}));
}
}
// -----------------------------------------------
// RETURN DATA
// -----------------------------------------------
return {
data,
queryConfig: {
...queryConfig,
total,
totalPages: Math.ceil(total / limit),
distinctValues
}
queryConfig: { ...queryConfig, total, totalPages: Math.ceil(total / limit), distinctValues }
};
} catch (err) {
@@ -369,9 +270,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
});
// -------------------------------------------------------------
// DETAIL (mit JOINS)
// DETAIL
// -------------------------------------------------------------
server.get("/resource/:resource/:id/:no_relations?", async (req, reply) => {
try {
@@ -391,40 +291,32 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!projRows.length)
return reply.code(404).send({ error: "Resource not found" })
// ------------------------------------
// LOAD RELATIONS
// ------------------------------------
let ids = {}
let lists = {}
let maps = {}
let data = {
...projRows[0]
}
let data = { ...projRows[0] }
if (!no_relations) {
if (resourceConfig[resource].mtoLoad) {
for await (const relation of resourceConfig[resource].mtoLoad) {
if (data[relation]) {
data[relation] = (await server.db.select().from(resourceConfig[relation + "s"].table).where(eq(resourceConfig[relation + "s"].table.id, data[relation])))[0]
const relConf = resourceConfig[relation + "s"] || resourceConfig[relation];
const relTable = relConf.table
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
data[relation] = relData[0] || null
}
}
}
if (resourceConfig[resource].mtmLoad) {
for await (const relation of resourceConfig[resource].mtmLoad) {
console.log(relation)
data[relation] = await server.db.select().from(resourceConfig[relation].table).where(eq(resourceConfig[relation].table[resource.substring(0,resource.length - 1)],id))
const relTable = resourceConfig[relation].table
const parentKey = resource.substring(0, resource.length - 1)
data[relation] = await server.db.select().from(relTable).where(eq(relTable[parentKey], id))
}
}
}
return data
} catch (err) {
console.error("ERROR /resource/projects/:id", err)
console.error("ERROR /resource/:resource/:id", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
@@ -432,132 +324,59 @@ export default async function resourceRoutes(server: FastifyInstance) {
// Create
server.post("/resource/:resource", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({error: "No tenant selected"});
}
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
const { resource } = req.params as { resource: string };
const body = req.body as Record<string, any>;
const config = resourceConfig[resource];
const table = config.table;
const table = resourceConfig[resource].table
let createData = { ...body, tenant: req.user.tenant_id, archived: false };
let createData = {
...body,
tenant: req.user.tenant_id,
archived: false, // Standardwert
}
console.log(resourceConfig[resource].numberRangeHolder)
if (resourceConfig[resource].numberRangeHolder && !body[resourceConfig[resource]]) {
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, resource)
console.log(result)
createData[resourceConfig[resource].numberRangeHolder] = result.usedNumber
}
const normalizeDate = (val: any) => {
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
createData[config.numberRangeHolder] = result.usedNumber
}
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
Object.keys(createData).forEach((key) => {
if (key.toLowerCase().includes("date") && key !== "deliveryDateType") createData[key] = normalizeDate(createData[key])
})
const [created] = await server.db
.insert(table)
.values(createData)
.returning()
/*await insertHistoryItem(server, {
entity: resource,
entityId: data.id,
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: data,
text: `${dataType.labelSingle} erstellt`,
});*/
const [created] = await server.db.insert(table).values(createData).returning()
return created;
} catch (error) {
console.log(error)
reply.status(500)
console.error(error);
reply.status(500);
}
});
// UPDATE (inkl. Soft-Delete/Archive)
// Update
server.put("/resource/:resource/:id", async (req, reply) => {
try {
const { resource, id } = req.params as { resource: string; id: string }
const body = req.body as Record<string, any>
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
const tenantId = (req.user as any)?.tenant_id
const userId = (req.user as any)?.user_id
if (!tenantId || !userId) {
return reply.code(401).send({error: "Unauthorized"})
}
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
const table = resourceConfig[resource].table
//TODO: HISTORY
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; }
let data = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
//@ts-ignore
delete data.updatedBy
//@ts-ignore
delete data.updatedAt
console.log(data)
delete data.updatedBy; delete data.updatedAt;
Object.keys(data).forEach((key) => {
console.log(key)
if(key.includes("_at") || key.includes("At") || key.toLowerCase().includes("date")) {
if ((key.includes("_at") || key.includes("At") || key.toLowerCase().includes("date")) && key !== "deliveryDateType") {
data[key] = normalizeDate(data[key])
}
})
console.log(data)
const [updated] = await server.db
.update(table)
.set(data)
.where(and(
eq(table.id, id),
eq(table.tenant, tenantId)))
.returning()
//const diffs = diffObjects(oldItem, newItem);
/*for (const d of diffs) {
await insertHistoryItem(server, {
entity: resource,
entityId: id,
action: d.type,
created_by: userId,
tenant_id: tenantId,
oldVal: d.oldValue ? String(d.oldValue) : null,
newVal: d.newValue ? String(d.newValue) : null,
text: `Feld "${d.label}" ${d.typeLabel}: ${d.oldValue ?? ""} → ${d.newValue ?? ""}`,
});
}*/
const [updated] = await server.db.update(table).set(data).where(and(eq(table.id, id), eq(table.tenant, tenantId))).returning()
return updated
} catch (err) {
console.log("ERROR /resource/projects/:id", err)
console.error(err)
}
})
}

View File

@@ -35,7 +35,7 @@ export default async function resourceRoutesSpecial(server: FastifyInstance) {
}
// ---------------------------------------
// 📌 SELECT: wir ignorieren select string (wie Supabase)
// 📌 SELECT: select-string wird in dieser Route bewusst ignoriert
// Drizzle kann kein dynamisches Select aus String!
// Wir geben IMMER alle Spalten zurück → kompatibel zum Frontend
// ---------------------------------------

View File

@@ -124,6 +124,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
eventtype: "invalidated",
source: "WEB",
related_event_id: id,
invalidates_event_id: id,
metadata: {
reason: reason || "Bearbeitung",
replaced_by_edit: true

View File

@@ -1,5 +1,7 @@
import { FastifyInstance } from 'fastify'
import { StaffTimeEntryConnect } from '../../types/staff'
import { asc, eq } from "drizzle-orm";
import { stafftimenetryconnects } from "../../../db/schema";
export default async function staffTimeConnectRoutes(server: FastifyInstance) {
@@ -8,16 +10,21 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
'/staff/time/:id/connects',
async (req, reply) => {
const { id } = req.params
const { started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes } = req.body
const { started_at, stopped_at, project_id, notes } = req.body
const parsedProjectId = project_id ? Number(project_id) : null
const { data, error } = await server.supabase
.from('staff_time_entry_connects')
.insert([{ time_entry_id: id, started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes }])
.select()
.maybeSingle()
const data = await server.db
.insert(stafftimenetryconnects)
.values({
stafftimeentry: id,
started_at: new Date(started_at),
stopped_at: new Date(stopped_at),
project_id: parsedProjectId,
notes
})
.returning()
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
return reply.send(data[0])
}
)
@@ -26,13 +33,12 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
'/staff/time/:id/connects',
async (req, reply) => {
const { id } = req.params
const { data, error } = await server.supabase
.from('staff_time_entry_connects')
.select('*')
.eq('time_entry_id', id)
.order('started_at', { ascending: true })
const data = await server.db
.select()
.from(stafftimenetryconnects)
.where(eq(stafftimenetryconnects.stafftimeentry, id))
.orderBy(asc(stafftimenetryconnects.started_at))
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
}
)
@@ -42,15 +48,20 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
'/staff/time/connects/:connectId',
async (req, reply) => {
const { connectId } = req.params
const { data, error } = await server.supabase
.from('staff_time_entry_connects')
.update({ ...req.body, updated_at: new Date().toISOString() })
.eq('id', connectId)
.select()
.maybeSingle()
const patchData = { ...req.body } as any
if (patchData.started_at) patchData.started_at = new Date(patchData.started_at)
if (patchData.stopped_at) patchData.stopped_at = new Date(patchData.stopped_at)
if (patchData.project_id !== undefined) {
patchData.project_id = patchData.project_id ? Number(patchData.project_id) : null
}
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
const data = await server.db
.update(stafftimenetryconnects)
.set({ ...patchData, updated_at: new Date() })
.where(eq(stafftimenetryconnects.id, connectId))
.returning()
return reply.send(data[0])
}
)
@@ -59,12 +70,10 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
'/staff/time/connects/:connectId',
async (req, reply) => {
const { connectId } = req.params
const { error } = await server.supabase
.from('staff_time_entry_connects')
.delete()
.eq('id', connectId)
await server.db
.delete(stafftimenetryconnects)
.where(eq(stafftimenetryconnects.id, connectId))
if (error) return reply.code(400).send({ error: error.message })
return reply.send({ success: true })
}
)

340
backend/src/routes/wiki.ts Normal file
View File

@@ -0,0 +1,340 @@
import { FastifyInstance } from "fastify"
import { and, eq, isNull, asc, inArray } from "drizzle-orm"
// WICHTIG: Hier müssen die Schemas der Entitäten importiert werden!
import {
wikiPages,
authUsers,
// Bereits vorhanden
customers,
projects,
plants,
products,
inventoryitems,
// NEU HINZUGEFÜGT (Basierend auf deinem DataStore)
tasks,
contacts,
contracts,
vehicles,
vendors,
spaces,
inventoryitemgroups,
services,
hourrates,
events,
productcategories,
servicecategories,
ownaccounts
} from "../../db/schema/"
// Konfiguration: Welche Entitäten sollen im Wiki auftauchen?
const ENTITY_CONFIG: Record<string, { table: any, labelField: any, rootLabel: string, idField: 'id' | 'uuid' }> = {
// --- BEREITS VORHANDEN ---
'customers': { table: customers, labelField: customers.name, rootLabel: 'Kunden', idField: 'id' },
'projects': { table: projects, labelField: projects.name, rootLabel: 'Projekte', idField: 'id' },
'plants': { table: plants, labelField: plants.name, rootLabel: 'Objekte', idField: 'id' },
'products': { table: products, labelField: products.name, rootLabel: 'Artikel', idField: 'id' },
'inventoryitems': { table: inventoryitems, labelField: inventoryitems.name, rootLabel: 'Inventarartikel', idField: 'id' },
// --- NEU BASIEREND AUF DATASTORE ---
'tasks': { table: tasks, labelField: tasks.name, rootLabel: 'Aufgaben', idField: 'id' },
'contacts': { table: contacts, labelField: contacts.fullName, rootLabel: 'Kontakte', idField: 'id' },
'contracts': { table: contracts, labelField: contracts.name, rootLabel: 'Verträge', idField: 'id' },
'vehicles': { table: vehicles, labelField: vehicles.license_plate, rootLabel: 'Fahrzeuge', idField: 'id' },
'vendors': { table: vendors, labelField: vendors.name, rootLabel: 'Lieferanten', idField: 'id' },
'spaces': { table: spaces, labelField: spaces.name, rootLabel: 'Lagerplätze', idField: 'id' },
'inventoryitemgroups': { table: inventoryitemgroups, labelField: inventoryitemgroups.name, rootLabel: 'Inventarartikelgruppen', idField: 'id' },
'services': { table: services, labelField: services.name, rootLabel: 'Leistungen', idField: 'id' },
'hourrates': { table: hourrates, labelField: hourrates.name, rootLabel: 'Stundensätze', idField: 'id' },
'events': { table: events, labelField: events.name, rootLabel: 'Termine', idField: 'id' },
'productcategories': { table: productcategories, labelField: productcategories.name, rootLabel: 'Artikelkategorien', idField: 'id' },
'servicecategories': { table: servicecategories, labelField: servicecategories.name, rootLabel: 'Leistungskategorien', idField: 'id' },
'ownaccounts': { table: ownaccounts, labelField: ownaccounts.name, rootLabel: 'Zusätzliche Buchungskonten', idField: 'id' },
}
// Types
interface WikiTreeQuery {
entityType?: string
entityId?: number
entityUuid?: string
}
interface WikiCreateBody {
title: string
parentId?: string
isFolder?: boolean
entityType?: string
entityId?: number
entityUuid?: string
}
interface WikiUpdateBody {
title?: string
content?: any
parentId?: string | null
sortOrder?: number
isFolder?: boolean
}
export default async function wikiRoutes(server: FastifyInstance) {
// ---------------------------------------------------------
// 1. GET /wiki/tree
// Lädt Struktur: Entweder gefiltert (Widget) oder Global (mit virtuellen Ordnern)
// ---------------------------------------------------------
server.get<{ Querystring: WikiTreeQuery }>("/wiki/tree", async (req, reply) => {
const user = req.user
const { entityType, entityId, entityUuid } = req.query
// FALL A: WIDGET-ANSICHT (Spezifische Entität)
// Wenn wir spezifisch filtern, wollen wir nur die echten Seiten ohne virtuelle Ordner
if (entityType && (entityId || entityUuid)) {
const filters = [
eq(wikiPages.tenantId, user.tenant_id),
eq(wikiPages.entityType, entityType)
]
if (entityId) filters.push(eq(wikiPages.entityId, Number(entityId)))
else if (entityUuid) filters.push(eq(wikiPages.entityUuid, entityUuid))
return server.db
.select({
id: wikiPages.id,
parentId: wikiPages.parentId,
title: wikiPages.title,
isFolder: wikiPages.isFolder,
sortOrder: wikiPages.sortOrder,
entityType: wikiPages.entityType,
updatedAt: wikiPages.updatedAt,
})
.from(wikiPages)
.where(and(...filters))
.orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title))
}
// FALL B: GLOBALE ANSICHT (Haupt-Wiki)
// Wir laden ALLES und bauen virtuelle Ordner für die Entitäten
// 1. Alle Wiki-Seiten des Tenants laden
const allPages = await server.db
.select({
id: wikiPages.id,
parentId: wikiPages.parentId,
title: wikiPages.title,
isFolder: wikiPages.isFolder,
sortOrder: wikiPages.sortOrder,
entityType: wikiPages.entityType,
entityId: wikiPages.entityId, // Wichtig für Zuordnung
entityUuid: wikiPages.entityUuid, // Wichtig für Zuordnung
updatedAt: wikiPages.updatedAt,
})
.from(wikiPages)
.where(eq(wikiPages.tenantId, user.tenant_id))
.orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title))
// Trennen in Standard-Seiten und Entity-Seiten
const standardPages = allPages.filter(p => !p.entityType)
const entityPages = allPages.filter(p => p.entityType)
const virtualNodes: any[] = []
// 2. Virtuelle Ordner generieren
// Wir iterieren durch unsere Config (Kunden, Projekte...)
await Promise.all(Object.entries(ENTITY_CONFIG).map(async ([typeKey, config]) => {
// Haben wir überhaupt Notizen für diesen Typ?
const pagesForType = entityPages.filter(p => p.entityType === typeKey)
if (pagesForType.length === 0) return
// IDs sammeln, um Namen aus der DB zu holen
// Wir unterscheiden zwischen ID (int) und UUID
let entities: any[] = []
if (config.idField === 'id') {
const ids = [...new Set(pagesForType.map(p => p.entityId).filter((id): id is number => id !== null))]
if (ids.length > 0) {
//@ts-ignore - Drizzle Typisierung bei dynamischen Tables ist tricky
entities = await server.db.select({ id: config.table.id, label: config.labelField })
.from(config.table)
//@ts-ignore
.where(inArray(config.table.id, ids))
}
} else {
// Falls UUID genutzt wird (z.B. IoT Devices)
const uuids = [...new Set(pagesForType.map(p => p.entityUuid).filter((uuid): uuid is string => uuid !== null))]
if (uuids.length > 0) {
//@ts-ignore
entities = await server.db.select({ id: config.table.id, label: config.labelField })
.from(config.table)
//@ts-ignore
.where(inArray(config.table.id, uuids))
}
}
if (entities.length === 0) return
// 3. Virtuellen Root Ordner erstellen (z.B. "Kunden")
const rootId = `virtual-root-${typeKey}`
virtualNodes.push({
id: rootId,
parentId: null, // Ganz oben im Baum
title: config.rootLabel,
isFolder: true,
isVirtual: true, // Flag fürs Frontend (read-only Folder)
sortOrder: 1000 // Ganz unten anzeigen
})
// 4. Virtuelle Entity Ordner erstellen (z.B. "Müller GmbH")
entities.forEach(entity => {
const entityNodeId = `virtual-entity-${typeKey}-${entity.id}`
virtualNodes.push({
id: entityNodeId,
parentId: rootId,
title: entity.label || 'Unbekannt',
isFolder: true,
isVirtual: true,
sortOrder: 0
})
// 5. Die echten Notizen verschieben
// Wir suchen alle Notizen, die zu dieser Entity gehören
const myPages = pagesForType.filter(p =>
(config.idField === 'id' && p.entityId === entity.id) ||
(config.idField === 'uuid' && p.entityUuid === entity.id)
)
myPages.forEach(page => {
// Nur Root-Notizen der Entity verschieben.
// Sub-Pages bleiben wo sie sind (parentId zeigt ja schon auf die richtige Seite)
if (!page.parentId) {
// Wir modifizieren das Objekt für die Response (nicht in der DB!)
// Wir müssen es clonen, sonst ändern wir es für alle Referenzen
const pageClone = { ...page }
pageClone.parentId = entityNodeId
virtualNodes.push(pageClone)
} else {
// Sub-Pages einfach so hinzufügen
virtualNodes.push(page)
}
})
})
}))
// Ergebnis: Normale Seiten + Virtuelle Struktur
return [...standardPages, ...virtualNodes]
})
// ---------------------------------------------------------
// 2. GET /wiki/:id
// Lädt EINEN Eintrag komplett MIT Content
// ---------------------------------------------------------
server.get<{ Params: { id: string } }>("/wiki/:id", async (req, reply) => {
const user = req.user
const { id } = req.params
const page = await server.db.query.wikiPages.findFirst({
where: and(
eq(wikiPages.id, id),
eq(wikiPages.tenantId, user.tenant_id)
),
with: {
author: {
columns: { id: true } // Name falls vorhanden
}
}
})
if (!page) return reply.code(404).send({ error: "Page not found" })
return page
})
// ---------------------------------------------------------
// 3. POST /wiki
// Erstellt neuen Eintrag
// ---------------------------------------------------------
server.post<{ Body: WikiCreateBody }>("/wiki", async (req, reply) => {
const user = req.user
const body = req.body
if (!body.title) return reply.code(400).send({ error: "Title required" })
const hasEntity = !!body.entityType
const [newPage] = await server.db
.insert(wikiPages)
.values({
tenantId: user.tenant_id,
title: body.title,
parentId: body.parentId || null,
isFolder: body.isFolder ?? false,
entityType: hasEntity ? body.entityType : null,
entityId: hasEntity && body.entityId ? body.entityId : null,
entityUuid: hasEntity && body.entityUuid ? body.entityUuid : null,
//@ts-ignore
createdBy: user.id,
//@ts-ignore
updatedBy: user.id
})
.returning()
return newPage
})
// ---------------------------------------------------------
// 4. PATCH /wiki/:id
// Universal-Update
// ---------------------------------------------------------
server.patch<{ Params: { id: string }; Body: WikiUpdateBody }>(
"/wiki/:id",
async (req, reply) => {
const user = req.user
const { id } = req.params
const body = req.body
const existing = await server.db.query.wikiPages.findFirst({
where: and(eq(wikiPages.id, id), eq(wikiPages.tenantId, user.tenant_id)),
columns: { id: true }
})
if (!existing) return reply.code(404).send({ error: "Not found" })
const [updatedPage] = await server.db
.update(wikiPages)
.set({
title: body.title,
content: body.content,
parentId: body.parentId,
sortOrder: body.sortOrder,
isFolder: body.isFolder,
updatedAt: new Date(),
//@ts-ignore
updatedBy: user.id
})
.where(eq(wikiPages.id, id))
.returning()
return updatedPage
}
)
// ---------------------------------------------------------
// 5. DELETE /wiki/:id
// Löscht Eintrag
// ---------------------------------------------------------
server.delete<{ Params: { id: string } }>("/wiki/:id", async (req, reply) => {
const user = req.user
const { id } = req.params
const result = await server.db
.delete(wikiPages)
.where(and(
eq(wikiPages.id, id),
eq(wikiPages.tenantId, user.tenant_id)
))
.returning({ id: wikiPages.id })
if (result.length === 0) return reply.code(404).send({ error: "Not found" })
return { success: true, deletedId: result[0].id }
})
}

View File

@@ -301,7 +301,7 @@ export async function buildExportZip(
else if(account.taxType === '7I') buschluessel = "18";
else buschluessel = "-";
let amountGross = account.amountGross ? account.amountGross : (account.amountNet || 0) + (account.amountTax || 0);
let amountGross =/* account.amountGross ? account.amountGross : */(account.amountNet || 0) + (account.amountTax || 0);
let shSelector = Math.sign(amountGross) === -1 ? "H" : "S";
let text = `ER ${ii.reference}: ${escapeString(ii.description)}`.substring(0,59);
const vend = ii.vendor; // durch Mapping verfügbar
@@ -325,27 +325,27 @@ export async function buildExportZip(
if(alloc.createddocument && alloc.createddocument.customer) {
const cd = alloc.createddocument;
const cust = cd.customer;
bookingLines.push(`${displayCurrency(alloc.amount,true)};"H";;;;;${cust?.customerNumber};${datevKonto};"3";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`ZE${alloc.description}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust?.name}";"Kundennummer";"${cust?.customerNumber}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(cd.documentDate).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
bookingLines.push(`${displayCurrency(alloc.amount,true)};"H";;;;;${cust?.customerNumber};${datevKonto};"3";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`ZE${alloc.description}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust?.name}";"Kundennummer";"${cust?.customerNumber}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.incominginvoice && alloc.incominginvoice.vendor) {
const ii = alloc.incominginvoice;
const vend = ii.vendor;
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${dayjs(ii.date).format("DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${dayjs(ii.date).format("DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.account) {
const acc = alloc.account;
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${acc.number};"";${dateVal};"";;;"${`${vorzeichen} ${acc.number} - ${escapeString(acc.label)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${bs.credName || ''}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${acc.number};"";${dateVal};"";;;"${`${vorzeichen} ${acc.number} - ${escapeString(acc.label)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${bs.credName || ''}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.vendor) {
const vend = alloc.vendor;
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend.vendorNumber};"";${dateVal};"";;;"${`${vorzeichen} ${vend.vendorNumber} - ${escapeString(vend.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend.vendorNumber};"";${dateVal};"";;;"${`${vorzeichen} ${vend.vendorNumber} - ${escapeString(vend.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.customer) {
const cust = alloc.customer;
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${cust.customerNumber};"";${dateVal};"";;;"${`${vorzeichen} ${cust.customerNumber} - ${escapeString(cust.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${cust.customerNumber};"";${dateVal};"";;;"${`${vorzeichen} ${cust.customerNumber} - ${escapeString(cust.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.ownaccount) {
const own = alloc.ownaccount;
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${own.number};"";${dateVal};"";;;"${`${vorzeichen} ${own.number} - ${escapeString(own.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${own.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${own.number};"";${dateVal};"";;;"${`${vorzeichen} ${own.number} - ${escapeString(own.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${own.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
}
});

View File

@@ -1,12 +1,25 @@
import xmlbuilder from "xmlbuilder";
import {randomUUID} from "node:crypto";
import dayjs from "dayjs";
import { and, eq, inArray } from "drizzle-orm";
import { createddocuments, tenants } from "../../../db/schema";
export const createSEPAExport = async (server,idsToExport, tenant_id) => {
const {data,error} = await server.supabase.from("createddocuments").select().eq("tenant", tenant_id).in("id", idsToExport)
const {data:tenantData,error:tenantError} = await server.supabase.from("tenants").select().eq("id", tenant_id).single()
const data = await server.db
.select()
.from(createddocuments)
.where(and(
eq(createddocuments.tenant, tenant_id),
inArray(createddocuments.id, idsToExport)
))
const tenantRows = await server.db
.select()
.from(tenants)
.where(eq(tenants.id, tenant_id))
.limit(1)
const tenantData = tenantRows[0]
console.log(tenantData)
console.log(tenantError)
console.log(data)

View File

@@ -86,7 +86,7 @@ const InstructionFormat = z.object({
});
// ---------------------------------------------------------
// MAIN FUNCTION REPLACES SUPABASE VERSION
// MAIN FUNCTION
// ---------------------------------------------------------
export const getInvoiceDataFromGPT = async function (
server: FastifyInstance,

View File

@@ -1,4 +1,5 @@
import { FastifyInstance } from "fastify"
import { historyitems } from "../../db/schema";
export async function insertHistoryItem(
server: FastifyInstance,
@@ -63,8 +64,5 @@ export async function insertHistoryItem(
newVal: params.newVal ? JSON.stringify(params.newVal) : null
}
const { error } = await server.supabase.from("historyitems").insert([entry])
if (error) { // @ts-ignore
console.log(error)
}
await server.db.insert(historyitems).values(entry as any)
}

View File

@@ -2,6 +2,9 @@ import {PDFDocument, StandardFonts, rgb} from "pdf-lib"
import dayjs from "dayjs"
import {renderAsCurrency, splitStringBySpace} from "./stringRendering";
import {FastifyInstance} from "fastify";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { s3 } from "./s3";
import { secrets } from "./secrets";
const getCoordinatesForPDFLib = (x:number ,y:number, page:any) => {
/*
@@ -25,9 +28,21 @@ const getCoordinatesForPDFLib = (x:number ,y:number, page:any) => {
const getBackgroundSourceBuffer = async (server:FastifyInstance, path:string) => {
const {data:backgroundPDFData,error:backgroundPDFError} = await server.supabase.storage.from("files").download(path)
console.log(path)
return backgroundPDFData.arrayBuffer()
const { Body } = await s3.send(
new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: path
})
)
const chunks: Buffer[] = []
for await (const chunk of Body as any) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return Buffer.concat(chunks)
}
const getDuration = (time) => {

View File

@@ -36,7 +36,7 @@ import {
export const resourceConfig = {
projects: {
searchColumns: ["name"],
searchColumns: ["name","customerRef","projectNumber","notes"],
mtoLoad: ["customer","plant","contract","projecttype"],
mtmLoad: ["tasks", "files","createddocuments"],
table: projects,
@@ -61,6 +61,7 @@ export const resourceConfig = {
},
plants: {
table: plants,
searchColumns: ["name"],
mtoLoad: ["customer"],
mtmLoad: ["projects","tasks","files"],
},

View File

@@ -14,8 +14,6 @@ export let secrets = {
PORT: number
HOST: string
DATABASE_URL: string
SUPABASE_URL: string
SUPABASE_SERVICE_ROLE_KEY: string
S3_BUCKET: string
ENCRYPTION_KEY: string
MAILER_SMTP_HOST: string

View File

@@ -0,0 +1,74 @@
// scripts/fill-file-sizes.ts
import 'dotenv/config';
import { db } from '../../db';
import { files } from '../../db/schema';
import { eq, isNull } from 'drizzle-orm';
import { HeadObjectCommand } from "@aws-sdk/client-s3";
import { s3, initS3 } from '../utils/s3';
import { loadSecrets, secrets } from '../utils/secrets';
async function migrate() {
console.log("🚀 Starte Migration der Dateigrößen...");
// 1. Setup
await loadSecrets();
await initS3();
// 2. Alle Dateien holen, die noch keine Größe haben (oder alle, um sicherzugehen)
// Wir nehmen erstmal ALLE, um sicherzustellen, dass alles stimmt.
const allFiles = await db.select().from(files);
console.log(`📦 ${allFiles.length} Dateien in der Datenbank gefunden.`);
let successCount = 0;
let errorCount = 0;
// 3. Loop durch alle Dateien
for (const file of allFiles) {
if (!file.path) {
console.log(`⏭️ Überspringe Datei ${file.id} (Kein Pfad)`);
continue;
}
try {
// S3 fragen (HeadObject lädt nur Metadaten, nicht die ganze Datei -> Schnell)
const command = new HeadObjectCommand({
Bucket: secrets.S3_BUCKET, // Oder secrets.S3_BUCKET_NAME je nach deiner Config
Key: file.path
});
const response = await s3.send(command);
const size = response.ContentLength || 0;
// In DB speichern
await db.update(files)
.set({ size: size })
.where(eq(files.id, file.id));
process.stdout.write("."); // Fortschrittsanzeige
successCount++;
} catch (error: any) {
process.stdout.write("X");
// console.error(`\n❌ Fehler bei ${file.path}: ${error.name}`);
// Optional: Wenn Datei in S3 fehlt, könnten wir sie markieren oder loggen
if (error.name === 'NotFound') {
// console.error(` -> Datei existiert nicht im Bucket!`);
}
errorCount++;
}
}
console.log("\n\n------------------------------------------------");
console.log(`✅ Fertig!`);
console.log(`Updated: ${successCount}`);
console.log(`Fehler: ${errorCount} (Meistens Dateien, die im Bucket fehlen)`);
console.log("------------------------------------------------");
process.exit(0);
}
migrate().catch(err => {
console.error("Fataler Fehler:", err);
process.exit(1);
});

View File

@@ -0,0 +1,200 @@
import 'dotenv/config';
import { v2 as webdav } from 'webdav-server';
import { db } from '../../db';
import { tenants, files, folders } from '../../db/schema';
import { Readable } from 'stream';
import { GetObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
import { s3, initS3 } from '../utils/s3';
import { secrets, loadSecrets } from '../utils/secrets';
// ============================================================================
// 1. SETUP
// ============================================================================
const userManager = new webdav.SimpleUserManager();
const user = userManager.addUser('admin', 'admin', true);
const privilegeManager = new webdav.SimplePathPrivilegeManager();
privilegeManager.setRights(user, '/', [ 'all' ]);
const server = new webdav.WebDAVServer({
httpAuthentication: new webdav.HTTPDigestAuthentication(userManager, 'Default realm'),
privilegeManager: privilegeManager,
port: 3200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS,PROPFIND,PROPPATCH,MKCOL,COPY,MOVE,LOCK,UNLOCK',
'Access-Control-Allow-Headers': 'Authorization, Content-Type, Depth, User-Agent, X-Expected-Entity-Length, If-Modified-Since, Cache-Control, Range, Overwrite, Destination',
}
});
// ============================================================================
// 2. CACHE
// ============================================================================
const pathToS3KeyMap = new Map<string, string>();
const pathToSizeMap = new Map<string, number>();
// ============================================================================
// 3. LOGIC
// ============================================================================
async function startServer() {
console.log('------------------------------------------------');
console.log('[WebDAV] Starte Server (Filtered Mode)...');
try {
await loadSecrets();
await initS3();
console.log('[WebDAV] S3 Verbindung OK.');
console.log('[WebDAV] Lade Datenbank...');
const allTenants = await db.select().from(tenants);
const allFolders = await db.select().from(folders);
const allFiles = await db.select().from(files);
// Zähler für Statistik
let hiddenFilesCount = 0;
// --------------------------------------------------------------------
// BUILDER
// --------------------------------------------------------------------
const buildFolderContent = (tenantId: string, parentFolderId: string | null, currentWebDavPath: string) => {
const currentDir: any = {};
// 1. UNTERORDNER
const subFolders = allFolders.filter(f => f.tenant === tenantId && f.parent === parentFolderId);
subFolders.forEach(folder => {
const folderName = folder.name.replace(/\//g, '-');
const nextPath = `${currentWebDavPath}/${folderName}`;
currentDir[folderName] = buildFolderContent(tenantId, folder.id, nextPath);
});
// 2. DATEIEN
//@ts-ignore
const dirFiles = allFiles.filter(f => f.tenant === tenantId && f.folder === parentFolderId);
dirFiles.forEach(file => {
// ============================================================
// ❌ FILTER: DATEIEN OHNE GRÖSSE AUSBLENDEN
// ============================================================
const fileSize = Number(file.size || 0);
if (fileSize <= 0) {
// Datei überspringen, wenn 0 Bytes oder null
hiddenFilesCount++;
return;
}
// ============================================================
// Name bestimmen
let fileName = 'Unbenannt';
if (file.path) fileName = file.path.split('/').pop() || 'Unbenannt';
else if (file.name) fileName = file.name;
// A) Eintrag im WebDAV
currentDir[fileName] = `Ref: ${file.id}`;
// B) Maps füllen
const webDavFullPath = `${currentWebDavPath}/${fileName}`;
if (file.path) {
pathToS3KeyMap.set(webDavFullPath, file.path);
}
// C) Größe setzen (wir wissen jetzt sicher, dass sie > 0 ist)
pathToSizeMap.set(webDavFullPath, fileSize);
});
return currentDir;
};
// --------------------------------------------------------------------
// BAUM ZUSAMMENSETZEN
// --------------------------------------------------------------------
const dbTree: any = {};
allTenants.forEach(tenant => {
const tName = tenant.name.replace(/\//g, '-');
const rootPath = `/${tName}`;
//@ts-ignore
const content = buildFolderContent(tenant.id, null, rootPath);
// Leere Ordner Hinweis (optional)
if (Object.keys(content).length === 0) {
content['(Leer).txt'] = 'Keine gültigen Dateien vorhanden.';
}
dbTree[tName] = content;
});
if (Object.keys(dbTree).length === 0) {
dbTree['Status.txt'] = 'Datenbank leer.';
}
// --------------------------------------------------------------------
// REGISTRIEREN
// --------------------------------------------------------------------
const rootFS = server.rootFileSystem();
//@ts-ignore
rootFS.addSubTree(server.createExternalContext(), dbTree);
// ====================================================================
// OVERRIDE 1: DOWNLOAD
// ====================================================================
(rootFS as any)._openReadStream = async (path: webdav.Path, ctx: any, callback: any) => {
const p = path.toString();
const s3Key = pathToS3KeyMap.get(p);
if (s3Key) {
try {
const command = new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: s3Key });
const response = await s3.send(command);
if (response.Body) return callback(null, response.Body as Readable);
} catch (e: any) {
console.error(`[S3 ERROR] ${e.message}`);
return callback(null, Readable.from([`Error: ${e.message}`]));
}
}
return callback(null, Readable.from(['System File']));
};
// ====================================================================
// OVERRIDE 2: SIZE
// ====================================================================
(rootFS as any)._size = async (path: webdav.Path, ctx: any, callback: any) => {
const p = path.toString();
const cachedSize = pathToSizeMap.get(p);
if (cachedSize !== undefined) return callback(null, cachedSize);
// Fallback S3 Check (sollte durch Filter kaum noch vorkommen)
const s3Key = pathToS3KeyMap.get(p);
if (s3Key) {
try {
const command = new HeadObjectCommand({ Bucket: secrets.S3_BUCKET, Key: s3Key });
const response = await s3.send(command);
const realSize = response.ContentLength || 0;
pathToSizeMap.set(p, realSize);
return callback(null, realSize);
} catch (e) {
return callback(null, 0);
}
}
return callback(null, 0);
};
// --------------------------------------------------------------------
// START
// --------------------------------------------------------------------
server.start(() => {
console.log('[WebDAV] 🚀 READY auf http://localhost:3200');
console.log(`[WebDAV] Sichtbare Dateien: ${pathToS3KeyMap.size}`);
console.log(`[WebDAV] Ausgeblendet (0 Bytes): ${hiddenFilesCount}`);
});
} catch (error) {
console.error('[WebDAV] 💥 ERROR:', error);
}
}
startServer();

View File

@@ -0,0 +1,235 @@
<template>
<div
ref="el"
:style="style"
class="fixed z-[999] w-72 bg-white dark:bg-gray-900 shadow-2xl rounded-xl border border-gray-200 dark:border-gray-800 p-4 select-none touch-none"
>
<div class="flex items-center justify-between mb-4 cursor-move border-b pb-2 dark:border-gray-800">
<div class="flex items-center gap-2 text-gray-500">
<UIcon name="i-heroicons-calculator" />
<span class="text-xs font-bold uppercase tracking-wider">Kalkulator</span>
</div>
<div class="flex items-center gap-1">
<UTooltip text="Verlauf">
<UButton
color="gray"
variant="ghost"
:icon="showHistory ? 'i-heroicons-clock-solid' : 'i-heroicons-clock'"
size="xs"
@click="showHistory = !showHistory"
/>
</UTooltip>
<UTooltip text="Schließen (Esc)">
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark"
size="xs"
@click="store.isOpen = false"
/>
</UTooltip>
</div>
</div>
<div v-if="!showHistory">
<div
class="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg mb-4 text-right border border-gray-200 dark:border-gray-700 cursor-pointer group relative"
@click="copyDisplay"
>
<div class="text-[10px] text-gray-500 h-4 font-mono uppercase tracking-tighter">
Speicher: {{ Number(store.memory).toFixed(2).replace('.', ',') }}
</div>
<div class="text-2xl font-mono truncate tracking-tighter">{{ store.display }}</div>
<div class="absolute inset-0 flex items-center justify-center bg-primary-500/10 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg">
<span class="text-[10px] font-bold text-primary-600 uppercase">
{{ copied ? 'Kopiert!' : 'Klicken zum Kopieren' }}
</span>
</div>
</div>
<div class="grid grid-cols-4 gap-2">
<UTooltip text="Brutto (+19%)"><UButton color="green" variant="soft" block size="xs" @click="applyTax(19)">+19%</UButton></UTooltip>
<UTooltip text="Brutto (+7%)"><UButton color="green" variant="soft" block size="xs" @click="applyTax(7)">+7%</UButton></UTooltip>
<UTooltip text="Netto (-19%)"><UButton color="rose" variant="soft" block size="xs" @click="removeTax(19)">-19%</UButton></UTooltip>
<UTooltip text="Netto (-7%)"><UButton color="rose" variant="soft" block size="xs" @click="removeTax(7)">-7%</UButton></UTooltip>
<UTooltip text="Löschen"><UButton color="gray" variant="ghost" block @click="clear">C</UButton></UTooltip>
<UTooltip text="Speicher +"><UButton color="gray" variant="ghost" block @click="addToSum">M+</UButton></UTooltip>
<UTooltip text="Speicher Reset"><UButton color="gray" variant="ghost" block @click="store.memory = 0">MC</UButton></UTooltip>
<UButton color="primary" variant="soft" @click="setOperator('/')">/</UButton>
<UButton v-for="n in [7, 8, 9]" :key="n" color="white" @click="appendNumber(n)">{{ n }}</UButton>
<UButton color="primary" variant="soft" @click="setOperator('*')">×</UButton>
<UButton v-for="n in [4, 5, 6]" :key="n" color="white" @click="appendNumber(n)">{{ n }}</UButton>
<UButton color="primary" variant="soft" @click="setOperator('-')">-</UButton>
<UButton v-for="n in [1, 2, 3]" :key="n" color="white" @click="appendNumber(n)">{{ n }}</UButton>
<UButton color="primary" variant="soft" @click="setOperator('+')">+</UButton>
<UButton color="white" class="col-span-2" @click="appendNumber(0)">0</UButton>
<UButton color="white" @click="addComma">,</UButton>
<UButton color="primary" block @click="calculate">=</UButton>
</div>
</div>
<div v-else class="h-[270px] flex flex-col animate-in fade-in duration-200">
<div class="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
<div v-if="store.history.length === 0" class="text-center text-gray-400 text-xs mt-10 italic">
Keine Berechnungen im Verlauf
</div>
<div
v-for="(item, i) in store.history" :key="i"
class="p-2 bg-gray-50 dark:bg-gray-800 rounded text-right border-l-2 border-primary-500 cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"
@click="useHistoryItem(item.result)"
>
<div class="text-[10px] text-gray-400">{{ item.expression }} =</div>
<div class="text-sm font-bold">{{ item.result }}</div>
</div>
</div>
<UButton
color="gray"
variant="ghost"
size="xs"
block
class="mt-2"
icon="i-heroicons-trash"
@click="store.history = []"
>
Verlauf leeren
</UButton>
</div>
</div>
</template>
<script setup>
import { useDraggable, useClipboard } from '@vueuse/core'
import { useCalculatorStore } from '~/stores/calculator'
const store = useCalculatorStore()
const { copy, copied } = useClipboard()
const el = ref(null)
const { style } = useDraggable(el, {
initialValue: { x: window.innerWidth - 350, y: 150 },
})
const shouldResetDisplay = ref(false)
const showHistory = ref(false)
const previousValue = ref(null)
const lastOperator = ref(null)
// --- Logik ---
const appendNumber = (num) => {
if (store.display === '0' || shouldResetDisplay.value) {
store.display = String(num)
shouldResetDisplay.value = false
} else {
store.display += String(num)
}
}
const addComma = () => {
if (!store.display.includes(',')) {
store.display += ','
}
}
const setOperator = (op) => {
previousValue.value = parseFloat(store.display.replace(',', '.'))
lastOperator.value = op
shouldResetDisplay.value = true
}
const calculate = () => {
if (lastOperator.value === null) return
const currentVal = parseFloat(store.display.replace(',', '.'))
const prevVal = previousValue.value
let result = 0
switch (lastOperator.value) {
case '+': result = prevVal + currentVal; break
case '-': result = prevVal - currentVal; break
case '*': result = prevVal * currentVal; break
case '/': result = currentVal !== 0 ? prevVal / currentVal : 0; break
}
const expression = `${prevVal} ${lastOperator.value} ${currentVal}`
const resultString = String(Number(result.toFixed(4))).replace('.', ',')
store.addHistory(expression, resultString)
store.display = resultString
lastOperator.value = null
shouldResetDisplay.value = true
}
const clear = () => {
store.display = '0'
previousValue.value = null
lastOperator.value = null
}
const applyTax = (percent) => {
const current = parseFloat(store.display.replace(',', '.'))
store.display = (current * (1 + percent / 100)).toFixed(2).replace('.', ',')
}
const removeTax = (percent) => {
const current = parseFloat(store.display.replace(',', '.'))
store.display = (current / (1 + percent / 100)).toFixed(2).replace('.', ',')
}
const addToSum = () => {
store.memory += parseFloat(store.display.replace(',', '.'))
shouldResetDisplay.value = true
}
const copyDisplay = () => {
copy(store.display)
}
const useHistoryItem = (val) => {
store.display = val
showHistory.value = false
}
// --- Shortcuts ---
defineShortcuts({
'0': () => appendNumber(0),
'1': () => appendNumber(1),
'2': () => appendNumber(2),
'3': () => appendNumber(3),
'4': () => appendNumber(4),
'5': () => appendNumber(5),
'6': () => appendNumber(6),
'7': () => appendNumber(7),
'8': () => appendNumber(8),
'9': () => appendNumber(9),
'comma': addComma,
'plus': () => setOperator('+'),
'minus': () => setOperator('-'),
'enter': { usingInput: true, handler: calculate },
'backspace': () => {
store.display = store.display.length > 1 ? store.display.slice(0, -1) : '0'
},
// Escape schließt nun das Fenster via Store
'escape': {
usingInput: true,
whenever: [computed(() => store.isOpen)],
handler: () => { store.isOpen = false }
}
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-200 dark:bg-gray-700 rounded-full;
}
</style>

View File

@@ -29,7 +29,6 @@ const props = defineProps({
const emit = defineEmits(["returnData"])
const {type} = props
defineShortcuts({
@@ -53,11 +52,10 @@ const route = useRoute()
const dataStore = useDataStore()
const modal = useModal()
const dataType = dataStore.dataTypes[type]
const openTab = ref(0)
const item = ref(JSON.parse(props.item))
console.log(item.value)
// console.log(item.value)
const oldItem = ref(null)
const generateOldItemData = () => {
@@ -66,6 +64,39 @@ const generateOldItemData = () => {
generateOldItemData()
// --- ÄNDERUNG START: Computed Property statt Watcher/Function ---
// Dies berechnet den Status automatisch neu, egal woher die Daten kommen (Init oder User-Eingabe)
const saveAllowed = computed(() => {
if (!item.value) return false
let allowedCount = 0
// Nur Input-Felder berücksichtigen
const relevantColumns = dataType.templateColumns.filter(i => i.inputType)
relevantColumns.forEach(datapoint => {
if(datapoint.required) {
if(datapoint.key.includes(".")){
const [parentKey, childKey] = datapoint.key.split('.')
// Prüfung: Existiert Parent UND ist Child "truthy" (nicht null/undefined/empty)
if(item.value[parentKey] && item.value[parentKey][childKey]) {
allowedCount += 1
}
} else {
if(item.value[datapoint.key]) {
allowedCount += 1
}
}
} else {
// Wenn nicht required, zählt es immer als "erlaubt"
allowedCount += 1
}
})
return allowedCount >= relevantColumns.length
})
// --- ÄNDERUNG ENDE ---
const setupCreate = () => {
dataType.templateColumns.forEach(datapoint => {
if(datapoint.key.includes(".")){
@@ -78,10 +109,7 @@ const setupCreate = () => {
} else {
item.value[datapoint.key] = {}
}
}
})
}
setupCreate()
@@ -91,7 +119,6 @@ const setupQuery = () => {
console.log(props.mode)
if(props.mode === "create" && (route.query || props.createQuery)) {
let data = !props.inModal ? route.query : props.createQuery
Object.keys(data).forEach(key => {
@@ -127,13 +154,10 @@ const setupQuery = () => {
} else {
item.value[holder] = [id]
}
})
}
})
// calcSaveAllowed() -> Entfernt, da computed automatisch reagiert
}
}
setupQuery()
@@ -166,40 +190,16 @@ loadOptions()
const contentChanged = (content, datapoint) => {
if (datapoint.key.includes(".")) {
item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].html = content.html
item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].text = content.text
item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].json = content.json
item.value[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].html = content.html
item.value[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].text = content.text
item.value[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].json = content.json
} else {
item[datapoint.key].html = content.html
item[datapoint.key].text = content.text
item[datapoint.key].json = content.json
item.value[datapoint.key].html = content.html
item.value[datapoint.key].text = content.text
item.value[datapoint.key].json = content.json
}
}
const saveAllowed = ref(false)
const calcSaveAllowed = (item) => {
let allowedCount = 0
dataType.templateColumns.filter(i => i.inputType).forEach(datapoint => {
if(datapoint.required) {
if(datapoint.key.includes(".")){
if(item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]) allowedCount += 1
} else {
if(item[datapoint.key]) allowedCount += 1
}
} else {
allowedCount += 1
}
})
saveAllowed.value = allowedCount >= dataType.templateColumns.filter(i => i.inputType).length
}
//calcSaveAllowed()
watch(item.value, async (newItem, oldItem) => {
calcSaveAllowed(newItem)
})
const createItem = async () => {
let ret = null
@@ -226,11 +226,7 @@ const updateItem = async () => {
ret = await useEntities(type).update(item.value.id, item.value)
emit('returnData', ret)
}
}
</script>
<template>
@@ -245,9 +241,8 @@ const updateItem = async () => {
<UButton
icon="i-heroicons-chevron-left"
variant="outline"
@click="router.back()/*router.push(`/standardEntity/${type}`)*/"
@click="router.back()"
>
<!-- {{dataType.label}}-->
</UButton>
</template>
<template #center>
@@ -332,10 +327,6 @@ const updateItem = async () => {
>
<UDivider>{{ columnName }}</UDivider>
<!--
Die Form Group darf nur in der ersten bearbeitet werden und muss dann runterkopiert werden
-->
<div
v-for="datapoint in dataType.templateColumns.filter(i => i.inputType && i.inputColumn === columnName)"
>
@@ -436,7 +427,6 @@ const updateItem = async () => {
/>
</template>
</UPopover>
<!-- TODO: DISABLED FOR TIPTAP -->
<Tiptap
v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)"
@@ -537,7 +527,6 @@ const updateItem = async () => {
/>
</template>
</UPopover>
<!-- TODO: Color/Required for TipTap and MaterialComposing -->
<Tiptap
v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)"
@@ -562,35 +551,8 @@ const updateItem = async () => {
icon="i-heroicons-x-mark"
/>
</InputGroup>
<!-- <div
v-if="profileStore.ownTenant.ownFields"
>
<UDivider
class="mt-3"
>Eigene Felder</UDivider>
<UFormGroup
v-for="field in profileStore.ownTenant.ownFields.contracts"
:key="field.key"
:label="field.label"
>
<UInput
v-if="field.type === 'text'"
v-model="item.ownFields[field.key]"
/>
<USelectMenu
v-else-if="field.type === 'select'"
:options="field.options"
v-model="item.ownFields[field.key]"
/>
</UFormGroup>
</div>-->
</UFormGroup>
</div>
</div>
</div>
<UFormGroup
@@ -690,7 +652,6 @@ const updateItem = async () => {
/>
</template>
</UPopover>
<!-- TODO: DISABLED FOR TIPTAP -->
<Tiptap
v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)"
@@ -791,7 +752,6 @@ const updateItem = async () => {
/>
</template>
</UPopover>
<!-- TODO: Color/Required for TipTap and MaterialComposing -->
<Tiptap
v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)"
@@ -816,30 +776,6 @@ const updateItem = async () => {
icon="i-heroicons-x-mark"
/>
</InputGroup>
<!-- <div
v-if="profileStore.ownTenant.ownFields"
>
<UDivider
class="mt-3"
>Eigene Felder</UDivider>
<UFormGroup
v-for="field in profileStore.ownTenant.ownFields.contracts"
:key="field.key"
:label="field.label"
>
<UInput
v-if="field.type === 'text'"
v-model="item.ownFields[field.key]"
/>
<USelectMenu
v-else-if="field.type === 'select'"
:options="field.options"
v-model="item.ownFields[field.key]"
/>
</UFormGroup>
</div>-->
</UFormGroup>
</UForm>
</UDashboardPanelContent>

View File

@@ -1,6 +1,7 @@
<script setup>
import EntityShowSubTimes from "~/components/EntityShowSubTimes.vue";
import WikiEntityWidget from "~/components/wiki/WikiEntityWidget.vue";
const props = defineProps({
type: {
@@ -288,6 +289,13 @@ const changePinned = async () => {
v-else-if="tab.label === 'Zeiten'"
:platform="platform"
/>
<div v-else-if="tab.label === 'Wiki'" class="h-[600px] w-full overflow-hidden">
<WikiEntityWidget
:entity-type="type"
:entity-id="typeof props.item.id === 'number' ? props.item.id : undefined"
:entity-uuid="typeof props.item.id === 'string' ? props.item.id : undefined"
/>
</div>
<EntityShowSub
:item="props.item"
:query-string-data="getAvailableQueryStringData()"

View File

@@ -60,7 +60,6 @@ const router = useRouter()
const createddocuments = ref([])
const setup = async () => {
//createddocuments.value = (await useSupabaseSelect("createddocuments")).filter(i => !i.archived)
createddocuments.value = (await useEntities("createddocuments").select()).filter(i => !i.archived)
}
setup()
@@ -78,9 +77,6 @@ const templateColumns = [
},{
key: 'state',
label: "Status"
},{
key: 'paid',
label: "Bezahlt"
},{
key: 'amount',
label: "Betrag"
@@ -283,12 +279,12 @@ const selectItem = (item) => {
{{row.state}}
</span>
</template>
<template #paid-data="{row}">
<!-- <template #paid-data="{row}">
<div v-if="(row.type === 'invoices' ||row.type === 'advanceInvoices') && row.state === 'Gebucht'">
<span v-if="useSum().getIsPaid(row,createddocuments)" class="text-primary-500">Bezahlt</span>
<span v-else class="text-rose-600">Offen</span>
</div>
</template>
</template>-->
<template #reference-data="{row}">
<span v-if="row === props.item.createddocuments[selectedItem]" class="text-primary-500 font-bold">{{row.documentNumber}}</span>
<span v-else>{{row.documentNumber}}</span>

View File

@@ -1,9 +1,6 @@
<script setup>
import dayjs from "dayjs";
const supabase = useSupabaseClient()
const route = useRoute()
const router = useRouter()
const profileStore = useProfileStore()
const props = defineProps({
@@ -28,8 +25,6 @@ const statementallocations = ref([])
const incominginvoices = ref([])
const setup = async () => {
//statementallocations.value = (await supabase.from("statementallocations").select("*, bs_id(*)").eq("account", route.params.id).eq("tenant",profileStore.currentTenant).order("created_at",{ascending: true})).data
//incominginvoices.value = (await useSupabaseSelect("incominginvoices", "*, vendor(*)")).filter(i => i.accounts.find(x => x.account == route.params.id))
}
setup()

View File

@@ -24,7 +24,6 @@ const emit = defineEmits(["updateNeeded"]);
const router = useRouter()
const profileStore = useProfileStore()
const supabase = useSupabaseClient()
const renderedPhases = computed(() => {
if(props.topLevelType === "projects" && props.item.phases) {
@@ -77,17 +76,6 @@ const changeActivePhase = async (key) => {
const res = await useEntities("projects").update(item.id, {phases:item.phases,active_phase: item.phases.find(i => i.active).label})
//const {error:updateError} = await supabase.from("projects").update({phases: item.phases}).eq("id",item.id)
/*const {error} = await supabase.from("historyitems").insert({
createdBy: profileStore.activeProfile.id,
tenant: profileStore.currentTenant,
text: `Aktive Phase zu "${phaseLabel}" gewechselt`,
project: item.id
})*/
emit("updateNeeded")
}

View File

@@ -1,10 +1,5 @@
<script setup>
import dayjs from "dayjs";
const supabase = useSupabaseClient()
const route = useRoute()
const router = useRouter()
const profileStore = useProfileStore()
const props = defineProps({
queryStringData: {

View File

@@ -57,7 +57,7 @@
const selectedItem = ref(0)
const sort = ref({
column: dataType.supabaseSortColumn || "date",
column: dataType.sortColumn || "date",
direction: 'desc'
})

View File

@@ -1,14 +1,17 @@
<script setup>
const supabase = useSupabaseClient()
const profileStore = useProfileStore()
const globalMessages = ref([])
const setup = async () => {
let {data} = await supabase.from("globalmessages").select("*, profiles(id)")
let data = []
try {
data = await useNuxtApp().$api("/api/resource/globalmessages")
} catch (e) {
data = []
}
data = data.filter((message) => message.profiles.length === 0)
data = (data || []).filter((message) => !message.profiles || message.profiles.length === 0)
globalMessages.value = data
@@ -29,10 +32,17 @@ const showMessage = (message) => {
showMessageModal.value = true
}
const markMessageAsRead = async () => {
await supabase.from("globalmessagesseen").insert({
try {
await useNuxtApp().$api("/api/resource/globalmessagesseen", {
method: "POST",
body: {
profile: profileStore.activeProfile.id,
message: messageToShow.value.id,
}
})
} catch (e) {
// noop: endpoint optional in newer backend versions
}
showMessageModal.value = false
setup()

View File

@@ -3,10 +3,7 @@ const { isHelpSlideoverOpen } = useDashboard()
const { metaSymbol } = useShortcuts()
const shortcuts = ref(false)
const dataStore = useDataStore()
const profileStore = useProfileStore()
const query = ref('')
const supabase = useSupabaseClient()
const toast = useToast()
const router = useRouter()

View File

@@ -1,10 +1,11 @@
<script setup>
const route = useRoute()
const auth = useAuthStore()
const { has } = usePermission()
// Lokaler State für den Taschenrechner
const showCalculator = ref(false)
const links = computed(() => {
return [
...(auth.profile?.pinned_on_navigation || []).map(pin => {
@@ -26,17 +27,13 @@ const links = computed(() => {
}
}),
... false ? [{
label: "Support Tickets",
to: "/support",
icon: "i-heroicons-rectangle-stack",
}] : [],
{
id: 'dashboard',
label: "Dashboard",
to: "/",
icon: "i-heroicons-home"
}, {
},
{
id: 'historyitems',
label: "Logbuch",
to: "/historyitems",
@@ -53,26 +50,11 @@ const links = computed(() => {
to: "/standardEntity/tasks",
icon: "i-heroicons-rectangle-stack"
}] : [],
/*... true ? [{
label: "Plantafel",
to: "/calendar/timeline",
icon: "i-heroicons-calendar-days"
}] : [],
...true ? [{
label: "Kalender",
to: "/calendar/grid",
icon: "i-heroicons-calendar-days"
label: "Wiki",
to: "/wiki",
icon: "i-heroicons-book-open"
}] : [],
... true ? [{
label: "Termine",
to: "/standardEntity/events",
icon: "i-heroicons-calendar-days"
}] : [],*/
/*{
label: "Dateien",
to: "/files",
icon: "i-heroicons-document"
},*/
]
},
{
@@ -113,15 +95,7 @@ const links = computed(() => {
to: "/email/new",
icon: "i-heroicons-envelope",
disabled: true
}/*, {
label: "Logbücher",
to: "/communication/historyItems",
icon: "i-heroicons-book-open"
}, {
label: "Chats",
to: "/chats",
icon: "i-heroicons-chat-bubble-left"
}*/
}
]
},
...(has("customers") || has("vendors") || has("contacts")) ? [{
@@ -145,7 +119,7 @@ const links = computed(() => {
icon: "i-heroicons-user-group"
}] : [],
]
},] : [],
}] : [],
{
label: "Mitarbeiter",
defaultOpen: false,
@@ -156,16 +130,6 @@ const links = computed(() => {
to: "/staff/time",
icon: "i-heroicons-clock",
}] : [],
/*... has("absencerequests") ? [{
label: "Abwesenheiten",
to: "/standardEntity/absencerequests",
icon: "i-heroicons-document-text"
}] : [],*/
/*{
label: "Fahrten",
to: "/trackingTrips",
icon: "i-heroicons-map"
},*/
]
},
...[{
@@ -210,22 +174,13 @@ const links = computed(() => {
icon: "i-heroicons-puzzle-piece",
defaultOpen: false,
children: [
/*{
label: "Vorgänge",
to: "/inventory",
icon: "i-heroicons-square-3-stack-3d"
},{
label: "Bestände",
to: "/inventory/stocks",
icon: "i-heroicons-square-3-stack-3d"
},*/
...has("spaces") ? [{
label: "Lagerplätze",
to: "/standardEntity/spaces",
icon: "i-heroicons-square-3-stack-3d"
}] : [],
]
},] : [],
}] : [],
{
label: "Stammdaten",
defaultOpen: false,
@@ -283,7 +238,7 @@ const links = computed(() => {
label: "Projekte",
to: "/standardEntity/projects",
icon: "i-heroicons-clipboard-document-check"
},] : [],
}] : [],
...has("contracts") ? [{
label: "Verträge",
to: "/standardEntity/contracts",
@@ -293,12 +248,7 @@ const links = computed(() => {
label: "Objekte",
to: "/standardEntity/plants",
icon: "i-heroicons-clipboard-document"
},] : [],
/*... has("checks") ? [{
label: "Überprüfungen",
to: "/standardEntity/checks",
icon: "i-heroicons-magnifying-glass"
},] : [],*/
}] : [],
{
label: "Einstellungen",
defaultOpen: false,
@@ -308,11 +258,7 @@ const links = computed(() => {
label: "Nummernkreise",
to: "/settings/numberRanges",
icon: "i-heroicons-clipboard-document-list",
},/*{
label: "Rollen",
to: "/roles",
icon: "i-heroicons-key"
},*/{
}, {
label: "E-Mail Konten",
to: "/settings/emailaccounts",
icon: "i-heroicons-envelope",
@@ -324,11 +270,7 @@ const links = computed(() => {
label: "Textvorlagen",
to: "/settings/texttemplates",
icon: "i-heroicons-clipboard-document-list",
},/*{
label: "Eigene Felder",
to: "/settings/ownfields",
icon: "i-heroicons-clipboard-document-list"
},*/{
}, {
label: "Firmeneinstellungen",
to: "/settings/tenant",
icon: "i-heroicons-building-office",
@@ -342,33 +284,31 @@ const links = computed(() => {
icon: "i-heroicons-clipboard-document-list"
}
]
}
},
]
})
// nur Items mit Children → für Accordion
const accordionItems = computed(() =>
links.value.filter(item => Array.isArray(item.children) && item.children.length > 0)
)
// nur Items ohne Children → als Buttons
const buttonItems = computed(() =>
links.value.filter(item => !item.children || item.children.length === 0)
)
</script>
<template>
<!-- Standalone Buttons -->
<div class="flex flex-col gap-1">
<UButton
v-for="item in buttonItems"
:key="item.label"
:variant="item.pinned ? 'ghost' : 'ghost'"
variant="ghost"
:color="(item.to && route.path === item.to) ? 'primary' : (item.pinned ? 'amber' : 'gray')"
:icon="item.pinned ? 'i-heroicons-star' : item.icon"
class="w-full"
:to="item.to"
:target="item.target"
@click="item.click ? item.click() : null"
>
<UIcon
v-if="item.pinned"
@@ -378,8 +318,9 @@ const buttonItems = computed(() =>
{{ item.label }}
</UButton>
</div>
<UDivider/>
<!-- Accordion für die Items mit Children -->
<UDivider class="my-2"/>
<UAccordion
:items="accordionItems"
:multiple="false"
@@ -387,7 +328,7 @@ const buttonItems = computed(() =>
>
<template #default="{ item, open }">
<UButton
:variant="'ghost'"
variant="ghost"
:color="(item.children?.some(c => route.path.includes(c.to))) ? 'primary' : 'gray'"
:icon="item.icon"
class="w-full"
@@ -415,56 +356,13 @@ const buttonItems = computed(() =>
:to="child.to"
:target="child.target"
:disabled="child.disabled"
@click="child.click ? child.click() : null"
>
{{ child.label }}
</UButton>
</div>
</template>
</UAccordion>
<!-- <UAccordion
:items="links"
:multiple="false"
>
<template #default="{ item, index, open }">
<UButton
:variant="item.pinned ? 'ghost' : 'ghost'"
:color="(item.to && route.path === item.to) || (item.children?.some(c => route.path.includes(c.to))) ? 'primary' : (item.pinned ? 'amber' : 'gray')"
:icon="item.pinned ? 'i-heroicons-star' : item.icon"
class="w-full"
:to="item.to"
:target="item.target"
>
<UIcon
v-if="item.pinned"
:name="item.icon" class="w-5 h-5 me-2" />
{{ item.label }}
<template v-if="item.children" #trailing>
<UIcon
name="i-heroicons-chevron-right-20-solid"
class="w-5 h-5 ms-auto transform transition-transform duration-200"
:class="[open && 'rotate-90']"
/>
</template>
</UButton>
</template>
<template #item="{ item }">
<div class="flex flex-col" v-if="item.children?.length > 0">
<UButton
v-for="child in item.children"
:key="child.label"
variant="ghost"
:color="child.to === route.path ? 'primary' : 'gray'"
:icon="child.icon"
class="ml-4"
:to="child.to"
:target="child.target"
>
{{ child.label }}
</UButton>
</div>
</template>
</UAccordion>-->
<Calculator v-if="showCalculator" v-model="showCalculator"/>
</template>

View File

@@ -1,9 +1,6 @@
<script setup>
import {formatTimeAgo} from '@vueuse/core'
const supabase = useSupabaseClient()
const { isNotificationsSlideoverOpen } = useDashboard()
watch(isNotificationsSlideoverOpen, async (newVal,oldVal) => {
@@ -15,18 +12,24 @@ watch(isNotificationsSlideoverOpen, async (newVal,oldVal) => {
const notifications = ref([])
const setup = async () => {
notifications.value = (await supabase.from("notifications").select()).data
try {
notifications.value = await useNuxtApp().$api("/api/resource/notifications_items")
} catch (e) {
notifications.value = []
}
}
setup()
const setNotificationAsRead = async (notification) => {
console.log(notification)
const {data,error} = await supabase.from("notifications").update({read: true}).eq("id", notification.id)
console.log(error)
try {
await useNuxtApp().$api(`/api/resource/notifications_items/${notification.id}`, {
method: "PUT",
body: { readAt: new Date() }
})
} catch (e) {
// noop: endpoint optional in older/newer backend variants
}
setup()
}
@@ -41,7 +44,7 @@ const setNotificationAsRead = async (notification) => {
class="p-3 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer flex items-center gap-3 relative"
@click="setNotificationAsRead(notification)"
>
<UChip color="primary" :show="!notification.read" inset>
<UChip color="primary" :show="!notification.read && !notification.readAt" inset>
<UAvatar alt="FEDEO" size="md" />
</UChip>
@@ -49,7 +52,7 @@ const setNotificationAsRead = async (notification) => {
<p class="flex items-center justify-between">
<span class="text-gray-900 dark:text-white font-medium">{{notification.title}}</span>
<time :datetime="notification.date" class="text-gray-500 dark:text-gray-400 text-xs" v-text="formatTimeAgo(new Date(notification.created_at))" />
<time :datetime="notification.date || notification.createdAt || notification.created_at" class="text-gray-500 dark:text-gray-400 text-xs" v-text="formatTimeAgo(new Date(notification.createdAt || notification.created_at))" />
</p>
<p class="text-gray-500 dark:text-gray-400">
{{ notification.message }}

View File

@@ -61,7 +61,7 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
Nein, bleiben
</button>
<button @click="confirmLeave" class="btn-confirm">
Ja, verwerfen
Ja, verlassen
</button>
</div>

View File

@@ -7,8 +7,8 @@ const props = defineProps({
pin: { type: String, default: '' }
})
const runtimeConfig = useRuntimeConfig()
const emit = defineEmits(['success'])
const { $api } = useNuxtApp()
const toast = useToast()
const config = computed(() => props.context.config)
@@ -16,12 +16,11 @@ const data = computed(() => props.context.data)
// Initiale Werte setzen
const form = ref({
deliveryDate: dayjs().format('YYYY-MM-DD'), // Standard: Heute
profile: props.context.meta?.defaultProfileId || null,
project: null,
service: config.value?.defaults?.serviceId || null,
// Wenn manualTime erlaubt, setze Startzeit auf jetzt, sonst null (wird im Backend gesetzt)
startDate: config.value?.features?.timeTracking?.allowManualTime ? new Date() : null,
endDate: config.value?.features?.timeTracking?.allowManualTime ? dayjs().add(1, 'hour').toDate() : null,
quantity: config.value?.features?.timeTracking?.defaultDurationHours || 1,
dieselUsage: 0,
description: ''
})
@@ -29,13 +28,17 @@ const form = ref({
const isSubmitting = ref(false)
const errors = ref({})
// Validierung basierend auf JSON Config
// Validierung basierend auf JSON Config & neuen Anforderungen
const validate = () => {
errors.value = {}
let isValid = true
const validationRules = config.value.validation || {}
// Standard-Validierung
if (!form.value.deliveryDate) {
errors.value.deliveryDate = 'Datum erforderlich'
isValid = false
}
if (!form.value.project && data.value.projects?.length > 0) {
errors.value.project = 'Pflichtfeld'
isValid = false
@@ -46,13 +49,18 @@ const validate = () => {
isValid = false
}
// Profil nur validieren, wenn Auswahl möglich ist
if (!form.value.quantity || form.value.quantity <= 0) {
errors.value.quantity = 'Menge erforderlich'
isValid = false
}
// Profil nur validieren, wenn Auswahl nötig und möglich ist
if (!form.value.profile && data.value.profiles?.length > 0 && !props.context.meta.defaultProfileId) {
errors.value.profile = 'Bitte Mitarbeiter wählen'
isValid = false
}
// Feature: Agriculture
// Feature: Agriculture Diesel
if (config.value.features?.agriculture?.showDieselUsage && validationRules.requireDiesel) {
if (!form.value.dieselUsage || form.value.dieselUsage <= 0) {
errors.value.diesel = 'Dieselverbrauch erforderlich'
@@ -70,12 +78,10 @@ const submit = async () => {
try {
const payload = { ...form.value }
// Headers vorbereiten (PIN mitsenden!)
const headers = {}
if (props.pin) headers['x-public-pin'] = props.pin
// An den Submit-Endpunkt senden (den müssen wir im Backend noch bauen!)
await $fetch(`http://localhost:3100/workflows/submit/${props.token}`, {
await $fetch(`${runtimeConfig.public.apiBase}/workflows/submit/${props.token}`, {
method: 'POST',
body: payload,
headers
@@ -84,11 +90,18 @@ const submit = async () => {
emit('success')
} catch (e) {
console.error(e)
toast.add({ title: 'Fehler beim Speichern', color: 'red' })
toast.add({ title: 'Fehler beim Speichern', color: 'red', icon: 'i-heroicons-exclamation-triangle' })
} finally {
isSubmitting.value = false
}
}
// Hilfsfunktion für die dynamische Einheiten-Anzeige
const currentUnit = computed(() => {
if (!form.value.service) return data.value?.units?.[0]?.symbol || 'h'
const selectedService = data.value.services?.find(s => s.id === form.value.service)
return selectedService?.unitSymbol || data.value?.units?.[0]?.symbol || 'h'
})
</script>
<template>
@@ -102,6 +115,19 @@ const submit = async () => {
<div class="space-y-5">
<UFormGroup
label="Datum der Ausführung"
:error="errors.deliveryDate"
required
>
<UInput
v-model="form.deliveryDate"
type="date"
size="lg"
icon="i-heroicons-calendar-days"
/>
</UFormGroup>
<UFormGroup
v-if="!context?.meta?.defaultProfileId && data?.profiles?.length > 0"
label="Mitarbeiter"
@@ -115,13 +141,13 @@ const submit = async () => {
value-attribute="id"
placeholder="Name auswählen..."
searchable
searchable-placeholder="Suchen..."
size="lg"
/>
</UFormGroup>
<UFormGroup
v-if="data?.projects?.length > 0"
:label="config.ui?.labels?.project || 'Projekt'"
:label="config.ui?.labels?.project || 'Projekt / Auftrag'"
:error="errors.project"
required
>
@@ -132,12 +158,13 @@ const submit = async () => {
value-attribute="id"
placeholder="Wählen..."
searchable
size="lg"
/>
</UFormGroup>
<UFormGroup
v-if="data?.services?.length > 0"
:label="config?.ui?.labels?.service || 'Leistung'"
:label="config?.ui?.labels?.service || 'Tätigkeit'"
:error="errors.service"
required
>
@@ -147,36 +174,28 @@ const submit = async () => {
option-attribute="name"
value-attribute="id"
placeholder="Wählen..."
searchable
size="lg"
/>
</UFormGroup>
<div v-if="config?.features?.timeTracking?.allowManualTime" class="grid grid-cols-2 gap-3">
<UFormGroup label="Start">
<input
type="datetime-local"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-primary-600 sm:text-sm sm:leading-6 px-2"
:value="dayjs(form.startDate).format('YYYY-MM-DDTHH:mm')"
@input="e => form.startDate = new Date(e.target.value)"
/>
</UFormGroup>
<UFormGroup label="Dauer (Stunden)">
<input
<UFormGroup
label="Menge / Dauer"
:error="errors.quantity"
required
>
<UInput
v-model="form.quantity"
type="number"
step="0.25"
placeholder="z.B. 1.5"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-primary-600 sm:text-sm sm:leading-6 px-2"
@input="e => form.endDate = dayjs(form.startDate).add(parseFloat(e.target.value), 'hour').toDate()"
/>
size="lg"
placeholder="0.00"
>
<template #trailing>
<span class="text-gray-500 text-sm pr-2">{{ currentUnit }}</span>
</template>
</UInput>
</UFormGroup>
<UFormGroup label="Ende" class="col-span-2">
<input
type="datetime-local"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-primary-600 sm:text-sm sm:leading-6 px-2"
:value="dayjs(form.endDate).format('YYYY-MM-DDTHH:mm')"
@input="e => form.endDate = new Date(e.target.value)"
/>
</UFormGroup>
</div>
<UFormGroup
v-if="config?.features?.agriculture?.showDieselUsage"
@@ -184,15 +203,15 @@ const submit = async () => {
:error="errors.diesel"
:required="config?.validation?.requireDiesel"
>
<UInput v-model="form.dieselUsage" type="number" step="0.1" placeholder="0.0">
<UInput v-model="form.dieselUsage" type="number" step="0.1" placeholder="0.0" size="lg">
<template #trailing>
<span class="text-gray-500 text-xs">Liter</span>
</template>
</UInput>
</UFormGroup>
<UFormGroup :label="config?.ui?.labels?.description || 'Notiz'">
<UTextarea v-model="form.description" :rows="3" />
<UFormGroup :label="config?.ui?.labels?.description || 'Notiz / Vorkommnisse'">
<UTextarea v-model="form.description" :rows="3" placeholder="Optional..." />
</UFormGroup>
</div>

View File

@@ -2,7 +2,6 @@
const toast = useToast()
const dataStore = useDataStore()
const supabase = useSupabaseClient()
const modal = useModal()
const props = defineProps({
type: {
@@ -35,11 +34,10 @@ const item = ref({})
const setupPage = async () => {
if(props.mode === "show") {
//Load Data for Show
item.value = await useEntities(props.type).selectSingle(props.id, dataType.supabaseSelectWithInformation || "*")
item.value = await useEntities(props.type).selectSingle(props.id, dataType.selectWithInformation || "*")
} else if(props.mode === "edit") {
//Load Data for Edit
const data = JSON.stringify(await useEntities(props.type).selectSingle(props.id)/*(await supabase.from(props.type).select().eq("id", props.id).single()).data*/)
//await useSupabaseSelectSingle(type, route.params.id)
const data = JSON.stringify(await useEntities(props.type).selectSingle(props.id))
item.value = data
} else if(props.mode === "create") {
@@ -48,7 +46,7 @@ const setupPage = async () => {
} else if(props.mode === "list") {
//Load Data for List
items.value = await useEntities(props.type).select(dataType.supabaseSelectWithInformation || "*", dataType.supabaseSortColumn,dataType.supabaseSortAscending || false)
items.value = await useEntities(props.type).select(dataType.selectWithInformation || "*", dataType.sortColumn,dataType.sortAscending || false)
}
loaded.value = true

View File

@@ -3,11 +3,6 @@
const { isHelpSlideoverOpen } = useDashboard()
const { isDashboardSearchModalOpen } = useUIState()
const { metaSymbol } = useShortcuts()
const user = useSupabaseUser()
const dataStore = useDataStore()
const profileStore = useProfileStore()
const supabase = useSupabaseClient()
const router = useRouter()
const auth = useAuthStore()
const items = computed(() => [

View File

@@ -7,11 +7,9 @@ const props = defineProps({
}
})
const supabase = useSupabaseClient()
let inventoryitemgroups = await Promise.all(props.row.inventoryitemgroups.map(async (i) => {
return (await supabase.from("inventoryitemgroups").select("id,name").eq("id",i).single()).data.name
const group = await useEntities("inventoryitemgroups").selectSingle(i)
return group?.name
}))
</script>

View File

@@ -7,11 +7,9 @@ const props = defineProps({
}
})
const supabase = useSupabaseClient()
let inventoryitems = await Promise.all(props.row.inventoryitems.map(async (i) => {
return (await supabase.from("inventoryitems").select("id,name").eq("id",i).single()).data.name
const item = await useEntities("inventoryitems").selectSingle(i)
return item?.name
}))
</script>

View File

@@ -7,11 +7,9 @@ const props = defineProps({
}
})
const supabase = useSupabaseClient()
let vehicles = await Promise.all(props.row.vehicles.map(async (i) => {
return (await supabase.from("vehicles").select("id,licensePlate").eq("id",i).single()).data.licensePlate
const vehicle = await useEntities("vehicles").selectSingle(i)
return vehicle?.licensePlate
}))
</script>

View File

@@ -1,8 +1,4 @@
<script setup>
const supabase = useSupabaseClient()
const profileStore = useProfileStore()
const props = defineProps({
item: {
required: true,

View File

@@ -8,15 +8,12 @@ let incomeData = ref({})
let expenseData = ref({})
const setup = async () => {
//let incomeRawData = (await supabase.from("createddocuments").select().eq("tenant",profileStore.currentTenant).eq("state","Gebucht").in('type',['invoices','advanceInvoices','cancellationInvoices'])).data
let incomeRawData = (await useEntities("createddocuments").select()).filter(i => i.state === "Gebucht" && ['invoices','advanceInvoices','cancellationInvoices'].includes(i.type))
let incomeRawFilteredData = incomeRawData.filter(x => x.state === 'Gebucht' && incomeRawData.find(i => i.linkedDocument && i.linkedDocument.id === x.id && i.type === 'cancellationInvoices') && ['invoices','advanceInvoices'].includes(row.type))
//let expenseRawData =(await supabase.from("incominginvoices").select().eq("tenant",profileStore.currentTenant)).data
let expenseRawData =(await useEntities("incominginvoices").select())
//let withoutInvoiceRawData = (await supabase.from("statementallocations").select().eq("tenant",profileStore.currentTenant).not("account","is",null)).data
let withoutInvoiceRawData = (await useEntities("statementallocations").select()).filter(i => i.account)
let withoutInvoiceRawDataExpenses = []

View File

@@ -2,8 +2,8 @@
import dayjs from "dayjs";
const profileStore = useProfileStore();
const supabase = useSupabaseClient()
const toast = useToast()
const staffTime = useStaffTime()
const runningTimeInfo = ref({})
@@ -11,12 +11,9 @@ const projects = ref([])
const platform = ref("default")
const setupPage = async () => {
runningTimeInfo.value = (await supabase.from("times").select().eq("profile", profileStore.activeProfile.id).is("endDate", null).single()).data || {}
//projects.value = (await useSupabaseSelect("projects"))
const rows = await staffTime.list({ user_id: profileStore.activeProfile?.user_id || profileStore.activeProfile?.id })
runningTimeInfo.value = rows.find((r) => !r.stopped_at && r.type === "work") || {}
projects.value = await useEntities("projects").select("*")
}
setupPage()
@@ -26,47 +23,25 @@ setupPage()
}*/
const startTime = async () => {
console.log("started")
runningTimeInfo.value = {
profile: profileStore.activeProfile.id,
startDate: dayjs(),
tenant: profileStore.currentTenant,
state: platform.value === "mobile" ? "In der App gestartet" : "Im Web gestartet",
source: "Dashboard"
}
const {data,error} = await supabase
.from("times")
.insert([runningTimeInfo.value])
.select()
if(error) {
try {
await staffTime.start("Arbeitszeit")
toast.add({title: "Projektzeit erfolgreich gestartet"})
await setupPage()
} catch (error) {
console.log(error)
toast.add({title: "Fehler beim starten der Projektzeit",color:"rose"})
} else if(data) {
toast.add({title: "Projektzeit erfolgreich gestartet"})
runningTimeInfo.value = data[0]
//console.log(runningTimeInfo.value)
}
}
const stopStartedTime = async () => {
runningTimeInfo.value.endDate = dayjs()
runningTimeInfo.value.state = platform.value === "mobile" ? "In der App gestoppt" : "Im Web gestoppt"
const {error,status} = await supabase
.from("times")
.update(runningTimeInfo.value)
.eq('id',runningTimeInfo.value.id)
if(error) {
console.log(error)
let errorId = await useError().logError(`${status} - ${JSON.stringify(error)}`)
toast.add({title: errorId ? `Fehler beim stoppen der Projektzeit (Fehler ID: ${errorId})` : `Fehler beim stoppen der Projektzeit`,color:"rose"})
} else {
try {
await staffTime.stop()
toast.add({title: "Projektzeit erfolgreich gestoppt"})
runningTimeInfo.value = {}
} catch (error) {
console.log(error)
let errorId = await useError().logError(`${JSON.stringify(error)}`)
toast.add({title: errorId ? `Fehler beim stoppen der Projektzeit (Fehler ID: ${errorId})` : `Fehler beim stoppen der Projektzeit`,color:"rose"})
}
}
@@ -74,9 +49,9 @@ const stopStartedTime = async () => {
</script>
<template>
<div v-if="runningTimeInfo.startDate">
<p>Start: {{dayjs(runningTimeInfo.startDate).format("HH:mm")}}</p>
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') + ' min' }}</p>
<div v-if="runningTimeInfo.started_at">
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p>
<UFormGroup
class="mt-2"

View File

@@ -2,14 +2,14 @@
import dayjs from "dayjs";
const profileStore = useProfileStore();
const supabase = useSupabaseClient()
const toast = useToast()
const staffTime = useStaffTime()
const runningTimeInfo = ref({})
const setupPage = async () => {
runningTimeInfo.value = (await supabase.from("workingtimes").select().eq("profile", profileStore.activeProfile.id).is("endDate", null).single()).data || {}
console.log(runningTimeInfo.value)
const rows = await staffTime.list({ user_id: profileStore.activeProfile?.user_id || profileStore.activeProfile?.id })
runningTimeInfo.value = rows.find((r) => !r.stopped_at && r.type === "work") || {}
}
setupPage()
@@ -19,47 +19,25 @@ setupPage()
}*/
const startTime = async () => {
console.log("started")
runningTimeInfo.value = {
profile: profileStore.activeProfile.id,
startDate: dayjs(),
tenant: profileStore.currentTenant,
state: "Im Web gestartet",
source: "Dashboard"
}
const {data,error} = await supabase
.from("workingtimes")
.insert([runningTimeInfo.value])
.select()
if(error) {
try {
await staffTime.start("Arbeitszeit")
toast.add({title: "Anwesenheit erfolgreich gestartet"})
await setupPage()
} catch (error) {
console.log(error)
toast.add({title: "Fehler beim starten der Zeit",color:"rose"})
} else if(data) {
toast.add({title: "Anwesenheit erfolgreich gestartet"})
runningTimeInfo.value = data[0]
console.log(runningTimeInfo.value)
}
}
const stopStartedTime = async () => {
runningTimeInfo.value.endDate = dayjs()
runningTimeInfo.value.state = "Im Web gestoppt"
const {error,status} = await supabase
.from("workingtimes")
.update(runningTimeInfo.value)
.eq('id',runningTimeInfo.value.id)
if(error) {
console.log(error)
let errorId = await useError().logError(`${status} - ${JSON.stringify(error)}`)
toast.add({title: errorId ? `Fehler beim stoppen der Anwesenheit (Fehler ID: ${errorId})` : `Fehler beim stoppen der Anwesenheit`,color:"rose"})
} else {
try {
await staffTime.stop()
toast.add({title: "Anwesenheit erfolgreich gestoppt"})
runningTimeInfo.value = {}
} catch (error) {
console.log(error)
let errorId = await useError().logError(`${JSON.stringify(error)}`)
toast.add({title: errorId ? `Fehler beim stoppen der Anwesenheit (Fehler ID: ${errorId})` : `Fehler beim stoppen der Anwesenheit`,color:"rose"})
}
}
@@ -67,9 +45,9 @@ const stopStartedTime = async () => {
</script>
<template>
<div v-if="runningTimeInfo.startDate">
<p>Start: {{dayjs(runningTimeInfo.startDate).format("HH:mm")}}</p>
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') + ' min' }}</p>
<div v-if="runningTimeInfo.started_at">
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p>
<UFormGroup
class="mt-2"

View File

@@ -0,0 +1,247 @@
<template>
<div class="email-editor">
<div v-if="editor" class="toolbar">
<div class="toolbar-group">
<button
class="toolbar-btn"
:class="{ 'is-active': editor.isActive('bold') }"
type="button"
title="Fett"
@click="editor.chain().focus().toggleBold().run()"
>
B
</button>
<button
class="toolbar-btn italic"
:class="{ 'is-active': editor.isActive('italic') }"
type="button"
title="Kursiv"
@click="editor.chain().focus().toggleItalic().run()"
>
I
</button>
<button
class="toolbar-btn line-through"
:class="{ 'is-active': editor.isActive('strike') }"
type="button"
title="Durchgestrichen"
@click="editor.chain().focus().toggleStrike().run()"
>
S
</button>
</div>
<div class="toolbar-group">
<button
class="toolbar-btn"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
type="button"
title="Überschrift"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
>
H2
</button>
<button
class="toolbar-btn"
:class="{ 'is-active': editor.isActive('bulletList') }"
type="button"
title="Liste"
@click="editor.chain().focus().toggleBulletList().run()"
>
</button>
<button
class="toolbar-btn"
:class="{ 'is-active': editor.isActive('orderedList') }"
type="button"
title="Nummerierte Liste"
@click="editor.chain().focus().toggleOrderedList().run()"
>
1.
</button>
</div>
<div class="toolbar-group">
<button
class="toolbar-btn"
:class="{ 'is-active': editor.isActive('link') }"
type="button"
title="Link"
@click="setLink"
>
Link
</button>
<button
class="toolbar-btn"
type="button"
title="Link entfernen"
@click="unsetLink"
>
Unlink
</button>
</div>
</div>
<EditorContent :editor="editor" class="editor-content" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import Link from '@tiptap/extension-link'
const props = withDefaults(defineProps<{
preloadedContent?: string
}>(), {
preloadedContent: '',
})
const emit = defineEmits<{
updateContent: [payload: { json: Record<string, any>; html: string; text: string }]
}>()
const emitContent = () => {
if (!editor.value) return
emit('updateContent', {
json: editor.value.getJSON(),
html: editor.value.getHTML(),
text: editor.value.getText(),
})
}
const editor = useEditor({
content: props.preloadedContent,
extensions: [
StarterKit,
Placeholder.configure({ placeholder: 'E-Mail Inhalt eingeben...' }),
Link.configure({ openOnClick: false, autolink: true }),
],
editorProps: {
attributes: {
class: 'prosemirror-email',
},
},
onCreate: emitContent,
onUpdate: emitContent,
})
watch(
() => props.preloadedContent,
(value) => {
if (!editor.value) return
if (value === editor.value.getHTML()) return
editor.value.commands.setContent(value || '', false)
}
)
const setLink = () => {
if (!editor.value) return
const previousUrl = editor.value.getAttributes('link').href
const url = window.prompt('URL eingeben:', previousUrl)
if (url === null) return
if (!url.trim()) {
editor.value.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
const unsetLink = () => {
if (!editor.value) return
editor.value.chain().focus().extendMarkRange('link').unsetLink().run()
}
onBeforeUnmount(() => {
editor.value?.destroy()
})
</script>
<style scoped>
.email-editor {
border: 1px solid #69c350;
border-radius: 10px;
background: #fff;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem;
border-bottom: 1px solid #e5e7eb;
}
.toolbar-group {
display: flex;
gap: 0.25rem;
padding-right: 0.5rem;
margin-right: 0.25rem;
border-right: 1px solid #e5e7eb;
}
.toolbar-group:last-child {
border-right: 0;
margin-right: 0;
padding-right: 0;
}
.toolbar-btn {
min-width: 2rem;
height: 2rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: #fff;
color: #374151;
padding: 0 0.5rem;
font-size: 0.875rem;
}
.toolbar-btn:hover {
background: #f3f4f6;
}
.toolbar-btn.is-active {
background: #dcfce7;
border-color: #69c350;
color: #14532d;
}
.editor-content {
min-height: 320px;
padding: 1rem;
}
:deep(.prosemirror-email) {
outline: none;
min-height: 280px;
}
:deep(.prosemirror-email p) {
margin: 0.3rem 0;
}
:deep(.prosemirror-email ul) {
list-style-type: disc;
padding-left: 1.5rem;
margin: 0.4rem 0;
}
:deep(.prosemirror-email ol) {
list-style-type: decimal;
padding-left: 1.5rem;
margin: 0.4rem 0;
}
:deep(.prosemirror-email li) {
display: list-item;
margin: 0.2rem 0;
}
:deep(.prosemirror-email a) {
color: #2563eb;
text-decoration: underline;
}
</style>

View File

@@ -32,15 +32,7 @@ const default_data = {
const newProjectDescription = ref(data|| default_data.value);
const saveProjectDescription = async () => {
//Update Project Description
/*const {data:updateData,error:updateError} = await supabase
.from("projects")
.update({description: newProjectDescription.value})
.eq('id',currentProject.id)
.select()
console.log(updateData)
console.log(updateError)*/

View File

@@ -1,10 +1,20 @@
<script setup>
const supabase = useSupabaseClient()
const profileStore = useProfileStore()
const workingtimes = ref([])
const setupPage = async () => {
workingtimes.value = (await supabase.from("workingtimes").select().eq("tenant",profileStore.currentTenant).is("endDate",null)).data
const profiles = profileStore.profiles || []
const checks = await Promise.all(profiles.map(async (profile) => {
try {
const spans = await useNuxtApp().$api(`/api/staff/time/spans?targetUserId=${profile.user_id || profile.id}`)
const openSpan = (spans || []).find((s) => !s.endedAt && s.type === "work")
if (openSpan) return { profile: profile.id }
} catch (e) {
return null
}
return null
}))
workingtimes.value = checks.filter(Boolean)
}
setupPage()

View File

@@ -2,8 +2,6 @@
import { v4 as uuidv4 } from 'uuid';
const supabase = useSupabaseClient()
const props = defineProps({
item: {
type: Object,

View File

@@ -0,0 +1,27 @@
<template>
<node-view-wrapper as="li" class="flex flex-row items-start gap-2 mb-2 !p-0 !m-0 bg-transparent">
<label class="flex-none pt-[0.15rem] cursor-pointer select-none" contenteditable="false">
<input
type="checkbox"
:checked="node.attrs.checked"
@change="onChange"
class="w-4 h-4 text-primary-600 rounded border-gray-300 focus:ring-primary-500 cursor-pointer"
/>
</label>
<node-view-content class="flex-1 min-w-0 [&>p]:!m-0 [&>p]:!inline-block" />
</node-view-wrapper>
</template>
<script setup lang="ts">
import { NodeViewWrapper, NodeViewContent, nodeViewProps } from '@tiptap/vue-3'
const props = defineProps(nodeViewProps)
function onChange(event: Event) {
const target = event.target as HTMLInputElement
props.updateAttributes({
checked: target.checked,
})
}
</script>

View File

@@ -0,0 +1,144 @@
<template>
<div class="select-none text-sm text-gray-700 dark:text-gray-200 relative">
<div
class="group flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors mb-0.5 relative pr-8"
:class="[
isActive ? 'bg-primary-50 dark:bg-primary-900/10 text-primary-600 dark:text-primary-400 font-medium' : 'hover:bg-gray-100 dark:hover:bg-gray-800',
item.isVirtual ? 'opacity-90' : ''
]"
:style="{ paddingLeft: `${indent}rem` }"
@click.stop="handleClick"
>
<button
v-if="item.isFolder"
class="h-4 w-4 flex items-center justify-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-transform duration-200"
:class="{ 'rotate-90': isOpen }"
@click.stop="toggleFolder"
>
<UIcon name="i-heroicons-chevron-right-20-solid" class="w-3 h-3" />
</button>
<span v-else class="w-4"></span>
<span class="shrink-0 flex items-center">
<UIcon
v-if="item.isFolder && item.isVirtual"
name="i-heroicons-folder"
class="w-4 h-4 text-primary-500 dark:text-primary-400"
/>
<UIcon
v-else-if="item.isFolder"
name="i-heroicons-folder-solid"
class="w-4 h-4 text-yellow-400"
/>
<UIcon
v-else
name="i-heroicons-document-text"
class="w-4 h-4 text-gray-400"
/>
</span>
<span class="truncate flex-1" :class="{'italic text-gray-500': item.isVirtual && !item.parentId}">
{{ item.title }}
</span>
<div v-if="!item.isVirtual" class="absolute right-1 top-1 opacity-0 group-hover:opacity-100 transition-opacity" :class="{ '!opacity-100': showMenu }">
<button @click.stop="showMenu = !showMenu" class="p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-500">
<UIcon name="i-heroicons-ellipsis-horizontal" class="w-4 h-4" />
</button>
</div>
<div v-else class="absolute right-1 top-1 opacity-0 group-hover:opacity-50 transition-opacity flex items-center justify-center pt-0.5 pr-0.5">
<UTooltip text="Automatisch generiert">
<UIcon name="i-heroicons-lock-closed" class="w-3 h-3 text-gray-300" />
</UTooltip>
</div>
<div v-if="showMenu" v-on-click-outside="() => showMenu = false" class="absolute right-0 top-8 z-50 w-40 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-100 dark:border-gray-700 py-1 text-xs text-gray-700 dark:text-gray-200 animate-in fade-in slide-in-from-top-1 duration-100">
<template v-if="item.isFolder">
<button @click.stop="triggerCreate(false)" class="w-full text-left px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2">
<UIcon name="i-heroicons-plus" class="w-3 h-3" /> Neue Seite
</button>
<button @click.stop="triggerCreate(true)" class="w-full text-left px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2">
<UIcon name="i-heroicons-folder-plus" class="w-3 h-3 text-yellow-500" /> Neuer Ordner
</button>
<div class="h-px bg-gray-100 dark:bg-gray-700 my-1"></div>
</template>
<button @click.stop="triggerDelete" class="w-full text-left px-3 py-1.5 hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600 flex items-center gap-2">
<UIcon name="i-heroicons-trash" class="w-3 h-3" /> Löschen
</button>
</div>
</div>
<div v-if="item.isFolder && isOpen">
<WikiTreeItem v-for="child in item.children" :key="child.id" :item="child" :depth="depth + 1" />
<div v-if="!item.children?.length" class="text-xs text-gray-400 py-1 italic" :style="{ paddingLeft: `${indent + 1.8}rem` }">Leer</div>
</div>
</div>
</template>
<script setup lang="ts">
import { vOnClickOutside } from '@vueuse/components'
import type { WikiPageItem } from '~/composables/useWikiTree'
const props = defineProps<{ item: WikiPageItem; depth?: number }>()
const router = useRouter()
const route = useRoute()
const { searchQuery } = useWikiTree()
const openWikiAction = inject('openWikiAction') as (action: 'create' | 'delete', contextItem: WikiPageItem | null, isFolder?: boolean) => void
const depth = props.depth ?? 0
const indent = computed(() => 0.5 + (depth * 0.7))
const isOpen = ref(false)
const showMenu = ref(false)
const isActive = computed(() => route.params.id === props.item.id)
// Auto-Open: Active Page
watch(() => route.params.id, (newId) => {
if (props.item.isFolder && hasActiveChild(props.item, newId as string)) isOpen.value = true
}, { immediate: true })
// Auto-Open: Search
watch(searchQuery, (newVal) => {
if (newVal.trim().length > 0 && props.item.isFolder) {
isOpen.value = true
}
}, { immediate: true })
function hasActiveChild(node: WikiPageItem, targetId: string): boolean {
if (node.id === targetId) return true
return node.children?.some(c => hasActiveChild(c, targetId)) ?? false
}
function handleClick() {
if (props.item.isFolder) {
isOpen.value = !isOpen.value
} else {
// Falls es eine virtuelle Seite ist (Read only Entity View?), leiten wir trotzdem weiter
// oder verhindern es, je nach Wunsch. Aktuell erlauben wir Navigation:
router.push(`/wiki/${props.item.id}`)
}
}
function toggleFolder() { isOpen.value = !isOpen.value }
function triggerCreate(isFolder: boolean) {
// Sicherheitscheck: Virtuelle Ordner dürfen nichts erstellen
if (props.item.isVirtual) return
showMenu.value = false
isOpen.value = true
openWikiAction('create', props.item, isFolder)
}
function triggerDelete() {
// Sicherheitscheck
if (props.item.isVirtual) return
showMenu.value = false
openWikiAction('delete', props.item)
}
</script>

View File

@@ -0,0 +1,272 @@
<template>
<div class="flex flex-col h-full bg-white dark:bg-gray-900 relative">
<div v-if="editor" class="border-b border-gray-100 dark:border-gray-800 px-4 py-2 flex flex-wrap gap-1 items-center sticky top-0 bg-white dark:bg-gray-900 z-20 shadow-sm select-none">
<div class="flex gap-0.5 border-r border-gray-200 dark:border-gray-700 pr-2 mr-2">
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }" class="toolbar-btn font-bold" title="Fett">B</button>
<button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }" class="toolbar-btn italic" title="Kursiv">I</button>
<button @click="editor.chain().focus().toggleStrike().run()" :class="{ 'is-active': editor.isActive('strike') }" class="toolbar-btn line-through" title="Durchgestrichen">S</button>
<button @click="editor.chain().focus().toggleHighlight().run()" :class="{ 'is-active': editor.isActive('highlight') }" class="toolbar-btn text-yellow-500 font-bold" title="Markieren">M</button>
</div>
<div class="flex gap-0.5 border-r border-gray-200 dark:border-gray-700 pr-2 mr-2">
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }" class="toolbar-btn" title="Überschrift 1">H1</button>
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" class="toolbar-btn" title="Überschrift 2">H2</button>
<button @click="editor.chain().focus().toggleCodeBlock().run()" :class="{ 'is-active': editor.isActive('codeBlock') }" class="toolbar-btn font-mono text-xs" title="Code Block">&lt;/&gt;</button>
</div>
<div class="flex gap-0.5 border-r border-gray-200 dark:border-gray-700 pr-2 mr-2">
<button @click="editor.chain().focus().toggleBulletList().run()" :class="{ 'is-active': editor.isActive('bulletList') }" class="toolbar-btn" title="Liste"></button>
<button @click="editor.chain().focus().toggleTaskList().run()" :class="{ 'is-active': editor.isActive('taskList') }" class="toolbar-btn" title="Aufgabenliste"></button>
</div>
<div class="flex gap-0.5 items-center">
<button @click="editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()" class="toolbar-btn" title="Tabelle"></button>
<button @click="addVideo" class="toolbar-btn text-red-600" title="YouTube Video">
<UIcon name="i-heroicons-video-camera" class="w-4 h-4" />
</button>
</div>
</div>
<BubbleMenu
v-if="editor"
:editor="editor"
:tippy-options="{ duration: 200, placement: 'top-start', animation: 'scale' }"
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl flex items-center p-1 gap-1 z-50"
>
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }" class="bubble-btn font-bold">B</button>
<button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }" class="bubble-btn italic">I</button>
<button @click="editor.chain().focus().toggleHighlight().run()" :class="{ 'is-active': editor.isActive('highlight') }" class="bubble-btn text-yellow-500 font-bold">M</button>
<div class="w-px h-4 bg-gray-200 mx-1"></div>
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" class="bubble-btn font-bold text-sm">H2</button>
<div class="w-px h-4 bg-gray-200 mx-1"></div>
<button @click="setLink" :class="{ 'is-active': editor.isActive('link') }" class="bubble-btn text-sm">Link</button>
</BubbleMenu>
<FloatingMenu
v-if="editor"
:editor="editor"
:tippy-options="{ duration: 100, placement: 'right-start' }"
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg flex items-center p-1 gap-1 -ml-4"
>
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" class="bubble-btn text-xs font-bold">H1</button>
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" class="bubble-btn text-xs font-bold">H2</button>
<button @click="editor.chain().focus().toggleBulletList().run()" class="bubble-btn"></button>
<button @click="editor.chain().focus().toggleTaskList().run()" class="bubble-btn"></button>
<button @click="editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()" class="bubble-btn"></button>
<button @click="editor.chain().focus().toggleCodeBlock().run()" class="bubble-btn font-mono text-xs">&lt;&gt;</button>
</FloatingMenu>
<editor-content
:editor="editor"
class="flex-1 overflow-y-auto px-8 py-6 prose prose-slate dark:prose-invert max-w-none focus:outline-none custom-editor-area"
/>
<div v-if="editor" class="border-t border-gray-100 dark:border-gray-800 px-4 py-1 text-xs text-gray-400 flex justify-end bg-gray-50 dark:bg-gray-900/50">
{{ editor.storage.characterCount.words() }} Wörter
</div>
</div>
</template>
<script setup lang="ts">
import { watch } from 'vue'
import { useRouter } from '#app'
import { useEditor, EditorContent, VueNodeViewRenderer } from '@tiptap/vue-3'
// @ts-ignore
import { BubbleMenu, FloatingMenu } from '@tiptap/vue-3/menus'
// Tiptap Extensions
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import Link from '@tiptap/extension-link'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import Image from '@tiptap/extension-image'
import Highlight from '@tiptap/extension-highlight'
import Youtube from '@tiptap/extension-youtube'
import Typography from '@tiptap/extension-typography'
import CharacterCount from '@tiptap/extension-character-count'
import CodeBlock from '@tiptap/extension-code-block'
// -----------------------------------------------------------
// WICHTIGE ÄNDERUNG: NUR Table, KEINE Row/Cell/Header Imports
// -----------------------------------------------------------
import { Table } from '@tiptap/extension-table'
import { TableRow } from '@tiptap/extension-table-row'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import BubbleMenuExtension from '@tiptap/extension-bubble-menu'
import FloatingMenuExtension from '@tiptap/extension-floating-menu'
import Mention from '@tiptap/extension-mention'
import TiptapTaskItem from './TiptapTaskItem.vue'
import wikiSuggestion from '~/composables/useWikiSuggestion'
const props = defineProps<{ modelValue: any }>()
const emit = defineEmits(['update:modelValue'])
const router = useRouter()
// Damit wir TaskItem nicht ständig neu initialisieren
const CustomTaskItem = TaskItem.extend({
addNodeView() {
return VueNodeViewRenderer(TiptapTaskItem)
},
})
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit.configure({ codeBlock: false }),
Placeholder.configure({ placeholder: 'Tippe "/" für Befehle oder "#" für Seiten...' }),
BubbleMenuExtension.configure({ shouldShow: ({ editor, from, to }) => !editor.state.selection.empty && (to - from > 0) }),
FloatingMenuExtension,
Link.configure({ openOnClick: false, autolink: true }),
Image, Highlight, Typography, CharacterCount, CodeBlock,
Youtube.configure({ width: 640, height: 480 }),
// -----------------------------------------------------------
// WICHTIGE ÄNDERUNG: Nur Table.configure()
// TableRow, TableHeader, TableCell wurden HIER ENTFERNT
// -----------------------------------------------------------
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
// TASK LIST
TaskList,
CustomTaskItem.configure({
nested: true,
}),
// INTERNAL LINKING
Mention.configure({
HTMLAttributes: {
class: 'wiki-mention',
},
suggestion: {
...wikiSuggestion,
char: '#'
},
}),
],
editorProps: {
attributes: {
class: 'min-h-[500px] pb-40',
},
handleClick: (view, pos, event) => {
const target = event.target as HTMLElement
if (target.closest('.wiki-mention')) {
const id = target.getAttribute('data-id')
if (id) {
router.push(`/wiki/${id}`)
return true
}
}
return false
}
},
onUpdate: ({ editor }) => {
emit('update:modelValue', editor.getJSON())
},
})
// Sync Model -> Editor
watch(() => props.modelValue, (val) => {
if (editor.value && JSON.stringify(val) !== JSON.stringify(editor.value.getJSON())) {
//@ts-ignore
editor.value.commands.setContent(val, false)
}
})
// ACTIONS
const setLink = () => {
if (!editor.value) return
const previousUrl = editor.value.getAttributes('link').href
const url = window.prompt('URL eingeben:', previousUrl)
if (url === null) return
if (url === '') {
editor.value.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
const addVideo = () => {
const url = prompt('Video URL:')
if (url && editor.value) editor.value.commands.setYoutubeVideo({ src: url })
}
</script>
<style scoped>
/* Toolbar & Buttons */
.toolbar-btn {
@apply p-1.5 rounded text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm font-medium min-w-[28px] h-[28px] flex items-center justify-center;
}
.toolbar-btn.is-active {
@apply bg-gray-200 dark:bg-gray-600 text-black dark:text-white shadow-inner;
}
.bubble-btn {
@apply px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors text-sm min-w-[24px] h-[24px] flex items-center justify-center text-gray-700 dark:text-gray-200;
}
.bubble-btn.is-active {
@apply bg-gray-200 dark:bg-gray-600 text-black dark:text-white;
}
/* GLOBAL EDITOR STYLES */
:deep(.prose) {
.ProseMirror {
outline: none;
caret-color: currentColor;
}
/* LIST RESET */
ul[data-type="taskList"] {
list-style: none;
padding: 0;
margin: 0;
}
/* MENTION */
.wiki-mention {
/* Pill-Shape, grau/neutral statt knallig blau */
@apply bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 px-1.5 py-0.5 rounded-md text-sm font-medium no-underline inline-block mx-0.5 align-middle border border-gray-200 dark:border-gray-700;
box-decoration-break: clone;
}
.wiki-mention::before {
@apply text-gray-400 dark:text-gray-500 mr-0.5;
}
.wiki-mention:hover {
@apply bg-primary-50 dark:bg-primary-900/30 border-primary-200 dark:border-primary-800 text-primary-700 dark:text-primary-400;
cursor: pointer;
}
/* TABLE */
table { width: 100% !important; border-collapse: collapse; table-layout: fixed; margin: 1rem 0; }
th, td { border: 1px solid #d1d5db; padding: 0.5rem; vertical-align: top; box-sizing: border-box; min-width: 1em; }
.dark th, .dark td { border-color: #374151; }
th { background-color: #f3f4f6; font-weight: 600; text-align: left; }
.dark th { background-color: #1f2937; }
.column-resize-handle { background-color: #3b82f6; width: 4px; }
/* CODE */
pre { background: #0d1117; color: #c9d1d9; font-family: 'JetBrains Mono', monospace; padding: 0.75rem 1rem; border-radius: 0.5rem; margin: 1rem 0; overflow-x: auto; }
code { color: inherit; padding: 0; background: none; font-size: 0.9rem; }
/* IMG */
img { max-width: 100%; height: auto; border-radius: 6px; display: block; margin: 1rem 0; }
/* MISC */
p.is-editor-empty:first-child::before { color: #9ca3af; content: attr(data-placeholder); float: left; height: 0; pointer-events: none; }
mark { background-color: #fef08a; padding: 0.1rem 0.2rem; border-radius: 0.2rem; }
.ProseMirror-selectednode { outline: 2px solid #3b82f6; }
}
</style>

View File

@@ -0,0 +1,236 @@
<template>
<div class="flex h-full w-full min-h-[500px] border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 shadow-sm relative isolate overflow-hidden">
<div
class="flex flex-col border-r border-gray-200 dark:border-gray-800 bg-gray-50/80 dark:bg-gray-900/50 transition-all duration-300"
:class="selectedPage ? 'w-64 hidden md:flex shrink-0' : 'w-full'"
>
<div class="flex items-center justify-between px-3 py-3 border-b border-gray-200 dark:border-gray-800 shrink-0">
<span class="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-1">
<UIcon name="i-heroicons-list-bullet" class="w-3 h-3" />
Inhalte
</span>
<UTooltip text="Neue Notiz erstellen">
<UButton size="2xs" color="primary" variant="ghost" icon="i-heroicons-plus" @click="isCreateModalOpen = true" />
</UTooltip>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-1 custom-scrollbar">
<div v-if="loadingList" class="space-y-2 pt-2">
<USkeleton class="h-8 w-full" />
<USkeleton class="h-8 w-full" />
</div>
<template v-else-if="pages.length">
<div
v-for="page in pages"
:key="page.id"
class="flex items-center gap-2 px-2.5 py-2 rounded-md cursor-pointer text-sm transition-all border border-transparent"
:class="selectedPage?.id === page.id ? 'bg-white dark:bg-gray-800 text-primary-600 shadow-sm border-gray-200 dark:border-gray-700 font-medium' : 'text-gray-600 hover:bg-white dark:hover:bg-gray-800 hover:shadow-sm'"
@click="selectPage(page.id)"
>
<UIcon :name="page.isFolder ? 'i-heroicons-folder' : 'i-heroicons-document-text'" class="w-4 h-4 shrink-0" />
<span class="truncate flex-1">{{ page.title }}</span>
</div>
</template>
<div v-else class="flex flex-col items-center justify-center py-12 px-4 text-center">
<p class="text-xs text-gray-500">Keine Notizen vorhanden.</p>
<UButton variant="link" size="xs" color="primary" @click="isCreateModalOpen = true" class="mt-1">Erstellen</UButton>
</div>
</div>
</div>
<div class="flex-1 flex flex-col bg-white dark:bg-gray-900 h-full relative min-w-0 w-full overflow-hidden">
<div v-if="selectedPage" class="flex flex-col h-full w-full">
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800 z-10 shrink-0 bg-white dark:bg-gray-900">
<div class="flex items-center gap-2 flex-1 min-w-0">
<UButton icon="i-heroicons-arrow-left" variant="ghost" color="gray" class="md:hidden shrink-0" @click="selectedPage = null" />
<input
v-model="selectedPage.title"
@input="onInputTitle"
class="bg-transparent font-semibold text-gray-900 dark:text-white focus:outline-none w-full truncate"
placeholder="Titel..."
/>
</div>
<div class="flex items-center gap-2 text-xs text-gray-400 shrink-0 ml-2">
<span class="hidden sm:inline">{{ isSaving ? 'Speichert...' : 'Gespeichert' }}</span>
<UButton icon="i-heroicons-trash" color="gray" variant="ghost" size="xs" @click="deleteCurrentPage"/>
</div>
</div>
<div class="flex-1 overflow-hidden relative w-full">
<div v-if="loadingContent" class="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-900/80 z-20">
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin text-primary-500" />
</div>
<WikiEditor
v-model="selectedPage.content"
class="h-full w-full"
:page-id="selectedPage.id"
/>
</div>
</div>
<div v-else class="hidden md:flex flex-1 flex-col items-center justify-center w-full h-full bg-gray-50/30 dark:bg-gray-900 p-6 text-center">
<div class="bg-white dark:bg-gray-800 p-4 rounded-full shadow-sm ring-1 ring-gray-100 dark:ring-gray-700 mb-4">
<UIcon name="i-heroicons-book-open" class="w-10 h-10 text-primary-200 dark:text-primary-800" />
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Wiki & Dokumentation
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto mb-6">
Wähle links eine Notiz aus oder erstelle einen neuen Eintrag für diesen Datensatz.
</p>
<UButton
icon="i-heroicons-plus"
color="primary"
@click="isCreateModalOpen = true"
>
Neue Seite erstellen
</UButton>
</div>
</div>
<UModal v-model="isCreateModalOpen">
<div class="p-5">
<h3 class="font-bold mb-4">Neue Seite</h3>
<form @submit.prevent="createPage">
<UInput v-model="newTitle" placeholder="Titel..." autofocus />
<div class="mt-4 flex justify-end gap-2">
<UButton color="gray" variant="ghost" @click="isCreateModalOpen = false">Abbrechen</UButton>
<UButton type="submit" color="primary" :loading="isCreating">Erstellen</UButton>
</div>
</form>
</div>
</UModal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import WikiEditor from '~/components/wiki/WikiEditor.vue'
const props = defineProps<{
entityType: string
entityId?: number | null
entityUuid?: string | null
}>()
const { $api } = useNuxtApp()
const toast = useToast()
const loadingList = ref(false)
const loadingContent = ref(false)
const isCreateModalOpen = ref(false)
const isCreating = ref(false)
const isSaving = ref(false)
const pages = ref<any[]>([])
const selectedPage = ref<any>(null)
const newTitle = ref('')
let saveTimeout: any = null
const getParams = () => {
const p: any = { entityType: props.entityType }
if (props.entityId) p.entityId = props.entityId
else if (props.entityUuid) p.entityUuid = props.entityUuid
return p
}
async function fetchList() {
loadingList.value = true
try {
const data = await $api('/api/wiki/tree', { method: 'GET', params: getParams() })
pages.value = data
} catch (e) {
console.error("Fetch Error:", e)
} finally {
loadingList.value = false
}
}
async function selectPage(id: string) {
if (selectedPage.value?.id === id) return
loadingContent.value = true
try {
const data = await $api(`/api/wiki/${id}`, { method: 'GET' })
selectedPage.value = data
} catch (e) {
toast.add({ title: 'Fehler beim Laden', color: 'red' })
} finally {
loadingContent.value = false
}
}
function triggerSave() {
if (saveTimeout) clearTimeout(saveTimeout)
isSaving.value = true
saveTimeout = setTimeout(async () => {
if (!selectedPage.value) return
try {
await $api(`/api/wiki/${selectedPage.value.id}`, {
method: 'PATCH',
body: {
title: selectedPage.value.title,
content: selectedPage.value.content
}
})
const item = pages.value.find(p => p.id === selectedPage.value.id)
if (item) item.title = selectedPage.value.title
} catch (e) {
console.error(e)
} finally {
isSaving.value = false
}
}, 1000)
}
function onInputTitle() {
triggerSave()
}
watch(() => selectedPage.value?.content, (newVal, oldVal) => {
if (newVal && oldVal && !loadingContent.value) triggerSave()
}, { deep: true })
async function createPage() {
if (!newTitle.value) return
isCreating.value = true
try {
const res = await $api('/api/wiki', {
method: 'POST',
body: { title: newTitle.value, parentId: null, isFolder: false, ...getParams() }
})
await fetchList()
newTitle.value = ''
isCreateModalOpen.value = false
await selectPage(res.id)
} catch (e) {
console.error(e)
} finally {
isCreating.value = false
}
}
async function deleteCurrentPage() {
if(!confirm('Löschen?')) return
await $api(`/api/wiki/${selectedPage.value.id}`, { method: 'DELETE' })
selectedPage.value = null
fetchList()
}
onMounted(fetchList)
watch(() => [props.entityId, props.entityUuid], fetchList)
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background-color: #ddd; border-radius: 4px; }
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl overflow-hidden min-w-[12rem] flex flex-col p-1 gap-0.5">
<template v-if="items.length">
<button
v-for="(item, index) in items"
:key="item.id"
class="flex items-center gap-2 px-2 py-1.5 text-sm rounded text-left transition-colors w-full"
:class="{ 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-400': index === selectedIndex, 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200': index !== selectedIndex }"
@click="selectItem(index)"
>
<UIcon :name="item.isFolder ? 'i-heroicons-folder' : 'i-heroicons-document-text'" class="w-4 h-4 text-gray-400" />
<span class="truncate">{{ item.title }}</span>
</button>
</template>
<div v-else class="px-3 py-2 text-xs text-gray-400 text-center">
Keine Seite gefunden
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps({
items: { type: Array, required: true },
command: { type: Function, required: true },
})
const selectedIndex = ref(0)
// Wenn sich die Liste ändert, Reset Selection
watch(() => props.items, () => { selectedIndex.value = 0 })
function selectItem(index: number) {
const item = props.items[index]
if (item) props.command({ id: item.id, label: item.title })
}
function onKeyDown({ event }: { event: KeyboardEvent }) {
if (event.key === 'ArrowUp') {
selectedIndex.value = (selectedIndex.value + props.items.length - 1) % props.items.length
return true
}
if (event.key === 'ArrowDown') {
selectedIndex.value = (selectedIndex.value + 1) % props.items.length
return true
}
if (event.key === 'Enter') {
selectItem(selectedIndex.value)
return true
}
return false
}
// Expose für Tiptap Render Logic
defineExpose({ onKeyDown })
</script>

View File

@@ -1,25 +0,0 @@
export const useErrorLogging = (resourceType) => {
const supabase = useSupabaseClient()
const toast = useToast()
const profileStore = useProfileStore()
const logError = async (error) => {
let errorData = {
message: error,
tenant: profileStore.currentTenant,
profile: profileStore.activeProfile.id
}
const {data:supabaseData,error:supabaseError} = await supabase.from("errors").insert(errorData).select().single()
if(supabaseError) {
console.error(supabaseError)
} else if(supabaseData) {
return supabaseData.id
}
}
return { logError}
}

View File

@@ -1,6 +1,5 @@
export const useFiles = () => {
const supabase = useSupabaseClient()
const toast = useToast()
const auth = useAuthStore()

View File

@@ -4,7 +4,6 @@ import dayjs from "dayjs";
const baseURL = /*"http://192.168.1.129:3333"*/ /*"http://localhost:3333"*/ "https://functions.fedeo.io"
export const useFunctions = () => {
const supabase = useSupabaseClient()
const getWorkingTimesEvaluationData = async (user_id, startDate, endDate) => {
// Der neue Endpunkt ist /staff/time/evaluation und erwartet die Benutzer-ID als targetUserId Query-Parameter.
@@ -30,26 +29,6 @@ export const useFunctions = () => {
}
const useCreateTicket = async (subject,message,url,source) => {
const {data:{session:{access_token}}} = await supabase.auth.getSession()
const {data} = await axios({
method: "POST",
url: `${baseURL}/functions/createticket`,
data: {
subject,
message,
source,
url
},
headers: {
Authorization: `Bearer ${access_token}`
}
})
return !!data.ticket_created;
}
const useBankingGenerateLink = async (institutionId) => {
return (await useNuxtApp().$api(`/api/banking/link/${institutionId}`)).link
@@ -81,46 +60,8 @@ export const useFunctions = () => {
const useGetInvoiceData = async (file) => {
const {data:{session:{access_token}}} = await supabase.auth.getSession()
const {data} = await axios({
method: "POST",
url: `${baseURL}/functions/getinvoicedatafromgpt`,
data: {
file
},
headers: {
Authorization: `Bearer ${access_token}`
}
})
console.log(data)
return data
}
const useSendTelegramNotification = async (message) => {
const {data:{session:{access_token}}} = await supabase.auth.getSession()
const {data,error} = await axios({
method: "POST",
url: `${baseURL}/functions/sendtelegramnotification`,
data: {
message: message
},
headers: {
Authorization: `Bearer ${access_token}`
}
})
if(error){
} else {
return true
}
}
const useBankingCheckInstitutions = async (bic) => {

View File

@@ -1,61 +0,0 @@
export const useNumberRange = (resourceType) => {
const supabase = useSupabaseClient()
const dataStore = useDataStore()
const profileStore = useProfileStore()
const numberRanges = profileStore.ownTenant.numberRanges
const numberRange = numberRanges[resourceType]
const useNextNumber = async () => {
let nextNumber = numberRange.nextNumber
let newNumberRanges = numberRanges
newNumberRanges[resourceType].nextNumber += 1
const {data,error} = await supabase
.from("tenants")
.update({numberRanges: newNumberRanges})
.eq('id',profileStore.currentTenant)
await profileStore.fetchOwnTenant()
return (numberRange.prefix ? numberRange.prefix : "") + nextNumber + (numberRange.suffix ? numberRange.suffix : "")
}
return { useNextNumber}
}
/*export const useNumberRange = (resourceType) => {
const supabase = useSupabaseClient()
const {numberRanges} = storeToRefs(useDataStore())
const {fetchNumberRanges} = useDataStore()
const numberRange = numberRanges.value.find(range => range.resourceType === resourceType)
const useNextNumber = async () => {
let nextNumber = numberRange.nextNumber
const {data,error} = await supabase
.from("numberranges")
.update({nextNumber: nextNumber + 1})
.eq('id',numberRange.id)
fetchNumberRanges()
return (numberRange.prefix ? numberRange.prefix : "") + nextNumber + (numberRange.suffix ? numberRange.suffix : "")
}
return { useNextNumber}
}*/

View File

@@ -1,27 +0,0 @@
import Handlebars from "handlebars";
export const usePrintLabel = async (printServerId,printerName , rawZPL ) => {
const supabase = useSupabaseClient()
const dataStore = useDataStore()
const profileStore = useProfileStore()
await supabase.from("printJobs").insert({
tenant: profileStore.currentTenant,
rawContent: rawZPL,
printerName: printerName,
printServer: printServerId
})
}
export const useGenerateZPL = (rawZPL,data) => {
let template = Handlebars.compile(rawZPL)
return template(data)
}

View File

@@ -41,18 +41,23 @@ export const useStaffTime = () => {
* aber wir nutzen dafür besser die createEntry Funktion unten.
*/
const start = async (description = "Arbeitszeit", time?: string) => {
console.log(auth.user)
await $api('/api/staff/time/event', {
method: 'POST',
body: {
eventtype: 'work_start',
eventtime: time || new Date().toISOString(), // 💡 Fix: Zeit akzeptieren
payload: { description }
payload: { description },
user_id: auth.user?.id
}
})
}
const stop = async () => {
await $api('/api/staff/time/event', { method: 'POST', body: { eventtype: 'work_end', eventtime: new Date().toISOString() } })
await $api('/api/staff/time/event', { method: 'POST', body: { eventtype: 'work_end', eventtime: new Date().toISOString(),
user_id: auth.user?.id } })
}
const submit = async (entry: any) => {

View File

@@ -1,8 +1,5 @@
export const useSum = () => {
const supabase = useSupabaseClient()
const getIncomingInvoiceSum = (invoice) => {
let sum = 0
invoice.accounts.forEach(account => {

View File

@@ -0,0 +1,65 @@
import { VueRenderer } from '@tiptap/vue-3'
import tippy from 'tippy.js'
import WikiPageList from '~/components/wiki/WikiPageList.vue'
// Wir brauchen Zugriff auf die rohen Items aus useWikiTree
// Da wir hier ausserhalb von setup() sind, müssen wir den State direkt holen oder übergeben.
// Einfacher: Wir nutzen useNuxtApp() oder übergeben die Items in der Config.
export default {
items: ({ query }: { query: string }) => {
// 1. Zugriff auf unsere Wiki Items
const { items } = useWikiTree()
// 2. Filtern
const allItems = items.value || []
return allItems
.filter(item => item.title.toLowerCase().includes(query.toLowerCase()))
.slice(0, 10) // Max 10 Vorschläge
},
render: () => {
let component: any
let popup: any
return {
onStart: (props: any) => {
component = new VueRenderer(WikiPageList, {
props,
editor: props.editor,
})
if (!props.clientRect) return
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
onUpdate(props: any) {
component.updateProps(props)
if (!props.clientRect) return
popup[0].setProps({ getReferenceClientRect: props.clientRect })
},
onKeyDown(props: any) {
if (props.event.key === 'Escape') {
popup[0].hide()
return true
}
return component.ref?.onKeyDown(props)
},
onExit() {
popup[0].destroy()
component.destroy()
},
}
},
}

View File

@@ -0,0 +1,128 @@
export interface WikiPageItem {
id: string
parentId: string | null
title: string
isFolder: boolean
sortOrder: number
entityType?: string | null
children?: WikiPageItem[]
}
export const useWikiTree = () => {
const { $api } = useNuxtApp()
// STATE
const items = useState<WikiPageItem[]>('wiki-items', () => [])
const isLoading = useState<boolean>('wiki-loading', () => false)
const isSidebarOpen = useState<boolean>('wiki-sidebar-open', () => true)
// NEU: Suchbegriff State
const searchQuery = useState<string>('wiki-search-query', () => '')
// 1. Basis-Baum bauen (Hierarchie & Sortierung)
const baseTree = computed(() => {
const rawItems = items.value || []
if (!rawItems.length) return []
const roots: WikiPageItem[] = []
const lookup: Record<string, WikiPageItem> = {}
// Init Lookup (Shallow Copy um Originaldaten nicht zu mutieren)
rawItems.forEach(item => {
lookup[item.id] = { ...item, children: [] }
})
// Build Hierarchy
rawItems.forEach(item => {
const node = lookup[item.id]
if (item.parentId && lookup[item.parentId]) {
lookup[item.parentId].children?.push(node)
} else {
roots.push(node)
}
})
// Sort Helper
const sortNodes = (nodes: WikiPageItem[]) => {
nodes.sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1
return a.title.localeCompare(b.title)
})
nodes.forEach(n => {
if (n.children?.length) sortNodes(n.children)
})
}
sortNodes(roots)
return roots
})
// 2. NEU: Gefilterter Baum (basiert auf baseTree + searchQuery)
const filteredTree = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
// Wenn keine Suche: Gib originalen Baum zurück
if (!query) return baseTree.value
// Rekursive Filterfunktion
const filterNodes = (nodes: WikiPageItem[]): WikiPageItem[] => {
return nodes.reduce((acc: WikiPageItem[], node) => {
// Matcht der Knoten selbst?
const matchesSelf = node.title.toLowerCase().includes(query)
// Matchen Kinder? (Rekursion)
const filteredChildren = node.children ? filterNodes(node.children) : []
// Wenn selbst matcht ODER Kinder matchen -> behalten
if (matchesSelf || filteredChildren.length > 0) {
// Wir erstellen eine Kopie des Knotens mit den gefilterten Kindern
acc.push({
...node,
children: filteredChildren
})
}
return acc
}, [])
}
return filterNodes(baseTree.value)
})
// ACTIONS
const loadTree = async () => {
isLoading.value = true
try {
const data = await $api<WikiPageItem[]>('/api/wiki/tree', { method: 'GET' })
items.value = data
} catch (e) { console.error(e) }
finally { isLoading.value = false }
}
const createItem = async (title: string, parentId: string | null, isFolder: boolean) => {
try {
const newItem = await $api('/api/wiki', { method: 'POST', body: { title, parentId, isFolder } })
await loadTree()
return newItem
} catch (e) { throw e }
}
const deleteItem = async (id: string) => {
try {
await $api(`/api/wiki/${id}`, { method: 'DELETE' })
await loadTree()
return true
} catch (e) { throw e }
}
return {
tree: filteredTree, // Wir geben jetzt immer den (evtl. gefilterten) Baum zurück
searchQuery, // Damit die UI das Input-Feld binden kann
items,
isLoading,
isSidebarOpen,
loadTree,
createItem,
deleteItem
}
}

View File

@@ -1,11 +1,10 @@
<script setup>
import MainNav from "~/components/MainNav.vue";
import dayjs from "dayjs";
import GlobalMessages from "~/components/GlobalMessages.vue";
import TenantDropdown from "~/components/TenantDropdown.vue";
import LabelPrinterButton from "~/components/LabelPrinterButton.vue";
import {useCalculatorStore} from '~/stores/calculator'
const dataStore = useDataStore()
const colorMode = useColorMode()
@@ -15,7 +14,7 @@ const route = useRoute()
const auth = useAuthStore()
const labelPrinter = useLabelPrinterStore()
const calculatorStore = useCalculatorStore()
const month = dayjs().format("MM")
@@ -64,7 +63,6 @@ const actions = [
}
]
const groups = computed(() => [
{
key: 'actions',
@@ -72,43 +70,61 @@ const groups = computed(() => [
}, {
key: "customers",
label: "Kunden",
commands: dataStore.customers.map(item => { return {id: item.id, label: item.name, to: `/customers/show/${item.id}`}})
commands: dataStore.customers.map(item => {
return {id: item.id, label: item.name, to: `/customers/show/${item.id}`}
})
}, {
key: "vendors",
label: "Lieferanten",
commands: dataStore.vendors.map(item => { return {id: item.id, label: item.name, to: `/vendors/show/${item.id}`}})
commands: dataStore.vendors.map(item => {
return {id: item.id, label: item.name, to: `/vendors/show/${item.id}`}
})
}, {
key: "contacts",
label: "Ansprechpartner",
commands: dataStore.contacts.map(item => { return {id: item.id, label: item.fullName, to: `/contacts/show/${item.id}`}})
commands: dataStore.contacts.map(item => {
return {id: item.id, label: item.fullName, to: `/contacts/show/${item.id}`}
})
}, {
key: "products",
label: "Artikel",
commands: dataStore.products.map(item => { return {id: item.id, label: item.name, to: `/products/show/${item.id}`}})
commands: dataStore.products.map(item => {
return {id: item.id, label: item.name, to: `/products/show/${item.id}`}
})
}, {
key: "tasks",
label: "Aufgaben",
commands: dataStore.tasks.map(item => { return {id: item.id, label: item.name, to: `/tasks/show/${item.id}`}})
commands: dataStore.tasks.map(item => {
return {id: item.id, label: item.name, to: `/tasks/show/${item.id}`}
})
}, {
key: "plants",
label: "Objekte",
commands: dataStore.plants.map(item => { return {id: item.id, label: item.name, to: `/plants/show/${item.id}`}})
commands: dataStore.plants.map(item => {
return {id: item.id, label: item.name, to: `/plants/show/${item.id}`}
})
}, {
key: "projects",
label: "Projekte",
commands: dataStore.projects.map(item => { return {id: item.id, label: item.name, to: `/projects/show/${item.id}`}})
commands: dataStore.projects.map(item => {
return {id: item.id, label: item.name, to: `/projects/show/${item.id}`}
})
}
].filter(Boolean))
const footerLinks = [
/*{
label: 'Invite people',
icon: 'i-heroicons-plus',
to: '/settings/members'
}, */{
// --- Footer Links nutzen jetzt den zentralen Calculator Store ---
const footerLinks = computed(() => [
{
label: 'Taschenrechner',
icon: 'i-heroicons-calculator',
click: () => calculatorStore.toggle()
},
{
label: 'Hilfe & Info',
icon: 'i-heroicons-question-mark-circle',
click: () => isHelpSlideoverOpen.value = true
}]
}
])
</script>
@@ -137,17 +153,17 @@ const footerLinks = [
Wartungsarbeiten
</h1>
<p class="text-gray-600 dark:text-gray-300 mb-8">
Dieser FEDEO Mandant wird derzeit gewartet. Bitte versuche es in einigen Minuten erneut oder verwende einen anderen Mandanten.
Dieser FEDEO Mandant wird derzeit gewartet. Bitte versuche es in einigen Minuten erneut oder verwende einen
anderen Mandanten.
</p>
<div class="mx-auto text-left flex flex-row justify-between my-3" v-for="tenant in auth.tenants">
{{ tenant.name }}
<UButton
:disabled="tenant.locked"
@click="auth.switchTenant(tenant.id)"
>Wählen</UButton>
>Wählen
</UButton>
</div>
</UCard>
</UContainer>
</div>
@@ -176,8 +192,6 @@ const footerLinks = [
<p class="text-gray-600 dark:text-gray-300 mb-8">
FEDEO wird derzeit gewartet. Bitte versuche es in einigen Minuten erneut.
</p>
</UCard>
</UContainer>
</div>
@@ -204,23 +218,24 @@ const footerLinks = [
Kein Aktives Abonnement für diesen Mandant.
</h1>
<p class="text-gray-600 dark:text-gray-300 mb-8">
Bitte wenden Sie sich an den FEDEO Support um ein Abonnement zu erhalten oder verwenden Sie einen anderen Mandanten.
Bitte wenden Sie sich an den FEDEO Support um ein Abonnement zu erhalten oder verwenden Sie einen anderen
Mandanten.
</p>
<div class="mx-auto text-left flex flex-row justify-between my-3" v-for="tenant in auth.tenants">
{{ tenant.name }}
<UButton
:disabled="tenant.locked"
@click="auth.switchTenant(tenant.id)"
>Wählen</UButton>
>Wählen
</UButton>
</div>
</UCard>
</UContainer>
</div>
<UDashboardLayout class="safearea" v-else>
<UDashboardPanel :width="250" :resizable="{ min: 200, max: 300 }" collapsible>
<UDashboardNavbar style="margin-top: env(safe-area-inset-top, 10px) !important;" :class="['!border-transparent']" :ui="{ left: 'flex-1' }">
<UDashboardNavbar style="margin-top: env(safe-area-inset-top, 10px) !important;"
:class="['!border-transparent']" :ui="{ left: 'flex-1' }">
<template #left>
<TenantDropdown class="w-full"/>
</template>
@@ -232,23 +247,17 @@ const footerLinks = [
<div class="flex-1"/>
<template #footer>
<div class="flex flex-col gap-3 w-full">
<div class="flex flex-col gap-3">
<UColorModeButton />
<LabelPrinterButton/>
<UColorModeToggle class="ml-3"/>
<LabelPrinterButton class="w-full"/>
<UDashboardSidebarLinks :links="footerLinks" class="w-full"/>
<!-- Footer Links -->
<UDashboardSidebarLinks :links="footerLinks" />
<UDivider class="sticky bottom-0" />
<UserDropdown style="margin-bottom: env(safe-area-inset-bottom, 10px) !important;"/>
<UDivider class="sticky bottom-0 w-full"/>
<UserDropdown style="margin-bottom: env(safe-area-inset-bottom, 10px) !important;" class="w-full"/>
</div>
</template>
</UDashboardSidebar>
@@ -260,10 +269,10 @@ const footerLinks = [
</UDashboardPanel>
</UDashboardPage>
<HelpSlideover/>
<Calculator v-if="calculatorStore.isOpen"/>
</UDashboardLayout>
</div>
@@ -285,21 +294,20 @@ const footerLinks = [
/>
<div v-if="!auth.activeTenant" class="w-1/2 mx-auto text-center">
<!-- Tenant Selection -->
<h3 class="text-center font-bold text-2xl mb-5">Kein Aktiver Mandant. Bitte wählen Sie ein Mandant.</h3>
<div class="mx-auto w-1/2 flex flex-row justify-between my-3" v-for="tenant in auth.tenants">
<span class="text-left">{{ tenant.name }}</span>
<UButton
@click="auth.switchTenant(tenant.id)"
>Wählen</UButton>
>Wählen
</UButton>
</div>
<UButton
variant="outline"
color="rose"
@click="auth.logout()"
>Abmelden</UButton>
>Abmelden
</UButton>
</div>
<div v-else>
<UProgress animation="carousel" class="w-3/4 mx-auto mt-10"/>
@@ -308,7 +316,3 @@ const footerLinks = [
</div>
</template>
<style scoped>
</style>

View File

@@ -7,7 +7,7 @@ export default defineNuxtConfig({
}
},
modules: ['@pinia/nuxt', '@nuxt/ui', '@nuxtjs/supabase', "nuxt-editorjs", '@nuxtjs/fontaine', 'nuxt-viewport', 'nuxt-tiptap-editor', '@nuxtjs/leaflet', '@vueuse/nuxt'],
modules: ['@vite-pwa/nuxt','@pinia/nuxt', '@nuxt/ui', "nuxt-editorjs", '@nuxtjs/fontaine', 'nuxt-viewport', '@nuxtjs/leaflet', '@vueuse/nuxt'],
ssr: false,
@@ -24,7 +24,8 @@ export default defineNuxtConfig({
}],
build: {
transpile: ['@vuepic/vue-datepicker']
transpile: ['@vuepic/vue-datepicker','@tiptap/vue-3','@tiptap/extension-code-block-lowlight',
'lowlight',]
},
@@ -33,15 +34,43 @@ export default defineNuxtConfig({
},
supabase: {
key: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV3cHB2Y3hmbHJjc2lidXpzYmlsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDA5MzgxOTQsImV4cCI6MjAxNjUxNDE5NH0.CkxYSQH0uLfwx9GVUlO6AYMU2FMLAxGMrwEKvyPv7Oo",
url: "https://uwppvcxflrcsibuzsbil.supabase.co",
redirect: false
},
vite: {
resolve: {
dedupe: [
'vue',
'@tiptap/vue-3',
'prosemirror-model',
'prosemirror-view',
'prosemirror-state',
'prosemirror-commands',
'prosemirror-schema-list',
'prosemirror-transform',
'prosemirror-history',
'prosemirror-gapcursor',
'prosemirror-dropcursor',
'prosemirror-tables'
]
},
optimizeDeps: {
include: ["@editorjs/editorjs", "dayjs"],
include: [
"@editorjs/editorjs",
"dayjs",
'@tiptap/vue-3',
'@tiptap/extension-code-block-lowlight',
'lowlight',
'vue',
'@tiptap/extension-task-item',
'@tiptap/extension-task-list',
'@tiptap/extension-table',
'@tiptap/extension-mention',
'prosemirror-model',
'prosemirror-view',
'prosemirror-state',
'prosemirror-commands',
'prosemirror-transform',
'tippy.js',
'prosemirror-tables',
],
},
},
@@ -53,11 +82,6 @@ export default defineNuxtConfig({
preference: 'system'
},
tiptap: {
prefix: "Tiptap"
},
runtimeConfig: {
public: {
@@ -66,5 +90,68 @@ export default defineNuxtConfig({
}
},
pwa: {
/* Automatische Updates des Service Workers (optional, aber empfohlen) */
registerType: 'autoUpdate',
manifest: {
name: 'FEDEO',
short_name: 'FEDEO',
description: 'FEDEO',
theme_color: '#69c350',
background_color: '#ffffff',
/* WICHTIG: Dies sorgt dafür, dass die URL-Leiste verschwindet */
display: 'standalone',
/* Icons sind essentiell für den Home Screen */
icons: [
{
src: '192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: '512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
},
/* WICHTIG FÜR SAFARI / iOS */
workbox: {
navigateFallback: '/',
},
devOptions: {
enabled: true, // Damit du es auch lokal testen kannst
type: 'module',
},
},
app: {
head: {
meta: [
// Sagt iOS, dass es eine WebApp ist
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
// Steuert die Farbe der Statusleiste (weiß, schwarz oder transparent)
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
// Name der App unter dem Icon
{ name: 'apple-mobile-web-app-title', content: 'FEDEO' },
],
link: [
// Wichtig: Das Icon für den Home Screen
{ rel: 'apple-touch-icon', href: '/512.png' }
]
}
},
compatibilityDate: '2024-12-18'
})

View File

@@ -12,11 +12,10 @@
"devDependencies": {
"@capacitor/cli": "^7.0.0",
"@nuxtjs/leaflet": "^1.2.3",
"@nuxtjs/supabase": "^1.1.4",
"@vite-pwa/nuxt": "^1.1.0",
"@vueuse/core": "^14.1.0",
"@vueuse/nuxt": "^14.1.0",
"nuxt": "^3.14.1592",
"nuxt-tiptap-editor": "^1.2.0",
"vue": "^3.5.13",
"vue-router": "^4.2.5"
},
@@ -46,14 +45,31 @@
"@popperjs/core": "^2.11.8",
"@sentry/browser": "^9.11.0",
"@sentry/integrations": "^7.114.0",
"@tiptap/extension-underline": "^2.1.15",
"@tiptap/pm": "^2.1.15",
"@tiptap/starter-kit": "^2.1.15",
"@tiptap/vue-3": "^2.1.15",
"@tiptap/extension-bubble-menu": "^3.17.1",
"@tiptap/extension-character-count": "^3.17.1",
"@tiptap/extension-code-block": "^3.17.1",
"@tiptap/extension-floating-menu": "^3.17.1",
"@tiptap/extension-highlight": "^3.17.1",
"@tiptap/extension-image": "^3.17.1",
"@tiptap/extension-link": "^3.17.1",
"@tiptap/extension-mention": "^3.17.1",
"@tiptap/extension-placeholder": "^3.17.1",
"@tiptap/extension-table": "^3.17.1",
"@tiptap/extension-table-cell": "^3.17.1",
"@tiptap/extension-table-header": "^3.17.1",
"@tiptap/extension-table-row": "^3.17.1",
"@tiptap/extension-task-item": "^3.17.1",
"@tiptap/extension-task-list": "^3.17.1",
"@tiptap/extension-typography": "^3.17.1",
"@tiptap/extension-youtube": "^3.17.1",
"@tiptap/pm": "^3.17.1",
"@tiptap/starter-kit": "^3.17.1",
"@tiptap/vue-3": "^3.17.1",
"@vicons/ionicons5": "^0.12.0",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vue-pdf-viewer/viewer": "^3.0.1",
"@vuepic/vue-datepicker": "^7.4.0",
"@vueuse/components": "^14.1.0",
"@zip.js/zip.js": "^2.7.32",
"array-sort": "^1.0.0",
"axios": "^1.6.7",
@@ -79,6 +95,7 @@
"sass": "^1.69.7",
"socket.io-client": "^4.7.2",
"tailwindcss-safe-area-capacitor": "^0.5.1",
"tippy.js": "^6.3.7",
"uuid": "^11.0.3",
"uuidv4": "^6.2.13",
"v-calendar": "^3.1.2",

View File

@@ -1,14 +1,9 @@
<script setup>
import dayjs from "dayjs";
// Zugriff auf $api und Toast Notification
const { $api } = useNuxtApp()
const {$api, $dayjs} = useNuxtApp()
const toast = useToast()
defineShortcuts({
'/': () => {
document.getElementById("searchinput").focus()
}
'/': () => document.getElementById("searchinput").focus()
})
const tempStore = useTempStore()
@@ -18,238 +13,280 @@ const route = useRoute()
const bankstatements = ref([])
const bankaccounts = ref([])
const filterAccount = ref([])
// Status für den Lade-Button
const isSyncing = ref(false)
const loadingDocs = ref(true) // Startet im Ladezustand
// Zeitraum-Optionen
const periodOptions = [
{label: 'Aktueller Monat', key: 'current_month'},
{label: 'Letzter Monat', key: 'last_month'},
{label: 'Aktuelles Quartal', key: 'current_quarter'},
{label: 'Letztes Quartal', key: 'last_quarter'},
{label: 'Benutzerdefiniert', key: 'custom'}
]
// Initialisierungswerte
const selectedPeriod = ref(periodOptions[0])
const dateRange = ref({
start: $dayjs().startOf('month').format('YYYY-MM-DD'),
end: $dayjs().endOf('month').format('YYYY-MM-DD')
})
const setupPage = async () => {
bankstatements.value = (await useEntities("bankstatements").select("*, statementallocations(*)", "date", false))
bankaccounts.value = await useEntities("bankaccounts").select()
if(bankaccounts.value.length > 0) filterAccount.value = bankaccounts.value
loadingDocs.value = true
try {
const [statements, accounts] = await Promise.all([
useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false),
useEntities("bankaccounts").select()
])
bankstatements.value = statements
bankaccounts.value = accounts
if (bankaccounts.value.length > 0 && filterAccount.value.length === 0) {
filterAccount.value = bankaccounts.value
}
// Funktion für den Bankabruf
// Erst nach dem Laden der Daten die Store-Werte anwenden
const savedBanking = tempStore.settings?.['banking'] || {}
if (savedBanking.periodKey) {
const found = periodOptions.find(p => p.key === savedBanking.periodKey)
if (found) selectedPeriod.value = found
}
if (savedBanking.range) {
dateRange.value = savedBanking.range
}
} catch (err) {
console.error("Setup Error:", err)
} finally {
loadingDocs.value = false
}
}
// Watcher für Schnellwahlen & Persistenz
watch([selectedPeriod, dateRange], ([newPeriod, newRange], [oldPeriod, oldRange]) => {
const now = $dayjs()
// Nur berechnen, wenn sich die Periode geändert hat
if (newPeriod.key !== oldPeriod?.key) {
switch (newPeriod.key) {
case 'current_month':
dateRange.value = {start: now.startOf('month').format('YYYY-MM-DD'), end: now.endOf('month').format('YYYY-MM-DD')}
break
case 'last_month':
const lastMonth = now.subtract(1, 'month')
dateRange.value = {start: lastMonth.startOf('month').format('YYYY-MM-DD'), end: lastMonth.endOf('month').format('YYYY-MM-DD')}
break
case 'current_quarter':
dateRange.value = {start: now.startOf('quarter').format('YYYY-MM-DD'), end: now.endOf('quarter').format('YYYY-MM-DD')}
break
case 'last_quarter':
const lastQuarter = now.subtract(1, 'quarter')
dateRange.value = {start: lastQuarter.startOf('quarter').format('YYYY-MM-DD'), end: lastQuarter.endOf('quarter').format('YYYY-MM-DD')}
break
}
}
// Speichern im Store
tempStore.modifyBankingPeriod(selectedPeriod.value.key, dateRange.value)
}, { deep: true })
const syncBankStatements = async () => {
isSyncing.value = true
try {
await $api('/api/functions/services/bankstatementsync', {method: 'POST'})
toast.add({
title: 'Erfolg',
description: 'Bankdaten wurden erfolgreich synchronisiert.',
icon: 'i-heroicons-check-circle',
color: 'green'
})
// Wichtig: Daten neu laden, damit die neuen Buchungen direkt sichtbar sind
toast.add({title: 'Erfolg', description: 'Bankdaten synchronisiert.', color: 'green'})
await setupPage()
} catch (error) {
console.error(error)
toast.add({
title: 'Fehler',
description: 'Beim Abrufen der Bankdaten ist ein Fehler aufgetreten.',
icon: 'i-heroicons-exclamation-circle',
color: 'red'
})
toast.add({title: 'Fehler', description: 'Fehler beim Abruf.', color: 'red'})
} finally {
isSyncing.value = false
}
}
const templateColumns = [
{
key: "account",
label: "Konto"
},{
key: "valueDate",
label: "Valuta"
},
{
key: "amount",
label: "Betrag"
},
{
key: "openAmount",
label: "Offener Betrag"
},
{
key: "partner",
label: "Name"
},
{
key: "text",
label: "Beschreibung"
}
{key: "account", label: "Konto"},
{key: "valueDate", label: "Valuta"},
{key: "amount", label: "Betrag"},
{key: "openAmount", label: "Offen"},
{key: "partner", label: "Name"},
{key: "text", label: "Beschreibung"}
]
const selectedColumns = ref(templateColumns)
const columns = computed(() => templateColumns.filter((column) => selectedColumns.value.includes(column)))
const searchString = ref(tempStore.searchStrings["bankstatements"] || '')
const selectedFilters = ref(tempStore.filters?.["banking"]?.["main"] || ['Nur offene anzeigen'])
const clearSearchString = () => {
tempStore.clearSearchString("bankstatements")
searchString.value = ''
const shouldShowMonthDivider = (row, index) => {
if (index === 0) return true;
const prevRow = filteredRows.value[index - 1];
return $dayjs(row.valueDate).format('MMMM YYYY') !== $dayjs(prevRow.valueDate).format('MMMM YYYY');
}
const displayCurrency = (value, currency = "€") => {
return `${Number(value).toFixed(2).replace(".",",")} ${currency}`
}
const calculateOpenSum = (statement) => {
let startingAmount = 0
statement.statementallocations.forEach(item => {
startingAmount += item.amount
})
return (statement.amount - startingAmount).toFixed(2)
const allocated = statement.statementallocations?.reduce((acc, curr) => acc + curr.amount, 0) || 0;
return (statement.amount - allocated).toFixed(2);
}
const selectedFilters = ref(tempStore.filters?.["banking"]?.["main"] ? tempStore.filters["banking"]["main"] : ['Nur offene anzeigen'])
const filteredRows = computed(() => {
let temp = bankstatements.value
if (!bankstatements.value.length) return []
if(route.query.filter) {
console.log(route.query.filter)
temp = temp.filter(i => JSON.parse(route.query.filter).includes(i.id))
} else {
let temp = [...bankstatements.value]
// Filterung nach Datum
if (dateRange.value.start) {
temp = temp.filter(i => $dayjs(i.valueDate).isSameOrAfter($dayjs(dateRange.value.start), 'day'))
}
if (dateRange.value.end) {
temp = temp.filter(i => $dayjs(i.valueDate).isSameOrBefore($dayjs(dateRange.value.end), 'day'))
}
// Status Filter
if (selectedFilters.value.includes("Nur offene anzeigen")) {
temp = temp.filter(i => Number(calculateOpenSum(i)) !== 0)
}
if (selectedFilters.value.includes("Nur positive anzeigen")) {
temp = temp.filter(i => i.amount >= 0)
}
if (selectedFilters.value.includes("Nur negative anzeigen")) {
temp = temp.filter(i => i.amount < 0)
}
// Konto Filter & Suche
let results = temp.filter(i => filterAccount.value.find(x => x.id === i.account))
if (searchString.value) {
results = useSearch(searchString.value, results)
}
return useSearch(searchString.value, temp.filter(i => filterAccount.value.find(x => x.id === i.account)))
return results.sort((a, b) => $dayjs(b.valueDate).unix() - $dayjs(a.valueDate).unix())
})
const displayCurrency = (value) => `${Number(value).toFixed(2).replace(".", ",")}`
onMounted(() => {
setupPage()
})
</script>
<template>
<UDashboardNavbar title="Bankbuchungen" :badge="filteredRows.length">
<template #right>
<UButton
label="Bankabruf"
icon="i-heroicons-arrow-path"
color="primary"
variant="solid"
:loading="isSyncing"
@click="syncBankStatements"
class="mr-2"
/>
<UInput
id="searchinput"
name="searchinput"
v-model="searchString"
icon="i-heroicons-funnel"
autocomplete="off"
icon="i-heroicons-magnifying-glass"
placeholder="Suche..."
class="hidden lg:block"
@keydown.esc="$event.target.blur()"
@change="tempStore.modifySearchString('bankstatements',searchString)"
>
<template #trailing>
<UKbd value="/" />
</template>
</UInput>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
@click="clearSearchString()"
v-if="searchString.length > 0"
/>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left>
<div class="flex items-center gap-3">
<USelectMenu
:options="bankaccounts"
v-model="filterAccount"
option-attribute="iban"
multiple
by="id"
:ui-menu="{ width: 'min-w-max' }"
>
<template #label>
Konto
</template>
</USelectMenu>
placeholder="Konten"
class="w-48"
/>
<UDivider orientation="vertical" class="h-6"/>
<div class="flex items-center gap-2">
<USelectMenu
v-model="selectedPeriod"
:options="periodOptions"
class="w-44"
icon="i-heroicons-calendar-days"
/>
<div v-if="selectedPeriod.key === 'custom'" class="flex items-center gap-1">
<UInput type="date" v-model="dateRange.start" size="xs" class="w-32"/>
<UInput type="date" v-model="dateRange.end" size="xs" class="w-32"/>
</div>
<div v-else class="text-xs text-gray-400 hidden sm:block italic">
{{ $dayjs(dateRange.start).format('DD.MM.') }} - {{ $dayjs(dateRange.end).format('DD.MM.YYYY') }}
</div>
</div>
</div>
</template>
<template #right>
<USelectMenu
icon="i-heroicons-adjustments-horizontal-solid"
icon="i-heroicons-adjustments-horizontal"
multiple
v-model="selectedFilters"
:options="['Nur offene anzeigen','Nur positive anzeigen','Nur negative anzeigen']"
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
:ui-menu="{ width: 'min-w-max' }"
@change="tempStore.modifyFilter('banking','main',selectedFilters)"
>
<template #label>
Filter
</template>
</USelectMenu>
/>
</template>
</UDashboardToolbar>
<UTable
:rows="filteredRows"
:columns="columns"
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(i) => router.push(`/banking/statements/edit/${i.id}`)"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
>
<template #account-data="{row}">
{{row.account ? bankaccounts.find(i => i.id === row.account).iban : ""}}
</template>
<template #valueDate-data="{row}">
{{dayjs(row.valueDate).format("DD.MM.YY")}}
</template>
<template #amount-data="{row}">
<span
v-if="row.amount >= 0"
class="text-primary-500"
>{{String(row.amount.toFixed(2)).replace(".",",")}} </span>
<span
v-else-if="row.amount < 0"
class="text-rose-500"
>{{String(row.amount.toFixed(2)).replace(".",",")}} </span>
</template>
<template #openAmount-data="{row}">
{{displayCurrency(calculateOpenSum(row))}}
</template>
<template #partner-data="{row}">
<span
v-if="row.amount < 0"
<div class="overflow-y-auto relative" style="height: calc(100vh - 200px)">
<div v-if="loadingDocs" class="p-20 flex flex-col items-center justify-center">
<UProgress animation="carousel" class="w-1/3 mb-4" />
<span class="text-sm text-gray-500 italic">Bankbuchungen werden geladen...</span>
</div>
<table v-else class="w-full text-left border-collapse">
<thead class="sticky top-0 bg-white dark:bg-gray-900 z-10 shadow-sm">
<tr class="text-xs font-semibold text-gray-500 uppercase">
<th v-for="col in templateColumns" :key="col.key" class="p-4 border-b dark:border-gray-800">
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<template v-for="(row, index) in filteredRows" :key="row.id">
<tr v-if="shouldShowMonthDivider(row, index)">
<td colspan="6" class="bg-gray-50 dark:bg-gray-800/50 p-2 pl-4 text-sm font-bold text-primary-600 border-y dark:border-gray-800">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-calendar" class="w-4 h-4"/>
{{ $dayjs(row.valueDate).format('MMMM YYYY') }}
</div>
</td>
</tr>
<tr
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 cursor-pointer border-b dark:border-gray-800 text-sm group"
@click="router.push(`/banking/statements/edit/${row.id}`)"
>
{{row.credName}}
</span>
<span
v-else-if="row.amount > 0"
>
{{row.debName}}
<td class="p-4 text-[10px] text-gray-400 font-mono truncate max-w-[150px]">
{{ row.account ? bankaccounts.find(i => i.id === row.account)?.iban : "" }}
</td>
<td class="p-4 whitespace-nowrap">{{ $dayjs(row.valueDate).format("DD.MM.YY") }}</td>
<td class="p-4 font-semibold">
<span :class="row.amount >= 0 ? 'text-green-600 dark:text-green-400' : 'text-rose-600 dark:text-rose-400'">
{{ displayCurrency(row.amount) }}
</span>
</td>
<td class="p-4 text-gray-400 italic text-xs">
{{ Number(calculateOpenSum(row)) !== 0 ? displayCurrency(calculateOpenSum(row)) : '-' }}
</td>
<td class="p-4 truncate max-w-[180px] font-medium">
{{ row.amount < 0 ? row.credName : row.debName }}
</td>
<td class="p-4 text-gray-500 truncate max-w-[350px] text-xs">
{{ row.text }}
</td>
</tr>
</template>
</UTable>
<tr v-if="filteredRows.length === 0">
<td colspan="6" class="p-32 text-center text-gray-400">
<div class="flex flex-col items-center">
<UIcon name="i-heroicons-magnifying-glass-circle" class="w-12 h-12 mb-3 opacity-20"/>
<p class="font-medium">Keine Buchungen gefunden</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<PageLeaveGuard :when="isSyncing"/>
</template>
<style scoped>
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ const router = useRouter()
const modal = useModal()
const auth = useAuthStore()
const guard = ref(true)
@@ -883,7 +883,7 @@ const documentReport = computed(() => {
if (row.product) {
let product = products.value.find(i => i.id === row.product)
totalProductsPurchasePrice += product.purchasePrice * row.quantity
totalProductsPurchasePrice += (product?.purchase_price || 0) * row.quantity
} else if (row.service) {
let service = services.value.find(i => i.id === row.service)
@@ -892,7 +892,7 @@ const documentReport = computed(() => {
service.materialComposition.forEach(entry => {
let productData = products.value.find(i => i.id === entry.product)
totalProductsFromServicesPurchasePrice += productData.purchasePrice * entry.quantity * row.quantity
totalProductsFromServicesPurchasePrice += (productData?.purchase_price || 0) * entry.quantity * row.quantity
})
}
@@ -1387,7 +1387,7 @@ const saveDocument = async (state, resetup = false) => {
endText: itemInfo.value.endText,
rows: itemInfo.value.rows,
contactPerson: itemInfo.value.contactPerson,
linkedDocument: itemInfo.value.linkedDocument,
createddocument: itemInfo.value.createddocument,
agriculture: itemInfo.value.agriculture,
letterhead: itemInfo.value.letterhead,
usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices,
@@ -1456,6 +1456,8 @@ const closeDocument = async () => {
await useFiles().uploadFiles(fileData, [file])
guard.value = false
await router.push(`/createDocument/show/${itemInfo.value.id}`)
}
@@ -3144,7 +3146,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
</UTabs>
<UProgress animation="carousel" v-else/>
</UDashboardPanelContent>
<PageLeaveGuard :when="true"/>
<PageLeaveGuard :when="guard"/>

View File

@@ -8,7 +8,6 @@
class="hidden lg:block"
icon="i-heroicons-funnel"
placeholder="Suche..."
@change="tempStore.modifySearchString('createddocuments',searchString)"
@keydown.esc="$event.target.blur()"
>
<template #trailing>
@@ -30,22 +29,7 @@
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #right>
<!-- <USelectMenu
v-model="selectedColumns"
:options="templateColumns"
by="key"
class="hidden lg:block"
icon="i-heroicons-adjustments-horizontal-solid"
multiple
@change="tempStore.modifyColumns('createddocuments',selectedColumns)"
>
<template #label>
Spalten
</template>
</USelectMenu>-->
<USelectMenu
v-if="selectableFilters.length > 0"
v-model="selectedFilters"
@@ -157,6 +141,31 @@
<script setup>
import dayjs from "dayjs";
import { ref, computed, watch } from 'vue';
const dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const type = "createddocuments"
const dataType = dataStore.dataTypes[type]
const items = ref([])
const selectedItem = ref(0)
const activeTabIndex = ref(0)
// Debounce-Logik für die Suche
const searchString = ref(tempStore.searchStrings['createddocuments'] || '')
const debouncedSearchString = ref(searchString.value)
let debounceTimeout = null
watch(searchString, (newVal) => {
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
debouncedSearchString.value = newVal
tempStore.modifySearchString('createddocuments', newVal)
}, 300) // 300ms warten nach dem letzten Tastendruck
})
defineShortcuts({
'/': () => {
@@ -168,10 +177,6 @@ defineShortcuts({
'Enter': {
usingInput: true,
handler: () => {
// Zugriff auf das aktuell sichtbare Element basierend auf Tab und Selektion
const currentList = getRowsForTab(selectedTypes.value[activeTabIndex.value]?.key || 'drafts')
// Fallback auf globale Liste falls nötig, aber Logik sollte auf Tab passen
if (filteredRows.value[selectedItem.value]) {
router.push(`/createDocument/show/${filteredRows.value[selectedItem.value].id}`)
}
@@ -193,17 +198,6 @@ defineShortcuts({
}
})
const dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const type = "createddocuments"
const dataType = dataStore.dataTypes[type]
const items = ref([])
const selectedItem = ref(0)
const activeTabIndex = ref(0)
const setupPage = async () => {
items.value = (await useEntities("createddocuments").select("*, customer(id,name), statementallocations(id,amount),linkedDocument(*)", "documentNumber", true, true))
}
@@ -222,10 +216,9 @@ const templateColumns = [
{key: "dueDate", label: "Fällig"}
]
// Eigene Spalten für Entwürfe: Referenz raus, Status rein
const draftColumns = [
{key: 'type', label: "Typ"},
{key: 'state', label: "Status"}, // Status wieder drin
{key: 'state', label: "Status"},
{key: 'partner', label: "Kunde"},
{key: "date", label: "Erstellt am"},
{key: "amount", label: "Betrag"}
@@ -242,31 +235,11 @@ const getColumnsForTab = (tabKey) => {
}
const templateTypes = [
{
key: "drafts",
label: "Entwürfe"
},
{
key: "invoices",
label: "Rechnungen"
},
/*,{
key: "cancellationInvoices",
label: "Stornorechnungen"
},{
key: "advanceInvoices",
label: "Abschlagsrechnungen"
},*/
{
key: "quotes",
label: "Angebote"
}, {
key: "deliveryNotes",
label: "Lieferscheine"
}, {
key: "confirmationOrders",
label: "Auftragsbestätigungen"
}
{ key: "drafts", label: "Entwürfe" },
{ key: "invoices", label: "Rechnungen" },
{ key: "quotes", label: "Angebote" },
{ key: "deliveryNotes", label: "Lieferscheine" },
{ key: "confirmationOrders", label: "Auftragsbestätigungen" }
]
const selectedTypes = ref(tempStore.filters["createddocuments"] ? tempStore.filters["createddocuments"] : templateTypes)
const types = computed(() => {
@@ -274,10 +247,9 @@ const types = computed(() => {
})
const selectItem = (item) => {
console.log(item)
if (item.state === "Entwurf") {
router.push(`/createDocument/edit/${item.id}`)
} else if (item.state !== "Entwurf") {
} else {
router.push(`/createDocument/show/${item.id}`)
}
}
@@ -286,24 +258,19 @@ const displayCurrency = (value, currency = "€") => {
return `${Number(value).toFixed(2).replace(".", ",")} ${currency}`
}
const searchString = ref(tempStore.searchStrings['createddocuments'] || '')
const clearSearchString = () => {
tempStore.clearSearchString('createddocuments')
searchString.value = ''
debouncedSearchString.value = ''
}
const selectableFilters = ref(dataType.filters.map(i => i.name))
const selectedFilters = ref(dataType.filters.filter(i => i.default).map(i => i.name) || [])
const filteredRows = computed(() => {
let tempItems = items.value.filter(i => types.value.find(x => {
// 1. Draft Tab Logic
if (x.key === 'drafts') return i.state === 'Entwurf'
// 2. Global Draft Exclusion (drafts shouldn't be in other tabs)
if (i.state === 'Entwurf' && x.key !== 'drafts') return false
// 3. Normal Type Logic
if (x.key === 'invoices') return ['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(i.type)
return x.key === i.type
}))
@@ -324,22 +291,16 @@ const filteredRows = computed(() => {
})
}
tempItems = useSearch(searchString.value, tempItems)
return useSearch(searchString.value, tempItems.slice().reverse())
// Hier nutzen wir nun den debounced Wert für die lokale Suche
const results = useSearch(debouncedSearchString.value, tempItems.slice().reverse())
return results
})
const getRowsForTab = (tabKey) => {
return filteredRows.value.filter(row => {
if (tabKey === 'drafts') {
return row.state === 'Entwurf'
}
if (tabKey === 'drafts') return row.state === 'Entwurf'
if (row.state === 'Entwurf') return false
if (tabKey === 'invoices') {
return ['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(row.type)
}
if (tabKey === 'invoices') return ['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(row.type)
return row.type === tabKey
})
}
@@ -350,6 +311,3 @@ const isPaid = (item) => {
return Number(amountPaid.toFixed(2)) === useSum().getCreatedDocumentSum(item, items.value)
}
</script>
<style scoped>
</style>

View File

@@ -17,11 +17,18 @@ const dataStore = useDataStore()
const itemInfo = ref({})
const linkedDocument =ref({})
const links = ref([])
const setupPage = async () => {
if(route.params) {
if(route.params.id) itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,"*,files(*),linkedDocument(*), statementallocations(bs_id)")
console.log(itemInfo.value)
if(itemInfo.value.type === "invoices"){
const createddocuments = await useEntities("createddocuments").select()
console.log(createddocuments)
links.value = createddocuments.filter(i => i.createddocument?.id === itemInfo.value.id)
console.log(links.value)
}
linkedDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].id)
}
@@ -80,17 +87,23 @@ const openBankstatements = () => {
>
E-Mail
</UButton>
<UTooltip
v-if="itemInfo.type === 'invoices' || itemInfo.type === 'advanceInvoices'"
:text="links.find(i => i.type === 'cancellationInvoices') ? 'Bereits stoniert' : ''"
>
<UButton
@click="router.push(`/createDocument/edit/?createddocument=${itemInfo.id}&loadMode=storno`)"
variant="outline"
color="rose"
v-if="itemInfo.type === 'invoices' || itemInfo.type === 'advanceInvoices'"
:disabled="links.find(i => i.type === 'cancellationInvoices')"
>
Stornieren
</UButton>
</UTooltip>
<UButton
v-if="itemInfo.project"
@click="router.push(`/standardEntity/projects/show/${itemInfo.project}`)"
@click="router.push(`/standardEntity/projects/show/${itemInfo.project?.id}`)"
icon="i-heroicons-link"
variant="outline"
>
@@ -98,15 +111,39 @@ const openBankstatements = () => {
</UButton>
<UButton
v-if="itemInfo.customer"
@click="router.push(`/standardEntity/customers/show/${itemInfo.customer}`)"
@click="router.push(`/standardEntity/customers/show/${itemInfo.customer?.id}`)"
icon="i-heroicons-link"
variant="outline"
>
Kunde
</UButton>
<UButton
v-if="itemInfo.plant"
@click="router.push(`/standardEntity/plants/show/${itemInfo.plant?.id}`)"
icon="i-heroicons-link"
variant="outline"
>
Objekt
</UButton>
<UButton
v-if="itemInfo.contract"
@click="router.push(`/standardEntity/contracts/show/${itemInfo.contract?.id}`)"
icon="i-heroicons-link"
variant="outline"
>
Vertrag
</UButton>
<UButton
v-if="itemInfo.contact"
@click="router.push(`/standardEntity/contacts/show/${itemInfo.contact?.id}`)"
icon="i-heroicons-link"
variant="outline"
>
Ansprechpartner
</UButton>
<UButton
v-if="itemInfo.createddocument"
@click="router.push(`/createDocument/show/${itemInfo.createddocument}`)"
@click="router.push(`/createDocument/show/${itemInfo.createddocument.id}`)"
icon="i-heroicons-link"
variant="outline"
>

View File

@@ -15,7 +15,7 @@ const showDocument = ref(false)
const uri = ref("")
const setupPage = async () => {
letterheads.value = await useSupabaseSelect("letterheads","*")
letterheads.value = await useEntities("letterheads").select("*")
preloadedContent.value = `<p></p><p></p><p></p>`
}

View File

@@ -1,5 +1,4 @@
<script setup>
//TODO: BACKENDCHANGE EMAIL SENDING
const dataStore = useDataStore()
const profileStore = useProfileStore()
const route = useRoute()
@@ -38,7 +37,7 @@ const setupPage = async () => {
if(route.query.to) emailData.value.to = route.query.to
if(route.query.cc) emailData.value.cc = route.query.cc
if(route.query.bcc) emailData.value.bcc = route.query.bcc
if(route.query.subject) emailData.value.to = route.query.subject
if(route.query.subject) emailData.value.subject = route.query.subject
if(route.query.loadDocuments) {
@@ -285,7 +284,7 @@ const sendEmail = async () => {
</div>
<Tiptap
<EmailTiptapEditor
class="mt-3"
@updateContent="contentChanged"
:preloadedContent="preloadedContent"

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref, computed } from "vue"
import dayjs from "dayjs"
// --- Helper für Schnellauswahl ---
@@ -39,6 +40,15 @@ const setMonthPreset = (type: 'current' | 'last') => {
const exports = ref([])
// Filtert die Exporte: Nur Einträge, deren valid_until nach dem jetzigen Zeitpunkt liegt
const filteredExports = computed(() => {
const now = dayjs()
return exports.value.filter(row => {
if (!row.valid_until) return true // Falls kein Ablaufdatum gesetzt ist, anzeigen
return dayjs(row.valid_until).isAfter(now)
})
})
const auth = useAuthStore()
const toast = useToast()
const router = useRouter()
@@ -100,7 +110,7 @@ const createExport = async () => {
</UDashboardNavbar>
<UTable
:rows="exports"
:rows="filteredExports"
:columns="[
{ key: 'created_at', label: 'Erstellt am' },
{ key: 'start_date', label: 'Start' },

View File

@@ -1,535 +1,500 @@
<script setup>
import {BlobReader, BlobWriter, ZipWriter} from "@zip.js/zip.js";
import {ref, computed, watch, onMounted, onUnmounted} from 'vue';
import DocumentDisplayModal from "~/components/DocumentDisplayModal.vue";
import DocumentUploadModal from "~/components/DocumentUploadModal.vue";
import dayjs from "dayjs";
import arraySort from "array-sort";
defineShortcuts({
'+': () => {
//Hochladen
uploadModalOpen.value = true
},
'Enter': {
usingInput: true,
handler: () => {
let entry = renderedFileList.value[selectedFileIndex.value]
if(entry.type === "file") {
showFile(entry.id)
} else if(createFolderModalOpen.value === false && entry.type === "folder") {
changeFolder(currentFolders.value.find(i => i.id === entry.id))
} else if(createFolderModalOpen.value === true) {
createFolder()
}
}
},
'arrowdown': () => {
if(selectedFileIndex.value < renderedFileList.value.length - 1) {
selectedFileIndex.value += 1
} else {
selectedFileIndex.value = 0
}
},
'arrowup': () => {
if(selectedFileIndex.value === 0) {
selectedFileIndex.value = renderedFileList.value.length - 1
} else {
selectedFileIndex.value -= 1
}
}
})
// --- Services & Stores ---
const dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const route = useRoute()
const modal = useModal()
const auth = useAuthStore()
const uploadModalOpen = ref(false)
const createFolderModalOpen = ref(false)
const uploadInProgress = ref(false)
const fileUploadFormData = ref({
tags: ["Eingang"],
path: "",
tenant: auth.activeTenant,
folder: null
})
const toast = useToast()
const {$api} = useNuxtApp()
const files = useFiles()
const displayMode = ref("list")
const displayModes = ref([{label: 'Liste',key:'list', icon: 'i-heroicons-list-bullet'},{label: 'Kacheln',key:'rectangles', icon: 'i-heroicons-squares-2x2'}])
// --- State ---
const documents = ref([])
const folders = ref([])
const filetags = ref([])
const currentFolder = ref(null)
const loadingDocs = ref(false)
const isDragTarget = ref(false)
const loadingDocs = ref(true)
const loaded = ref(false)
const displayMode = ref("list")
const displayModes = [
{label: 'Liste', key: 'list', icon: 'i-heroicons-list-bullet'},
{label: 'Kacheln', key: 'rectangles', icon: 'i-heroicons-squares-2x2'}
]
const createFolderModalOpen = ref(false)
const initialCreateFolderData = { name: '', standardFiletype: null, standardFiletypeIsOptional: true }
const createFolderData = ref({ ...initialCreateFolderData })
const renameModalOpen = ref(false)
const renameData = ref({id: null, name: '', type: ''})
const selectedFileIndex = ref(0)
const draggedItem = ref(null)
// --- Search & Debounce ---
const searchString = ref(tempStore.searchStrings["files"] || '')
const debouncedSearch = ref(searchString.value)
let debounceTimeout = null
watch(searchString, (val) => {
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
debouncedSearch.value = val
tempStore.modifySearchString('files', val)
}, 300)
})
// --- Data Fetching ---
const setupPage = async () => {
folders.value = await useEntities("folders").select()
documents.value = await files.selectDocuments()
filetags.value = await useEntities("filetags").select()
if(route.query) {
if(route.query.folder) {
currentFolder.value = await useEntities("folders").selectSingle(route.query.folder)
loadingDocs.value = true
try {
const [fRes, dRes, tRes] = await Promise.all([
useEntities("folders").select(),
files.selectDocuments(),
useEntities("filetags").select()
])
folders.value = fRes || []
documents.value = dRes || []
filetags.value = tRes || []
if (route.query?.folder) {
currentFolder.value = folders.value.find(i => i.id === route.query.folder) || null
} else {
currentFolder.value = null
}
}
const dropZone = document.getElementById("drop_zone")
dropZone.ondragover = function (event) {
modal.open(DocumentUploadModal,{fileData: {folder: currentFolder.value.id, type: currentFolder.value.standardFiletype, typeEnabled: currentFolder.value.standardFiletypeIsOptional}, onUploadFinished: () => {
setupPage()
}})
event.preventDefault()
}
dropZone.ondragleave = function (event) {
isDragTarget.value = false
}
dropZone.ondrop = async function (event) {
event.preventDefault()
}
} finally {
loadingDocs.value = false
loaded.value = true
}
}
// --- Global Drag & Drop (Auto-Open Upload Modal) ---
let dragCounter = 0
const handleGlobalDragEnter = (e) => {
dragCounter++
if (draggedItem.value) return
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) {
modal.open(DocumentUploadModal, {
fileData: {
folder: currentFolder.value?.id,
type: currentFolder?.value?.standardFiletype,
typeEnabled: currentFolder?.value?.standardFiletype ? currentFolder?.value?.standardFiletypeIsOptional : true
},
onUploadFinished: () => {
setupPage()
const currentFolders = computed(() => {
if(folders.value.length > 0) {
let tempFolders = folders.value.filter(i => currentFolder.value ? i.parent === currentFolder.value.id : !i.parent)
return tempFolders
} else return []
})
const breadcrumbLinks = computed(() => {
if(currentFolder.value) {
let parents = []
const addParent = (parent) => {
parents.push(parent)
if(parent.parent) {
addParent(folders.value.find(i => i.id === parent.parent))
}
}
if(currentFolder.value.parent) {
addParent(folders.value.find(i => i.id === currentFolder.value.parent))
}
return [{
label: "Home",
click: () => {
changeFolder(null)
},
icon: "i-heroicons-folder"
},
...parents.map(i => {
return {
label: folders.value.find(x => x.id === i.id).name,
click: () => {
changeFolder(i)
},
icon: "i-heroicons-folder"
}
}).reverse(),
{
label: currentFolder.value.name,
click: () => {
changeFolder(currentFolder.value)
},
icon: "i-heroicons-folder"
}]
} else {
return [{
label: "Home",
click: () => {
changeFolder(null)
},
icon: "i-heroicons-folder"
}]
dragCounter = 0
}
})
}
}
const filteredDocuments = computed(() => {
const handleGlobalDragLeave = (e) => {
dragCounter--
}
return documents.value.filter(i => currentFolder.value ? i.folder === currentFolder.value.id : !i.folder)
const handleGlobalDrop = (e) => {
dragCounter = 0
e.preventDefault()
}
const handleGlobalDragOver = (e) => {
e.preventDefault()
}
onMounted(() => {
setupPage()
window.addEventListener('dragenter', handleGlobalDragEnter)
window.addEventListener('dragleave', handleGlobalDragLeave)
window.addEventListener('dragover', handleGlobalDragOver)
window.addEventListener('drop', handleGlobalDrop)
})
const changeFolder = async (newFolder) => {
onUnmounted(() => {
window.removeEventListener('dragenter', handleGlobalDragEnter)
window.removeEventListener('dragleave', handleGlobalDragLeave)
window.removeEventListener('dragover', handleGlobalDragOver)
window.removeEventListener('drop', handleGlobalDrop)
})
// --- Navigation ---
const changeFolder = async (folder) => {
currentFolder.value = folder
await router.push(folder ? `/files?folder=${folder.id}` : `/files`)
}
const navigateUp = () => {
if (!currentFolder.value) return
const parent = folders.value.find(f => f.id === currentFolder.value.parent)
changeFolder(parent || null)
}
// --- Drag & Drop (Internal Sort) ---
const handleDragStart = (entry) => {
draggedItem.value = entry
}
const handleDrop = async (targetFolderId) => {
if (!draggedItem.value) return
if (draggedItem.value.id === targetFolderId) return
loadingDocs.value = true
currentFolder.value = newFolder
if(newFolder) {
fileUploadFormData.value.folder = newFolder.id
await router.push(`/files?folder=${newFolder.id}`)
try {
if (draggedItem.value.type === 'file') {
await useEntities("files").update(draggedItem.value.id, {folder: targetFolderId})
} else {
fileUploadFormData.value.folder = null
await router.push(`/files`)
await useEntities("folders").update(draggedItem.value.id, {parent: targetFolderId})
}
toast.add({title: 'Erfolgreich verschoben', icon: 'i-heroicons-check-circle', color: 'green'})
await setupPage()
} catch (e) {
toast.add({title: 'Fehler beim Verschieben', color: 'red'})
} finally {
draggedItem.value = null
loadingDocs.value = false
}
}
setupPage()
// --- Breadcrumbs ---
const breadcrumbLinks = computed(() => {
const links = [{label: "Home", icon: "i-heroicons-home", click: () => changeFolder(null)}]
if (currentFolder.value) {
const path = []
let curr = currentFolder.value
while (curr) {
const folderObj = curr
path.unshift({
label: folderObj.name,
icon: "i-heroicons-folder",
click: () => changeFolder(folderObj)
})
curr = folders.value.find(f => f.id === curr.parent)
}
const createFolderData = ref({})
const createFolder = async () => {
const res = await useEntities("folders").create({
parent: currentFolder.value ? currentFolder.value.id : undefined,
name: createFolderData.value.name,
return [...links, ...path]
}
return links
})
createFolderModalOpen.value = false
setupPage()
}
const downloadSelected = async () => {
let files = []
files = filteredDocuments.value.filter(i => selectedFiles.value[i.id] === true).map(i => i.path)
await useFiles().downloadFile(undefined,Object.keys(selectedFiles.value))
}
const searchString = ref(tempStore.searchStrings["files"] ||'')
// --- Data Mapping ---
const renderedFileList = computed(() => {
let files = filteredDocuments.value.map(i => {
return {
label: i.path.split("/")[i.path.split("/").length -1],
id: i.id,
type: "file"
const folderList = folders.value
.filter(i => currentFolder.value ? i.parent === currentFolder.value.id : !i.parent)
.map(i => ({...i, label: i.name, type: "folder"}))
.sort((a, b) => a.label.localeCompare(b.label))
const fileList = documents.value
.filter(i => currentFolder.value ? i.folder === currentFolder.value.id : !i.folder)
.map(i => ({...i, label: i.path.split("/").pop(), type: "file"}))
.sort((a, b) => a.label.localeCompare(b.label))
let combined = [...folderList, ...fileList]
if (debouncedSearch.value) {
combined = useSearch(debouncedSearch.value, combined)
}
if (currentFolder.value && !debouncedSearch.value) {
combined.unshift({
id: 'go-up',
label: '..',
type: 'up',
parentId: currentFolder.value.parent || null
})
}
return combined
})
arraySort(files, (a,b) => {
let aVal = a.path ? a.path.split("/")[a.path.split("/").length -1] : null
let bVal = b.path ? b.path.split("/")[b.path.split("/").length -1] : null
// --- Logic: Mandatory File Types ---
// Prüft, ob der aktuelle Ordner strikte Vorgaben macht
const isParentTypeMandatory = computed(() => {
return currentFolder.value
&& currentFolder.value.standardFiletype
&& !currentFolder.value.standardFiletypeIsOptional
})
if(aVal && bVal) {
return aVal.localeCompare(bVal)
} else if(!aVal && bVal) {
return 1
// Überwacht das Öffnen des "Ordner erstellen" Modals, um Vorgaben zu setzen
watch(createFolderModalOpen, (isOpen) => {
if (isOpen) {
if (isParentTypeMandatory.value) {
// Wenn Elternordner strikt ist: Werte übernehmen und erzwingen
createFolderData.value = {
name: '',
standardFiletype: currentFolder.value.standardFiletype,
standardFiletypeIsOptional: false // Muss auch false sein, damit Kette weitergeht
}
} else {
return -1
// Reset auf Standardwerte
createFolderData.value = { ...initialCreateFolderData }
}
}, {reverse: true})
if(searchString.value.length > 0) {
files = useSearch(searchString.value, files)
}
let folders = currentFolders.value.map(i => {
return {
label: i.name,
id: i.id,
type: "folder"
}
})
arraySort(folders, "label")
/*folders.sort(function(a, b) {
// Compare the 2 dates
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
});*/
return [...folders,...files]
// --- Actions ---
const createFolder = async () => {
await useEntities("folders").create({
parent: currentFolder.value?.id,
name: createFolderData.value.name,
standardFiletype: createFolderData.value.standardFiletype,
standardFiletypeIsOptional: createFolderData.value.standardFiletypeIsOptional
})
const selectedFileIndex = ref(0)
createFolderModalOpen.value = false
// Reset passiert beim nächsten Öffnen durch den Watcher, aber sicherheitshalber hier clean
createFolderData.value = { ...initialCreateFolderData }
setupPage()
}
const openRenameModal = (entry) => {
renameData.value = {id: entry.id, name: entry.label, type: entry.type}
renameModalOpen.value = true
}
const updateName = async () => {
if (!renameData.value.name) return
loadingDocs.value = true
try {
if (renameData.value.type === 'folder') {
await useEntities("folders").update(renameData.value.id, {name: renameData.value.name})
} else {
const file = documents.value.find(d => d.id === renameData.value.id)
const pathParts = file.path.split('/')
pathParts[pathParts.length - 1] = renameData.value.name
await useEntities("files").update(renameData.value.id, {path: pathParts.join('/')})
}
toast.add({title: 'Umbenannt', color: 'green'})
setupPage()
} finally {
renameModalOpen.value = false
loadingDocs.value = false
}
}
const showFile = (fileId) => {
modal.open(DocumentDisplayModal, {
documentData: documents.value.find(i => i.id === fileId),
onUpdatedNeeded: setupPage()
onUpdatedNeeded: () => setupPage()
})
}
const selectedFiles = ref({});
const selectAll = () => {
if(Object.keys(selectedFiles.value).find(i => selectedFiles.value[i] === true)) {
selectedFiles.value = {}
} else {
selectedFiles.value = Object.fromEntries(filteredDocuments.value.map(i => i.id).map(k => [k,true]))
defineShortcuts({
'/': () => document.getElementById("searchinput")?.focus(),
'Enter': {
usingInput: true,
handler: () => {
const entry = renderedFileList.value[selectedFileIndex.value]
if (!entry) return
if (entry.type === "file") showFile(entry.id)
else if (entry.type === "folder") changeFolder(entry)
else if (entry.type === "up") navigateUp()
}
},
'arrowdown': () => {
if (selectedFileIndex.value < renderedFileList.value.length - 1) selectedFileIndex.value++
},
'arrowup': () => {
if (selectedFileIndex.value > 0) selectedFileIndex.value--
}
})
const isSyncing = ref(false)
const syncdokubox = async () => {
isSyncing.value = true
try {
await $api('/api/functions/services/syncdokubox', {method: 'POST'})
toast.add({
title: 'Erfolg',
description: 'Dokubox wurde synchronisiert.',
icon: 'i-heroicons-check-circle',
color: 'green'
})
await setupPage()
} catch (error) {
console.error(error)
toast.add({
title: 'Fehler',
description: 'Beim Synchronisieren der Dokubox ist ein Fehler aufgetreten.',
icon: 'i-heroicons-exclamation-circle',
color: 'red'
})
} finally {
isSyncing.value = false
}
}
const clearSearchString = () => {
tempStore.clearSearchString("files")
searchString.value = ''
}
</script>
<template>
<UDashboardNavbar
title="Dateien"
>
<UDashboardNavbar title="Dateien">
<template #right>
<UInput
id="searchinput"
v-model="searchString"
icon="i-heroicons-funnel"
autocomplete="off"
placeholder="Suche..."
class="hidden lg:block"
@keydown.esc="$event.target.blur()"
@change="tempStore.modifySearchString('files',searchString)"
>
<template #trailing>
<UKbd value="/" />
</template>
</UInput>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
@click="clearSearchString()"
v-if="searchString.length > 0"
label="Dokubox Sync"
icon="i-heroicons-sparkles"
color="primary"
variant="solid"
:loading="isSyncing"
@click="syncdokubox"
class="mr-2"
/>
<UInput id="searchinput" v-model="searchString" icon="i-heroicons-magnifying-glass" placeholder="Suche..." class="w-64"/>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<UDashboardToolbar class="sticky top-0 z-30 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
<template #left>
<UBreadcrumb
:links="breadcrumbLinks"
/>
<UBreadcrumb :links="breadcrumbLinks"/>
</template>
<template #right>
<USelectMenu
:options="displayModes"
value-attribute="key"
option-attribute="label"
v-model="displayMode"
:ui-menu="{ width: 'min-w-max'}"
>
<USelectMenu v-model="displayMode" :options="displayModes" value-attribute="key" class="w-32" :ui-menu="{ zIndex: 'z-50' }">
<template #label>
<UIcon class="w-5 h-5" :name="displayModes.find(i => i.key === displayMode).icon"/>
<UIcon :name="displayModes.find(i => i.key === displayMode).icon" class="w-4 h-4"/>
<span>{{ displayModes.find(i => i.key === displayMode).label }}</span>
</template>
</USelectMenu>
<UButton
:disabled="!currentFolder"
@click="modal.open(DocumentUploadModal,{fileData: {folder: currentFolder.id, type: currentFolder.standardFiletype, typeEnabled: currentFolder.standardFiletypeIsOptional}, onUploadFinished: () => {setupPage()}})"
>+ Datei</UButton>
<UButton
@click="createFolderModalOpen = true"
variant="outline"
>+ Ordner</UButton>
<UButton
@click="downloadSelected"
icon="i-heroicons-cloud-arrow-down"
variant="outline"
v-if="Object.keys(selectedFiles).find(i => selectedFiles[i] === true)"
>Herunterladen</UButton>
<UModal v-model="createFolderModalOpen">
<UCard :ui="{ body: { base: 'space-y-4' } }">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Ordner Erstellen
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="createFolderModalOpen = false" />
</div>
<UButtonGroup size="sm">
<UButton icon="i-heroicons-document-plus" color="primary"
@click="modal.open(DocumentUploadModal, { fileData: { folder: currentFolder?.id,type: currentFolder?.standardFiletype, typeEnabled: currentFolder?.standardFiletype ? currentFolder?.standardFiletypeIsOptional : true }, onUploadFinished: setupPage })">
Datei
</UButton>
<UButton icon="i-heroicons-folder-plus" color="white" @click="createFolderModalOpen = true">Ordner</UButton>
</UButtonGroup>
</template>
</UDashboardToolbar>
<UFormGroup label="Name des Ordners" required>
<UInput
v-model="createFolderData.name"
placeholder="z.B. Rechnungen 2024"
autofocus
<UDashboardPanelContent class="p-0 overflow-y-auto">
<div v-if="!loaded" class="p-10 flex justify-center">
<UProgress animation="carousel" class="w-1/2"/>
</div>
<div v-else>
<div v-if="displayMode === 'list'">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0 z-20">
<tr>
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold sm:pl-6">Name</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold">Erstellt</th>
<th class="relative py-3.5 pl-3 pr-4 sm:pr-6"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800 bg-white dark:bg-gray-900">
<tr
v-for="(entry, index) in renderedFileList" :key="entry.id"
:draggable="entry.type !== 'up'"
@dragstart="handleDragStart(entry)"
@dragover.prevent
@drop="entry.type === 'folder' ? handleDrop(entry.id) : (entry.type === 'up' ? handleDrop(entry.parentId) : null)"
class="hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer transition-colors"
:class="{'bg-primary-50 dark:bg-primary-900/10': index === selectedFileIndex}"
@click="entry.type === 'folder' ? changeFolder(entry) : (entry.type === 'up' ? navigateUp() : showFile(entry.id))"
>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm sm:pl-6">
<div class="flex items-center font-medium">
<UIcon
:name="entry.type === 'up' ? 'i-heroicons-arrow-uturn-left' : (entry.type === 'folder' ? 'i-heroicons-folder-solid' : 'i-heroicons-document-text')"
class="h-5 w-5 mr-3"
:class="entry.type === 'up' ? 'text-orange-500' : (entry.type === 'folder' ? 'text-primary-500' : 'text-gray-400')"
/>
{{ entry.label }}
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{{ entry.type !== 'up' ? dayjs(entry.createdAt).format("DD.MM.YY") : '-' }}
</td>
<td class="px-3 text-right" @click.stop>
<UDropdown v-if="entry.type !== 'up'"
:items="[[{ label: 'Umbenennen', icon: 'i-heroicons-pencil-square', click: () => openRenameModal(entry) }]]">
<UButton color="gray" variant="ghost" icon="i-heroicons-ellipsis-horizontal"/>
</UDropdown>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="p-6 grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4">
<div
v-for="entry in renderedFileList" :key="entry.id"
:draggable="entry.type !== 'up'"
@dragstart="handleDragStart(entry)"
@dragover.prevent
@drop="entry.type === 'folder' ? handleDrop(entry.id) : (entry.type === 'up' ? handleDrop(entry.parentId) : null)"
class="group relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 flex flex-col items-center hover:border-primary-500 transition-all cursor-pointer"
@click="entry.type === 'folder' ? changeFolder(entry) : (entry.type === 'up' ? navigateUp() : showFile(entry.id))"
>
<UDropdown v-if="entry.type !== 'up'"
:items="[[{ label: 'Umbenennen', icon: 'i-heroicons-pencil-square', click: () => openRenameModal(entry) }]]"
class="absolute top-1 right-1 opacity-0 group-hover:opacity-100" @click.stop>
<UButton color="gray" variant="ghost" icon="i-heroicons-ellipsis-vertical" size="xs"/>
</UDropdown>
<UIcon
:name="entry.type === 'up' ? 'i-heroicons-arrow-uturn-left' : (entry.type === 'folder' ? 'i-heroicons-folder-solid' : 'i-heroicons-document-text')"
class="w-12 h-12 mb-2"
:class="entry.type === 'up' ? 'text-orange-500' : (entry.type === 'folder' ? 'text-primary-500' : 'text-gray-400')"
/>
<span class="text-xs font-medium text-center truncate w-full">{{ entry.label }}</span>
</div>
</div>
</div>
</UDashboardPanelContent>
<UModal v-model="createFolderModalOpen">
<UCard>
<template #header><h3 class="font-bold">Ordner erstellen</h3></template>
<div class="space-y-4">
<UFormGroup label="Name" required>
<UInput v-model="createFolderData.name" autofocus @keyup.enter="createFolder"/>
</UFormGroup>
<UFormGroup
label="Standard Dateityp"
label="Standard Dateityp (Tag)"
:help="isParentTypeMandatory ? 'Vom übergeordneten Ordner vorgegeben' : ''"
>
<USelectMenu
v-model="createFolderData.standardFiletype"
:options="filetags"
option-attribute="name"
value-attribute="id"
option-attribute="name"
placeholder="Kein Standardtyp"
searchable
searchable-placeholder="Typ suchen..."
placeholder="Kein Standard-Typ"
clear-search-on-close
>
<template #label>
<span v-if="createFolderData.standardFiletype">
{{ filetags.find(t => t.id === createFolderData.standardFiletype)?.name }}
</span>
<span v-else class="text-gray-400">Kein Typ ausgewählt</span>
</template>
</USelectMenu>
:disabled="isParentTypeMandatory"
/>
</UFormGroup>
<div v-if="createFolderData.standardFiletype">
<UCheckbox
v-model="createFolderData.standardFiletypeIsOptional"
name="isOptional"
label="Dateityp ist optional"
help="Wenn deaktiviert, MUSS der Nutzer beim Upload diesen Typ verwenden."
:disabled="isParentTypeMandatory"
/>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="ghost" @click="createFolderModalOpen = false">
Abbrechen
</UButton>
<UButton @click="createFolder" :disabled="!createFolderData.name">
Erstellen
</UButton>
<UButton color="gray" @click="createFolderModalOpen = false">Abbrechen</UButton>
<UButton color="primary" @click="createFolder">Erstellen</UButton>
</div>
</template>
</UCard>
</UModal>
</template>
</UDashboardToolbar>
<div id="drop_zone" class="h-full scrollList" >
<div v-if="loaded">
<UDashboardPanelContent>
<div v-if="displayMode === 'list'">
<table class="w-full">
<thead>
<tr>
<td>
<UCheckbox
v-if="renderedFileList.find(i => i.type === 'file')"
@change="selectAll"
/>
</td>
<td class="font-bold">Name</td>
<td class="font-bold">Erstellt am</td>
</tr>
</thead>
<tr v-for="(entry,index) in renderedFileList">
<td>
<UCheckbox
v-if="entry.type === 'file'"
v-model="selectedFiles[entry.id]"
/>
</td>
<td>
<UIcon class="mr-1" :name="entry.type === 'folder' ? 'i-heroicons-folder' : 'i-heroicons-document'"/>
<a
style="cursor: pointer"
:class="[...index === selectedFileIndex ? ['text-primary', 'text-xl'] : ['dark:text-white','text-black','text-xl']]"
@click="entry.type === 'folder' ? changeFolder(currentFolders.find(i => i.id === entry.id)) : showFile(entry.id)"
>{{entry.label}}</a>
</td>
<td>
<span v-if="entry.type === 'file'" class="text-xl">{{dayjs(documents.find(i => i.id === entry.id).createdAt).format("DD.MM.YY HH:mm")}}</span>
<span v-if="entry.type === 'folder'" class="text-xl">{{dayjs(currentFolders.find(i => i.id === entry.id).createdAt).format("DD.MM.YY HH:mm")}}</span>
</td>
</tr>
</table>
</div>
<div v-else-if="displayMode === 'rectangles'">
<div class="flex flex-row w-full flex-wrap" v-if="currentFolders.length > 0">
<a
class="w-1/6 folderIcon flex flex-col p-5 m-2"
v-for="folder in currentFolders"
@click="changeFolder(folder)"
>
<UIcon
name="i-heroicons-folder"
class="w-20 h-20"
/>
<span class="text-center truncate">{{folder.name}}</span>
</a>
</div>
<UDivider class="my-5" v-if="currentFolder">{{currentFolder.name}}</UDivider>
<UDivider class="my-5" v-else>Ablage</UDivider>
<div v-if="!loadingDocs">
<DocumentList
v-if="filteredDocuments.length > 0"
:documents="filteredDocuments"
@selectDocument="(info) => console.log(info)"
/>
<UAlert
v-else
class="mt-5 w-1/2 mx-auto"
icon="i-heroicons-light-bulb"
title="Keine Dokumente vorhanden"
color="primary"
variant="outline"
/>
</div>
<UProgress
animation="carousel"
v-else
class="w-2/3 my-5 mx-auto"
/>
</div>
</UDashboardPanelContent>
</div>
<UProgress animation="carousel" v-else class="w-5/6 mx-auto mt-5"/>
<UModal v-model="renameModalOpen">
<UCard>
<template #header><h3 class="font-bold">Umbenennen</h3></template>
<UFormGroup label="Neuer Name">
<UInput v-model="renameData.name" autofocus @keyup.enter="updateName"/>
</UFormGroup>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="gray" @click="renameModalOpen = false">Abbrechen</UButton>
<UButton color="primary" @click="updateName">Speichern</UButton>
</div>
</template>
<style scoped>
.folderIcon {
border: 1px solid lightgrey;
border-radius: 10px;
color: dimgrey;
}
.folderIcon:hover {
border: 1px solid #69c350;
color: #69c350;
}
tr:nth-child(odd) {
background-color: rgba(0, 0, 0, 0.05);
}
</style>
</UCard>
</UModal>
</template>

View File

@@ -1,15 +1,25 @@
<script setup>
import InputGroup from "~/components/InputGroup.vue";
import dayjs from "dayjs";
import HistoryDisplay from "~/components/HistoryDisplay.vue";
import { useDraggable } from '@vueuse/core'
// --- Standard Setup & Data ---
const dataStore = useDataStore()
const route = useRoute()
const mode = ref(route.params.mode)
// --- Page Leave Logic ---
const isModified = ref(false) // Speichert, ob Änderungen vorgenommen wurden
// State für das PDF Fenster
const isPdfDetached = ref(false)
const pdfEl = ref(null)
const { style: pdfStyle } = useDraggable(pdfEl, {
initialValue: { x: 50, y: 100 },
})
const itemInfo = ref({
vendor: 0,
vendor: null,
expense: true,
reference: "",
date: null,
@@ -23,7 +33,8 @@ const itemInfo = ref({
amountNet: null,
amountTax: null,
taxType: "19",
costCentre: null
costCentre: null,
amountGross: null
}
]
})
@@ -31,151 +42,126 @@ const itemInfo = ref({
const costcentres = ref([])
const vendors = ref([])
const accounts = ref([])
const mode = ref(route.params.mode)
const loadedFileId = ref(null)
const setup = async () => {
let filetype = (await useEntities("filetags").select()).find(i=> i.incomingDocumentType === "invoices").id
console.log(filetype)
// 1. Daten laden
costcentres.value = await useEntities("costcentres").select()
vendors.value = await useEntities("vendors").select()
accounts.value = await useEntities("accounts").selectSpecial()
itemInfo.value = await useEntities("incominginvoices").selectSingle(route.params.id, "*, files(*)")
const invoiceData = await useEntities("incominginvoices").selectSingle(route.params.id, "*, files(*)")
//TODO: Dirty Fix
itemInfo.value.vendor = itemInfo.value.vendor?.id
// 2. Mapping
itemInfo.value = {
...invoiceData,
vendor: invoiceData.vendor?.id || invoiceData.vendor,
accounts: invoiceData.accounts || []
}
await loadFile(itemInfo.value.files[itemInfo.value.files.length-1].id)
// Fallback Accounts
if(itemInfo.value.accounts.length === 0) {
itemInfo.value.accounts.push({ account: null, amountNet: null, amountTax: null, taxType: "19", costCentre: null })
}
// Datei laden
if (itemInfo.value.files && itemInfo.value.files.length > 0) {
loadedFileId.value = itemInfo.value.files[itemInfo.value.files.length-1].id
}
if(itemInfo.value.date && !itemInfo.value.dueDate) itemInfo.value.dueDate = itemInfo.value.date
// 3. Watcher initialisieren (erst NACH dem Laden der Daten)
// Wir warten einen Tick, damit die Initialisierung nicht als Änderung zählt
await nextTick()
isModified.value = false // Sicherstellen, dass Status sauber ist
watch(itemInfo, () => {
if (mode.value !== 'show') {
isModified.value = true
}
}, { deep: true })
}
setup()
// --- Berechnungslogik ---
const useNetMode = ref(false)
const loadedFile = ref(null)
const loadFile = async (id) => {
console.log(id)
loadedFile.value = await useFiles().selectDocument(id)
console.log(loadedFile.value)
}
const changeNetMode = (mode) => {
useNetMode.value = mode
//itemInfo.value.accounts = [{account: null,amountNet: null,amountTax: null,taxType: '19'}]
}
const taxOptions = ref([
{
label: "19% USt",
percentage: 19,
key: "19"
},{
label: "7% USt",
percentage: 7,
key: "7"
},{
label: "Innergemeintschaftlicher Erwerb 19%",
percentage: 0,
key: "19I"
},{
label: "Innergemeintschaftlicher Erwerb 7%",
percentage: 0,
key: "7I"
},{
label: "§13b UStG",
percentage: 0,
key: "13B"
},{
label: "Keine USt",
percentage: 0,
key: "null"
},
{ label: "19% USt", percentage: 19, key: "19" },
{ label: "7% USt", percentage: 7, key: "7" },
{ label: "IG Erwerb 19%", percentage: 0, key: "19I" },
{ label: "IG Erwerb 7%", percentage: 0, key: "7I" },
{ label: "§13b UStG", percentage: 0, key: "13B" },
{ label: "Keine USt", percentage: 0, key: "null" },
])
const totalCalculated = computed(() => {
let totalNet = 0
let totalAmount19Tax = 0
let totalAmount7Tax = 0
let totalAmount0Tax = 0
let totalGross = 0
itemInfo.value.accounts.forEach(account => {
if(account.amountNet) totalNet += account.amountNet
if(account.amountNet) totalNet += Number(account.amountNet)
if(account.taxType === "19" && account.amountTax) {
totalAmount19Tax += account.amountTax
totalAmount19Tax += Number(account.amountTax)
} else if(account.taxType === "7" && account.amountTax) {
totalAmount7Tax += account.amountTax
totalAmount7Tax += Number(account.amountTax)
}
})
totalGross = Number(totalNet + totalAmount19Tax + totalAmount7Tax)
return {
totalNet,
totalAmount19Tax,
totalAmount7Tax,
totalGross
}
return { totalNet, totalAmount19Tax, totalAmount7Tax, totalGross }
})
const updateIncomingInvoice = async (setBooked = false) => {
const recalculateItem = (item, source) => {
const taxRate = Number(taxOptions.value.find(i => i.key === item.taxType)?.percentage || 0);
let item = itemInfo.value
delete item.files
if(item.state === "Vorbereitet" && !setBooked) {
item.state = "Entwurf"
} else if(item.state === "Vorbereitet" && setBooked) {
item.state = "Gebucht"
} else if(item.state === "Entwurf" && setBooked) {
item.state = "Gebucht"
} else {
item.state = "Entwurf"
if (source === 'net') {
item.amountTax = Number((item.amountNet * (taxRate/100)).toFixed(2))
item.amountGross = Number((Number(item.amountNet) + item.amountTax).toFixed(2))
} else if (source === 'gross') {
item.amountNet = Number((item.amountGross / (1 + taxRate/100)).toFixed(2))
item.amountTax = Number((item.amountGross - item.amountNet).toFixed(2))
} else if (source === 'taxType') {
if(item.amountNet) {
item.amountTax = Number((item.amountNet * (taxRate/100)).toFixed(2))
item.amountGross = Number((Number(item.amountNet) + item.amountTax).toFixed(2))
}
const data = await useEntities('incominginvoices').update(itemInfo.value.id,item,true)
}
}
// --- Saving ---
const updateIncomingInvoice = async (setBooked = false) => {
let item = { ...itemInfo.value }
delete item.files
item.state = setBooked ? "Gebucht" : "Entwurf"
await useEntities('incominginvoices').update(itemInfo.value.id, item, !setBooked)
// WICHTIG: Nach dem Speichern ist das Formular "sauber"
isModified.value = false
}
const findIncomingInvoiceErrors = computed(() => {
let errors = []
const i = itemInfo.value
if(itemInfo.value.vendor === null) errors.push({message: "Es ist kein Lieferant ausgewählt", type: "breaking"})
if(itemInfo.value.reference === null || itemInfo.value.reference.length === 0) errors.push({message: "Es ist keine Referenz angegeben", type: "breaking"})
if(itemInfo.value.date === null) errors.push({message: "Es ist kein Datum ausgewählt", type: "breaking"})
if(itemInfo.value.dueDate === null) errors.push({message: "Es ist kein Fälligkeitsdatum ausgewählt", type: "breaking"})
if(itemInfo.value.paymentType === null) errors.push({message: "Es ist keine Zahlart ausgewählt", type: "breaking"})
if(itemInfo.value.description === null) errors.push({message: "Es ist keine Beschreibung angegeben", type: "info"})
itemInfo.value.accounts.forEach(account => {
if(account.account === null) errors.push({message: "Es ist keine Kategorie ausgewählt", type: "breaking"})
if(!accounts.value.find(i => i.id === account.account)) errors.push({message: "Es ist keine Kategorie ausgewählt", type: "breaking"})
if(account.amountNet === null) errors.push({message: "Es ist kein Nettobetrag angegeben", type: "breaking"})
if(account.taxType === null) errors.push({message: "Es ist kein Steuertyp ausgewählt", type: "breaking"})
if(account.costCentre === null) errors.push({message: "Es ist keine Kostenstelle ausgewählt", type: "info"})
if(account.taxType === null || account.taxType === "0") errors.push({message: "Es ist keine Steuerart ausgewählt", type: "breaking"})
if(!i.vendor) errors.push({message: "Es ist kein Lieferant ausgewählt", type: "breaking"})
if(!i.reference) errors.push({message: "Es ist keine Referenz angegeben", type: "breaking"})
if(!i.date) errors.push({message: "Es ist kein Datum ausgewählt", type: "breaking"})
i.accounts.forEach((account, idx) => {
if(!account.account) errors.push({message: `Pos ${idx+1}: Keine Kategorie`, type: "breaking"})
if(account.amountNet === null) errors.push({message: `Pos ${idx+1}: Kein Betrag`, type: "breaking"})
})
return errors.sort((a,b) => (a.type === "breaking") ? -1 : 1)
})
</script>
<template>
@@ -190,437 +176,355 @@ const findIncomingInvoiceErrors = computed(() => {
</UButton>
</template>
<template #center>
<h1
class="text-xl font-medium"
>{{`Eingangsbeleg ${mode === 'show' ? 'anzeigen' : 'bearbeiten'}`}}</h1>
<h1 class="text-xl font-medium">
{{`Eingangsbeleg ${mode === 'show' ? 'anzeigen' : 'bearbeiten'}`}}
</h1>
</template>
<template #right>
<ArchiveButton
v-if="mode !== 'show'"
color="rose"
variant="outline"
type="incominginvoices"
@confirmed="useEntities('incominginvoices').archive(route.params.id)"
v-if="mode !== 'show'"
/>
<UButton
@click="updateIncomingInvoice(false)"
v-if="mode !== 'show'"
>
<UButton v-if="mode !== 'show'" @click="updateIncomingInvoice(false)">
Speichern
</UButton>
<UButton
@click="updateIncomingInvoice(true)"
v-if="mode !== 'show'"
@click="updateIncomingInvoice(true)"
:disabled="findIncomingInvoiceErrors.filter(i => i.type === 'breaking').length > 0"
>
Speichern & Buchen
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<UDashboardPanelContent class="p-0 overflow-hidden relative">
<div class="flex h-[calc(100vh-4rem)]">
<div
class="flex justify-between mt-5 workingContainer"
v-if="loadedFile"
v-if="!isPdfDetached && loadedFileId"
class="w-1/2 h-full bg-gray-100 border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700 flex flex-col"
>
<object
v-if="loadedFile"
:data="loadedFile.url + '#toolbar=0&navpanes=0&scrollbar=0&statusbar=0&messages=0&scrollbar=0'"
type="application/pdf"
class="mx-5 documentPreview"
<div class="p-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 flex justify-between items-center">
<span class="text-xs text-gray-500 font-mono pl-2">Vorschau</span>
<UTooltip text="Dokument lösen (Draggable Window)">
<UButton
icon="i-heroicons-arrows-pointing-out"
variant="ghost"
size="xs"
@click="isPdfDetached = true"
>
Lösen
</UButton>
</UTooltip>
</div>
<div class="flex-grow overflow-hidden relative">
<PDFViewer
:file-id="loadedFileId"
location="split_view"
class="h-full w-full"
/>
<div class="w-3/5 mx-5">
<UAlert
class="mb-5"
title="Vorhandene Probleme und Informationen:"
:color="findIncomingInvoiceErrors.filter(i => i.type === 'breaking').length > 0 ? 'rose' : 'white'"
</div>
</div>
<div
class="flex flex-col bg-white dark:bg-gray-900 overflow-y-auto transition-all duration-300"
:class="(!isPdfDetached && loadedFileId) ? 'w-1/2' : 'w-full'"
>
<div class="p-6 max-w-4xl mx-auto w-full space-y-6 pb-20">
<UButton
v-if="isPdfDetached && loadedFileId"
icon="i-heroicons-arrows-pointing-in"
variant="outline"
class="mb-4"
@click="isPdfDetached = false"
>
Dokument andocken
</UButton>
<UAlert
v-if="findIncomingInvoiceErrors.length > 0"
title="Prüfung erforderlich"
:color="findIncomingInvoiceErrors.some(i => i.type === 'breaking') ? 'rose' : 'orange'"
variant="soft"
icon="i-heroicons-exclamation-triangle"
>
<template #description>
<ul class="list-disc ml-5">
<li v-for="error in findIncomingInvoiceErrors" :class="[...error.type === 'breaking' ? ['text-rose-600'] : ['dark:text-white','text-black']]">
{{error.message}}
</li>
<ul class="list-disc list-inside text-sm mt-1">
<li v-for="(err, idx) in findIncomingInvoiceErrors" :key="idx">{{ err.message }}</li>
</ul>
</template>
</UAlert>
<div class="scrollContainer">
<InputGroup class="mb-3">
<UButton
:variant="itemInfo.expense ? 'solid' : 'outline'"
@click="itemInfo.expense = true"
:disabled="mode === 'show'"
>
Ausgabe
</UButton>
<UButton
:variant="!itemInfo.expense ? 'solid' : 'outline'"
@click="itemInfo.expense = false"
:disabled="mode === 'show'"
>
Einnahme
</UButton>
</InputGroup>
<UCard>
<template #header>
<div class="flex justify-between items-center">
<h3 class="font-semibold">Stammdaten</h3>
<div class="flex bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
<UButton size="xs" :variant="itemInfo.expense ? 'solid' : 'ghost'" color="rose" @click="itemInfo.expense = true" :disabled="mode === 'show'">Ausgabe</UButton>
<UButton size="xs" :variant="!itemInfo.expense ? 'solid' : 'ghost'" color="emerald" @click="itemInfo.expense = false" :disabled="mode === 'show'">Einnahme</UButton>
</div>
</div>
</template>
<UFormGroup label="Lieferant:" >
<InputGroup>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormGroup label="Lieferant / Partner" class="md:col-span-2">
<div class="flex gap-2">
<USelectMenu
:disabled="mode === 'show'"
class="w-full"
v-model="itemInfo.vendor"
:options="vendors"
option-attribute="name"
value-attribute="id"
searchable
:disabled="mode === 'show'"
:search-attributes="['name', 'vendorNumber']"
class="flex-auto"
searchable-placeholder="Suche..."
:color="!itemInfo.vendor ? 'rose' : 'primary'"
placeholder="Lieferant suchen..."
>
<template #option="{option}">
{{option.vendorNumber}} - {{option.name}}
</template>
<template #label>
{{vendors.find(vendor => vendor.id === itemInfo.vendor) ? vendors.find(vendor => vendor.id === itemInfo.vendor).name : 'Lieferant auswählen'}}
{{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }}
</template>
<template #option="{ option }">
<span class="font-mono text-xs opacity-75 mr-2">{{ option.vendorNumber }}</span> {{ option.name }}
</template>
</USelectMenu>
<EntityModalButtons
v-if="mode !== 'show'"
type="vendors"
:id="itemInfo.vendor"
@return-data="(data) => itemInfo.vendor = data.id"
:button-edit="mode !== 'show'"
:button-create="mode !== 'show'"
/>
<UButton
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
@click="itemInfo.vendor = null"
v-if="itemInfo.vendor && mode !== 'show'"
/>
</InputGroup>
</div>
</UFormGroup>
<UFormGroup
class="mt-3"
label="Rechnungsreferenz:"
>
<UInput
v-model="itemInfo.reference"
:disabled="mode === 'show'"
/>
<UFormGroup label="Rechnungsnummer">
<UInput v-model="itemInfo.reference" icon="i-heroicons-hashtag" :disabled="mode === 'show'" />
</UFormGroup>
<InputGroup class="mt-3" gap="2">
<UFormGroup label="Rechnungsdatum:">
<UFormGroup label="Zahlart">
<USelectMenu v-model="itemInfo.paymentType" :options="['Überweisung', 'Lastschrift', 'Kreditkarte', 'PayPal', 'Bar', 'Sonstiges']" :disabled="mode === 'show'" />
</UFormGroup>
<UFormGroup label="Rechnungsdatum">
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="itemInfo.date ? dayjs(itemInfo.date).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline"
:color="!itemInfo.date ? 'rose' : 'primary'"
:disabled="mode === 'show'"
/>
<UButton block color="white" icon="i-heroicons-calendar" :label="itemInfo.date ? dayjs(itemInfo.date).format('DD.MM.YYYY') : '-'" :disabled="mode === 'show'" />
<template #panel="{ close }">
<LazyDatePicker v-model="itemInfo.date" @close="itemInfo.dueDate = itemInfo.date" />
<LazyDatePicker v-model="itemInfo.date" @close="() => { if(!itemInfo.dueDate) itemInfo.dueDate = itemInfo.date; close() }" />
</template>
</UPopover>
</UFormGroup>
<UFormGroup label="Fälligkeitsdatum:">
<UFormGroup label="Fälligkeitsdatum">
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
icon="i-heroicons-calendar-days-20-solid"
:label="itemInfo.dueDate ? dayjs(itemInfo.dueDate).format('DD.MM.YYYY') : 'Datum auswählen'"
variant="outline"
:disabled="mode === 'show'"
/>
<UButton block color="white" icon="i-heroicons-calendar" :label="itemInfo.dueDate ? dayjs(itemInfo.dueDate).format('DD.MM.YYYY') : '-'" :disabled="mode === 'show'" />
<template #panel="{ close }">
<LazyDatePicker v-model="itemInfo.dueDate" @close="close" />
</template>
</UPopover>
</UFormGroup>
</InputGroup>
<UFormGroup label="Zahlart:" >
<USelectMenu
:options="['Einzug','Kreditkarte','Überweisung','Sonstiges']"
v-model="itemInfo.paymentType"
:disabled="mode === 'show'"
/>
<UFormGroup label="Beschreibung / Notiz" class="md:col-span-2">
<UTextarea v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" />
</UFormGroup>
</div>
</UCard>
<UFormGroup label="Beschreibung:" >
<UTextarea
v-model="itemInfo.description"
:disabled="mode === 'show'"
/>
</UFormGroup>
<div class="space-y-4">
<div class="flex justify-between items-end border-b pb-2 dark:border-gray-700">
<h3 class="font-semibold text-lg">Positionen</h3>
<div class="flex items-center gap-2 text-sm">
<span :class="{'font-bold': !useNetMode, 'opacity-50': useNetMode}">Brutto</span>
<UToggle v-model="useNetMode" color="primary" :disabled="mode === 'show'" />
<span :class="{'font-bold': useNetMode, 'opacity-50': !useNetMode}">Netto Eingabe</span>
</div>
</div>
<InputGroup class="my-3">
<UButton
:variant="!useNetMode ? 'solid' : 'outline'"
@click="changeNetMode(false)"
:disabled="mode === 'show'"
>
Brutto
</UButton>
<UButton
:variant="useNetMode ? 'solid' : 'outline'"
@click="changeNetMode(true)"
:disabled="mode === 'show'"
>
Netto
</UButton>
<!-- Brutto
<UToggle
v-model="useNetMode"
@update:model-value="itemInfo.accounts = [{account: null,amountNet: null,amountTax: null,taxType: '19'}]"
/>
Netto-->
</InputGroup>
<table v-if="itemInfo.accounts.length > 1" class="w-full">
<tr>
<td>Gesamt exkl. Steuer: </td>
<td class="text-right">{{totalCalculated.totalNet.toFixed(2).replace(".",",")}} €</td>
</tr>
<tr>
<td>7% Steuer: </td>
<td class="text-right">{{totalCalculated.totalAmount7Tax.toFixed(2).replace(".",",")}} €</td>
</tr>
<tr>
<td>19% Steuer: </td>
<td class="text-right">{{totalCalculated.totalAmount19Tax.toFixed(2).replace(".",",")}} €</td>
</tr>
<tr>
<td>Gesamt inkl. Steuer: </td>
<td class="text-right">{{totalCalculated.totalGross.toFixed(2).replace(".",",")}} €</td>
</tr>
</table>
<div
class="my-3"
<UCard
v-for="(item, index) in itemInfo.accounts"
:key="index"
:ui="{ body: { padding: 'p-4 sm:p-4' } }"
class="relative border-l-4"
:class="item.amountNet ? 'border-l-primary-500' : 'border-l-gray-300 dark:border-l-gray-700'"
>
<UFormGroup
label="Kategorie"
class="mb-3"
>
<UButton
v-if="itemInfo.accounts.length > 1 && mode !== 'show'"
icon="i-heroicons-trash"
color="rose"
variant="ghost"
size="xs"
class="absolute top-2 right-2"
@click="itemInfo.accounts.splice(index, 1)"
/>
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 md:col-span-6">
<UFormGroup label="Konto / Kategorie">
<USelectMenu
v-model="item.account"
:options="accounts"
searchable
placeholder="Kategorie wählen"
option-attribute="label"
value-attribute="id"
searchable
:disabled="mode === 'show'"
:search-attributes="['label', 'number']"
searchable-placeholder="Suche..."
v-model="item.account"
:color="(item.account && accounts.find(i => i.id === item.account)) ? 'primary' : 'rose'"
>
<template #label>
{{accounts.find(account => account.id === item.account) ? accounts.find(account => account.id === item.account).label : "Keine Kategorie ausgewählt" }}
</template>
<template #option="{ option }">
{{option.number}} - {{option.label}}
<span class="font-mono text-xs text-gray-500 mr-2">{{ option.number }}</span> {{ option.label }}
</template>
<template #label>
{{ accounts.find(a => a.id === item.account)?.label || 'Auswählen' }}
</template>
</USelectMenu>
</UFormGroup>
<UFormGroup
label="Kostenstelle"
class="w-full mb-3"
>
<InputGroup class="w-full">
</div>
<div class="col-span-12 md:col-span-6">
<UFormGroup label="Kostenstelle">
<USelectMenu
v-model="item.costCentre"
:options="costcentres"
searchable
option-attribute="name"
value-attribute="id"
searchable
placeholder="Optional"
:disabled="mode === 'show'"
:search-attributes="['label','name','description','number']"
searchable-placeholder="Suche..."
v-model="item.costCentre"
class="flex-auto"
>
<template #label>
{{costcentres.find(i => i.id === item.costCentre) ? costcentres.find(i => i.id === item.costCentre).name : "Keine Kostenstelle ausgewählt" }}
{{ costcentres.find(c => c.id === item.costCentre)?.name || 'Keine' }}
</template>
<template #option="{option}">
<span v-if="option.vehicle">{{option.number}} - Fahrzeug - {{option.name}}</span>
<span v-else-if="option.project">{{option.number}} - Projekt - {{option.name}}</span>
<span v-else-if="option.inventoryitem">{{option.number}} - Inventarartikel - {{option.name}}</span>
<span v-else>{{option.number}} - {{option.name}}</span>
</template>
</USelectMenu>
<UButton
variant="outline"
color="rose"
v-if="item.costCentre && mode !== 'show'"
icon="i-heroicons-x-mark"
@click="item.costCentre = null"
/>
</InputGroup>
</UFormGroup>
<UFormGroup
label="Beschreibung"
class="w-full mb-3"
>
<InputGroup class="w-full">
</div>
<div class="col-span-12 md:col-span-4">
<UFormGroup :label="useNetMode ? 'Betrag (Netto)' : 'Betrag (Brutto)'">
<UInput
v-model="item.description"
class="flex-auto"
type="number"
step="0.01"
:disabled="mode === 'show'"
></UInput>
<UButton
variant="outline"
color="rose"
v-if="item.description && mode !== 'show'"
icon="i-heroicons-x-mark"
@click="item.description = null"
/>
</InputGroup>
</UFormGroup>
<InputGroup>
<UFormGroup
v-if="useNetMode"
label="Gesamtbetrag exkl. Steuer in EUR"
class="flex-auto truncate"
:help="item.taxType !== null ? `Betrag inkl. Steuern: ${String(Number(item.amountNet + item.amountTax).toFixed(2)).replace('.',',')}` : 'Zuerst Steuertyp festlegen' "
:model-value="useNetMode ? item.amountNet : item.amountGross"
@update:model-value="(val) => {
if(useNetMode) { item.amountNet = Number(val); recalculateItem(item, 'net') }
else { item.amountGross = Number(val); recalculateItem(item, 'gross') }
}"
>
<UInput
type="number"
step="0.01"
v-model="item.amountNet"
:color="!item.amountNet ? 'rose' : 'primary'"
:disabled="item.taxType === null || mode === 'show'"
@keyup="item.amountTax = Number((item.amountNet * (Number(taxOptions.find(i => i.key === item.taxType).percentage)/100)).toFixed(2)),
item.amountGross = Number(item.amountNet) + NUmber(item.amountTax)"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">EUR</span>
</template>
<template #trailing>€</template>
</UInput>
</UFormGroup>
<UFormGroup
v-else
label="Gesamtbetrag inkl. Steuer in EUR"
class="flex-auto"
:help="item.taxType !== null ? `Betrag exkl. Steuern: ${item.amountNet ? String(item.amountNet.toFixed(2)).replace('.',',') : '0,00'}` : 'Zuerst Steuertyp festlegen' "
</div>
>
<UInput
type="number"
step="0.01"
:disabled="item.taxType === null || mode === 'show'"
v-model="item.amountGross"
:color="!item.amountGross ? 'rose' : 'primary'"
:ui-menu="{ width: 'min-w-max' }"
@keyup="item.amountNet = Number((item.amountGross / (1 + Number(taxOptions.find(i => i.key === item.taxType).percentage)/100)).toFixed(2)),
item.amountTax = Number((item.amountGross - item.amountNet).toFixed(2))"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">EUR</span>
</template>
</UInput>
</UFormGroup>
<UFormGroup
label="Umsatzsteuer"
class="w-32"
:help="`Betrag: ${item.amountTax ? String(item.amountTax).replace('.',',') : '0,00'}`"
>
<div class="col-span-6 md:col-span-4">
<UFormGroup label="Steuerschlüssel">
<USelectMenu
:options="taxOptions"
:disabled="mode === 'show'"
:color="item.taxType === null || item.taxType === '0' ? 'rose' : 'primary'"
v-model="item.taxType"
:options="taxOptions"
value-attribute="key"
:ui-menu="{ width: 'min-w-max' }"
option-attribute="label"
@change="item.amountNet = Number((item.amountGross / (1 + Number(taxOptions.find(i => i.key === item.taxType).percentage)/100)).toFixed(2)),
item.amountTax = Number(((item.amountNet ? item.amountNet : 0) * (Number(taxOptions.find(i => i.key === item.taxType).percentage)/100)).toFixed(2))"
>
<template #label>
<span class="truncate">{{taxOptions.find(i => i.key === item.taxType) ? taxOptions.find(i => i.key === item.taxType).label : ""}}</span>
</template>
</USelectMenu>
:disabled="mode === 'show'"
@change="recalculateItem(item, 'taxType')"
/>
</UFormGroup>
</InputGroup>
</div>
<div class="col-span-6 md:col-span-4">
<UFormGroup label="Steuerbetrag" help="Automatisch berechnet">
<UInput :model-value="item.amountTax" disabled color="gray" >
<template #trailing>€</template>
</UInput>
</UFormGroup>
</div>
<div class="col-span-12">
<UInput v-model="item.description" :disabled="mode === 'show'" placeholder="Positionstext (optional)" icon="i-heroicons-bars-3-bottom-left" variant="none" class="border-b border-gray-100 dark:border-gray-800" />
</div>
</div>
</UCard>
<UButton
class="mt-3"
v-if="mode !== 'show'"
@click="itemInfo.accounts = [...itemInfo.accounts.slice(0,index+1),{account:null, amountNet: null, amountTax:null, taxType: '19'} , ...itemInfo.accounts.slice(index+1)]"
icon="i-heroicons-plus"
variant="soft"
block
@click="itemInfo.accounts.push({account:null, amountNet: null, amountTax:0, amountGross: null, taxType: '19'})"
>
Betrag aufteilen
</UButton>
<UButton
v-if="index !== 0 && mode !== 'show'"
class="mt-3"
variant="ghost"
color="rose"
@click="itemInfo.accounts = itemInfo.accounts.filter((account,itemIndex) => itemIndex !== index)"
>
Position entfernen
Weitere Position hinzufügen
</UButton>
</div>
<div class="mt-8 border-t pt-4 dark:border-gray-700">
<div class="flex justify-end">
<div class="w-full md:w-1/2 space-y-2 text-sm">
<div class="flex justify-between text-gray-500">
<span>Netto Gesamt</span>
<span>{{ totalCalculated.totalNet.toFixed(2) }} €</span>
</div>
<div class="flex justify-between text-gray-500" v-if="totalCalculated.totalAmount7Tax > 0">
<span>+ 7% USt</span>
<span>{{ totalCalculated.totalAmount7Tax.toFixed(2) }} €</span>
</div>
<div class="flex justify-between text-gray-500" v-if="totalCalculated.totalAmount19Tax > 0">
<span>+ 19% USt</span>
<span>{{ totalCalculated.totalAmount19Tax.toFixed(2) }} €</span>
</div>
<div class="flex justify-between font-bold text-xl text-gray-900 dark:text-white pt-2 border-t dark:border-gray-700">
<span>Rechnungsbetrag</span>
<span>{{ totalCalculated.totalGross.toFixed(2) }} €</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<UProgress v-else animation="carousel"/>
</UDashboardPanelContent>
<PageLeaveGuard :when="true"/>
<PageLeaveGuard :when="mode !== 'show' && isModified"/>
<div
v-if="isPdfDetached && loadedFileId"
ref="pdfEl"
:style="pdfStyle"
class="fixed z-[999] w-[600px] h-[750px] bg-white dark:bg-gray-900 shadow-2xl rounded-xl border border-gray-200 dark:border-gray-800 flex flex-col resize overflow-hidden"
>
<div class="flex items-center justify-between p-2 cursor-move border-b border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 select-none">
<div class="flex items-center gap-2 text-gray-500 px-2">
<UIcon name="i-heroicons-paper-clip" />
<span class="text-xs font-bold uppercase tracking-wider">Dokumentenansicht</span>
</div>
<div class="flex items-center gap-1">
<UTooltip text="Wieder andocken">
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-arrows-pointing-in"
size="xs"
@click="isPdfDetached = false"
/>
</UTooltip>
</div>
</div>
<div class="flex-grow relative bg-gray-200 dark:bg-gray-900 overflow-hidden">
<PDFViewer
:file-id="loadedFileId"
location="draggable_window"
class="w-full h-full"
/>
</div>
<div class="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize opacity-50">
<UIcon name="i-heroicons-arrows-pointing-out" class="w-3 h-3 text-gray-400 transform rotate-90" />
</div>
</div>
</template>
<style scoped>
.documentPreview {
aspect-ratio: 1 / 1.414;
height: 80vh;
}
.scrollContainer {
overflow-y: scroll;
height: 70vh;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollContainer::-webkit-scrollbar {
display: none;
}
.lineItemRow {
display: flex;
flex-direction: row;
}
.workingContainer {
height: 80vh;
.resize {
resize: both;
}
</style>

View File

@@ -56,7 +56,7 @@
>
<display-open-tasks/>
</UDashboardCard>
<UDashboardCard
<!-- <UDashboardCard
title="Label Test"
>
<UButton
@@ -70,7 +70,7 @@
>
Label Drucken
</UButton>
</UDashboardCard>
</UDashboardCard>-->
</UPageGrid>
</UDashboardPanelContent>
</template>

View File

@@ -20,7 +20,6 @@ defineShortcuts({
})
const dataStore = useDataStore()
const supabase = useSupabaseClient()
const route = useRoute()
const router = useRouter()
const toast = useToast()
@@ -38,7 +37,7 @@ const openTab = ref(0)
//Functions
const setupPage = async () => {
if(mode.value === "show" || mode.value === "edit"){
itemInfo.value = await useSupabaseSelectSingle("roles",route.params.id,"*")
itemInfo.value = await useEntities("roles").selectSingle(route.params.id,"*")
}
}

View File

@@ -3,7 +3,7 @@
const items = ref([])
const setup = async () => {
items.value = await useSupabaseSelect("roles","*")
items.value = await useEntities("roles").select("*")
}
setup()

Some files were not shown because too many files have changed in this diff Show More