Compare commits
69 Commits
main
...
f793d4cce6
| Author | SHA1 | Date | |
|---|---|---|---|
| f793d4cce6 | |||
| c3f46cd184 | |||
| 6bf336356d | |||
| 55699da42c | |||
| 053f184a33 | |||
| 6541cb2adf | |||
| 7dca84947e | |||
| 45fd6fda08 | |||
| 31e80fb386 | |||
| 7ea28cc6c0 | |||
| c0faa398b8 | |||
| 19be1f0d03 | |||
| c43d3225e3 | |||
| 7125d15b3f | |||
| 4b7cf171c8 | |||
| 59fdedfaa0 | |||
| 71d249d8bf | |||
| e496a62b36 | |||
| 0bfef0806b | |||
| 5c69388f1c | |||
| 7ed0388acb | |||
| 3aa0c7d77a | |||
| 77aa277347 | |||
| 2fff1ca8a8 | |||
| e58929d9a0 | |||
| 90560ecd2c | |||
| b07953fb7d | |||
| 01ef3c5a42 | |||
| 2aed851224 | |||
| c56fcfbd14 | |||
| ca2020b9c6 | |||
| c87212d54a | |||
| db22d47900 | |||
| 143485e107 | |||
| c1d4b24418 | |||
| 9655d4fa05 | |||
| 4efe452f1c | |||
| cb21a85736 | |||
| d2b70e5883 | |||
| 1a065b649c | |||
| 34c58c3755 | |||
| 37d8a414d3 | |||
| 7f4f232c32 | |||
| d6f257bcc6 | |||
| 3109f4d5ff | |||
| 235b33ae08 | |||
| 2d135b7068 | |||
| 8831320a4c | |||
| 000d409e4d | |||
| 160124a184 | |||
| 26dad422ec | |||
| e59cbade53 | |||
| 6423886930 | |||
| 6adf09faa0 | |||
| d7f3920763 | |||
| 3af92ebf71 | |||
| 5ab90830a0 | |||
| 4f72919269 | |||
| f2c9dcc900 | |||
| b4ec792cc0 | |||
| 9b3f48defe | |||
| 5edc90bd4d | |||
| d140251aa0 | |||
| e7fb2df5c7 | |||
| f27fd3f6da | |||
| d3e2b106af | |||
| 769d2059ca | |||
| 53349fae83 | |||
| d8eb1559c8 |
3
backend/.secretlintrc.json
Normal file
3
backend/.secretlintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"rules": []
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -71,4 +71,5 @@ export * from "./vendors"
|
||||
export * from "./staff_time_events"
|
||||
export * from "./serialtypes"
|
||||
export * from "./serialexecutions"
|
||||
export * from "./public_links"
|
||||
export * from "./public_links"
|
||||
export * from "./wikipages"
|
||||
99
backend/db/schema/wikipages.ts
Normal file
99
backend/db/schema/wikipages.ts
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -19,241 +19,238 @@ import {
|
||||
and,
|
||||
} from "drizzle-orm"
|
||||
|
||||
let badMessageDetected = false
|
||||
let badMessageMessageSent = false
|
||||
|
||||
let client: ImapFlow | null = null
|
||||
export function syncDokuboxService (server: FastifyInstance) {
|
||||
let badMessageDetected = false
|
||||
let badMessageMessageSent = false
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// IMAP CLIENT INITIALIZEN
|
||||
// -------------------------------------------------------------
|
||||
export async function initDokuboxClient() {
|
||||
client = new ImapFlow({
|
||||
host: secrets.DOKUBOX_IMAP_HOST,
|
||||
port: secrets.DOKUBOX_IMAP_PORT,
|
||||
secure: secrets.DOKUBOX_IMAP_SECURE,
|
||||
auth: {
|
||||
user: secrets.DOKUBOX_IMAP_USER,
|
||||
pass: secrets.DOKUBOX_IMAP_PASSWORD
|
||||
},
|
||||
logger: false
|
||||
})
|
||||
let client: ImapFlow | null = null
|
||||
|
||||
console.log("Dokubox E-Mail Client Initialized")
|
||||
async function initDokuboxClient() {
|
||||
client = new ImapFlow({
|
||||
host: secrets.DOKUBOX_IMAP_HOST,
|
||||
port: secrets.DOKUBOX_IMAP_PORT,
|
||||
secure: secrets.DOKUBOX_IMAP_SECURE,
|
||||
auth: {
|
||||
user: secrets.DOKUBOX_IMAP_USER,
|
||||
pass: secrets.DOKUBOX_IMAP_PASSWORD
|
||||
},
|
||||
logger: false
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
}
|
||||
console.log("Dokubox E-Mail Client Initialized")
|
||||
|
||||
await client.connect()
|
||||
}
|
||||
|
||||
const syncDokubox = async () => {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// MAIN SYNC FUNCTION (DRIZZLE VERSION)
|
||||
// -------------------------------------------------------------
|
||||
export const syncDokubox = (server: FastifyInstance) =>
|
||||
async () => {
|
||||
console.log("Perform Dokubox Sync")
|
||||
|
||||
console.log("Perform Dokubox Sync")
|
||||
await initDokuboxClient()
|
||||
|
||||
await initDokuboxClient()
|
||||
|
||||
if (!client?.usable) {
|
||||
throw new Error("E-Mail Client not usable")
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// TENANTS LADEN (DRIZZLE)
|
||||
// -------------------------------
|
||||
const tenantList = await server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
emailAddresses: tenants.dokuboxEmailAddresses,
|
||||
key: tenants.dokuboxkey
|
||||
})
|
||||
.from(tenants)
|
||||
|
||||
const lock = await client.getMailboxLock("INBOX")
|
||||
|
||||
try {
|
||||
|
||||
for await (let msg of client.fetch({ seen: false }, { envelope: true, source: true })) {
|
||||
|
||||
const parsed = await simpleParser(msg.source)
|
||||
|
||||
const message = {
|
||||
id: msg.uid,
|
||||
subject: parsed.subject,
|
||||
to: parsed.to?.value || [],
|
||||
cc: parsed.cc?.value || [],
|
||||
attachments: parsed.attachments || []
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// MAPPING / FIND TENANT
|
||||
// -------------------------------------------------
|
||||
const config = await getMessageConfigDrizzle(server, message, tenantList)
|
||||
|
||||
if (!config) {
|
||||
badMessageDetected = true
|
||||
|
||||
if (!badMessageMessageSent) {
|
||||
badMessageMessageSent = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (message.attachments.length > 0) {
|
||||
for (const attachment of message.attachments) {
|
||||
await saveFile(
|
||||
server,
|
||||
config.tenant,
|
||||
message.id,
|
||||
attachment,
|
||||
config.folder,
|
||||
config.filetype
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!client?.usable) {
|
||||
throw new Error("E-Mail Client not usable")
|
||||
}
|
||||
|
||||
if (!badMessageDetected) {
|
||||
badMessageDetected = false
|
||||
badMessageMessageSent = false
|
||||
// -------------------------------
|
||||
// TENANTS LADEN (DRIZZLE)
|
||||
// -------------------------------
|
||||
const tenantList = await server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
emailAddresses: tenants.dokuboxEmailAddresses,
|
||||
key: tenants.dokuboxkey
|
||||
})
|
||||
.from(tenants)
|
||||
|
||||
const lock = await client.getMailboxLock("INBOX")
|
||||
|
||||
try {
|
||||
|
||||
for await (let msg of client.fetch({ seen: false }, { envelope: true, source: true })) {
|
||||
|
||||
const parsed = await simpleParser(msg.source)
|
||||
|
||||
const message = {
|
||||
id: msg.uid,
|
||||
subject: parsed.subject,
|
||||
to: parsed.to?.value || [],
|
||||
cc: parsed.cc?.value || [],
|
||||
attachments: parsed.attachments || []
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// MAPPING / FIND TENANT
|
||||
// -------------------------------------------------
|
||||
const config = await getMessageConfigDrizzle(server, message, tenantList)
|
||||
|
||||
if (!config) {
|
||||
badMessageDetected = true
|
||||
|
||||
if (!badMessageMessageSent) {
|
||||
badMessageMessageSent = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (message.attachments.length > 0) {
|
||||
for (const attachment of message.attachments) {
|
||||
await saveFile(
|
||||
server,
|
||||
config.tenant,
|
||||
message.id,
|
||||
attachment,
|
||||
config.folder,
|
||||
config.filetype
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!badMessageDetected) {
|
||||
badMessageDetected = false
|
||||
badMessageMessageSent = false
|
||||
}
|
||||
|
||||
await client.messageFlagsAdd({ seen: false }, ["\\Seen"])
|
||||
await client.messageDelete({ seen: true })
|
||||
|
||||
} finally {
|
||||
lock.release()
|
||||
client.close()
|
||||
}
|
||||
|
||||
await client.messageFlagsAdd({ seen: false }, ["\\Seen"])
|
||||
await client.messageDelete({ seen: true })
|
||||
|
||||
} finally {
|
||||
lock.release()
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
const getMessageConfigDrizzle = async (
|
||||
server: FastifyInstance,
|
||||
message,
|
||||
tenantsList: any[]
|
||||
) => {
|
||||
|
||||
let possibleKeys: string[] = []
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TENANT ERKENNEN + FOLDER/FILETYPES (DRIZZLE VERSION)
|
||||
// -------------------------------------------------------------
|
||||
const getMessageConfigDrizzle = async (
|
||||
server: FastifyInstance,
|
||||
message,
|
||||
tenantsList: any[]
|
||||
) => {
|
||||
if (message.to) {
|
||||
message.to.forEach((item) =>
|
||||
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
let possibleKeys: string[] = []
|
||||
if (message.cc) {
|
||||
message.cc.forEach((item) =>
|
||||
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
if (message.to) {
|
||||
message.to.forEach((item) =>
|
||||
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||
)
|
||||
}
|
||||
// -------------------------------------------
|
||||
// TENANT IDENTIFY
|
||||
// -------------------------------------------
|
||||
let tenant = tenantsList.find((t) => possibleKeys.includes(t.key))
|
||||
|
||||
if (message.cc) {
|
||||
message.cc.forEach((item) =>
|
||||
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||
)
|
||||
}
|
||||
if (!tenant && message.to?.length) {
|
||||
const address = message.to[0].address.toLowerCase()
|
||||
|
||||
// -------------------------------------------
|
||||
// TENANT IDENTIFY
|
||||
// -------------------------------------------
|
||||
let tenant = tenantsList.find((t) => possibleKeys.includes(t.key))
|
||||
tenant = tenantsList.find((t) =>
|
||||
(t.emailAddresses || []).map((m) => m.toLowerCase()).includes(address)
|
||||
)
|
||||
}
|
||||
|
||||
if (!tenant && message.to?.length) {
|
||||
const address = message.to[0].address.toLowerCase()
|
||||
if (!tenant) return null
|
||||
|
||||
tenant = tenantsList.find((t) =>
|
||||
(t.emailAddresses || []).map((m) => m.toLowerCase()).includes(address)
|
||||
)
|
||||
}
|
||||
// -------------------------------------------
|
||||
// FOLDER + FILETYPE VIA SUBJECT
|
||||
// -------------------------------------------
|
||||
let folderId = null
|
||||
let filetypeId = null
|
||||
|
||||
if (!tenant) return null
|
||||
// -------------------------------------------
|
||||
// Rechnung / Invoice
|
||||
// -------------------------------------------
|
||||
if (message.subject?.match(/(Rechnung|Beleg|Invoice|Quittung)/gi)) {
|
||||
|
||||
// -------------------------------------------
|
||||
// FOLDER + FILETYPE VIA SUBJECT
|
||||
// -------------------------------------------
|
||||
let folderId = null
|
||||
let filetypeId = null
|
||||
|
||||
// -------------------------------------------
|
||||
// Rechnung / Invoice
|
||||
// -------------------------------------------
|
||||
if (message.subject?.match(/(Rechnung|Beleg|Invoice|Quittung)/gi)) {
|
||||
|
||||
const folder = await server.db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.tenant, tenant.id),
|
||||
const folder = await server.db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.function, "incomingInvoices"),
|
||||
//@ts-ignore
|
||||
eq(folders.year, dayjs().format("YYYY"))
|
||||
eq(folders.tenant, tenant.id),
|
||||
and(
|
||||
eq(folders.function, "incomingInvoices"),
|
||||
//@ts-ignore
|
||||
eq(folders.year, dayjs().format("YYYY"))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.limit(1)
|
||||
|
||||
folderId = folder[0]?.id ?? null
|
||||
folderId = folder[0]?.id ?? null
|
||||
|
||||
const tag = await server.db
|
||||
.select({ id: filetags.id })
|
||||
.from(filetags)
|
||||
.where(
|
||||
and(
|
||||
eq(filetags.tenant, tenant.id),
|
||||
eq(filetags.incomingDocumentType, "invoices")
|
||||
const tag = await server.db
|
||||
.select({ id: filetags.id })
|
||||
.from(filetags)
|
||||
.where(
|
||||
and(
|
||||
eq(filetags.tenant, tenant.id),
|
||||
eq(filetags.incomingDocumentType, "invoices")
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.limit(1)
|
||||
|
||||
filetypeId = tag[0]?.id ?? null
|
||||
}
|
||||
filetypeId = tag[0]?.id ?? null
|
||||
}
|
||||
|
||||
// -------------------------------------------
|
||||
// Mahnung
|
||||
// -------------------------------------------
|
||||
// Mahnung
|
||||
// -------------------------------------------
|
||||
else if (message.subject?.match(/(Mahnung|Zahlungsaufforderung|Zahlungsverzug)/gi)) {
|
||||
else if (message.subject?.match(/(Mahnung|Zahlungsaufforderung|Zahlungsverzug)/gi)) {
|
||||
|
||||
const tag = await server.db
|
||||
.select({ id: filetags.id })
|
||||
.from(filetags)
|
||||
.where(
|
||||
and(
|
||||
eq(filetags.tenant, tenant.id),
|
||||
eq(filetags.incomingDocumentType, "reminders")
|
||||
const tag = await server.db
|
||||
.select({ id: filetags.id })
|
||||
.from(filetags)
|
||||
.where(
|
||||
and(
|
||||
eq(filetags.tenant, tenant.id),
|
||||
eq(filetags.incomingDocumentType, "reminders")
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.limit(1)
|
||||
|
||||
filetypeId = tag[0]?.id ?? null
|
||||
}
|
||||
filetypeId = tag[0]?.id ?? null
|
||||
}
|
||||
|
||||
// -------------------------------------------
|
||||
// Sonstige Dokumente → Deposit Folder
|
||||
// -------------------------------------------
|
||||
// Sonstige Dokumente → Deposit Folder
|
||||
// -------------------------------------------
|
||||
else {
|
||||
else {
|
||||
|
||||
const folder = await server.db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.tenant, tenant.id),
|
||||
eq(folders.function, "deposit")
|
||||
const folder = await server.db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.tenant, tenant.id),
|
||||
eq(folders.function, "deposit")
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.limit(1)
|
||||
|
||||
folderId = folder[0]?.id ?? null
|
||||
folderId = folder[0]?.id ?? null
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
tenant: tenant.id,
|
||||
folder: folderId,
|
||||
filetype: filetypeId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
tenant: tenant.id,
|
||||
folder: folderId,
|
||||
filetype: filetypeId
|
||||
run: async () => {
|
||||
await initDokuboxClient()
|
||||
await syncDokubox()
|
||||
console.log("Service: Dokubox sync finished")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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 = 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)
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) throw error
|
||||
|
||||
const mappedData = data.map(entry => {
|
||||
return {
|
||||
...entry,
|
||||
customer: entry.customer_id
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -38,4 +41,4 @@ declare module "fastify" {
|
||||
settings?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
backend/src/routes/devices/management.ts
Normal file
58
backend/src/routes/devices/management.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,39 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {and, desc, eq} from "drizzle-orm";
|
||||
import {authProfiles, devices, stafftimeevents} from "../../../db/schema";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { authProfiles, devices, stafftimeevents } from "../../../db/schema";
|
||||
|
||||
export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
||||
server.post(
|
||||
"/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};
|
||||
const { terminal_id } = req.params as { terminal_id: string };
|
||||
|
||||
if(!rfid_id ||!terminal_id) {
|
||||
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) {
|
||||
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)
|
||||
@@ -44,55 +46,56 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
if(!profile) {
|
||||
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
|
||||
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
})
|
||||
@@ -131,4 +140,4 @@ export default async function exportRoutes(server: FastifyInstance) {
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -219,4 +213,4 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})*/
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
...historyitem,
|
||||
created_by_profile: filteredUsers.find(i => i.id === historyitem.created_by) ? filteredUsers.find(i => i.id === historyitem.created_by).auth_profiles[0] : null
|
||||
}
|
||||
})
|
||||
const dataCombined = data.map((historyitem) => ({
|
||||
...historyitem,
|
||||
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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
import { resourceConfig } from "../../utils/resource.config";
|
||||
import { useNextNumberRangeNumber } from "../../utils/functions";
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -54,96 +47,86 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
asc?: string
|
||||
}
|
||||
|
||||
const {resource} = req.params as {resource: string}
|
||||
const table = resourceConfig[resource].table
|
||||
const { resource } = req.params as { resource: string }
|
||||
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()
|
||||
|
||||
// 🔍 SQL Search
|
||||
if(search) {
|
||||
const searchCond = buildSearchCondition(
|
||||
table,
|
||||
resourceConfig[resource].searchColumns,
|
||||
search.trim()
|
||||
)
|
||||
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
|
||||
|
||||
if (searchCond) {
|
||||
whereCond = and(whereCond, searchCond)
|
||||
}
|
||||
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])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Base Query
|
||||
let q = server.db.select().from(table).where(whereCond)
|
||||
if (search) {
|
||||
const searchCond = buildSearchCondition(searchCols, search.trim())
|
||||
if (searchCond) whereCond = and(whereCond, searchCond)
|
||||
}
|
||||
|
||||
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 = queryData.map(row => {
|
||||
let toReturn = {
|
||||
...row
|
||||
}
|
||||
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
|
||||
data = rows.map(row => {
|
||||
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)
|
||||
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 {queryConfig} = req;
|
||||
const {
|
||||
pagination,
|
||||
sort,
|
||||
filters,
|
||||
paginationDisabled
|
||||
} = queryConfig;
|
||||
|
||||
const { search, distinctColumns } = req.query as {
|
||||
search?: string;
|
||||
distinctColumns?: string;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
let table = resourceConfig[resource].table
|
||||
const { resource } = req.params as { resource: string };
|
||||
const config = resourceConfig[resource];
|
||||
const table = config.table;
|
||||
|
||||
const { queryConfig } = req;
|
||||
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(search) {
|
||||
const searchCond = buildSearchCondition(
|
||||
table,
|
||||
resourceConfig[resource].searchColumns,
|
||||
search.trim()
|
||||
)
|
||||
if (config.mtoLoad) {
|
||||
config.mtoLoad.forEach(rel => {
|
||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
|
||||
if (relConfig) {
|
||||
const relTable = relConfig.table;
|
||||
|
||||
if (searchCond) {
|
||||
whereCond = and(whereCond, searchCond)
|
||||
}
|
||||
// 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(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);
|
||||
|
||||
if (orderField) {
|
||||
//@ts-ignore
|
||||
q = direction === "asc"
|
||||
? q.orderBy(asc(orderField))
|
||||
: q.orderBy(desc(orderField));
|
||||
}
|
||||
|
||||
const rows = await q;
|
||||
|
||||
if (!rows.length) {
|
||||
return {
|
||||
data: [],
|
||||
queryConfig: {
|
||||
...queryConfig,
|
||||
total,
|
||||
totalPages: 0,
|
||||
distinctValues
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
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])) : []
|
||||
const rawRows = await mainQuery;
|
||||
// Transformation für Drizzle Joins
|
||||
let rows = rawRows.map(r => r[resource] || r.table || r);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
|
||||
})
|
||||
|
||||
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]));
|
||||
}
|
||||
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 {
|
||||
@@ -379,7 +279,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
|
||||
|
||||
const {resource, no_relations} = req.params as { resource: string, no_relations?: boolean }
|
||||
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
|
||||
const table = resourceConfig[resource].table
|
||||
|
||||
const projRows = await server.db
|
||||
@@ -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 data = { ...projRows[0] }
|
||||
|
||||
let ids = {}
|
||||
let lists = {}
|
||||
let maps = {}
|
||||
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]
|
||||
if (!no_relations) {
|
||||
if (resourceConfig[resource].mtoLoad) {
|
||||
for await (const relation of resourceConfig[resource].mtoLoad) {
|
||||
if (data[relation]) {
|
||||
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))
|
||||
if (resourceConfig[resource].mtmLoad) {
|
||||
for await (const relation of resourceConfig[resource].mtmLoad) {
|
||||
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"});
|
||||
}
|
||||
|
||||
const {resource} = req.params as { resource: string };
|
||||
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])
|
||||
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 { 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
|
||||
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
|
||||
|
||||
//TODO: HISTORY
|
||||
|
||||
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}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
340
backend/src/routes/wiki.ts
Normal 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 }
|
||||
})
|
||||
}
|
||||
@@ -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;;;;"";;;;;;`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -111,4 +124,4 @@ export const createSEPAExport = async (server,idsToExport, tenant_id) => {
|
||||
|
||||
console.log(doc.end({pretty:true}))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ const InstructionFormat = z.object({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// MAIN FUNCTION – REPLACES SUPABASE VERSION
|
||||
// MAIN FUNCTION
|
||||
// ---------------------------------------------------------
|
||||
export const getInvoiceDataFromGPT = async function (
|
||||
server: FastifyInstance,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
74
backend/src/webdav/fill-file-sizes.ts
Normal file
74
backend/src/webdav/fill-file-sizes.ts
Normal 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);
|
||||
});
|
||||
200
backend/src/webdav/server.ts
Normal file
200
backend/src/webdav/server.ts
Normal 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();
|
||||
235
frontend/components/Calculator.vue
Normal file
235
frontend/components/Calculator.vue
Normal 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>
|
||||
@@ -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,49 +119,45 @@ 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 => {
|
||||
if(dataType.templateColumns.find(i => i.key === key)) {
|
||||
if (dataType.templateColumns.find(i => i.key === key)) {
|
||||
if (["customer", "contract", "plant", "contact", "project"].includes(key)) {
|
||||
item.value[key] = Number(data[key])
|
||||
} else {
|
||||
item.value[key] = data[key]
|
||||
}
|
||||
} else if(key === "resources") {
|
||||
} else if (key === "resources") {
|
||||
/*item.value[key] = data[key]*/
|
||||
JSON.parse(data[key]).forEach(async (i) => {
|
||||
console.log(i)
|
||||
let type = i.substring(0,1)
|
||||
let id = i.substring(2,i.length)
|
||||
let type = i.substring(0, 1)
|
||||
let id = i.substring(2, i.length)
|
||||
console.log(type)
|
||||
console.log(id)
|
||||
let holder = ""
|
||||
if(type === "P"){
|
||||
if (type === "P") {
|
||||
holder = "profiles"
|
||||
} else if(type === "F"){
|
||||
} else if (type === "F") {
|
||||
holder = "vehicles"
|
||||
id = Number(id)
|
||||
} else if(type === "I"){
|
||||
} else if (type === "I") {
|
||||
holder = "inventoryitems"
|
||||
id = Number(id)
|
||||
} else if(type === "G"){
|
||||
} else if (type === "G") {
|
||||
holder = "inventoryitemgroups"
|
||||
}
|
||||
|
||||
if(typeof item.value[holder] === "object") {
|
||||
if (typeof item.value[holder] === "object") {
|
||||
item.value[holder].push(id)
|
||||
} else {
|
||||
item.value[holder] = [id]
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
})
|
||||
// calcSaveAllowed() -> Entfernt, da computed automatisch reagiert
|
||||
}
|
||||
}
|
||||
setupQuery()
|
||||
@@ -148,14 +172,14 @@ const loadOptions = async () => {
|
||||
})
|
||||
|
||||
for await(const option of optionsToLoad) {
|
||||
if(option.option === "countrys") {
|
||||
if (option.option === "countrys") {
|
||||
loadedOptions.value[option.option] = useEntities("countrys").selectSpecial()
|
||||
} else if(option.option === "units") {
|
||||
} else if (option.option === "units") {
|
||||
loadedOptions.value[option.option] = useEntities("units").selectSpecial()
|
||||
} else {
|
||||
loadedOptions.value[option.option] = (await useEntities(option.option).select())
|
||||
|
||||
if(dataType.templateColumns.find(x => x.key === option.key).selectDataTypeFilter){
|
||||
if (dataType.templateColumns.find(x => x.key === option.key).selectDataTypeFilter) {
|
||||
loadedOptions.value[option.option] = loadedOptions.value[option.option].filter(i => dataType.templateColumns.find(x => x.key === option.key).selectDataTypeFilter(i, item))
|
||||
}
|
||||
}
|
||||
@@ -165,47 +189,23 @@ const loadOptions = async () => {
|
||||
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
|
||||
if (datapoint.key.includes(".")) {
|
||||
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
|
||||
|
||||
if(props.inModal) {
|
||||
ret = await useEntities(type).create(item.value, true)
|
||||
if (props.inModal) {
|
||||
ret = await useEntities(type).create(item.value, true)
|
||||
|
||||
} else {
|
||||
ret = await useEntities(type).create(item.value)//dataStore.createNewItem(type,item.value)
|
||||
@@ -218,7 +218,7 @@ const createItem = async () => {
|
||||
const updateItem = async () => {
|
||||
let ret = null
|
||||
|
||||
if(props.inModal) {
|
||||
if (props.inModal) {
|
||||
ret = await useEntities(type).update(item.value.id, item.value, true)
|
||||
emit('returnData', ret)
|
||||
modal.close()
|
||||
@@ -226,11 +226,7 @@ const updateItem = async () => {
|
||||
ret = await useEntities(type).update(item.value.id, item.value)
|
||||
emit('returnData', ret)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -245,16 +241,15 @@ 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>
|
||||
<h1
|
||||
v-if="item"
|
||||
:class="['text-xl','font-medium', 'text-center']"
|
||||
>{{item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
||||
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
||||
</template>
|
||||
<template #right>
|
||||
<ArchiveButton
|
||||
@@ -295,7 +290,7 @@ const updateItem = async () => {
|
||||
<h1
|
||||
v-if="item"
|
||||
:class="['text-xl','font-medium']"
|
||||
>{{item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
||||
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
||||
</template>
|
||||
<template #right>
|
||||
<UButton
|
||||
@@ -330,11 +325,7 @@ const updateItem = async () => {
|
||||
v-for="(columnName,index) in dataType.inputColumns"
|
||||
:class="platform === 'mobile' ? ['w-full'] : [`w-1/${dataType.inputColumns.length}`, ... index < dataType.inputColumns.length -1 ? ['mr-5'] : []]"
|
||||
>
|
||||
<UDivider>{{columnName}}</UDivider>
|
||||
|
||||
<!--
|
||||
Die Form Group darf nur in der ersten bearbeitet werden und muss dann runterkopiert werden
|
||||
-->
|
||||
<UDivider>{{ columnName }}</UDivider>
|
||||
|
||||
<div
|
||||
v-for="datapoint in dataType.templateColumns.filter(i => i.inputType && i.inputColumn === columnName)"
|
||||
@@ -362,7 +353,7 @@ const updateItem = async () => {
|
||||
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
|
||||
>
|
||||
<template #trailing v-if="datapoint.inputTrailing">
|
||||
<span class="text-gray-500 dark:text-gray-400 text-xs">{{datapoint.inputTrailing}}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 text-xs">{{ datapoint.inputTrailing }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
<UToggle
|
||||
@@ -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)"
|
||||
@@ -463,7 +453,7 @@ const updateItem = async () => {
|
||||
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
|
||||
>
|
||||
<template #trailing v-if="datapoint.inputTrailing">
|
||||
{{datapoint.inputTrailing}}
|
||||
{{ datapoint.inputTrailing }}
|
||||
</template>
|
||||
</UInput>
|
||||
<UToggle
|
||||
@@ -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
|
||||
@@ -616,7 +578,7 @@ const updateItem = async () => {
|
||||
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
|
||||
>
|
||||
<template #trailing v-if="datapoint.inputTrailing">
|
||||
<span class="text-gray-500 dark:text-gray-400 text-xs">{{datapoint.inputTrailing}}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 text-xs">{{ datapoint.inputTrailing }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
<UToggle
|
||||
@@ -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)"
|
||||
@@ -717,7 +678,7 @@ const updateItem = async () => {
|
||||
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
|
||||
>
|
||||
<template #trailing v-if="datapoint.inputTrailing">
|
||||
{{datapoint.inputTrailing}}
|
||||
{{ datapoint.inputTrailing }}
|
||||
</template>
|
||||
</UInput>
|
||||
<UToggle
|
||||
@@ -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)"
|
||||
@@ -800,8 +760,8 @@ const updateItem = async () => {
|
||||
|
||||
|
||||
<MaterialComposing
|
||||
v-else-if="datapoint.inputType === 'materialComposing'"
|
||||
:item="item"
|
||||
v-else-if="datapoint.inputType === 'materialComposing'"
|
||||
:item="item"
|
||||
/>
|
||||
<PersonalComposing
|
||||
v-else-if="datapoint.inputType === 'personalComposing'"
|
||||
@@ -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>
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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>
|
||||
@@ -311,4 +307,4 @@ const selectItem = (item) => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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()
|
||||
@@ -105,4 +100,4 @@ const renderedAllocations = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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")
|
||||
|
||||
}
|
||||
@@ -166,4 +154,4 @@ const changeActivePhase = async (key) => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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: {
|
||||
@@ -114,4 +109,4 @@ const columns = [
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
const selectedItem = ref(0)
|
||||
const sort = ref({
|
||||
column: dataType.supabaseSortColumn || "date",
|
||||
column: dataType.sortColumn || "date",
|
||||
direction: 'desc'
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
profile: profileStore.activeProfile.id,
|
||||
message: messageToShow.value.id,
|
||||
})
|
||||
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()
|
||||
|
||||
@@ -86,4 +96,4 @@ setup()
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -228,4 +225,4 @@ const resetContactRequest = () => {
|
||||
</div>
|
||||
<UProgress class="mt-5" animation="carousel" v-else/>-->
|
||||
</UDashboardSlideover>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,42 +1,39 @@
|
||||
<script setup>
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const { has } = usePermission()
|
||||
|
||||
const {has} = usePermission()
|
||||
// Lokaler State für den Taschenrechner
|
||||
const showCalculator = ref(false)
|
||||
|
||||
const links = computed(() => {
|
||||
return [
|
||||
...(auth.profile?.pinned_on_navigation || []).map(pin => {
|
||||
if(pin.type === "external") {
|
||||
return {
|
||||
label: pin.label,
|
||||
to: pin.link,
|
||||
icon: pin.icon,
|
||||
target: "_blank",
|
||||
pinned: true
|
||||
}
|
||||
}else if(pin.type === "standardEntity") {
|
||||
return {
|
||||
label: pin.label,
|
||||
to: `/standardEntity/${pin.datatype}/show/${pin.id}`,
|
||||
icon: pin.icon,
|
||||
pinned: true
|
||||
}
|
||||
...(auth.profile?.pinned_on_navigation || []).map(pin => {
|
||||
if (pin.type === "external") {
|
||||
return {
|
||||
label: pin.label,
|
||||
to: pin.link,
|
||||
icon: pin.icon,
|
||||
target: "_blank",
|
||||
pinned: true
|
||||
}
|
||||
}),
|
||||
} else if (pin.type === "standardEntity") {
|
||||
return {
|
||||
label: pin.label,
|
||||
to: `/standardEntity/${pin.datatype}/show/${pin.id}`,
|
||||
icon: pin.icon,
|
||||
pinned: true
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
... 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",
|
||||
@@ -48,31 +45,16 @@ const links = computed(() => {
|
||||
icon: "i-heroicons-rectangle-stack",
|
||||
defaultOpen: false,
|
||||
children: [
|
||||
... has("tasks") ? [{
|
||||
...has("tasks") ? [{
|
||||
label: "Aufgaben",
|
||||
to: "/standardEntity/tasks",
|
||||
icon: "i-heroicons-rectangle-stack"
|
||||
}] : [],
|
||||
/*... true ? [{
|
||||
label: "Plantafel",
|
||||
to: "/calendar/timeline",
|
||||
icon: "i-heroicons-calendar-days"
|
||||
...true ? [{
|
||||
label: "Wiki",
|
||||
to: "/wiki",
|
||||
icon: "i-heroicons-book-open"
|
||||
}] : [],
|
||||
... true ? [{
|
||||
label: "Kalender",
|
||||
to: "/calendar/grid",
|
||||
icon: "i-heroicons-calendar-days"
|
||||
}] : [],
|
||||
... true ? [{
|
||||
label: "Termine",
|
||||
to: "/standardEntity/events",
|
||||
icon: "i-heroicons-calendar-days"
|
||||
}] : [],*/
|
||||
/*{
|
||||
label: "Dateien",
|
||||
to: "/files",
|
||||
icon: "i-heroicons-document"
|
||||
},*/
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -84,12 +66,12 @@ const links = computed(() => {
|
||||
label: "Dateien",
|
||||
to: "/files",
|
||||
icon: "i-heroicons-document"
|
||||
},{
|
||||
}, {
|
||||
label: "Anschreiben",
|
||||
to: "/createdletters",
|
||||
icon: "i-heroicons-document",
|
||||
disabled: true
|
||||
},{
|
||||
}, {
|
||||
label: "Boxen",
|
||||
to: "/standardEntity/documentboxes",
|
||||
icon: "i-heroicons-archive-box",
|
||||
@@ -113,62 +95,44 @@ 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")) ? [{
|
||||
...(has("customers") || has("vendors") || has("contacts")) ? [{
|
||||
label: "Kontakte",
|
||||
defaultOpen: false,
|
||||
icon: "i-heroicons-user-group",
|
||||
children: [
|
||||
... has("customers") ? [{
|
||||
...has("customers") ? [{
|
||||
label: "Kunden",
|
||||
to: "/standardEntity/customers",
|
||||
icon: "i-heroicons-user-group"
|
||||
}] : [],
|
||||
... has("vendors") ? [{
|
||||
...has("vendors") ? [{
|
||||
label: "Lieferanten",
|
||||
to: "/standardEntity/vendors",
|
||||
icon: "i-heroicons-truck"
|
||||
}] : [],
|
||||
... has("contacts") ? [{
|
||||
...has("contacts") ? [{
|
||||
label: "Ansprechpartner",
|
||||
to: "/standardEntity/contacts",
|
||||
icon: "i-heroicons-user-group"
|
||||
}] : [],
|
||||
]
|
||||
},] : [],
|
||||
}] : [],
|
||||
{
|
||||
label: "Mitarbeiter",
|
||||
defaultOpen:false,
|
||||
defaultOpen: false,
|
||||
icon: "i-heroicons-user-group",
|
||||
children: [
|
||||
... true ? [{
|
||||
...true ? [{
|
||||
label: "Zeiten",
|
||||
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"
|
||||
},*/
|
||||
]
|
||||
},
|
||||
... [{
|
||||
...[{
|
||||
label: "Buchhaltung",
|
||||
defaultOpen: false,
|
||||
icon: "i-heroicons-chart-bar-square",
|
||||
@@ -177,23 +141,23 @@ const links = computed(() => {
|
||||
label: "Ausgangsbelege",
|
||||
to: "/createDocument",
|
||||
icon: "i-heroicons-document-text"
|
||||
},{
|
||||
}, {
|
||||
label: "Serienvorlagen",
|
||||
to: "/createDocument/serialInvoice",
|
||||
icon: "i-heroicons-document-text"
|
||||
},{
|
||||
}, {
|
||||
label: "Eingangsbelege",
|
||||
to: "/incomingInvoices",
|
||||
icon: "i-heroicons-document-text",
|
||||
},{
|
||||
}, {
|
||||
label: "Kostenstellen",
|
||||
to: "/standardEntity/costcentres",
|
||||
icon: "i-heroicons-document-currency-euro"
|
||||
},{
|
||||
}, {
|
||||
label: "Buchungskonten",
|
||||
to: "/accounts",
|
||||
icon: "i-heroicons-document-text",
|
||||
},{
|
||||
}, {
|
||||
label: "zusätzliche Buchungskonten",
|
||||
to: "/standardEntity/ownaccounts",
|
||||
icon: "i-heroicons-document-text"
|
||||
@@ -205,48 +169,39 @@ const links = computed(() => {
|
||||
},
|
||||
]
|
||||
}],
|
||||
... has("inventory") ? [{
|
||||
...has("inventory") ? [{
|
||||
label: "Lager",
|
||||
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") ? [{
|
||||
...has("spaces") ? [{
|
||||
label: "Lagerplätze",
|
||||
to: "/standardEntity/spaces",
|
||||
icon: "i-heroicons-square-3-stack-3d"
|
||||
}] : [],
|
||||
]
|
||||
},] : [],
|
||||
}] : [],
|
||||
{
|
||||
label: "Stammdaten",
|
||||
defaultOpen: false,
|
||||
icon: "i-heroicons-clipboard-document",
|
||||
children: [
|
||||
... has("products") ? [{
|
||||
...has("products") ? [{
|
||||
label: "Artikel",
|
||||
to: "/standardEntity/products",
|
||||
icon: "i-heroicons-puzzle-piece"
|
||||
}] : [],
|
||||
... has("productcategories") ? [{
|
||||
...has("productcategories") ? [{
|
||||
label: "Artikelkategorien",
|
||||
to: "/standardEntity/productcategories",
|
||||
icon: "i-heroicons-puzzle-piece"
|
||||
}] : [],
|
||||
... has("services") ? [{
|
||||
...has("services") ? [{
|
||||
label: "Leistungen",
|
||||
to: "/standardEntity/services",
|
||||
icon: "i-heroicons-wrench-screwdriver"
|
||||
}] : [],
|
||||
... has("servicecategories") ? [{
|
||||
...has("servicecategories") ? [{
|
||||
label: "Leistungskategorien",
|
||||
to: "/standardEntity/servicecategories",
|
||||
icon: "i-heroicons-wrench-screwdriver"
|
||||
@@ -261,17 +216,17 @@ const links = computed(() => {
|
||||
to: "/standardEntity/hourrates",
|
||||
icon: "i-heroicons-user-group"
|
||||
},
|
||||
... has("vehicles") ? [{
|
||||
...has("vehicles") ? [{
|
||||
label: "Fahrzeuge",
|
||||
to: "/standardEntity/vehicles",
|
||||
icon: "i-heroicons-truck"
|
||||
}] : [],
|
||||
... has("inventoryitems") ? [{
|
||||
...has("inventoryitems") ? [{
|
||||
label: "Inventar",
|
||||
to: "/standardEntity/inventoryitems",
|
||||
icon: "i-heroicons-puzzle-piece"
|
||||
}] : [],
|
||||
... has("inventoryitems") ? [{
|
||||
...has("inventoryitems") ? [{
|
||||
label: "Inventargruppen",
|
||||
to: "/standardEntity/inventoryitemgroups",
|
||||
icon: "i-heroicons-puzzle-piece"
|
||||
@@ -279,26 +234,21 @@ const links = computed(() => {
|
||||
]
|
||||
},
|
||||
|
||||
... has("projects") ? [{
|
||||
...has("projects") ? [{
|
||||
label: "Projekte",
|
||||
to: "/standardEntity/projects",
|
||||
icon: "i-heroicons-clipboard-document-check"
|
||||
},] : [],
|
||||
... has("contracts") ? [{
|
||||
}] : [],
|
||||
...has("contracts") ? [{
|
||||
label: "Verträge",
|
||||
to: "/standardEntity/contracts",
|
||||
icon: "i-heroicons-clipboard-document"
|
||||
}] : [],
|
||||
... has("plants") ? [{
|
||||
...has("plants") ? [{
|
||||
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,67 +258,57 @@ 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",
|
||||
},{
|
||||
}, {
|
||||
label: "Bankkonten",
|
||||
to: "/settings/banking",
|
||||
icon: "i-heroicons-currency-euro",
|
||||
},{
|
||||
}, {
|
||||
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",
|
||||
},{
|
||||
}, {
|
||||
label: "Projekttypen",
|
||||
to: "/projecttypes",
|
||||
icon: "i-heroicons-clipboard-document-list",
|
||||
},{
|
||||
}, {
|
||||
label: "Export",
|
||||
to: "/export",
|
||||
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>
|
||||
@@ -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 }}
|
||||
|
||||
@@ -61,7 +61,7 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
||||
Nein, bleiben
|
||||
</button>
|
||||
<button @click="confirmLeave" class="btn-confirm">
|
||||
Ja, verwerfen
|
||||
Ja, verlassen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
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()"
|
||||
/>
|
||||
</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
|
||||
label="Menge / Dauer"
|
||||
:error="errors.quantity"
|
||||
required
|
||||
>
|
||||
<UInput
|
||||
v-model="form.quantity"
|
||||
type="number"
|
||||
step="0.25"
|
||||
size="lg"
|
||||
placeholder="0.00"
|
||||
>
|
||||
<template #trailing>
|
||||
<span class="text-gray-500 text-sm pr-2">{{ currentUnit }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormGroup>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
@@ -95,4 +93,4 @@ setupPage()
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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(() => [
|
||||
@@ -59,4 +54,4 @@ const items = computed(() => [
|
||||
</div>
|
||||
</template>
|
||||
</UDropdown>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
<script setup>
|
||||
|
||||
const supabase = useSupabaseClient()
|
||||
const profileStore = useProfileStore()
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
required: true,
|
||||
@@ -27,4 +23,4 @@ setupPage()
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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 = []
|
||||
@@ -241,4 +238,4 @@ setup()
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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"
|
||||
@@ -119,4 +94,4 @@ const stopStartedTime = async () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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"
|
||||
@@ -98,4 +76,4 @@ const stopStartedTime = async () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
247
frontend/components/email/EmailTiptapEditor.vue
Normal file
247
frontend/components/email/EmailTiptapEditor.vue
Normal 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>
|
||||
@@ -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)*/
|
||||
|
||||
|
||||
|
||||
@@ -61,4 +53,4 @@ const saveProjectDescription = async () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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()
|
||||
@@ -21,4 +31,4 @@ setupPage()
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const supabase = useSupabaseClient()
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
|
||||
27
frontend/components/wiki/TiptapTaskItem.vue
Normal file
27
frontend/components/wiki/TiptapTaskItem.vue
Normal 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>
|
||||
144
frontend/components/wiki/TreeItem.vue
Normal file
144
frontend/components/wiki/TreeItem.vue
Normal 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>
|
||||
272
frontend/components/wiki/WikiEditor.vue
Normal file
272
frontend/components/wiki/WikiEditor.vue
Normal 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"></></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"><></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>
|
||||
236
frontend/components/wiki/WikiEntityWidget.vue
Normal file
236
frontend/components/wiki/WikiEntityWidget.vue
Normal 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>
|
||||
57
frontend/components/wiki/WikiPageList.vue
Normal file
57
frontend/components/wiki/WikiPageList.vue
Normal 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>
|
||||
@@ -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}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
export const useFiles = () => {
|
||||
const supabase = useSupabaseClient()
|
||||
const toast = useToast()
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
|
||||
@@ -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}
|
||||
}*/
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
|
||||
export const useSum = () => {
|
||||
|
||||
const supabase = useSupabaseClient()
|
||||
|
||||
const getIncomingInvoiceSum = (invoice) => {
|
||||
let sum = 0
|
||||
invoice.accounts.forEach(account => {
|
||||
@@ -135,4 +132,4 @@ export const useSum = () => {
|
||||
|
||||
return {getIncomingInvoiceSum, getCreatedDocumentSum, getCreatedDocumentSumDetailed, getIsPaid}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
65
frontend/composables/useWikiSuggestion.ts
Normal file
65
frontend/composables/useWikiSuggestion.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
128
frontend/composables/useWikiTree.ts
Normal file
128
frontend/composables/useWikiTree.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
<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()
|
||||
const { isHelpSlideoverOpen } = useDashboard()
|
||||
const {isHelpSlideoverOpen} = useDashboard()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const labelPrinter = useLabelPrinterStore()
|
||||
|
||||
const calculatorStore = useCalculatorStore()
|
||||
|
||||
const month = dayjs().format("MM")
|
||||
|
||||
@@ -24,91 +23,108 @@ const actions = [
|
||||
id: 'new-customer',
|
||||
label: 'Kunde hinzufügen',
|
||||
icon: 'i-heroicons-user-group',
|
||||
to: "/customers/create" ,
|
||||
to: "/customers/create",
|
||||
},
|
||||
{
|
||||
id: 'new-vendor',
|
||||
label: 'Lieferant hinzufügen',
|
||||
icon: 'i-heroicons-truck',
|
||||
to: "/vendors/create" ,
|
||||
to: "/vendors/create",
|
||||
},
|
||||
{
|
||||
id: 'new-contact',
|
||||
label: 'Ansprechpartner hinzufügen',
|
||||
icon: 'i-heroicons-user-group',
|
||||
to: "/contacts/create" ,
|
||||
to: "/contacts/create",
|
||||
},
|
||||
{
|
||||
id: 'new-task',
|
||||
label: 'Aufgabe hinzufügen',
|
||||
icon: 'i-heroicons-rectangle-stack',
|
||||
to: "/tasks/create" ,
|
||||
to: "/tasks/create",
|
||||
},
|
||||
{
|
||||
id: 'new-plant',
|
||||
label: 'Objekt hinzufügen',
|
||||
icon: 'i-heroicons-clipboard-document',
|
||||
to: "/plants/create" ,
|
||||
to: "/plants/create",
|
||||
},
|
||||
{
|
||||
id: 'new-product',
|
||||
label: 'Artikel hinzufügen',
|
||||
icon: 'i-heroicons-puzzle-piece',
|
||||
to: "/products/create" ,
|
||||
to: "/products/create",
|
||||
},
|
||||
{
|
||||
id: 'new-project',
|
||||
label: 'Projekt hinzufügen',
|
||||
icon: 'i-heroicons-clipboard-document-check',
|
||||
to: "/projects/create" ,
|
||||
to: "/projects/create",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
const groups = computed(() => [
|
||||
{
|
||||
key: 'actions',
|
||||
commands: actions
|
||||
},{
|
||||
key: "customers",
|
||||
label: "Kunden",
|
||||
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}`}})
|
||||
},{
|
||||
key: "contacts",
|
||||
label: "Ansprechpartner",
|
||||
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}`}})
|
||||
},{
|
||||
key: "tasks",
|
||||
label: "Aufgaben",
|
||||
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}`}})
|
||||
},{
|
||||
key: "projects",
|
||||
label: "Projekte",
|
||||
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'
|
||||
}, */{
|
||||
label: 'Hilfe & Info',
|
||||
icon: 'i-heroicons-question-mark-circle',
|
||||
click: () => isHelpSlideoverOpen.value = true
|
||||
}]
|
||||
{
|
||||
key: 'actions',
|
||||
commands: actions
|
||||
}, {
|
||||
key: "customers",
|
||||
label: "Kunden",
|
||||
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}`}
|
||||
})
|
||||
}, {
|
||||
key: "contacts",
|
||||
label: "Ansprechpartner",
|
||||
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}`}
|
||||
})
|
||||
}, {
|
||||
key: "tasks",
|
||||
label: "Aufgaben",
|
||||
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}`}
|
||||
})
|
||||
}, {
|
||||
key: "projects",
|
||||
label: "Projekte",
|
||||
commands: dataStore.projects.map(item => {
|
||||
return {id: item.id, label: item.name, to: `/projects/show/${item.id}`}
|
||||
})
|
||||
}
|
||||
].filter(Boolean))
|
||||
|
||||
// --- 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>
|
||||
|
||||
@@ -130,24 +146,24 @@ const footerLinks = [
|
||||
v-else
|
||||
/>
|
||||
<div class="flex justify-center mb-6">
|
||||
<UIcon name="i-heroicons-exclamation-triangle-solid" class="w-16 h-16 text-yellow-500" />
|
||||
<UIcon name="i-heroicons-exclamation-triangle-solid" class="w-16 h-16 text-yellow-500"/>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
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}}
|
||||
{{ tenant.name }}
|
||||
<UButton
|
||||
:disabled="tenant.locked"
|
||||
@click="auth.switchTenant(tenant.id)"
|
||||
>Wählen</UButton>
|
||||
>Wählen
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
|
||||
</UCard>
|
||||
</UContainer>
|
||||
</div>
|
||||
@@ -167,7 +183,7 @@ const footerLinks = [
|
||||
v-else
|
||||
/>
|
||||
<div class="flex justify-center mb-6">
|
||||
<UIcon name="i-heroicons-exclamation-triangle-solid" class="w-16 h-16 text-yellow-500" />
|
||||
<UIcon name="i-heroicons-exclamation-triangle-solid" class="w-16 h-16 text-yellow-500"/>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
@@ -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>
|
||||
@@ -197,32 +211,33 @@ const footerLinks = [
|
||||
v-else
|
||||
/>
|
||||
<div class="flex justify-center mb-6">
|
||||
<UIcon name="i-heroicons-credit-card" class="w-16 h-16 text-red-600" />
|
||||
<UIcon name="i-heroicons-credit-card" class="w-16 h-16 text-red-600"/>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
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}}
|
||||
{{ 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 >
|
||||
<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" />
|
||||
<TenantDropdown class="w-full"/>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
@@ -230,25 +245,19 @@ const footerLinks = [
|
||||
|
||||
<MainNav/>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<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>
|
||||
@@ -256,14 +265,14 @@ const footerLinks = [
|
||||
|
||||
<UDashboardPage>
|
||||
<UDashboardPanel grow>
|
||||
<slot />
|
||||
<slot/>
|
||||
</UDashboardPanel>
|
||||
</UDashboardPage>
|
||||
|
||||
|
||||
|
||||
<HelpSlideover/>
|
||||
|
||||
<Calculator v-if="calculatorStore.isOpen"/>
|
||||
|
||||
</UDashboardLayout>
|
||||
</div>
|
||||
|
||||
@@ -278,37 +287,32 @@ const footerLinks = [
|
||||
v-if="month === '12'"
|
||||
/>
|
||||
<UColorModeImage
|
||||
light="/Logo.png"
|
||||
dark="/Logo_Dark.png"
|
||||
class="w-1/3 mx-auto my-10"
|
||||
v-else
|
||||
light="/Logo.png"
|
||||
dark="/Logo_Dark.png"
|
||||
class="w-1/3 mx-auto my-10"
|
||||
v-else
|
||||
/>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<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
|
||||
variant="outline"
|
||||
color="rose"
|
||||
@click="auth.switchTenant(tenant.id)"
|
||||
>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" />
|
||||
<UProgress animation="carousel" class="w-3/4 mx-auto mt-10"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</template>
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Funktion für den Bankabruf
|
||||
// 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
|
||||
await $api('/api/functions/services/bankstatementsync', {method: 'POST'})
|
||||
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 searchString = ref(tempStore.searchStrings["bankstatements"] ||'')
|
||||
|
||||
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 {
|
||||
if(selectedFilters.value.includes("Nur offene anzeigen")){
|
||||
temp = temp.filter(i => Number(calculateOpenSum(i)) !== 0)
|
||||
}
|
||||
let temp = [...bankstatements.value]
|
||||
|
||||
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)
|
||||
}
|
||||
// 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'))
|
||||
}
|
||||
|
||||
return useSearch(searchString.value, temp.filter(i => filterAccount.value.find(x => x.id === i.account)))
|
||||
// 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 results.sort((a, b) => $dayjs(b.valueDate).unix() - $dayjs(a.valueDate).unix())
|
||||
})
|
||||
|
||||
setupPage()
|
||||
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>
|
||||
<USelectMenu
|
||||
:options="bankaccounts"
|
||||
v-model="filterAccount"
|
||||
option-attribute="iban"
|
||||
multiple
|
||||
by="id"
|
||||
:ui-menu="{ width: 'min-w-max' }"
|
||||
>
|
||||
<template #label>
|
||||
Konto
|
||||
</template>
|
||||
</USelectMenu>
|
||||
<div class="flex items-center gap-3">
|
||||
<USelectMenu
|
||||
:options="bankaccounts"
|
||||
v-model="filterAccount"
|
||||
option-attribute="iban"
|
||||
multiple
|
||||
by="id"
|
||||
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"
|
||||
>
|
||||
{{row.credName}}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.amount > 0"
|
||||
>
|
||||
{{row.debName}}
|
||||
</span>
|
||||
</template>
|
||||
</UTable>
|
||||
<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}`)"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"/>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -349,7 +310,4 @@ const isPaid = (item) => {
|
||||
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
|
||||
return Number(amountPaid.toFixed(2)) === useSum().getCreatedDocumentSum(item, items.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
</script>
|
||||
@@ -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>
|
||||
<UButton
|
||||
@click="router.push(`/createDocument/edit/?createddocument=${itemInfo.id}&loadMode=storno`)"
|
||||
variant="outline"
|
||||
color="rose"
|
||||
<UTooltip
|
||||
v-if="itemInfo.type === 'invoices' || itemInfo.type === 'advanceInvoices'"
|
||||
:text="links.find(i => i.type === 'cancellationInvoices') ? 'Bereits stoniert' : ''"
|
||||
>
|
||||
Stornieren
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="router.push(`/createDocument/edit/?createddocument=${itemInfo.id}&loadMode=storno`)"
|
||||
variant="outline"
|
||||
color="rose"
|
||||
: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"
|
||||
>
|
||||
|
||||
@@ -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>`
|
||||
}
|
||||
@@ -105,4 +105,4 @@ const contentChanged = (content) => {
|
||||
.previewDocumentMobile {
|
||||
aspect-ratio: 1 / 1.414;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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"
|
||||
@@ -346,4 +345,4 @@ const sendEmail = async () => {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 setupPage = async () => {
|
||||
folders.value = await useEntities("folders").select()
|
||||
const createFolderModalOpen = ref(false)
|
||||
const initialCreateFolderData = { name: '', standardFiletype: null, standardFiletypeIsOptional: true }
|
||||
const createFolderData = ref({ ...initialCreateFolderData })
|
||||
|
||||
documents.value = await files.selectDocuments()
|
||||
const renameModalOpen = ref(false)
|
||||
const renameData = ref({id: null, name: '', type: ''})
|
||||
|
||||
filetags.value = await useEntities("filetags").select()
|
||||
|
||||
if(route.query) {
|
||||
if(route.query.folder) {
|
||||
currentFolder.value = await useEntities("folders").selectSingle(route.query.folder)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
|
||||
}
|
||||
|
||||
|
||||
loadingDocs.value = false
|
||||
loaded.value = true
|
||||
|
||||
}
|
||||
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"
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const filteredDocuments = computed(() => {
|
||||
|
||||
return documents.value.filter(i => currentFolder.value ? i.folder === currentFolder.value.id : !i.folder)
|
||||
|
||||
})
|
||||
|
||||
const changeFolder = async (newFolder) => {
|
||||
loadingDocs.value = true
|
||||
currentFolder.value = newFolder
|
||||
|
||||
if(newFolder) {
|
||||
fileUploadFormData.value.folder = newFolder.id
|
||||
await router.push(`/files?folder=${newFolder.id}`)
|
||||
} else {
|
||||
fileUploadFormData.value.folder = null
|
||||
await router.push(`/files`)
|
||||
}
|
||||
|
||||
setupPage()
|
||||
}
|
||||
|
||||
const createFolderData = ref({})
|
||||
const createFolder = async () => {
|
||||
const res = await useEntities("folders").create({
|
||||
parent: currentFolder.value ? currentFolder.value.id : undefined,
|
||||
name: createFolderData.value.name,
|
||||
})
|
||||
|
||||
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"] ||'')
|
||||
const renderedFileList = computed(() => {
|
||||
let files = filteredDocuments.value.map(i => {
|
||||
return {
|
||||
label: i.path.split("/")[i.path.split("/").length -1],
|
||||
id: i.id,
|
||||
type: "file"
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
if(aVal && bVal) {
|
||||
return aVal.localeCompare(bVal)
|
||||
} else if(!aVal && bVal) {
|
||||
return 1
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
}, {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]
|
||||
|
||||
})
|
||||
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 () => {
|
||||
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
|
||||
}
|
||||
} 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()
|
||||
dragCounter = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalDragLeave = (e) => {
|
||||
dragCounter--
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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
|
||||
try {
|
||||
if (draggedItem.value.type === 'file') {
|
||||
await useEntities("files").update(draggedItem.value.id, {folder: targetFolderId})
|
||||
} else {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
}
|
||||
return [...links, ...path]
|
||||
}
|
||||
return links
|
||||
})
|
||||
|
||||
// --- Data Mapping ---
|
||||
const renderedFileList = computed(() => {
|
||||
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
|
||||
})
|
||||
|
||||
// --- 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
|
||||
})
|
||||
|
||||
// Ü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 {
|
||||
// Reset auf Standardwerte
|
||||
createFolderData.value = { ...initialCreateFolderData }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// --- Actions ---
|
||||
const createFolder = async () => {
|
||||
await useEntities("folders").create({
|
||||
parent: currentFolder.value?.id,
|
||||
name: createFolderData.value.name,
|
||||
standardFiletype: createFolderData.value.standardFiletype,
|
||||
standardFiletypeIsOptional: createFolderData.value.standardFiletypeIsOptional
|
||||
})
|
||||
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,{
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
<UFormGroup label="Name des Ordners" required>
|
||||
<UInput
|
||||
v-model="createFolderData.name"
|
||||
placeholder="z.B. Rechnungen 2024"
|
||||
autofocus
|
||||
<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."
|
||||
/>
|
||||
</div>
|
||||
<UCheckbox
|
||||
v-model="createFolderData.standardFiletypeIsOptional"
|
||||
label="Dateityp ist optional"
|
||||
: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>
|
||||
</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>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton color="gray" @click="createFolderModalOpen = false">Abbrechen</UButton>
|
||||
<UButton color="primary" @click="createFolder">Erstellen</UButton>
|
||||
</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)"
|
||||
>
|
||||
</template>
|
||||
</UCard>
|
||||
</UModal>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
</UDashboardPanelContent>
|
||||
</div>
|
||||
<UProgress animation="carousel" v-else class="w-5/6 mx-auto mt-5"/>
|
||||
|
||||
</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>
|
||||
</template>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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,"*")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,4 +194,4 @@ td {
|
||||
padding-bottom: 0.15em;
|
||||
padding-top: 0.15em;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
const items = ref([])
|
||||
const setup = async () => {
|
||||
items.value = await useSupabaseSelect("roles","*")
|
||||
items.value = await useEntities("roles").select("*")
|
||||
}
|
||||
|
||||
setup()
|
||||
@@ -19,4 +19,4 @@ setup()
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user