Compare commits

...

40 Commits

Author SHA1 Message Date
c56fcfbd14 #84
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 33s
Build and Push Docker Images / build-frontend (push) Successful in 3m25s
2026-01-26 21:52:24 +01:00
ca2020b9c6 Added more text functions 2026-01-26 21:11:16 +01:00
c87212d54a Folders in Wiki 2026-01-26 20:43:35 +01:00
db22d47900 Changes More Functions to wiki 2026-01-26 20:31:17 +01:00
143485e107 Changes More Functions to wiki 2026-01-26 20:18:33 +01:00
c1d4b24418 Added Wiki 2026-01-26 19:44:08 +01:00
9655d4fa05 Fix Ownaccount Booking
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m9s
2026-01-22 19:45:30 +01:00
4efe452f1c Redone Layouts
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 30s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-22 19:38:09 +01:00
cb21a85736 Added Dokubox Sync Service and Button Fix #12 2026-01-22 19:35:45 +01:00
d2b70e5883 Added Dokubox Sync Service and Button Fix #12
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 30s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
2026-01-22 17:05:22 +01:00
1a065b649c Fixed #71
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
2026-01-22 14:48:35 +01:00
34c58c3755 Fixed #71
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-01-22 11:28:39 +01:00
37d8a414d3 Fixed DB
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 30s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-21 15:16:23 +01:00
7f4f232c32 Added Health Ednpoint for Devices
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 31s
Build and Push Docker Images / build-frontend (push) Successful in 17s
Added Offline Sync for times
2026-01-21 12:38:36 +01:00
d6f257bcc6 Fix für #71
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-21 10:52:56 +01:00
3109f4d5ff Fix for Object Create from Customer
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-20 16:16:08 +01:00
235b33ae08 Fix for #46
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m11s
2026-01-20 15:16:47 +01:00
2d135b7068 Fix for #65
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
2026-01-20 15:08:58 +01:00
8831320a4c Fix ts
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m2s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-20 14:14:58 +01:00
000d409e4d fix for no createddocuments
Some checks failed
Build and Push Docker Images / build-backend (push) Failing after 31s
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-01-20 14:14:09 +01:00
160124a184 fix for #68
Some checks failed
Build and Push Docker Images / build-backend (push) Failing after 1m28s
Build and Push Docker Images / build-frontend (push) Successful in 1m12s
2026-01-20 13:55:10 +01:00
26dad422ec added size column 2026-01-17 16:04:39 +01:00
e59cbade53 Webdav 2026-01-17 16:04:32 +01:00
6423886930 added webdav server 2026-01-17 15:15:34 +01:00
6adf09faa0 DB Change con string 2026-01-17 15:15:26 +01:00
d7f3920763 Cors Change 2026-01-17 15:15:06 +01:00
3af92ebf71 #16 Added Move Up 2026-01-17 12:55:39 +01:00
5ab90830a0 Redone Files Index, #16 Added Drag and Drop for Files 2026-01-17 12:36:28 +01:00
4f72919269 #64 Fix
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-16 13:14:09 +01:00
f2c9dcc900 #64 Fix
Some checks failed
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Failing after 35s
2026-01-16 13:11:54 +01:00
b4ec792cc0 Diasbled Label Test Card
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-01-15 19:09:26 +01:00
9b3f48defe Added Calculator 2026-01-15 19:08:26 +01:00
5edc90bd4d Fix #8
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-15 18:45:35 +01:00
d140251aa0 Fix #7 Added Month Markings, Range Select 2026-01-15 18:45:25 +01:00
e7fb2df5c7 Added Debouncing #36
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-15 18:19:05 +01:00
f27fd3f6da Fix TS
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 29s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-15 18:07:58 +01:00
d3e2b106af Storno Fix createddocument link. Added Disable and Tooltip for Storno Button
Some checks failed
Build and Push Docker Images / build-backend (push) Failing after 29s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-01-15 18:05:44 +01:00
769d2059ca Redone Search to inluce more Columns #36
TODO: Spalten nachpflegen
2026-01-15 18:05:14 +01:00
53349fae83 Fix Link Buttons Added New link buttons
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-01-15 13:38:01 +01:00
d8eb1559c8 Update Problem bei #54
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 31s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-15 13:18:58 +01:00
43 changed files with 3882 additions and 3066 deletions

View File

@@ -1,13 +1,26 @@
import { drizzle } from "drizzle-orm/node-postgres" // src/db/index.ts
import { Pool } from "pg" import { drizzle } from "drizzle-orm/node-postgres";
import {secrets} from "../src/utils/secrets"; import { Pool } from "pg";
import * as schema from "./schema" 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({ export const pool = new Pool({
connectionString: secrets.DATABASE_URL, connectionString,
max: 10, // je nach Last max: 10,
}) });
export const db = drizzle(pool , {schema}) // TEST: Ist die DB wirklich da?
pool.query('SELECT NOW()')
.then(res => console.log(`[DB INIT] ✅ VERBINDUNG ERFOLGREICH! Zeit auf DB: ${res.rows[0].now}`))
.catch(err => console.error(`[DB INIT] ❌ VERBINDUNGSFEHLER:`, err.message));
export const db = drizzle(pool, { schema });

View File

@@ -3,7 +3,7 @@ import {
uuid, uuid,
timestamp, timestamp,
text, text,
bigint, bigint, jsonb,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
import { tenants } from "./tenants" import { tenants } from "./tenants"
@@ -23,6 +23,11 @@ export const devices = pgTable("devices", {
password: text("password"), password: text("password"),
externalId: text("externalId"), 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 export type Device = typeof devices.$inferSelect

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,8 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "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", "build": "tsc",
"start": "node dist/src/index.js", "start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts" "schema:index": "ts-node scripts/generate-schema-index.ts"
@@ -48,6 +50,7 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"webdav-server": "^2.6.2",
"xmlbuilder": "^15.1.1", "xmlbuilder": "^15.1.1",
"zpl-image": "^0.2.0", "zpl-image": "^0.2.0",
"zpl-renderer-js": "^2.0.2" "zpl-renderer-js": "^2.0.2"

View File

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

View File

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

View File

@@ -14,8 +14,9 @@ export default fp(async (server: FastifyInstance) => {
"https://app.fedeo.de", // dein Nuxt-Frontend "https://app.fedeo.de", // dein Nuxt-Frontend
"capacitor://localhost", // dein Nuxt-Frontend "capacitor://localhost", // dein Nuxt-Frontend
], ],
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS","PATCH",
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin"], "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 exposedHeaders: ["Authorization", "Content-Disposition", "Content-Type", "Content-Length"], // optional, falls du ihn auch auslesen willst
credentials: true, // wichtig, falls du Cookies nutzt credentials: true, // wichtig, falls du Cookies nutzt
}); });

View File

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

View File

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

View File

@@ -7,31 +7,33 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
"/rfid/createevent/:terminal_id", "/rfid/createevent/:terminal_id",
async (req, reply) => { async (req, reply) => {
try { 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`); 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 const device = await server.db
.select() .select()
.from(devices) .from(devices)
.where( .where(eq(devices.externalId, terminal_id))
eq(devices.externalId, terminal_id)
)
.limit(1) .limit(1)
.then(rows => rows[0]); .then(rows => rows[0]);
if (!device) { if (!device) {
console.log(`Device ${terminal_id} not found`); 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 const profile = await server.db
.select() .select()
.from(authProfiles) .from(authProfiles)
@@ -46,53 +48,54 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
if (!profile) { if (!profile) {
console.log(`Profile for Token ${rfid_id} not found`); 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 const lastEvent = await server.db
.select() .select()
.from(stafftimeevents) .from(stafftimeevents)
.where( .where(eq(stafftimeevents.user_id, profile.user_id))
eq(stafftimeevents.user_id, profile.user_id) .orderBy(desc(stafftimeevents.eventtime))
)
.orderBy(desc(stafftimeevents.eventtime)) // <-- Sortierung: Neuestes zuerst
.limit(1) .limit(1)
.then(rows => rows[0]); .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 = { const dataToInsert = {
tenant_id: device.tenant, tenant_id: device.tenant,
user_id: profile.user_id, user_id: profile.user_id,
actortype: "system", actortype: "system",
eventtime: new Date(), eventtime: actualEventTime, // Hier nutzen wir die berechnete Zeit
eventtype: lastEvent.eventtype === "work_start" ? "work_end" : "work_start", eventtype: nextEventType,
source: "WEB" 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 const [created] = await server.db
.insert(stafftimeevents) .insert(stafftimeevents)
//@ts-ignore //@ts-ignore
.values(dataToInsert) .values(dataToInsert)
.returning() .returning();
return created;
return created
} catch (err: any) { } catch (err: any) {
console.error(err) console.error(err);
return reply.code(400).send({ error: err.message }) return reply.code(400).send({ error: err.message });
} }
console.log(req.body)
return
} }
); );
} }

View File

@@ -179,6 +179,11 @@ export default async function functionRoutes(server: FastifyInstance) {
await server.services.prepareIncomingInvoices.run(req.user.tenant_id) 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) => { /*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} const { zpl, widthMm = 50, heightMm = 30, dpmm = 8, asBase64 = false } = req.body as {zpl:string,widthMm:number,heightMm:number,dpmm:number,asBase64:string}

View File

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

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

@@ -0,0 +1,203 @@
import { FastifyInstance } from "fastify"
import { and, eq, isNull, asc, desc } from "drizzle-orm"
import { wikiPages, authUsers } from "../../db/schema/"
// Types für Request Body & Query (statt Zod)
interface WikiTreeQuery {
entityType?: string
entityId?: number // Kommt als string oder number an, je nach parser
entityUuid?: string
}
interface WikiCreateBody {
title: string
parentId?: string // UUID
isFolder?: boolean
entityType?: string
entityId?: number
entityUuid?: string
}
interface WikiUpdateBody {
title?: string
content?: any // Das Tiptap JSON Object
parentId?: string | null
sortOrder?: number
isFolder?: boolean
}
export default async function wikiRoutes (server: FastifyInstance) {
// ---------------------------------------------------------
// 1. GET /wiki/tree
// Lädt die Struktur für die Sidebar (OHNE Content -> Schnell)
// ---------------------------------------------------------
server.get<{ Querystring: WikiTreeQuery }>("/wiki/tree", async (req, reply) => {
const user = req.user
const { entityType, entityId, entityUuid } = req.query
// Basis-Filter: Tenant Sicherheit
const filters = [eq(wikiPages.tenantId, user.tenant_id)]
// Logik: Laden wir "Allgemein" oder "Entitäts-Spezifisch"?
if (entityType) {
// Spezifisch (z.B. Kunde)
filters.push(eq(wikiPages.entityType, entityType))
if (entityId) {
filters.push(eq(wikiPages.entityId, Number(entityId)))
} else if (entityUuid) {
filters.push(eq(wikiPages.entityUuid, entityUuid))
}
} else {
// Allgemein (alles wo entityType NULL ist)
filters.push(isNull(wikiPages.entityType))
}
// Performance: Nur Metadaten laden, kein riesiges JSON-Content
const pages = await 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))
return pages
})
// ---------------------------------------------------------
// 2. GET /wiki/:id
// Lädt EINEN Eintrag komplett MIT Content (für den Editor)
// ---------------------------------------------------------
server.get<{ Params: { id: string } }>("/wiki/:id", async (req, reply) => {
const user = req.user
const { id } = req.params
// Drizzle Query API nutzen für Relation (optional Author laden)
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: true, // Falls im authUsers schema 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
// Simple Validierung
if (!body.title) return reply.code(400).send({ error: "Title required" })
// Split Logik für Polymorphie
const hasEntity = !!body.entityType
const [newPage] = await server.db
.insert(wikiPages)
.values({
tenantId: user.tenant_id,
title: body.title,
parentId: body.parentId || null, // undefined abfangen
isFolder: body.isFolder ?? false,
// Polymorphe Zuweisung
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 (Inhalt, Titel, Verschieben, Sortieren)
// ---------------------------------------------------------
server.patch<{ Params: { id: string }; Body: WikiUpdateBody }>(
"/wiki/:id",
async (req, reply) => {
const user = req.user
const { id } = req.params
const body = req.body
// 1. Prüfen ob Eintrag existiert & Tenant gehört
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" })
// 2. Update durchführen
const [updatedPage] = await server.db
.update(wikiPages)
.set({
title: body.title, // update title if defined
content: body.content, // update content if defined
parentId: body.parentId, // update parent (move) if defined
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 (DB Cascade erledigt die Kinder)
// ---------------------------------------------------------
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) // WICHTIG: Security
))
.returning({ id: wikiPages.id })
if (result.length === 0) {
return reply.code(404).send({ error: "Not found or not authorized" })
}
return { success: true, deletedId: result[0].id }
})
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,6 @@ const props = defineProps({
const emit = defineEmits(["returnData"]) const emit = defineEmits(["returnData"])
const {type} = props const {type} = props
defineShortcuts({ defineShortcuts({
@@ -53,11 +52,10 @@ const route = useRoute()
const dataStore = useDataStore() const dataStore = useDataStore()
const modal = useModal() const modal = useModal()
const dataType = dataStore.dataTypes[type] const dataType = dataStore.dataTypes[type]
const openTab = ref(0) const openTab = ref(0)
const item = ref(JSON.parse(props.item)) const item = ref(JSON.parse(props.item))
console.log(item.value) // console.log(item.value)
const oldItem = ref(null) const oldItem = ref(null)
const generateOldItemData = () => { const generateOldItemData = () => {
@@ -66,6 +64,39 @@ const generateOldItemData = () => {
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 = () => { const setupCreate = () => {
dataType.templateColumns.forEach(datapoint => { dataType.templateColumns.forEach(datapoint => {
if(datapoint.key.includes(".")){ if(datapoint.key.includes(".")){
@@ -78,10 +109,7 @@ const setupCreate = () => {
} else { } else {
item.value[datapoint.key] = {} item.value[datapoint.key] = {}
} }
} }
}) })
} }
setupCreate() setupCreate()
@@ -91,7 +119,6 @@ const setupQuery = () => {
console.log(props.mode) console.log(props.mode)
if(props.mode === "create" && (route.query || props.createQuery)) { if(props.mode === "create" && (route.query || props.createQuery)) {
let data = !props.inModal ? route.query : props.createQuery let data = !props.inModal ? route.query : props.createQuery
Object.keys(data).forEach(key => { Object.keys(data).forEach(key => {
@@ -127,13 +154,10 @@ const setupQuery = () => {
} else { } else {
item.value[holder] = [id] item.value[holder] = [id]
} }
}) })
} }
}) })
// calcSaveAllowed() -> Entfernt, da computed automatisch reagiert
} }
} }
setupQuery() setupQuery()
@@ -166,40 +190,16 @@ loadOptions()
const contentChanged = (content, datapoint) => { const contentChanged = (content, datapoint) => {
if (datapoint.key.includes(".")) { if (datapoint.key.includes(".")) {
item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].html = content.html item.value[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].html = content.html
item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].text = content.text item.value[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].text = content.text
item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].json = content.json item.value[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].json = content.json
} else { } else {
item[datapoint.key].html = content.html item.value[datapoint.key].html = content.html
item[datapoint.key].text = content.text item.value[datapoint.key].text = content.text
item[datapoint.key].json = content.json 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 () => { const createItem = async () => {
let ret = null let ret = null
@@ -226,11 +226,7 @@ const updateItem = async () => {
ret = await useEntities(type).update(item.value.id, item.value) ret = await useEntities(type).update(item.value.id, item.value)
emit('returnData', ret) emit('returnData', ret)
} }
} }
</script> </script>
<template> <template>
@@ -245,9 +241,8 @@ const updateItem = async () => {
<UButton <UButton
icon="i-heroicons-chevron-left" icon="i-heroicons-chevron-left"
variant="outline" variant="outline"
@click="router.back()/*router.push(`/standardEntity/${type}`)*/" @click="router.back()"
> >
<!-- {{dataType.label}}-->
</UButton> </UButton>
</template> </template>
<template #center> <template #center>
@@ -332,10 +327,6 @@ const updateItem = async () => {
> >
<UDivider>{{ columnName }}</UDivider> <UDivider>{{ columnName }}</UDivider>
<!--
Die Form Group darf nur in der ersten bearbeitet werden und muss dann runterkopiert werden
-->
<div <div
v-for="datapoint in dataType.templateColumns.filter(i => i.inputType && i.inputColumn === columnName)" v-for="datapoint in dataType.templateColumns.filter(i => i.inputType && i.inputColumn === columnName)"
> >
@@ -436,7 +427,6 @@ const updateItem = async () => {
/> />
</template> </template>
</UPopover> </UPopover>
<!-- TODO: DISABLED FOR TIPTAP -->
<Tiptap <Tiptap
v-else-if="datapoint.inputType === 'editor'" v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)" @updateContent="(i) => contentChanged(i,datapoint)"
@@ -537,7 +527,6 @@ const updateItem = async () => {
/> />
</template> </template>
</UPopover> </UPopover>
<!-- TODO: Color/Required for TipTap and MaterialComposing -->
<Tiptap <Tiptap
v-else-if="datapoint.inputType === 'editor'" v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)" @updateContent="(i) => contentChanged(i,datapoint)"
@@ -562,35 +551,8 @@ const updateItem = async () => {
icon="i-heroicons-x-mark" icon="i-heroicons-x-mark"
/> />
</InputGroup> </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> </UFormGroup>
</div> </div>
</div> </div>
</div> </div>
<UFormGroup <UFormGroup
@@ -690,7 +652,6 @@ const updateItem = async () => {
/> />
</template> </template>
</UPopover> </UPopover>
<!-- TODO: DISABLED FOR TIPTAP -->
<Tiptap <Tiptap
v-else-if="datapoint.inputType === 'editor'" v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)" @updateContent="(i) => contentChanged(i,datapoint)"
@@ -791,7 +752,6 @@ const updateItem = async () => {
/> />
</template> </template>
</UPopover> </UPopover>
<!-- TODO: Color/Required for TipTap and MaterialComposing -->
<Tiptap <Tiptap
v-else-if="datapoint.inputType === 'editor'" v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)" @updateContent="(i) => contentChanged(i,datapoint)"
@@ -816,30 +776,6 @@ const updateItem = async () => {
icon="i-heroicons-x-mark" icon="i-heroicons-x-mark"
/> />
</InputGroup> </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> </UFormGroup>
</UForm> </UForm>
</UDashboardPanelContent> </UDashboardPanelContent>

View File

@@ -78,9 +78,6 @@ const templateColumns = [
},{ },{
key: 'state', key: 'state',
label: "Status" label: "Status"
},{
key: 'paid',
label: "Bezahlt"
},{ },{
key: 'amount', key: 'amount',
label: "Betrag" label: "Betrag"
@@ -283,12 +280,12 @@ const selectItem = (item) => {
{{row.state}} {{row.state}}
</span> </span>
</template> </template>
<template #paid-data="{row}"> <!-- <template #paid-data="{row}">
<div v-if="(row.type === 'invoices' ||row.type === 'advanceInvoices') && row.state === 'Gebucht'"> <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-if="useSum().getIsPaid(row,createddocuments)" class="text-primary-500">Bezahlt</span>
<span v-else class="text-rose-600">Offen</span> <span v-else class="text-rose-600">Offen</span>
</div> </div>
</template> </template>-->
<template #reference-data="{row}"> <template #reference-data="{row}">
<span v-if="row === props.item.createddocuments[selectedItem]" class="text-primary-500 font-bold">{{row.documentNumber}}</span> <span v-if="row === props.item.createddocuments[selectedItem]" class="text-primary-500 font-bold">{{row.documentNumber}}</span>
<span v-else>{{row.documentNumber}}</span> <span v-else>{{row.documentNumber}}</span>

View File

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

View File

@@ -7,6 +7,9 @@ const props = defineProps({
pin: { type: String, default: '' } pin: { type: String, default: '' }
}) })
const runtimeConfig = useRuntimeConfig()
const emit = defineEmits(['success']) const emit = defineEmits(['success'])
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const toast = useToast() const toast = useToast()
@@ -75,7 +78,7 @@ const submit = async () => {
if (props.pin) headers['x-public-pin'] = props.pin if (props.pin) headers['x-public-pin'] = props.pin
// An den Submit-Endpunkt senden (den müssen wir im Backend noch bauen!) // 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', method: 'POST',
body: payload, body: payload,
headers headers
@@ -132,6 +135,7 @@ const submit = async () => {
value-attribute="id" value-attribute="id"
placeholder="Wählen..." placeholder="Wählen..."
searchable searchable
searchable-placeholder="Suchen..."
/> />
</UFormGroup> </UFormGroup>
@@ -147,6 +151,8 @@ const submit = async () => {
option-attribute="name" option-attribute="name"
value-attribute="id" value-attribute="id"
placeholder="Wählen..." placeholder="Wählen..."
searchable
searchable-placeholder="Suchen..."
/> />
</UFormGroup> </UFormGroup>

View File

@@ -0,0 +1,104 @@
<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']"
: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="text-gray-400 shrink-0 flex items-center">
<UIcon v-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" />
</span>
<span class="truncate flex-1">{{ item.title }}</span>
<div 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-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()
// NEU: searchQuery importieren für Auto-Expand
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 Logic 1: Aktive Seite
watch(() => route.params.id, (newId) => {
if (props.item.isFolder && hasActiveChild(props.item, newId as string)) isOpen.value = true
}, { immediate: true })
// NEU: Auto-Open Logic 2: Suche aktiv -> Alles aufklappen
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() {
props.item.isFolder ? (isOpen.value = !isOpen.value) : router.push(`/wiki/${props.item.id}`)
}
function toggleFolder() { isOpen.value = !isOpen.value }
function triggerCreate(isFolder: boolean) {
showMenu.value = false
isOpen.value = true
openWikiAction('create', props.item, isFolder)
}
function triggerDelete() {
showMenu.value = false
openWikiAction('delete', props.item)
}
</script>

View File

@@ -0,0 +1,265 @@
<template>
<div class="flex flex-col h-full bg-white relative">
<div v-if="editor" class="border-b border-gray-100 px-4 py-2 flex flex-wrap gap-1 items-center sticky top-0 bg-white z-20 shadow-sm select-none">
<div class="flex gap-0.5 border-r border-gray-200 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 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="H1">H1</button>
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" class="toolbar-btn" title="H2">H2</button>
<button @click="editor.chain().focus().toggleCodeBlock().run()" :class="{ 'is-active': editor.isActive('codeBlock') }" class="toolbar-btn font-mono text-xs" title="Code Block">&lt;/&gt;</button>
</div>
<div class="flex gap-0.5 border-r border-gray-200 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="Checkliste"></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">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" /></svg>
</button>
<div v-if="editor.isActive('table')" class="flex gap-0.5 ml-2 pl-2 border-l border-gray-200 items-center">
<button @click="editor.chain().focus().addColumnAfter().run()" class="toolbar-btn text-xs">+Col</button>
<button @click="editor.chain().focus().deleteColumn().run()" class="toolbar-btn text-xs text-red-400">-Col</button>
<button @click="editor.chain().focus().addRowAfter().run()" class="toolbar-btn text-xs">+Row</button>
<button @click="editor.chain().focus().deleteRow().run()" class="toolbar-btn text-xs text-red-400">-Row</button>
<button @click="editor.chain().focus().mergeCells().run()" class="toolbar-btn text-xs">Merge</button>
<button @click="editor.chain().focus().deleteTable().run()" class="toolbar-btn text-red-500 hover:bg-red-50 ml-1">🗑</button>
</div>
</div>
</div>
<BubbleMenu
v-if="editor"
:editor="editor"
:tippy-options="{ duration: 200, placement: 'top-start', animation: 'scale' }"
class="bg-white border border-gray-200 text-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().toggleStrike().run()" :class="{ 'is-active': editor.isActive('strike') }" class="bubble-btn line-through">S</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 border border-gray-200 text-gray-600 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" title="H1">H1</button>
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" class="bubble-btn text-xs font-bold" title="H2">H2</button>
<button @click="editor.chain().focus().toggleBulletList().run()" class="bubble-btn" title="Liste"></button>
<button @click="editor.chain().focus().toggleTaskList().run()" class="bubble-btn" title="Task"></button>
<button @click="editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()" class="bubble-btn" title="Tabelle"></button>
<button @click="editor.chain().focus().toggleCodeBlock().run()" class="bubble-btn font-mono text-xs" title="Code">&lt;&gt;</button>
</FloatingMenu>
<editor-content
:editor="editor"
class="flex-1 overflow-y-auto px-8 py-6 prose prose-slate max-w-none focus:outline-none custom-editor-area"
/>
<div v-if="editor" class="border-t border-gray-100 px-4 py-1 text-xs text-gray-400 flex justify-end bg-gray-50">
{{ editor.storage.characterCount.words() }} Wörter {{ editor.storage.characterCount.characters() }} Zeichen
</div>
</div>
</template>
<script setup lang="ts">
import { watch } from 'vue'
// Tiptap Core
import { useEditor, EditorContent } from '@tiptap/vue-3'
// @ts-ignore
import { BubbleMenu, FloatingMenu } from '@tiptap/vue-3/menus'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
// Extensions
import BubbleMenuExtension from '@tiptap/extension-bubble-menu'
import FloatingMenuExtension from '@tiptap/extension-floating-menu'
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'
// --- FIX: Standard CodeBlock statt Lowlight nutzen (Vermeidet Vite Fehler) ---
import CodeBlock from '@tiptap/extension-code-block'
// Table 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'
const props = defineProps<{ modelValue: any }>()
const emit = defineEmits(['update:modelValue'])
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit.configure({
codeBlock: false, // Standard StarterKit Block aus, wir laden unseren eigenen
}),
Placeholder.configure({ placeholder: 'Tippe "/" oder schreibe los...' }),
// UI
BubbleMenuExtension.configure({
shouldShow: ({ editor, from, to }) => !editor.state.selection.empty && (to - from > 0),
}),
FloatingMenuExtension,
// Basics
Link.configure({ openOnClick: false, autolink: true }),
Image,
Highlight,
Typography,
CharacterCount,
// Struktur
TaskList,
TaskItem.configure({ nested: true }),
Youtube.configure({ width: 640, height: 480 }),
// Code (Standard Block ohne Bunte Farben, dafür stabil)
CodeBlock,
// Tables
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
],
editorProps: {
attributes: {
class: 'focus:outline-none min-h-[500px] pb-40',
},
},
onUpdate: ({ editor }) => {
emit('update:modelValue', editor.getJSON())
},
})
watch(() => props.modelValue, (val) => {
if (editor.value && JSON.stringify(val) !== JSON.stringify(editor.value.getJSON())) {
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('YouTube URL eingeben:')
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 hover:bg-gray-100 transition-colors text-sm font-medium min-w-[28px] h-[28px] flex items-center justify-center;
}
.toolbar-btn.is-active {
@apply bg-gray-200 text-black shadow-inner;
}
.bubble-btn {
@apply px-2 py-1 hover:bg-gray-100 rounded transition-colors text-sm min-w-[24px] h-[24px] flex items-center justify-center;
}
.bubble-btn.is-active {
@apply bg-gray-200 text-black;
}
/* EDITOR CSS RESET & STYLES */
:deep(.prose) {
/* 1. TABLE FIX */
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;
}
th { background-color: #f3f4f6; font-weight: 600; text-align: left; }
.column-resize-handle { background-color: #3b82f6; bottom: -2px; position: absolute; right: -2px; pointer-events: none; top: 0; width: 4px; }
/* 2. TASK LIST FIX */
ul[data-type="taskList"] { list-style: none !important; padding: 0 !important; margin: 0 !important; }
li[data-type="taskItem"] { display: flex !important; flex-direction: row !important; align-items: flex-start !important; margin-bottom: 0.25rem !important; padding-left: 0 !important; }
li[data-type="taskItem"] > label { display: inline-flex !important; margin-right: 0.5rem !important; margin-top: 0.25rem !important; user-select: none; cursor: pointer; flex-shrink: 0; }
li[data-type="taskItem"] > label > input { margin: 0 !important; cursor: pointer; }
li[data-type="taskItem"] > div { flex: 1 1 auto !important; min-width: 0 !important; display: block !important; }
li[data-type="taskItem"] div > p { margin: 0 !important; display: block !important; }
li[data-type="taskItem"]::marker, li[data-type="taskItem"]::before { content: none !important; display: none !important; }
/* 3. HIGHLIGHT */
mark { background-color: #fef08a; padding: 0.1rem 0.2rem; border-radius: 0.2rem; }
/* 4. YOUTUBE / VIDEO */
div[data-youtube-video] { cursor: move; padding-right: 1.5rem; }
iframe { border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); margin: 1rem 0; }
/* 5. CODE BLOCK (Standard Style - Dark Mode Look) */
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;
}
/* Placeholder & Selection */
.ProseMirror-selectednode { outline: 2px solid #3b82f6; }
p.is-editor-empty:first-child::before { color: #9ca3af; content: attr(data-placeholder); float: left; height: 0; pointer-events: none; }
img { max-width: 100%; height: auto; border-radius: 6px; display: block; margin: 1rem 0; }
}
</style>

View File

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

View File

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

View File

@@ -24,7 +24,8 @@ export default defineNuxtConfig({
}], }],
build: { build: {
transpile: ['@vuepic/vue-datepicker'] transpile: ['@vuepic/vue-datepicker','@tiptap/vue-3','@tiptap/extension-code-block-lowlight',
'lowlight',]
}, },
@@ -41,7 +42,8 @@ export default defineNuxtConfig({
vite: { vite: {
optimizeDeps: { optimizeDeps: {
include: ["@editorjs/editorjs", "dayjs"], include: ["@editorjs/editorjs", "dayjs",'@tiptap/vue-3','@tiptap/extension-code-block-lowlight',
'lowlight',],
}, },
}, },

View File

@@ -46,14 +46,30 @@
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@sentry/browser": "^9.11.0", "@sentry/browser": "^9.11.0",
"@sentry/integrations": "^7.114.0", "@sentry/integrations": "^7.114.0",
"@tiptap/extension-underline": "^2.1.15", "@tiptap/extension-bubble-menu": "^3.17.1",
"@tiptap/pm": "^2.1.15", "@tiptap/extension-character-count": "^3.17.1",
"@tiptap/starter-kit": "^2.1.15", "@tiptap/extension-code-block": "^3.17.1",
"@tiptap/vue-3": "^2.1.15", "@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-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", "@vicons/ionicons5": "^0.12.0",
"@vue-leaflet/vue-leaflet": "^0.10.1", "@vue-leaflet/vue-leaflet": "^0.10.1",
"@vue-pdf-viewer/viewer": "^3.0.1", "@vue-pdf-viewer/viewer": "^3.0.1",
"@vuepic/vue-datepicker": "^7.4.0", "@vuepic/vue-datepicker": "^7.4.0",
"@vueuse/components": "^14.1.0",
"@zip.js/zip.js": "^2.7.32", "@zip.js/zip.js": "^2.7.32",
"array-sort": "^1.0.0", "array-sort": "^1.0.0",
"axios": "^1.6.7", "axios": "^1.6.7",

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1387,7 +1387,7 @@ const saveDocument = async (state, resetup = false) => {
endText: itemInfo.value.endText, endText: itemInfo.value.endText,
rows: itemInfo.value.rows, rows: itemInfo.value.rows,
contactPerson: itemInfo.value.contactPerson, contactPerson: itemInfo.value.contactPerson,
linkedDocument: itemInfo.value.linkedDocument, createddocument: itemInfo.value.createddocument,
agriculture: itemInfo.value.agriculture, agriculture: itemInfo.value.agriculture,
letterhead: itemInfo.value.letterhead, letterhead: itemInfo.value.letterhead,
usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices, usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices,

View File

@@ -8,7 +8,6 @@
class="hidden lg:block" class="hidden lg:block"
icon="i-heroicons-funnel" icon="i-heroicons-funnel"
placeholder="Suche..." placeholder="Suche..."
@change="tempStore.modifySearchString('createddocuments',searchString)"
@keydown.esc="$event.target.blur()" @keydown.esc="$event.target.blur()"
> >
<template #trailing> <template #trailing>
@@ -30,22 +29,7 @@
</template> </template>
</UDashboardNavbar> </UDashboardNavbar>
<UDashboardToolbar> <UDashboardToolbar>
<template #right> <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 <USelectMenu
v-if="selectableFilters.length > 0" v-if="selectableFilters.length > 0"
v-model="selectedFilters" v-model="selectedFilters"
@@ -157,6 +141,31 @@
<script setup> <script setup>
import dayjs from "dayjs"; 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({ defineShortcuts({
'/': () => { '/': () => {
@@ -168,10 +177,6 @@ defineShortcuts({
'Enter': { 'Enter': {
usingInput: true, usingInput: true,
handler: () => { 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]) { if (filteredRows.value[selectedItem.value]) {
router.push(`/createDocument/show/${filteredRows.value[selectedItem.value].id}`) 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 () => { const setupPage = async () => {
items.value = (await useEntities("createddocuments").select("*, customer(id,name), statementallocations(id,amount),linkedDocument(*)", "documentNumber", true, true)) 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"} {key: "dueDate", label: "Fällig"}
] ]
// Eigene Spalten für Entwürfe: Referenz raus, Status rein
const draftColumns = [ const draftColumns = [
{key: 'type', label: "Typ"}, {key: 'type', label: "Typ"},
{key: 'state', label: "Status"}, // Status wieder drin {key: 'state', label: "Status"},
{key: 'partner', label: "Kunde"}, {key: 'partner', label: "Kunde"},
{key: "date", label: "Erstellt am"}, {key: "date", label: "Erstellt am"},
{key: "amount", label: "Betrag"} {key: "amount", label: "Betrag"}
@@ -242,31 +235,11 @@ const getColumnsForTab = (tabKey) => {
} }
const templateTypes = [ const templateTypes = [
{ { key: "drafts", label: "Entwürfe" },
key: "drafts", { key: "invoices", label: "Rechnungen" },
label: "Entwürfe" { key: "quotes", label: "Angebote" },
}, { key: "deliveryNotes", label: "Lieferscheine" },
{ { key: "confirmationOrders", label: "Auftragsbestätigungen" }
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"
}
] ]
const selectedTypes = ref(tempStore.filters["createddocuments"] ? tempStore.filters["createddocuments"] : templateTypes) const selectedTypes = ref(tempStore.filters["createddocuments"] ? tempStore.filters["createddocuments"] : templateTypes)
const types = computed(() => { const types = computed(() => {
@@ -274,10 +247,9 @@ const types = computed(() => {
}) })
const selectItem = (item) => { const selectItem = (item) => {
console.log(item)
if (item.state === "Entwurf") { if (item.state === "Entwurf") {
router.push(`/createDocument/edit/${item.id}`) router.push(`/createDocument/edit/${item.id}`)
} else if (item.state !== "Entwurf") { } else {
router.push(`/createDocument/show/${item.id}`) router.push(`/createDocument/show/${item.id}`)
} }
} }
@@ -286,24 +258,19 @@ const displayCurrency = (value, currency = "€") => {
return `${Number(value).toFixed(2).replace(".", ",")} ${currency}` return `${Number(value).toFixed(2).replace(".", ",")} ${currency}`
} }
const searchString = ref(tempStore.searchStrings['createddocuments'] || '')
const clearSearchString = () => { const clearSearchString = () => {
tempStore.clearSearchString('createddocuments') tempStore.clearSearchString('createddocuments')
searchString.value = '' searchString.value = ''
debouncedSearchString.value = ''
} }
const selectableFilters = ref(dataType.filters.map(i => i.name)) const selectableFilters = ref(dataType.filters.map(i => i.name))
const selectedFilters = ref(dataType.filters.filter(i => i.default).map(i => i.name) || []) const selectedFilters = ref(dataType.filters.filter(i => i.default).map(i => i.name) || [])
const filteredRows = computed(() => { const filteredRows = computed(() => {
let tempItems = items.value.filter(i => types.value.find(x => { let tempItems = items.value.filter(i => types.value.find(x => {
// 1. Draft Tab Logic
if (x.key === 'drafts') return i.state === 'Entwurf' 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 if (i.state === 'Entwurf' && x.key !== 'drafts') return false
// 3. Normal Type Logic
if (x.key === 'invoices') return ['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(i.type) if (x.key === 'invoices') return ['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(i.type)
return x.key === i.type return x.key === i.type
})) }))
@@ -324,22 +291,16 @@ const filteredRows = computed(() => {
}) })
} }
tempItems = useSearch(searchString.value, tempItems) // Hier nutzen wir nun den debounced Wert für die lokale Suche
const results = useSearch(debouncedSearchString.value, tempItems.slice().reverse())
return useSearch(searchString.value, tempItems.slice().reverse()) return results
}) })
const getRowsForTab = (tabKey) => { const getRowsForTab = (tabKey) => {
return filteredRows.value.filter(row => { return filteredRows.value.filter(row => {
if (tabKey === 'drafts') { if (tabKey === 'drafts') return row.state === 'Entwurf'
return row.state === 'Entwurf'
}
if (row.state === 'Entwurf') return false 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 return row.type === tabKey
}) })
} }
@@ -350,6 +311,3 @@ const isPaid = (item) => {
return Number(amountPaid.toFixed(2)) === useSum().getCreatedDocumentSum(item, items.value) return Number(amountPaid.toFixed(2)) === useSum().getCreatedDocumentSum(item, items.value)
} }
</script> </script>
<style scoped>
</style>

View File

@@ -17,11 +17,18 @@ const dataStore = useDataStore()
const itemInfo = ref({}) const itemInfo = ref({})
const linkedDocument =ref({}) const linkedDocument =ref({})
const links = ref([])
const setupPage = async () => { const setupPage = async () => {
if(route.params) { if(route.params) {
if(route.params.id) itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,"*,files(*),linkedDocument(*), statementallocations(bs_id)") 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) linkedDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].id)
} }
@@ -80,17 +87,23 @@ const openBankstatements = () => {
> >
E-Mail E-Mail
</UButton> </UButton>
<UTooltip
v-if="itemInfo.type === 'invoices' || itemInfo.type === 'advanceInvoices'"
:text="links.find(i => i.type === 'cancellationInvoices') ? 'Bereits stoniert' : ''"
>
<UButton <UButton
@click="router.push(`/createDocument/edit/?createddocument=${itemInfo.id}&loadMode=storno`)" @click="router.push(`/createDocument/edit/?createddocument=${itemInfo.id}&loadMode=storno`)"
variant="outline" variant="outline"
color="rose" color="rose"
v-if="itemInfo.type === 'invoices' || itemInfo.type === 'advanceInvoices'" :disabled="links.find(i => i.type === 'cancellationInvoices')"
> >
Stornieren Stornieren
</UButton> </UButton>
</UTooltip>
<UButton <UButton
v-if="itemInfo.project" 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" icon="i-heroicons-link"
variant="outline" variant="outline"
> >
@@ -98,12 +111,36 @@ const openBankstatements = () => {
</UButton> </UButton>
<UButton <UButton
v-if="itemInfo.customer" 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" icon="i-heroicons-link"
variant="outline" variant="outline"
> >
Kunde Kunde
</UButton> </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 <UButton
v-if="itemInfo.createddocument" v-if="itemInfo.createddocument"
@click="router.push(`/createDocument/show/${itemInfo.createddocument}`)" @click="router.push(`/createDocument/show/${itemInfo.createddocument}`)"

View File

@@ -1,535 +1,378 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted } from 'vue';
import {BlobReader, BlobWriter, ZipWriter} from "@zip.js/zip.js";
import DocumentDisplayModal from "~/components/DocumentDisplayModal.vue"; import DocumentDisplayModal from "~/components/DocumentDisplayModal.vue";
import DocumentUploadModal from "~/components/DocumentUploadModal.vue"; import DocumentUploadModal from "~/components/DocumentUploadModal.vue";
import dayjs from "dayjs"; 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 dataStore = useDataStore()
const tempStore = useTempStore() const tempStore = useTempStore()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const modal = useModal() const modal = useModal()
const toast = useToast()
const auth = useAuthStore() const { $api } = useNuxtApp()
const uploadModalOpen = ref(false)
const createFolderModalOpen = ref(false)
const uploadInProgress = ref(false)
const fileUploadFormData = ref({
tags: ["Eingang"],
path: "",
tenant: auth.activeTenant,
folder: null
})
const files = useFiles() const files = useFiles()
const displayMode = ref("list") // --- State ---
const displayModes = ref([{label: 'Liste',key:'list', icon: 'i-heroicons-list-bullet'},{label: 'Kacheln',key:'rectangles', icon: 'i-heroicons-squares-2x2'}])
const documents = ref([]) const documents = ref([])
const folders = ref([]) const folders = ref([])
const filetags = ref([]) const filetags = ref([])
const currentFolder = ref(null) const currentFolder = ref(null)
const loadingDocs = ref(true)
const loadingDocs = ref(false)
const isDragTarget = ref(false)
const loaded = ref(false) const loaded = ref(false)
const displayMode = ref("list")
const displayModes = [
{ label: 'Liste', key: 'list', icon: 'i-heroicons-list-bullet' },
{ label: 'Kacheln', key: 'rectangles', icon: 'i-heroicons-squares-2x2' }
]
const createFolderModalOpen = ref(false)
const createFolderData = ref({ name: '', standardFiletype: null, standardFiletypeIsOptional: true })
const renameModalOpen = ref(false)
const renameData = ref({ id: null, name: '', type: '' })
const selectedFileIndex = ref(0)
const draggedItem = ref(null)
// --- Search & Debounce ---
const searchString = ref(tempStore.searchStrings["files"] || '')
const debouncedSearch = ref(searchString.value)
let debounceTimeout = null
watch(searchString, (val) => {
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
debouncedSearch.value = val
tempStore.modifySearchString('files', val)
}, 300)
})
// --- Data Fetching ---
const setupPage = async () => { const setupPage = async () => {
folders.value = await useEntities("folders").select() loadingDocs.value = true
try {
documents.value = await files.selectDocuments() const [fRes, dRes, tRes] = await Promise.all([
useEntities("folders").select(),
filetags.value = await useEntities("filetags").select() files.selectDocuments(),
useEntities("filetags").select()
if(route.query) { ])
if(route.query.folder) { folders.value = fRes || []
currentFolder.value = await useEntities("folders").selectSingle(route.query.folder) 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 {
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 loadingDocs.value = false
loaded.value = true 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) { onMounted(() => setupPage())
addParent(folders.value.find(i => i.id === currentFolder.value.parent))
// --- Navigation ---
const changeFolder = async (folder) => {
currentFolder.value = folder
await router.push(folder ? `/files?folder=${folder.id}` : `/files`)
} }
return [{ const navigateUp = () => {
label: "Home", if (!currentFolder.value) return
click: () => { const parent = folders.value.find(f => f.id === currentFolder.value.parent)
changeFolder(null) changeFolder(parent || 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 { // --- Drag & Drop ---
return [{ const handleDragStart = (entry) => {
label: "Home", draggedItem.value = entry
click: () => {
changeFolder(null)
},
icon: "i-heroicons-folder"
}]
} }
})
const filteredDocuments = computed(() => { const handleDrop = async (targetFolderId) => {
// targetFolderId kann null sein (Root)
if (!draggedItem.value) return
if (draggedItem.value.id === targetFolderId) return
return documents.value.filter(i => currentFolder.value ? i.folder === currentFolder.value.id : !i.folder)
})
const changeFolder = async (newFolder) => {
loadingDocs.value = true loadingDocs.value = true
currentFolder.value = newFolder try {
if (draggedItem.value.type === 'file') {
if(newFolder) { await useEntities("files").update(draggedItem.value.id, { folder: targetFolderId })
fileUploadFormData.value.folder = newFolder.id
await router.push(`/files?folder=${newFolder.id}`)
} else { } else {
fileUploadFormData.value.folder = null await useEntities("folders").update(draggedItem.value.id, { parent: targetFolderId })
await router.push(`/files`) }
toast.add({ title: 'Erfolgreich verschoben', icon: 'i-heroicons-check-circle', color: 'green' })
await setupPage()
} catch (e) {
toast.add({ title: 'Fehler beim Verschieben', color: 'red' })
} finally {
draggedItem.value = null
loadingDocs.value = false
}
} }
setupPage() // --- Breadcrumbs ---
const breadcrumbLinks = computed(() => {
const links = [{ label: "Home", icon: "i-heroicons-home", click: () => changeFolder(null) }]
if (currentFolder.value) {
const path = []
let curr = currentFolder.value
while (curr) {
const folderObj = curr
path.unshift({
label: folderObj.name,
icon: "i-heroicons-folder",
click: () => changeFolder(folderObj)
})
curr = folders.value.find(f => f.id === curr.parent)
} }
return [...links, ...path]
const createFolderData = ref({}) }
const createFolder = async () => { return links
const res = await useEntities("folders").create({
parent: currentFolder.value ? currentFolder.value.id : undefined,
name: createFolderData.value.name,
}) })
createFolderModalOpen.value = false // --- Data Mapping ---
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(() => { const renderedFileList = computed(() => {
let files = filteredDocuments.value.map(i => { // 1. Aktuelle Ordner filtern
return { const folderList = folders.value
label: i.path.split("/")[i.path.split("/").length -1], .filter(i => currentFolder.value ? i.parent === currentFolder.value.id : !i.parent)
id: i.id, .map(i => ({ ...i, label: i.name, type: "folder" }))
type: "file" .sort((a, b) => a.label.localeCompare(b.label))
// 2. Aktuelle Dateien filtern
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)
} }
// 3. "Nach oben" (..) einfügen, wenn wir nicht im Root sind und nicht suchen
if (currentFolder.value && !debouncedSearch.value) {
combined.unshift({
id: 'go-up',
label: '..',
type: 'up',
parentId: currentFolder.value.parent || null
})
}
return combined
}) })
arraySort(files, (a,b) => { // --- Actions ---
let aVal = a.path ? a.path.split("/")[a.path.split("/").length -1] : null const createFolder = async () => {
let bVal = b.path ? b.path.split("/")[b.path.split("/").length -1] : null await useEntities("folders").create({
parent: currentFolder.value?.id,
name: createFolderData.value.name
})
createFolderModalOpen.value = false
createFolderData.value = { name: '' }
setupPage()
}
if(aVal && bVal) { const openRenameModal = (entry) => {
return aVal.localeCompare(bVal) renameData.value = { id: entry.id, name: entry.label, type: entry.type }
} else if(!aVal && bVal) { renameModalOpen.value = true
return 1 }
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 { } else {
return -1 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('/') })
} }
}, {reverse: true}) toast.add({ title: 'Umbenannt', color: 'green' })
setupPage()
if(searchString.value.length > 0) { } finally {
files = useSearch(searchString.value, files) renameModalOpen.value = false
loadingDocs.value = false
} }
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 showFile = (fileId) => { const showFile = (fileId) => {
modal.open(DocumentDisplayModal, { modal.open(DocumentDisplayModal, {
documentData: documents.value.find(i => i.id === fileId), documentData: documents.value.find(i => i.id === fileId),
onUpdatedNeeded: setupPage() onUpdatedNeeded: () => setupPage()
}) })
} }
const selectedFiles = ref({});
const selectAll = () => { defineShortcuts({
if(Object.keys(selectedFiles.value).find(i => selectedFiles.value[i] === true)) { '/': () => document.getElementById("searchinput")?.focus(),
selectedFiles.value = {} 'Enter': {
} else { usingInput: true,
selectedFiles.value = Object.fromEntries(filteredDocuments.value.map(i => i.id).map(k => [k,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'
})
// Liste neu laden
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> </script>
<template> <template>
<UDashboardNavbar title="Dateien">
<UDashboardNavbar
title="Dateien"
>
<template #right> <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 <UButton
icon="i-heroicons-x-mark" label="Dokubox Sync"
variant="outline" icon="i-heroicons-sparkles"
color="rose" color="primary"
@click="clearSearchString()" variant="solid"
v-if="searchString.length > 0" :loading="isSyncing"
@click="syncdokubox"
class="mr-2"
/> />
<UInput id="searchinput" v-model="searchString" icon="i-heroicons-magnifying-glass" placeholder="Suche..." class="w-64" />
</template> </template>
</UDashboardNavbar> </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> <template #left>
<UBreadcrumb <UBreadcrumb :links="breadcrumbLinks" />
:links="breadcrumbLinks"
/>
</template> </template>
<template #right> <template #right>
<USelectMenu <USelectMenu v-model="displayMode" :options="displayModes" value-attribute="key" class="w-32" :ui-menu="{ zIndex: 'z-50' }">
:options="displayModes"
value-attribute="key"
option-attribute="label"
v-model="displayMode"
:ui-menu="{ width: 'min-w-max'}"
>
<template #label> <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> </template>
</USelectMenu> </USelectMenu>
<UButtonGroup size="sm">
<UButton icon="i-heroicons-document-plus" color="primary" @click="modal.open(DocumentUploadModal, { fileData: { folder: currentFolder?.id }, 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>
<div v-else>
<div v-if="displayMode === 'list'">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0 z-20">
<tr>
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold sm:pl-6">Name</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold">Erstellt</th>
<th class="relative py-3.5 pl-3 pr-4 sm:pr-6"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800 bg-white dark:bg-gray-900">
<tr
v-for="(entry, index) in renderedFileList" :key="entry.id"
:draggable="entry.type !== 'up'"
@dragstart="handleDragStart(entry)"
@dragover.prevent
@drop="entry.type === 'folder' ? handleDrop(entry.id) : (entry.type === 'up' ? handleDrop(entry.parentId) : null)"
class="hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer transition-colors"
:class="{'bg-primary-50 dark:bg-primary-900/10': index === selectedFileIndex}"
@click="entry.type === 'folder' ? changeFolder(entry) : (entry.type === 'up' ? navigateUp() : showFile(entry.id))"
>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm sm:pl-6">
<div class="flex items-center font-medium">
<UIcon
:name="entry.type === 'up' ? 'i-heroicons-arrow-uturn-left' : (entry.type === 'folder' ? 'i-heroicons-folder-solid' : 'i-heroicons-document-text')"
class="h-5 w-5 mr-3"
:class="entry.type === 'up' ? 'text-orange-500' : (entry.type === 'folder' ? 'text-primary-500' : 'text-gray-400')"
/>
{{ entry.label }}
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{{ entry.type !== 'up' ? dayjs(entry.createdAt).format("DD.MM.YY") : '-' }}
</td>
<td class="px-3 text-right" @click.stop>
<UDropdown v-if="entry.type !== 'up'" :items="[[{ label: 'Umbenennen', icon: 'i-heroicons-pencil-square', click: () => openRenameModal(entry) }]]">
<UButton color="gray" variant="ghost" icon="i-heroicons-ellipsis-horizontal" />
</UDropdown>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="p-6 grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4">
<div
v-for="entry in renderedFileList" :key="entry.id"
:draggable="entry.type !== 'up'"
@dragstart="handleDragStart(entry)"
@dragover.prevent
@drop="entry.type === 'folder' ? handleDrop(entry.id) : (entry.type === 'up' ? handleDrop(entry.parentId) : null)"
class="group relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 flex flex-col items-center hover:border-primary-500 transition-all cursor-pointer"
@click="entry.type === 'folder' ? changeFolder(entry) : (entry.type === 'up' ? navigateUp() : showFile(entry.id))"
>
<UDropdown v-if="entry.type !== 'up'" :items="[[{ label: 'Umbenennen', icon: 'i-heroicons-pencil-square', click: () => openRenameModal(entry) }]]" class="absolute top-1 right-1 opacity-0 group-hover:opacity-100" @click.stop>
<UButton color="gray" variant="ghost" icon="i-heroicons-ellipsis-vertical" size="xs" />
</UDropdown>
<UIcon
:name="entry.type === 'up' ? 'i-heroicons-arrow-uturn-left' : (entry.type === 'folder' ? 'i-heroicons-folder-solid' : 'i-heroicons-document-text')"
class="w-12 h-12 mb-2"
:class="entry.type === 'up' ? 'text-orange-500' : (entry.type === 'folder' ? 'text-primary-500' : 'text-gray-400')"
/>
<span class="text-xs font-medium text-center truncate w-full">{{ entry.label }}</span>
</div>
</div>
</div>
</UDashboardPanelContent>
<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"> <UModal v-model="createFolderModalOpen">
<UCard :ui="{ body: { base: 'space-y-4' } }"> <UCard>
<template #header> <template #header><h3 class="font-bold">Ordner erstellen</h3></template>
<div class="flex items-center justify-between"> <UFormGroup label="Name" required><UInput v-model="createFolderData.name" autofocus @keyup.enter="createFolder" /></UFormGroup>
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white"> <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></template>
Ordner Erstellen
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="createFolderModalOpen = false" />
</div>
</template>
<UFormGroup label="Name des Ordners" required>
<UInput
v-model="createFolderData.name"
placeholder="z.B. Rechnungen 2024"
autofocus
/>
</UFormGroup>
<UFormGroup
label="Standard Dateityp"
>
<USelectMenu
v-model="createFolderData.standardFiletype"
:options="filetags"
option-attribute="name"
value-attribute="id"
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>
</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>
<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> </UCard>
</UModal> </UModal>
<UModal v-model="renameModalOpen">
<UCard>
<template #header><h3 class="font-bold">Umbenennen</h3></template>
<UFormGroup label="Neuer Name"><UInput v-model="renameData.name" autofocus @keyup.enter="updateName" /></UFormGroup>
<template #footer><div class="flex justify-end gap-2"><UButton color="gray" @click="renameModalOpen = false">Abbrechen</UButton><UButton color="primary" @click="updateName">Speichern</UButton></div></template>
</UCard>
</UModal>
</template> </template>
</UDashboardToolbar>
<div id="drop_zone" class="h-full scrollList" >
<div v-if="loaded">
<UDashboardPanelContent>
<div v-if="displayMode === 'list'">
<table class="w-full">
<thead>
<tr>
<td>
<UCheckbox
v-if="renderedFileList.find(i => i.type === 'file')"
@change="selectAll"
/>
</td>
<td class="font-bold">Name</td>
<td class="font-bold">Erstellt am</td>
</tr>
</thead>
<tr v-for="(entry,index) in renderedFileList">
<td>
<UCheckbox
v-if="entry.type === 'file'"
v-model="selectedFiles[entry.id]"
/>
</td>
<td>
<UIcon class="mr-1" :name="entry.type === 'folder' ? 'i-heroicons-folder' : 'i-heroicons-document'"/>
<a
style="cursor: pointer"
:class="[...index === selectedFileIndex ? ['text-primary', 'text-xl'] : ['dark:text-white','text-black','text-xl']]"
@click="entry.type === 'folder' ? changeFolder(currentFolders.find(i => i.id === entry.id)) : showFile(entry.id)"
>{{entry.label}}</a>
</td>
<td>
<span v-if="entry.type === 'file'" class="text-xl">{{dayjs(documents.find(i => i.id === entry.id).createdAt).format("DD.MM.YY HH:mm")}}</span>
<span v-if="entry.type === 'folder'" class="text-xl">{{dayjs(currentFolders.find(i => i.id === entry.id).createdAt).format("DD.MM.YY HH:mm")}}</span>
</td>
</tr>
</table>
</div>
<div v-else-if="displayMode === 'rectangles'">
<div class="flex flex-row w-full flex-wrap" v-if="currentFolders.length > 0">
<a
class="w-1/6 folderIcon flex flex-col p-5 m-2"
v-for="folder in currentFolders"
@click="changeFolder(folder)"
>
<UIcon
name="i-heroicons-folder"
class="w-20 h-20"
/>
<span class="text-center truncate">{{folder.name}}</span>
</a>
</div>
<UDivider class="my-5" v-if="currentFolder">{{currentFolder.name}}</UDivider>
<UDivider class="my-5" v-else>Ablage</UDivider>
<div v-if="!loadingDocs">
<DocumentList
v-if="filteredDocuments.length > 0"
:documents="filteredDocuments"
@selectDocument="(info) => console.log(info)"
/>
<UAlert
v-else
class="mt-5 w-1/2 mx-auto"
icon="i-heroicons-light-bulb"
title="Keine Dokumente vorhanden"
color="primary"
variant="outline"
/>
</div>
<UProgress
animation="carousel"
v-else
class="w-2/3 my-5 mx-auto"
/>
</div>
</UDashboardPanelContent>
</div>
<UProgress animation="carousel" v-else class="w-5/6 mx-auto mt-5"/>
</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>

View File

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

View File

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

View File

@@ -0,0 +1,239 @@
<template>
<div class="flex h-full w-full bg-white dark:bg-gray-900 overflow-hidden relative">
<aside
class="flex flex-col border-r border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/50 transition-all duration-300 ease-in-out overflow-hidden whitespace-nowrap h-full"
:class="[isSidebarOpen ? 'w-80 min-w-[20rem] translate-x-0' : 'w-0 min-w-0 -translate-x-full opacity-0 border-none']"
>
<div class="flex items-center justify-between px-4 py-3 bg-white dark:bg-gray-900 h-14 shrink-0 relative z-20">
<h2 class="font-semibold text-gray-800 dark:text-gray-100 flex items-center gap-2">
<span class="text-primary-600">📚</span> Wiki
</h2>
<div class="flex items-center gap-1">
<UDropdown :items="rootMenuItems" :popper="{ placement: 'bottom-end' }">
<UButton color="gray" variant="ghost" icon="i-heroicons-plus" size="sm" />
</UDropdown>
<UButton @click="isSidebarOpen = false" color="gray" variant="ghost" icon="i-heroicons-chevron-double-left" size="sm" />
</div>
</div>
<div class="px-3 pb-2 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
<UInput
v-model="searchQuery"
icon="i-heroicons-magnifying-glass"
placeholder="Suchen..."
size="sm"
:ui="{ icon: { trailing: { pointer: '' } } }"
>
<template #trailing v-if="searchQuery">
<UButton
color="gray"
variant="link"
icon="i-heroicons-x-mark"
:padded="false"
@click="searchQuery = ''"
/>
</template>
</UInput>
</div>
<div class="flex-1 overflow-y-auto px-2 py-3 custom-scrollbar">
<div v-if="isLoadingTree && !tree.length" class="space-y-2 px-2 mt-2">
<USkeleton class="h-4 w-3/4" />
<USkeleton class="h-4 w-1/2" />
</div>
<div v-else>
<WikiTreeItem v-for="node in tree" :key="node.id" :item="node" />
<div v-if="!tree.length" class="text-center text-sm text-gray-400 mt-10 px-4">
<span v-if="searchQuery">Keine Ergebnisse gefunden.</span>
<span v-else>Keine Seiten vorhanden.<br>Erstelle den ersten Eintrag oben rechts.</span>
</div>
</div>
</div>
</aside>
<main class="flex-1 overflow-hidden relative bg-white dark:bg-gray-900 h-full flex flex-col min-w-0">
<div v-if="pageId && page" class="flex flex-col h-full">
<header class="px-8 pt-8 pb-4 flex flex-col gap-2 shrink-0">
<div class="flex items-center gap-3 w-full">
<UButton v-show="!isSidebarOpen" @click="isSidebarOpen = true" icon="i-heroicons-bars-3" color="gray" variant="ghost" class="-ml-2 shrink-0" />
<input v-model="page.title" @blur="saveTitle" @keydown.enter="($event.target as HTMLInputElement).blur()" class="text-4xl font-bold text-gray-900 dark:text-white w-full outline-none bg-transparent min-w-0" placeholder="Unbenannte Seite" />
</div>
<div class="flex items-center gap-4 text-xs text-gray-400 h-6 ml-0.5">
<span>{{ new Date(page.createdAt).toLocaleDateString() }}</span>
<transition name="fade">
<span v-if="isSaving" class="text-primary-600 flex items-center gap-1">
<UIcon name="i-heroicons-arrow-path" class="w-3 h-3 animate-spin" /> Speichert...
</span>
<span v-else-if="lastSaved" class="text-green-600">Gespeichert</span>
</transition>
</div>
</header>
<div class="flex-1 overflow-hidden border-t border-gray-100 dark:border-gray-800">
<WikiEditor v-model="contentBuffer" @update:modelValue="handleContentChange" />
</div>
</div>
<div v-else-if="pageId && pendingPage" class="flex h-full items-center justify-center text-gray-400">
<div class="flex flex-col items-center gap-2">
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin text-gray-300" />
<span>Lade Seite...</span>
</div>
</div>
<div v-else class="flex h-full flex-col items-center justify-center text-gray-400 bg-gray-50/30 dark:bg-gray-800/30">
<UButton v-if="!isSidebarOpen" @click="isSidebarOpen = true" icon="i-heroicons-bars-3" color="white" class="absolute left-4 top-4" />
<div class="bg-gray-100 dark:bg-gray-800 p-6 rounded-full mb-4">
<UIcon name="i-heroicons-document" class="w-12 h-12 text-gray-400" />
</div>
<p class="text-lg font-medium text-gray-600 dark:text-gray-300">Keine Seite ausgewählt</p>
</div>
</main>
<UModal v-model="isModalOpen">
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">{{ modalTitle }}</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isModalOpen = false" />
</div>
</template>
<div v-if="modalState.type === 'create'" class="space-y-4">
<UFormGroup label="Titel" name="title">
<UInput v-model="modalState.inputValue" autofocus placeholder="z.B. Meeting Notes" @keyup.enter="handleModalConfirm" />
</UFormGroup>
</div>
<div v-else-if="modalState.type === 'delete'">
<p class="text-sm text-gray-500 dark:text-gray-300">
Möchtest du <strong>"{{ modalState.targetItem?.title }}"</strong> wirklich löschen?
<br><span v-if="modalState.targetItem?.isFolder" class="text-red-500 font-medium">Alle Unterseiten werden ebenfalls gelöscht.</span>
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="ghost" @click="isModalOpen = false">Abbrechen</UButton>
<UButton :color="modalState.type === 'delete' ? 'red' : 'primary'" :loading="modalLoading" @click="handleModalConfirm">
{{ modalState.type === 'delete' ? 'Löschen' : 'Erstellen' }}
</UButton>
</div>
</template>
</UCard>
</UModal>
</div>
</template>
<script setup lang="ts">
import WikiTreeItem from '~/components/wiki/TreeItem.vue'
import WikiEditor from '~/components/wiki/WikiEditor.vue'
import type { WikiPageItem } from '~/composables/useWikiTree'
const route = useRoute()
const router = useRouter()
const { $api } = useNuxtApp()
// NEU: searchQuery hier entpacken, damit es an UInput gebunden werden kann
const { tree, isLoading: isLoadingTree, loadTree, isSidebarOpen, createItem, deleteItem, searchQuery } = useWikiTree()
// --- EDITOR LOGIC ---
const pageId = computed(() => route.params.id as string | undefined)
const isSaving = ref(false)
const lastSaved = ref(false)
const saveTimeout = ref<any>(null)
const contentBuffer = ref(null)
onMounted(() => loadTree())
watch(pageId, () => {
if (saveTimeout.value) clearTimeout(saveTimeout.value)
isSaving.value = false
lastSaved.value = false
})
const { data: page, pending: pendingPage } = await useAsyncData(
() => `wiki-page-${pageId.value}`,
async () => {
if (!pageId.value) return null
const res = await $api<any>(`/api/wiki/${pageId.value}`)
contentBuffer.value = res.content
return res
}, { watch: [pageId], immediate: true }
)
// --- MODAL LOGIC (Identisch wie vorher) ---
const isModalOpen = ref(false)
const modalLoading = ref(false)
const modalState = reactive({
type: 'create' as 'create' | 'delete',
targetItem: null as WikiPageItem | null,
isFolderCreation: false,
inputValue: ''
})
const modalTitle = computed(() => {
if (modalState.type === 'delete') return 'Eintrag löschen'
return modalState.isFolderCreation ? 'Neuen Ordner erstellen' : 'Neue Seite erstellen'
})
function openWikiAction(type: 'create' | 'delete', contextItem: WikiPageItem | null, isFolder = false) {
modalState.type = type
modalState.targetItem = contextItem
modalState.isFolderCreation = isFolder
modalState.inputValue = ''
isModalOpen.value = true
}
provide('openWikiAction', openWikiAction)
async function handleModalConfirm() {
modalLoading.value = true
try {
if (modalState.type === 'create') {
if (!modalState.inputValue.trim()) return
const parentId = modalState.targetItem ? modalState.targetItem.id : null
const newItem = await createItem(modalState.inputValue, parentId, modalState.isFolderCreation)
if (newItem && !modalState.isFolderCreation) router.push(`/wiki/${newItem.id}`)
} else if (modalState.type === 'delete' && modalState.targetItem) {
const success = await deleteItem(modalState.targetItem.id)
if (success && pageId.value === modalState.targetItem.id) router.push('/wiki')
}
isModalOpen.value = false
} catch (e) { console.error(e) } finally { modalLoading.value = false }
}
const rootMenuItems = [
[{ label: 'Neue Seite', icon: 'i-heroicons-document-plus', click: () => openWikiAction('create', null, false) }],
[{ label: 'Neuer Ordner', icon: 'i-heroicons-folder-plus', click: () => openWikiAction('create', null, true) }]
]
// --- EDITOR ACTIONS (Identisch wie vorher) ---
async function saveTitle() {
if (!page.value || !pageId.value) return
isSaving.value = true
try {
await $api(`/api/wiki/${pageId.value}`, { method: 'PATCH', body: { title: page.value.title } })
loadTree()
showSavedFeedback()
} finally { isSaving.value = false }
}
function handleContentChange(newContent: any) {
contentBuffer.value = newContent
isSaving.value = true
lastSaved.value = false
if (saveTimeout.value) clearTimeout(saveTimeout.value)
saveTimeout.value = setTimeout(async () => {
if (!pageId.value) return
try {
await $api(`/api/wiki/${pageId.value}`, { method: 'PATCH', body: { content: contentBuffer.value } })
showSavedFeedback()
} catch (e) { console.error(e) } finally { isSaving.value = false }
}, 1000)
}
function showSavedFeedback() {
lastSaved.value = true
setTimeout(() => { lastSaved.value = false }, 2000)
}
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background-color: #e5e7eb; border-radius: 10px; }
:deep(.dark) .custom-scrollbar::-webkit-scrollbar-thumb { background-color: #374151; }
</style>

View File

@@ -3,10 +3,16 @@ import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import localizedFormat from 'dayjs/plugin/localizedFormat' import localizedFormat from 'dayjs/plugin/localizedFormat'
import 'dayjs/locale/de' import 'dayjs/locale/de'
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import isSameOrAfter from "dayjs/plugin/isSameOrAfter"
import isSameOrBefore from "dayjs/plugin/isSameOrBefore"
dayjs.extend(duration) dayjs.extend(duration)
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
dayjs.extend(localizedFormat) dayjs.extend(localizedFormat)
dayjs.extend(quarterOfYear)
dayjs.extend(isSameOrAfter)
dayjs.extend(isSameOrBefore)
dayjs.locale('de') dayjs.locale('de')
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {

View File

@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
export const useCalculatorStore = defineStore('calculator', () => {
const tempStore = useTempStore()
// Initialisierung aus dem TempStore
const isOpen = ref(false)
const display = computed({
get: () => tempStore.settings?.calculator?.display || '0',
set: (val) => tempStore.modifySettings('calculator', { ...tempStore.settings.calculator, display: val })
})
const memory = computed({
get: () => tempStore.settings?.calculator?.memory || 0,
set: (val) => tempStore.modifySettings('calculator', { ...tempStore.settings.calculator, memory: val })
})
const history = computed({
get: () => tempStore.filters?.calculator?.history || [],
set: (val) => tempStore.modifyFilter('calculator', 'history', val)
})
function toggle() {
isOpen.value = !isOpen.value
}
function addHistory(expression: string, result: string) {
const newHistory = [{ expression, result }, ...history.value].slice(0, 10)
history.value = newHistory
}
return {
isOpen,
display,
memory,
history,
toggle,
addHistory
}
})

View File

@@ -230,19 +230,7 @@ export const useDataStore = defineStore('data', () => {
label: "Anrede", label: "Anrede",
inputType: "text", inputType: "text",
inputChangeFunction: function (row) { inputChangeFunction: function (row) {
row.name = "" row.name = `${row.salutation ? (row.salutation + " ") : ""}${row.title ? (row.title + " ") : ""}${row.firstname ? (row.firstname + " ") : ""}${row.lastname}`;
if(row.salutation) {
row.name += `${row.salutation}`
}
if(row.title) {
row.name += ` ${row.title}`
}
if(row.firstname) {
row.name += ` ${row.firstname}`
}
if(row.lastname) {
row.name += ` ${row.lastname}`
}
}, },
disabledFunction: function (item) { disabledFunction: function (item) {
return item.isCompany return item.isCompany
@@ -258,19 +246,7 @@ export const useDataStore = defineStore('data', () => {
label: "Titel", label: "Titel",
inputType: "text", inputType: "text",
inputChangeFunction: function (row) { inputChangeFunction: function (row) {
row.name = "" row.name = `${row.salutation ? (row.salutation + " ") : ""}${row.title ? (row.title + " ") : ""}${row.firstname ? (row.firstname + " ") : ""}${row.lastname}`;
if(row.salutation) {
row.name += `${row.salutation}`
}
if(row.title) {
row.name += ` ${row.title}`
}
if(row.firstname) {
row.name += ` ${row.firstname}`
}
if(row.lastname) {
row.name += ` ${row.lastname}`
}
}, },
disabledFunction: function (item) { disabledFunction: function (item) {
return item.isCompany return item.isCompany
@@ -285,19 +261,7 @@ export const useDataStore = defineStore('data', () => {
title: true, title: true,
inputType: "text", inputType: "text",
inputChangeFunction: function (row) { inputChangeFunction: function (row) {
row.name = "" row.name = `${row.salutation ? (row.salutation + " ") : ""}${row.title ? (row.title + " ") : ""}${row.firstname ? (row.firstname + " ") : ""}${row.lastname}`;
if(row.salutation) {
row.name += `${row.salutation}`
}
if(row.title) {
row.name += ` ${row.title}`
}
if(row.firstname) {
row.name += ` ${row.firstname}`
}
if(row.lastname) {
row.name += ` ${row.lastname}`
}
}, },
disabledFunction: function (item) { disabledFunction: function (item) {
return item.isCompany return item.isCompany
@@ -312,19 +276,7 @@ export const useDataStore = defineStore('data', () => {
title: true, title: true,
inputType: "text", inputType: "text",
inputChangeFunction: function (row) { inputChangeFunction: function (row) {
row.name = "" row.name = `${row.salutation ? (row.salutation + " ") : ""}${row.title ? (row.title + " ") : ""}${row.firstname ? (row.firstname + " ") : ""}${row.lastname}`;
if(row.salutation) {
row.name += `${row.salutation}`
}
if(row.title) {
row.name += ` ${row.title}`
}
if(row.firstname) {
row.name += ` ${row.firstname}`
}
if(row.lastname) {
row.name += ` ${row.lastname}`
}
}, },
disabledFunction: function (item) { disabledFunction: function (item) {
return item.isCompany return item.isCompany

View File

@@ -2,7 +2,6 @@ import {defineStore} from 'pinia'
// @ts-ignore // @ts-ignore
export const useTempStore = defineStore('temp', () => { export const useTempStore = defineStore('temp', () => {
const auth = useAuthStore() const auth = useAuthStore()
const searchStrings = ref({}) const searchStrings = ref({})
@@ -27,10 +26,10 @@ export const useTempStore = defineStore('temp', () => {
} }
function setStoredTempConfig(config) { function setStoredTempConfig(config) {
searchStrings.value = config.searchStrings searchStrings.value = config.searchStrings || {}
columns.value = config.columns columns.value = config.columns || {}
pages.value = config.pages pages.value = config.pages || {}
settings.value = config.settings settings.value = config.settings || {}
filters.value = config.filters || {} filters.value = config.filters || {}
} }
@@ -46,7 +45,6 @@ export const useTempStore = defineStore('temp', () => {
function modifyFilter(domain, type, input) { function modifyFilter(domain, type, input) {
if (!filters.value[domain]) filters.value[domain] = {} if (!filters.value[domain]) filters.value[domain] = {}
filters.value[domain][type] = input filters.value[domain][type] = input
storeTempConfig() storeTempConfig()
} }
@@ -66,6 +64,15 @@ export const useTempStore = defineStore('temp', () => {
storeTempConfig() storeTempConfig()
} }
// Spezifisch für das Banking-Datum
function modifyBankingPeriod(periodKey, range) {
if (!settings.value['banking']) settings.value['banking'] = {}
settings.value['banking'].periodKey = periodKey
settings.value['banking'].range = range
storeTempConfig()
}
return { return {
setStoredTempConfig, setStoredTempConfig,
@@ -79,8 +86,7 @@ export const useTempStore = defineStore('temp', () => {
modifyPages, modifyPages,
pages, pages,
modifySettings, modifySettings,
modifyBankingPeriod, // Neue Funktion exportiert
settings settings
} }
}) })