Compare commits
8 Commits
c2901dc0a9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e7554fa2cc | |||
| 7c1fabf58a | |||
| 1203b6cbd1 | |||
| 525f2906fb | |||
| b105382abf | |||
| b1cdec7d17 | |||
| f1d512b2e5 | |||
| db21b43120 |
@@ -2,37 +2,18 @@
|
|||||||
name: 🐛 Bug Report
|
name: 🐛 Bug Report
|
||||||
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
|
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
|
||||||
title: '[BUG] '
|
title: '[BUG] '
|
||||||
labels: bug
|
labels: Problem
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Beschreibung**
|
**Beschreibung**
|
||||||
Eine klare und prägnante Beschreibung des Fehlers.
|
|
||||||
|
|
||||||
**Reproduktion**
|
**Reproduktion**
|
||||||
Schritte, um den Fehler zu reproduzieren:
|
|
||||||
|
|
||||||
Entweder:
|
|
||||||
1. Gehe zu '...'
|
|
||||||
2. Klicke auf '...'
|
|
||||||
3. Scrolle runter zu '...'
|
|
||||||
4. Siehe Fehler
|
|
||||||
|
|
||||||
Oder Link zur Seite
|
|
||||||
|
|
||||||
**Erwartetes Verhalten**
|
|
||||||
Eine klare Beschreibung dessen, was du erwartet hast.
|
|
||||||
|
|
||||||
**Screenshots**
|
**Screenshots**
|
||||||
Falls zutreffend, füge hier Screenshots oder Gifs hinzu, um das Problem zu verdeutlichen.
|
|
||||||
|
|
||||||
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
|
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
|
||||||
|
|
||||||
**Umgebung:**
|
|
||||||
- Betriebssystem: [z.B. Windows, macOS, Linux]
|
|
||||||
- Browser / Version (falls relevant): [z.B. Chrome 120]
|
|
||||||
- Projekt-Version: [z.B. v1.0.2]
|
|
||||||
|
|
||||||
**Zusätzlicher Kontext**
|
|
||||||
Füge hier alle anderen Informationen zum Problem hinzu.
|
|
||||||
@@ -2,19 +2,16 @@
|
|||||||
name: ✨ Feature Request
|
name: ✨ Feature Request
|
||||||
about: Schlage eine Idee für dieses Projekt vor.
|
about: Schlage eine Idee für dieses Projekt vor.
|
||||||
title: '[FEATURE] '
|
title: '[FEATURE] '
|
||||||
labels: enhancement
|
labels: Funktionswunsch
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Ist dein Feature-Wunsch mit einem Problem verbunden?**
|
**Ist dein Feature-Wunsch mit einem Problem verbunden?**
|
||||||
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
|
|
||||||
|
|
||||||
**Lösungsvorschlag**
|
**Lösungsvorschlag**
|
||||||
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
|
|
||||||
|
|
||||||
**Alternativen**
|
**Alternativen**
|
||||||
Hast du über alternative Lösungen oder Workarounds nachgedacht?
|
|
||||||
|
|
||||||
**Zusätzlicher Kontext**
|
|
||||||
Hier ist Platz für weitere Informationen, Skizzen oder Beispiele von anderen Tools.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,13 @@
|
|||||||
// src/db/index.ts
|
import { drizzle } from "drizzle-orm/node-postgres"
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
import { Pool } from "pg"
|
||||||
import { Pool } from "pg";
|
import {secrets} from "../src/utils/secrets";
|
||||||
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,
|
connectionString: secrets.DATABASE_URL,
|
||||||
max: 10,
|
max: 10, // je nach Last
|
||||||
});
|
})
|
||||||
|
|
||||||
// TEST: Ist die DB wirklich da?
|
export const db = drizzle(pool , {schema})
|
||||||
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 });
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- No-op migration: Datei war im Journal referenziert, aber fehlte im Repository.
|
|
||||||
SELECT 1;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- No-op migration: Datei war im Journal referenziert, aber fehlte im Repository.
|
|
||||||
SELECT 1;
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
CREATE TABLE "m2m_api_keys" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"tenant_id" bigint NOT NULL,
|
|
||||||
"user_id" uuid NOT NULL,
|
|
||||||
"created_by" uuid,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"key_prefix" text NOT NULL,
|
|
||||||
"key_hash" text NOT NULL,
|
|
||||||
"active" boolean DEFAULT true NOT NULL,
|
|
||||||
"last_used_at" timestamp with time zone,
|
|
||||||
"expires_at" timestamp with time zone,
|
|
||||||
CONSTRAINT "m2m_api_keys_key_hash_unique" UNIQUE("key_hash")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "staff_time_events" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"tenant_id" bigint NOT NULL,
|
|
||||||
"user_id" uuid NOT NULL,
|
|
||||||
"actor_type" text NOT NULL,
|
|
||||||
"actor_user_id" uuid,
|
|
||||||
"event_time" timestamp with time zone NOT NULL,
|
|
||||||
"event_type" text NOT NULL,
|
|
||||||
"source" text NOT NULL,
|
|
||||||
"invalidates_event_id" uuid,
|
|
||||||
"related_event_id" uuid,
|
|
||||||
"metadata" jsonb,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "time_events_actor_user_check" CHECK (
|
|
||||||
(actor_type = 'system' AND actor_user_id IS NULL)
|
|
||||||
OR
|
|
||||||
(actor_type = 'user' AND actor_user_id IS NOT NULL)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "serialtypes" (
|
|
||||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "serialtypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"intervall" text,
|
|
||||||
"icon" text,
|
|
||||||
"tenant" bigint NOT NULL,
|
|
||||||
"archived" boolean DEFAULT false NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone,
|
|
||||||
"updated_by" uuid
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "serial_executions" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"tenant" bigint NOT NULL,
|
|
||||||
"execution_date" timestamp NOT NULL,
|
|
||||||
"status" text DEFAULT 'draft',
|
|
||||||
"created_by" text,
|
|
||||||
"created_at" timestamp DEFAULT now(),
|
|
||||||
"summary" text
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "public_links" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"token" text NOT NULL,
|
|
||||||
"tenant" integer NOT NULL,
|
|
||||||
"default_profile" uuid,
|
|
||||||
"is_protected" boolean DEFAULT false NOT NULL,
|
|
||||||
"pin_hash" text,
|
|
||||||
"config" jsonb DEFAULT '{}'::jsonb,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"description" text,
|
|
||||||
"active" boolean DEFAULT true NOT NULL,
|
|
||||||
"created_at" timestamp DEFAULT now(),
|
|
||||||
"updated_at" timestamp DEFAULT now(),
|
|
||||||
CONSTRAINT "public_links_token_unique" UNIQUE("token")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "wiki_pages" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"tenant_id" bigint NOT NULL,
|
|
||||||
"parent_id" uuid,
|
|
||||||
"title" text NOT NULL,
|
|
||||||
"content" jsonb,
|
|
||||||
"is_folder" boolean DEFAULT false NOT NULL,
|
|
||||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
|
||||||
"entity_type" text,
|
|
||||||
"entity_id" bigint,
|
|
||||||
"entity_uuid" uuid,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone,
|
|
||||||
"created_by" uuid,
|
|
||||||
"updated_by" uuid
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "time_events" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
|
||||||
DROP TABLE "time_events" CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "projects" ALTER COLUMN "active_phase" SET DEFAULT 'Erstkontakt';--> statement-breakpoint
|
|
||||||
ALTER TABLE "createddocuments" ADD COLUMN "serialexecution" uuid;--> statement-breakpoint
|
|
||||||
ALTER TABLE "devices" ADD COLUMN "last_seen" timestamp with time zone;--> statement-breakpoint
|
|
||||||
ALTER TABLE "devices" ADD COLUMN "last_debug_info" jsonb;--> statement-breakpoint
|
|
||||||
ALTER TABLE "files" ADD COLUMN "size" bigint;--> statement-breakpoint
|
|
||||||
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
|
||||||
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
|
||||||
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_actor_user_id_auth_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_invalidates_event_id_staff_time_events_id_fk" FOREIGN KEY ("invalidates_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_related_event_id_staff_time_events_id_fk" FOREIGN KEY ("related_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "serialtypes" ADD CONSTRAINT "serialtypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "serialtypes" ADD CONSTRAINT "serialtypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "serial_executions" ADD CONSTRAINT "serial_executions_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "public_links" ADD CONSTRAINT "public_links_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "public_links" ADD CONSTRAINT "public_links_default_profile_auth_profiles_id_fk" FOREIGN KEY ("default_profile") REFERENCES "public"."auth_profiles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_parent_id_wiki_pages_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."wiki_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX "idx_time_events_tenant_user_time" ON "staff_time_events" USING btree ("tenant_id","user_id","event_time");--> statement-breakpoint
|
|
||||||
CREATE INDEX "idx_time_events_created_at" ON "staff_time_events" USING btree ("created_at");--> statement-breakpoint
|
|
||||||
CREATE INDEX "idx_time_events_invalidates" ON "staff_time_events" USING btree ("invalidates_event_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "wiki_pages_tenant_idx" ON "wiki_pages" USING btree ("tenant_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "wiki_pages_parent_idx" ON "wiki_pages" USING btree ("parent_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "wiki_pages_entity_int_idx" ON "wiki_pages" USING btree ("tenant_id","entity_type","entity_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "wiki_pages_entity_uuid_idx" ON "wiki_pages" USING btree ("tenant_id","entity_type","entity_uuid");--> statement-breakpoint
|
|
||||||
ALTER TABLE "createddocuments" ADD CONSTRAINT "createddocuments_serialexecution_serial_executions_id_fk" FOREIGN KEY ("serialexecution") REFERENCES "public"."serial_executions"("id") ON DELETE no action ON UPDATE no action;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE "services" ADD COLUMN "priceUpdateLocked" boolean DEFAULT false NOT NULL;
|
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
uuid,
|
uuid,
|
||||||
timestamp,
|
timestamp,
|
||||||
text,
|
text,
|
||||||
bigint, jsonb,
|
bigint,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
@@ -23,11 +23,6 @@ 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
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ 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
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const hourrates = pgTable("hourrates", {
|
|||||||
|
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
|
|
||||||
purchase_price: doublePrecision("purchasePrice").notNull(),
|
purchasePrice: doublePrecision("purchasePrice").notNull(),
|
||||||
sellingPrice: doublePrecision("sellingPrice").notNull(),
|
sellingPrice: doublePrecision("sellingPrice").notNull(),
|
||||||
|
|
||||||
archived: boolean("archived").notNull().default(false),
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
|||||||
@@ -71,5 +71,4 @@ export * from "./vendors"
|
|||||||
export * from "./staff_time_events"
|
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"
|
|
||||||
@@ -54,7 +54,6 @@ export const services = pgTable("services", {
|
|||||||
|
|
||||||
materialComposition: jsonb("materialComposition").notNull().default([]),
|
materialComposition: jsonb("materialComposition").notNull().default([]),
|
||||||
personalComposition: jsonb("personalComposition").notNull().default([]),
|
personalComposition: jsonb("personalComposition").notNull().default([]),
|
||||||
priceUpdateLocked: boolean("priceUpdateLocked").notNull().default(false),
|
|
||||||
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
import {
|
|
||||||
pgTable,
|
|
||||||
bigint,
|
|
||||||
text,
|
|
||||||
timestamp,
|
|
||||||
boolean,
|
|
||||||
jsonb,
|
|
||||||
integer,
|
|
||||||
index,
|
|
||||||
uuid,
|
|
||||||
AnyPgColumn
|
|
||||||
} from "drizzle-orm/pg-core"
|
|
||||||
import { relations } from "drizzle-orm"
|
|
||||||
import { tenants } from "./tenants"
|
|
||||||
import { authUsers } from "./auth_users"
|
|
||||||
|
|
||||||
export const wikiPages = pgTable(
|
|
||||||
"wiki_pages",
|
|
||||||
{
|
|
||||||
// ID des Wiki-Eintrags selbst (neu = UUID)
|
|
||||||
id: uuid("id")
|
|
||||||
.primaryKey()
|
|
||||||
.defaultRandom(),
|
|
||||||
|
|
||||||
tenantId: bigint("tenant_id", { mode: "number" })
|
|
||||||
.notNull()
|
|
||||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
|
||||||
|
|
||||||
parentId: uuid("parent_id")
|
|
||||||
.references((): AnyPgColumn => wikiPages.id, { onDelete: "cascade" }),
|
|
||||||
|
|
||||||
title: text("title").notNull(),
|
|
||||||
|
|
||||||
content: jsonb("content"),
|
|
||||||
|
|
||||||
isFolder: boolean("is_folder").notNull().default(false),
|
|
||||||
|
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
|
||||||
|
|
||||||
// --- POLYMORPHE BEZIEHUNG (Split) ---
|
|
||||||
|
|
||||||
// Art der Entität (z.B. 'customer', 'invoice', 'iot_device')
|
|
||||||
entityType: text("entity_type"),
|
|
||||||
|
|
||||||
// SPALTE 1: Für Legacy-Tabellen (BigInt)
|
|
||||||
// Nutzung: Wenn entityType='customer', wird hier die ID 1050 gespeichert
|
|
||||||
entityId: bigint("entity_id", { mode: "number" }),
|
|
||||||
|
|
||||||
// SPALTE 2: Für neue Tabellen (UUID)
|
|
||||||
// Nutzung: Wenn entityType='iot_device', wird hier die UUID gespeichert
|
|
||||||
entityUuid: uuid("entity_uuid"),
|
|
||||||
|
|
||||||
// ------------------------------------
|
|
||||||
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow(),
|
|
||||||
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
|
||||||
|
|
||||||
createdBy: uuid("created_by").references(() => authUsers.id),
|
|
||||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
|
||||||
},
|
|
||||||
(table) => ({
|
|
||||||
tenantIdx: index("wiki_pages_tenant_idx").on(table.tenantId),
|
|
||||||
parentIdx: index("wiki_pages_parent_idx").on(table.parentId),
|
|
||||||
|
|
||||||
// ZWEI separate Indexe für schnelle Lookups, je nachdem welche ID genutzt wird
|
|
||||||
// Fall 1: Suche nach Notizen für Kunde 1050
|
|
||||||
entityIntIdx: index("wiki_pages_entity_int_idx")
|
|
||||||
.on(table.tenantId, table.entityType, table.entityId),
|
|
||||||
|
|
||||||
// Fall 2: Suche nach Notizen für IoT-Device 550e84...
|
|
||||||
entityUuidIdx: index("wiki_pages_entity_uuid_idx")
|
|
||||||
.on(table.tenantId, table.entityType, table.entityUuid),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
export const wikiPagesRelations = relations(wikiPages, ({ one, many }) => ({
|
|
||||||
tenant: one(tenants, {
|
|
||||||
fields: [wikiPages.tenantId],
|
|
||||||
references: [tenants.id],
|
|
||||||
}),
|
|
||||||
parent: one(wikiPages, {
|
|
||||||
fields: [wikiPages.parentId],
|
|
||||||
references: [wikiPages.id],
|
|
||||||
relationName: "parent_child",
|
|
||||||
}),
|
|
||||||
children: many(wikiPages, {
|
|
||||||
relationName: "parent_child",
|
|
||||||
}),
|
|
||||||
author: one(authUsers, {
|
|
||||||
fields: [wikiPages.createdBy],
|
|
||||||
references: [authUsers.id],
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export type WikiPage = typeof wikiPages.$inferSelect
|
|
||||||
export type NewWikiPage = typeof wikiPages.$inferInsert
|
|
||||||
@@ -5,8 +5,6 @@
|
|||||||
"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"
|
||||||
@@ -29,6 +27,7 @@
|
|||||||
"@infisical/sdk": "^4.0.6",
|
"@infisical/sdk": "^4.0.6",
|
||||||
"@mmote/niimbluelib": "^0.0.1-alpha.29",
|
"@mmote/niimbluelib": "^0.0.1-alpha.29",
|
||||||
"@prisma/client": "^6.15.0",
|
"@prisma/client": "^6.15.0",
|
||||||
|
"@supabase/supabase-js": "^2.56.1",
|
||||||
"@zip.js/zip.js": "^2.7.73",
|
"@zip.js/zip.js": "^2.7.73",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"axios": "^1.12.1",
|
"axios": "^1.12.1",
|
||||||
@@ -49,7 +48,6 @@
|
|||||||
"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"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import swaggerPlugin from "./plugins/swagger"
|
import swaggerPlugin from "./plugins/swagger"
|
||||||
|
import supabasePlugin from "./plugins/supabase";
|
||||||
import dayjsPlugin from "./plugins/dayjs";
|
import dayjsPlugin from "./plugins/dayjs";
|
||||||
import healthRoutes from "./routes/health";
|
import healthRoutes from "./routes/health";
|
||||||
import meRoutes from "./routes/auth/me";
|
import meRoutes from "./routes/auth/me";
|
||||||
@@ -28,7 +29,6 @@ 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,7 +45,6 @@ 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";
|
||||||
@@ -53,7 +52,6 @@ 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";
|
||||||
|
|
||||||
@@ -72,6 +70,8 @@ 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(tenantPlugin);
|
await app.register(tenantPlugin);
|
||||||
await app.register(dayjsPlugin);
|
await app.register(dayjsPlugin);
|
||||||
await app.register(dbPlugin);
|
await app.register(dbPlugin);
|
||||||
@@ -115,10 +115,8 @@ 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
|
||||||
|
|
||||||
@@ -143,7 +141,6 @@ 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"})
|
||||||
|
|
||||||
|
|||||||
@@ -19,238 +19,241 @@ import {
|
|||||||
and,
|
and,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
|
||||||
|
let badMessageDetected = false
|
||||||
|
let badMessageMessageSent = false
|
||||||
|
|
||||||
export function syncDokuboxService (server: FastifyInstance) {
|
let client: ImapFlow | null = null
|
||||||
let badMessageDetected = false
|
|
||||||
let badMessageMessageSent = false
|
|
||||||
|
|
||||||
let client: ImapFlow | null = null
|
// -------------------------------------------------------------
|
||||||
|
// IMAP CLIENT INITIALIZEN
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
export async function initDokuboxClient() {
|
||||||
|
client = new ImapFlow({
|
||||||
|
host: secrets.DOKUBOX_IMAP_HOST,
|
||||||
|
port: secrets.DOKUBOX_IMAP_PORT,
|
||||||
|
secure: secrets.DOKUBOX_IMAP_SECURE,
|
||||||
|
auth: {
|
||||||
|
user: secrets.DOKUBOX_IMAP_USER,
|
||||||
|
pass: secrets.DOKUBOX_IMAP_PASSWORD
|
||||||
|
},
|
||||||
|
logger: false
|
||||||
|
})
|
||||||
|
|
||||||
async function initDokuboxClient() {
|
console.log("Dokubox E-Mail Client Initialized")
|
||||||
client = new ImapFlow({
|
|
||||||
host: secrets.DOKUBOX_IMAP_HOST,
|
|
||||||
port: secrets.DOKUBOX_IMAP_PORT,
|
|
||||||
secure: secrets.DOKUBOX_IMAP_SECURE,
|
|
||||||
auth: {
|
|
||||||
user: secrets.DOKUBOX_IMAP_USER,
|
|
||||||
pass: secrets.DOKUBOX_IMAP_PASSWORD
|
|
||||||
},
|
|
||||||
logger: false
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log("Dokubox E-Mail Client Initialized")
|
await client.connect()
|
||||||
|
}
|
||||||
|
|
||||||
await client.connect()
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncDokubox = async () => {
|
|
||||||
|
|
||||||
console.log("Perform Dokubox Sync")
|
// -------------------------------------------------------------
|
||||||
|
// MAIN SYNC FUNCTION (DRIZZLE VERSION)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
export const syncDokubox = (server: FastifyInstance) =>
|
||||||
|
async () => {
|
||||||
|
|
||||||
await initDokuboxClient()
|
console.log("Perform Dokubox Sync")
|
||||||
|
|
||||||
if (!client?.usable) {
|
await initDokuboxClient()
|
||||||
throw new Error("E-Mail Client not usable")
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------
|
if (!client?.usable) {
|
||||||
// TENANTS LADEN (DRIZZLE)
|
throw new Error("E-Mail Client not usable")
|
||||||
// -------------------------------
|
}
|
||||||
const tenantList = await server.db
|
|
||||||
.select({
|
|
||||||
id: tenants.id,
|
|
||||||
name: tenants.name,
|
|
||||||
emailAddresses: tenants.dokuboxEmailAddresses,
|
|
||||||
key: tenants.dokuboxkey
|
|
||||||
})
|
|
||||||
.from(tenants)
|
|
||||||
|
|
||||||
const lock = await client.getMailboxLock("INBOX")
|
// -------------------------------
|
||||||
|
// TENANTS LADEN (DRIZZLE)
|
||||||
|
// -------------------------------
|
||||||
|
const tenantList = await server.db
|
||||||
|
.select({
|
||||||
|
id: tenants.id,
|
||||||
|
name: tenants.name,
|
||||||
|
emailAddresses: tenants.dokuboxEmailAddresses,
|
||||||
|
key: tenants.dokuboxkey
|
||||||
|
})
|
||||||
|
.from(tenants)
|
||||||
|
|
||||||
try {
|
const lock = await client.getMailboxLock("INBOX")
|
||||||
|
|
||||||
for await (let msg of client.fetch({ seen: false }, { envelope: true, source: true })) {
|
try {
|
||||||
|
|
||||||
const parsed = await simpleParser(msg.source)
|
for await (let msg of client.fetch({ seen: false }, { envelope: true, source: true })) {
|
||||||
|
|
||||||
const message = {
|
const parsed = await simpleParser(msg.source)
|
||||||
id: msg.uid,
|
|
||||||
subject: parsed.subject,
|
|
||||||
to: parsed.to?.value || [],
|
|
||||||
cc: parsed.cc?.value || [],
|
|
||||||
attachments: parsed.attachments || []
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------
|
const message = {
|
||||||
// MAPPING / FIND TENANT
|
id: msg.uid,
|
||||||
// -------------------------------------------------
|
subject: parsed.subject,
|
||||||
const config = await getMessageConfigDrizzle(server, message, tenantList)
|
to: parsed.to?.value || [],
|
||||||
|
cc: parsed.cc?.value || [],
|
||||||
if (!config) {
|
attachments: parsed.attachments || []
|
||||||
badMessageDetected = true
|
|
||||||
|
|
||||||
if (!badMessageMessageSent) {
|
|
||||||
badMessageMessageSent = true
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.attachments.length > 0) {
|
|
||||||
for (const attachment of message.attachments) {
|
|
||||||
await saveFile(
|
|
||||||
server,
|
|
||||||
config.tenant,
|
|
||||||
message.id,
|
|
||||||
attachment,
|
|
||||||
config.folder,
|
|
||||||
config.filetype
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!badMessageDetected) {
|
// -------------------------------------------------
|
||||||
badMessageDetected = false
|
// MAPPING / FIND TENANT
|
||||||
badMessageMessageSent = false
|
// -------------------------------------------------
|
||||||
|
const config = await getMessageConfigDrizzle(server, message, tenantList)
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
badMessageDetected = true
|
||||||
|
|
||||||
|
if (!badMessageMessageSent) {
|
||||||
|
badMessageMessageSent = true
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.messageFlagsAdd({ seen: false }, ["\\Seen"])
|
if (message.attachments.length > 0) {
|
||||||
await client.messageDelete({ seen: true })
|
for (const attachment of message.attachments) {
|
||||||
|
await saveFile(
|
||||||
} finally {
|
server,
|
||||||
lock.release()
|
config.tenant,
|
||||||
client.close()
|
message.id,
|
||||||
}
|
attachment,
|
||||||
}
|
config.folder,
|
||||||
|
config.filetype
|
||||||
const getMessageConfigDrizzle = async (
|
|
||||||
server: FastifyInstance,
|
|
||||||
message,
|
|
||||||
tenantsList: any[]
|
|
||||||
) => {
|
|
||||||
|
|
||||||
let possibleKeys: string[] = []
|
|
||||||
|
|
||||||
if (message.to) {
|
|
||||||
message.to.forEach((item) =>
|
|
||||||
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.cc) {
|
|
||||||
message.cc.forEach((item) =>
|
|
||||||
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------
|
|
||||||
// TENANT IDENTIFY
|
|
||||||
// -------------------------------------------
|
|
||||||
let tenant = tenantsList.find((t) => possibleKeys.includes(t.key))
|
|
||||||
|
|
||||||
if (!tenant && message.to?.length) {
|
|
||||||
const address = message.to[0].address.toLowerCase()
|
|
||||||
|
|
||||||
tenant = tenantsList.find((t) =>
|
|
||||||
(t.emailAddresses || []).map((m) => m.toLowerCase()).includes(address)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tenant) return null
|
|
||||||
|
|
||||||
// -------------------------------------------
|
|
||||||
// FOLDER + FILETYPE VIA SUBJECT
|
|
||||||
// -------------------------------------------
|
|
||||||
let folderId = null
|
|
||||||
let filetypeId = null
|
|
||||||
|
|
||||||
// -------------------------------------------
|
|
||||||
// Rechnung / Invoice
|
|
||||||
// -------------------------------------------
|
|
||||||
if (message.subject?.match(/(Rechnung|Beleg|Invoice|Quittung)/gi)) {
|
|
||||||
|
|
||||||
const folder = await server.db
|
|
||||||
.select({ id: folders.id })
|
|
||||||
.from(folders)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(folders.tenant, tenant.id),
|
|
||||||
and(
|
|
||||||
eq(folders.function, "incomingInvoices"),
|
|
||||||
//@ts-ignore
|
|
||||||
eq(folders.year, dayjs().format("YYYY"))
|
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
)
|
}
|
||||||
.limit(1)
|
}
|
||||||
|
|
||||||
folderId = folder[0]?.id ?? null
|
if (!badMessageDetected) {
|
||||||
|
badMessageDetected = false
|
||||||
|
badMessageMessageSent = false
|
||||||
|
}
|
||||||
|
|
||||||
const tag = await server.db
|
await client.messageFlagsAdd({ seen: false }, ["\\Seen"])
|
||||||
.select({ id: filetags.id })
|
await client.messageDelete({ seen: true })
|
||||||
.from(filetags)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(filetags.tenant, tenant.id),
|
|
||||||
eq(filetags.incomingDocumentType, "invoices")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
filetypeId = tag[0]?.id ?? null
|
} finally {
|
||||||
}
|
lock.release()
|
||||||
|
client.close()
|
||||||
// -------------------------------------------
|
|
||||||
// Mahnung
|
|
||||||
// -------------------------------------------
|
|
||||||
else if (message.subject?.match(/(Mahnung|Zahlungsaufforderung|Zahlungsverzug)/gi)) {
|
|
||||||
|
|
||||||
const tag = await server.db
|
|
||||||
.select({ id: filetags.id })
|
|
||||||
.from(filetags)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(filetags.tenant, tenant.id),
|
|
||||||
eq(filetags.incomingDocumentType, "reminders")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
filetypeId = tag[0]?.id ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------
|
|
||||||
// Sonstige Dokumente → Deposit Folder
|
|
||||||
// -------------------------------------------
|
|
||||||
else {
|
|
||||||
|
|
||||||
const folder = await server.db
|
|
||||||
.select({ id: folders.id })
|
|
||||||
.from(folders)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(folders.tenant, tenant.id),
|
|
||||||
eq(folders.function, "deposit")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
folderId = folder[0]?.id ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return {
|
|
||||||
tenant: tenant.id,
|
|
||||||
folder: folderId,
|
|
||||||
filetype: filetypeId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// TENANT ERKENNEN + FOLDER/FILETYPES (DRIZZLE VERSION)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
const getMessageConfigDrizzle = async (
|
||||||
|
server: FastifyInstance,
|
||||||
|
message,
|
||||||
|
tenantsList: any[]
|
||||||
|
) => {
|
||||||
|
|
||||||
|
let possibleKeys: string[] = []
|
||||||
|
|
||||||
|
if (message.to) {
|
||||||
|
message.to.forEach((item) =>
|
||||||
|
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.cc) {
|
||||||
|
message.cc.forEach((item) =>
|
||||||
|
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------
|
||||||
|
// TENANT IDENTIFY
|
||||||
|
// -------------------------------------------
|
||||||
|
let tenant = tenantsList.find((t) => possibleKeys.includes(t.key))
|
||||||
|
|
||||||
|
if (!tenant && message.to?.length) {
|
||||||
|
const address = message.to[0].address.toLowerCase()
|
||||||
|
|
||||||
|
tenant = tenantsList.find((t) =>
|
||||||
|
(t.emailAddresses || []).map((m) => m.toLowerCase()).includes(address)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenant) return null
|
||||||
|
|
||||||
|
// -------------------------------------------
|
||||||
|
// FOLDER + FILETYPE VIA SUBJECT
|
||||||
|
// -------------------------------------------
|
||||||
|
let folderId = null
|
||||||
|
let filetypeId = null
|
||||||
|
|
||||||
|
// -------------------------------------------
|
||||||
|
// Rechnung / Invoice
|
||||||
|
// -------------------------------------------
|
||||||
|
if (message.subject?.match(/(Rechnung|Beleg|Invoice|Quittung)/gi)) {
|
||||||
|
|
||||||
|
const folder = await server.db
|
||||||
|
.select({ id: folders.id })
|
||||||
|
.from(folders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(folders.tenant, tenant.id),
|
||||||
|
and(
|
||||||
|
eq(folders.function, "incomingInvoices"),
|
||||||
|
//@ts-ignore
|
||||||
|
eq(folders.year, dayjs().format("YYYY"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
folderId = folder[0]?.id ?? null
|
||||||
|
|
||||||
|
const tag = await server.db
|
||||||
|
.select({ id: filetags.id })
|
||||||
|
.from(filetags)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(filetags.tenant, tenant.id),
|
||||||
|
eq(filetags.incomingDocumentType, "invoices")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
filetypeId = tag[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------
|
||||||
|
// Mahnung
|
||||||
|
// -------------------------------------------
|
||||||
|
else if (message.subject?.match(/(Mahnung|Zahlungsaufforderung|Zahlungsverzug)/gi)) {
|
||||||
|
|
||||||
|
const tag = await server.db
|
||||||
|
.select({ id: filetags.id })
|
||||||
|
.from(filetags)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(filetags.tenant, tenant.id),
|
||||||
|
eq(filetags.incomingDocumentType, "reminders")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
filetypeId = tag[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------
|
||||||
|
// Sonstige Dokumente → Deposit Folder
|
||||||
|
// -------------------------------------------
|
||||||
|
else {
|
||||||
|
|
||||||
|
const folder = await server.db
|
||||||
|
.select({ id: folders.id })
|
||||||
|
.from(folders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(folders.tenant, tenant.id),
|
||||||
|
eq(folders.function, "deposit")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
folderId = folder[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
run: async () => {
|
tenant: tenant.id,
|
||||||
await initDokuboxClient()
|
folder: folderId,
|
||||||
await syncDokubox()
|
filetype: filetypeId
|
||||||
console.log("Service: Dokubox sync finished")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,108 +8,9 @@ import {
|
|||||||
files,
|
files,
|
||||||
filetags,
|
filetags,
|
||||||
incominginvoices,
|
incominginvoices,
|
||||||
vendors,
|
|
||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
|
|
||||||
import { eq, and, isNull, not, desc } from "drizzle-orm"
|
import { eq, and, isNull, not } from "drizzle-orm"
|
||||||
|
|
||||||
type InvoiceAccount = {
|
|
||||||
account?: number | null
|
|
||||||
description?: string | null
|
|
||||||
taxType?: string | number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeAccounts = (accounts: unknown): InvoiceAccount[] => {
|
|
||||||
if (!Array.isArray(accounts)) return []
|
|
||||||
return accounts
|
|
||||||
.map((entry: any) => ({
|
|
||||||
account: typeof entry?.account === "number" ? entry.account : null,
|
|
||||||
description: typeof entry?.description === "string" ? entry.description : null,
|
|
||||||
taxType: entry?.taxType ?? null,
|
|
||||||
}))
|
|
||||||
.filter((entry) => entry.account !== null || entry.description || entry.taxType !== null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildLearningContext = (historicalInvoices: any[]) => {
|
|
||||||
if (!historicalInvoices.length) return null
|
|
||||||
|
|
||||||
const vendorProfiles = new Map<number, {
|
|
||||||
vendorName: string
|
|
||||||
paymentTypes: Map<string, number>
|
|
||||||
accountUsage: Map<number, number>
|
|
||||||
sampleDescriptions: string[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const recentExamples: any[] = []
|
|
||||||
|
|
||||||
for (const invoice of historicalInvoices) {
|
|
||||||
const accounts = normalizeAccounts(invoice.accounts)
|
|
||||||
const vendorId = typeof invoice.vendorId === "number" ? invoice.vendorId : null
|
|
||||||
const vendorName = typeof invoice.vendorName === "string" ? invoice.vendorName : "Unknown"
|
|
||||||
|
|
||||||
if (vendorId) {
|
|
||||||
if (!vendorProfiles.has(vendorId)) {
|
|
||||||
vendorProfiles.set(vendorId, {
|
|
||||||
vendorName,
|
|
||||||
paymentTypes: new Map(),
|
|
||||||
accountUsage: new Map(),
|
|
||||||
sampleDescriptions: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = vendorProfiles.get(vendorId)!
|
|
||||||
if (invoice.paymentType) {
|
|
||||||
const key = String(invoice.paymentType)
|
|
||||||
profile.paymentTypes.set(key, (profile.paymentTypes.get(key) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
for (const account of accounts) {
|
|
||||||
if (typeof account.account === "number") {
|
|
||||||
profile.accountUsage.set(account.account, (profile.accountUsage.get(account.account) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (invoice.description && profile.sampleDescriptions.length < 3) {
|
|
||||||
profile.sampleDescriptions.push(String(invoice.description).slice(0, 120))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recentExamples.length < 20) {
|
|
||||||
recentExamples.push({
|
|
||||||
vendorId,
|
|
||||||
vendorName,
|
|
||||||
paymentType: invoice.paymentType ?? null,
|
|
||||||
accounts: accounts.map((entry) => ({
|
|
||||||
account: entry.account,
|
|
||||||
description: entry.description ?? null,
|
|
||||||
taxType: entry.taxType ?? null,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const vendorPatterns = Array.from(vendorProfiles.entries())
|
|
||||||
.map(([vendorId, profile]) => {
|
|
||||||
const commonPaymentType = Array.from(profile.paymentTypes.entries())
|
|
||||||
.sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
|
|
||||||
const topAccounts = Array.from(profile.accountUsage.entries())
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 4)
|
|
||||||
.map(([accountId, count]) => ({ accountId, count }))
|
|
||||||
|
|
||||||
return {
|
|
||||||
vendorId,
|
|
||||||
vendorName: profile.vendorName,
|
|
||||||
commonPaymentType,
|
|
||||||
topAccounts,
|
|
||||||
sampleDescriptions: profile.sampleDescriptions,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.slice(0, 50)
|
|
||||||
|
|
||||||
return JSON.stringify({
|
|
||||||
vendorPatterns,
|
|
||||||
recentExamples,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||||
const processInvoices = async (tenantId:number) => {
|
const processInvoices = async (tenantId:number) => {
|
||||||
@@ -171,34 +72,13 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const historicalInvoices = await server.db
|
|
||||||
.select({
|
|
||||||
vendorId: incominginvoices.vendor,
|
|
||||||
vendorName: vendors.name,
|
|
||||||
paymentType: incominginvoices.paymentType,
|
|
||||||
description: incominginvoices.description,
|
|
||||||
accounts: incominginvoices.accounts,
|
|
||||||
})
|
|
||||||
.from(incominginvoices)
|
|
||||||
.leftJoin(vendors, eq(incominginvoices.vendor, vendors.id))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(incominginvoices.tenant, tenantId),
|
|
||||||
eq(incominginvoices.archived, false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(incominginvoices.createdAt))
|
|
||||||
.limit(120)
|
|
||||||
|
|
||||||
const learningContext = buildLearningContext(historicalInvoices)
|
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// 3️⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen
|
// 3️⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
for (const file of filesRes) {
|
for (const file of filesRes) {
|
||||||
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
||||||
|
|
||||||
const data = await getInvoiceDataFromGPT(server,file, tenantId, learningContext ?? undefined)
|
const data = await getInvoiceDataFromGPT(server,file, tenantId)
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// modules/helpdesk/helpdesk.contact.service.ts
|
// modules/helpdesk/helpdesk.contact.service.ts
|
||||||
import { FastifyInstance } from 'fastify'
|
import { FastifyInstance } from 'fastify'
|
||||||
import { and, eq, or } from "drizzle-orm";
|
|
||||||
import { helpdesk_contacts } from "../../../db/schema";
|
|
||||||
|
|
||||||
export async function getOrCreateContact(
|
export async function getOrCreateContact(
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
@@ -11,35 +9,30 @@ export async function getOrCreateContact(
|
|||||||
if (!email && !phone) throw new Error('Contact must have at least an email or phone')
|
if (!email && !phone) throw new Error('Contact must have at least an email or phone')
|
||||||
|
|
||||||
// Bestehenden Kontakt prüfen
|
// Bestehenden Kontakt prüfen
|
||||||
const matchConditions = []
|
const { data: existing, error: findError } = await server.supabase
|
||||||
if (email) matchConditions.push(eq(helpdesk_contacts.email, email))
|
.from('helpdesk_contacts')
|
||||||
if (phone) matchConditions.push(eq(helpdesk_contacts.phone, phone))
|
.select('*')
|
||||||
|
.eq('tenant_id', tenant_id)
|
||||||
|
.or(`email.eq.${email || ''},phone.eq.${phone || ''}`)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
const existing = await server.db
|
if (findError) throw findError
|
||||||
.select()
|
if (existing) return existing
|
||||||
.from(helpdesk_contacts)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(helpdesk_contacts.tenantId, tenant_id),
|
|
||||||
or(...matchConditions)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (existing[0]) return existing[0]
|
|
||||||
|
|
||||||
// Anlegen
|
// Anlegen
|
||||||
const created = await server.db
|
const { data: created, error: insertError } = await server.supabase
|
||||||
.insert(helpdesk_contacts)
|
.from('helpdesk_contacts')
|
||||||
.values({
|
.insert({
|
||||||
tenantId: tenant_id,
|
tenant_id,
|
||||||
email,
|
email,
|
||||||
phone,
|
phone,
|
||||||
displayName: display_name,
|
display_name,
|
||||||
customerId: customer_id,
|
customer_id,
|
||||||
contactId: contact_id
|
contact_id
|
||||||
})
|
})
|
||||||
.returning()
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
return created[0]
|
if (insertError) throw insertError
|
||||||
|
return created
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import { FastifyInstance } from 'fastify'
|
import { FastifyInstance } from 'fastify'
|
||||||
import { getOrCreateContact } from './helpdesk.contact.service.js'
|
import { getOrCreateContact } from './helpdesk.contact.service.js'
|
||||||
import {useNextNumberRangeNumber} from "../../utils/functions";
|
import {useNextNumberRangeNumber} from "../../utils/functions";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
|
||||||
import { customers, helpdesk_contacts, helpdesk_conversations } from "../../../db/schema";
|
|
||||||
|
|
||||||
export async function createConversation(
|
export async function createConversation(
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
@@ -27,34 +25,24 @@ export async function createConversation(
|
|||||||
|
|
||||||
const {usedNumber } = await useNextNumberRangeNumber(server, tenant_id, "tickets")
|
const {usedNumber } = await useNextNumberRangeNumber(server, tenant_id, "tickets")
|
||||||
|
|
||||||
const inserted = await server.db
|
const { data, error } = await server.supabase
|
||||||
.insert(helpdesk_conversations)
|
.from('helpdesk_conversations')
|
||||||
.values({
|
.insert({
|
||||||
tenantId: tenant_id,
|
tenant_id,
|
||||||
contactId: contactRecord.id,
|
contact_id: contactRecord.id,
|
||||||
channelInstanceId: channel_instance_id,
|
channel_instance_id,
|
||||||
subject: subject || null,
|
subject: subject || null,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
createdAt: new Date(),
|
created_at: new Date().toISOString(),
|
||||||
customerId: customer_id,
|
customer_id,
|
||||||
contactPersonId: contact_person_id,
|
contact_person_id,
|
||||||
ticketNumber: usedNumber
|
ticket_number: usedNumber
|
||||||
})
|
})
|
||||||
.returning()
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
const data = inserted[0]
|
if (error) throw error
|
||||||
|
return data
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
channel_instance_id: data.channelInstanceId,
|
|
||||||
contact_id: data.contactId,
|
|
||||||
contact_person_id: data.contactPersonId,
|
|
||||||
created_at: data.createdAt,
|
|
||||||
customer_id: data.customerId,
|
|
||||||
last_message_at: data.lastMessageAt,
|
|
||||||
tenant_id: data.tenantId,
|
|
||||||
ticket_number: data.ticketNumber,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getConversations(
|
export async function getConversations(
|
||||||
@@ -64,34 +52,22 @@ export async function getConversations(
|
|||||||
) {
|
) {
|
||||||
const { status, limit = 50 } = opts || {}
|
const { status, limit = 50 } = opts || {}
|
||||||
|
|
||||||
const filters = [eq(helpdesk_conversations.tenantId, tenant_id)]
|
let query = server.supabase.from('helpdesk_conversations').select('*, customer_id(*)').eq('tenant_id', tenant_id)
|
||||||
if (status) filters.push(eq(helpdesk_conversations.status, status))
|
|
||||||
|
|
||||||
const data = await server.db
|
if (status) query = query.eq('status', status)
|
||||||
.select({
|
query = query.order('last_message_at', { ascending: false }).limit(limit)
|
||||||
conversation: helpdesk_conversations,
|
|
||||||
contact: helpdesk_contacts,
|
|
||||||
customer: customers,
|
|
||||||
})
|
|
||||||
.from(helpdesk_conversations)
|
|
||||||
.leftJoin(helpdesk_contacts, eq(helpdesk_contacts.id, helpdesk_conversations.contactId))
|
|
||||||
.leftJoin(customers, eq(customers.id, helpdesk_conversations.customerId))
|
|
||||||
.where(and(...filters))
|
|
||||||
.orderBy(desc(helpdesk_conversations.lastMessageAt))
|
|
||||||
.limit(limit)
|
|
||||||
|
|
||||||
return data.map((entry) => ({
|
const { data, error } = await query
|
||||||
...entry.conversation,
|
if (error) throw error
|
||||||
helpdesk_contacts: entry.contact,
|
|
||||||
channel_instance_id: entry.conversation.channelInstanceId,
|
const mappedData = data.map(entry => {
|
||||||
contact_id: entry.conversation.contactId,
|
return {
|
||||||
contact_person_id: entry.conversation.contactPersonId,
|
...entry,
|
||||||
created_at: entry.conversation.createdAt,
|
customer: entry.customer_id
|
||||||
customer_id: entry.customer,
|
}
|
||||||
last_message_at: entry.conversation.lastMessageAt,
|
})
|
||||||
tenant_id: entry.conversation.tenantId,
|
|
||||||
ticket_number: entry.conversation.ticketNumber,
|
return mappedData
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateConversationStatus(
|
export async function updateConversationStatus(
|
||||||
@@ -102,22 +78,13 @@ export async function updateConversationStatus(
|
|||||||
const valid = ['open', 'in_progress', 'waiting_for_customer', 'answered', 'closed']
|
const valid = ['open', 'in_progress', 'waiting_for_customer', 'answered', 'closed']
|
||||||
if (!valid.includes(status)) throw new Error('Invalid status')
|
if (!valid.includes(status)) throw new Error('Invalid status')
|
||||||
|
|
||||||
const updated = await server.db
|
const { data, error } = await server.supabase
|
||||||
.update(helpdesk_conversations)
|
.from('helpdesk_conversations')
|
||||||
.set({ status })
|
.update({ status })
|
||||||
.where(eq(helpdesk_conversations.id, conversation_id))
|
.eq('id', conversation_id)
|
||||||
.returning()
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
const data = updated[0]
|
if (error) throw error
|
||||||
return {
|
return data
|
||||||
...data,
|
|
||||||
channel_instance_id: data.channelInstanceId,
|
|
||||||
contact_id: data.contactId,
|
|
||||||
contact_person_id: data.contactPersonId,
|
|
||||||
created_at: data.createdAt,
|
|
||||||
customer_id: data.customerId,
|
|
||||||
last_message_at: data.lastMessageAt,
|
|
||||||
tenant_id: data.tenantId,
|
|
||||||
ticket_number: data.ticketNumber,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// modules/helpdesk/helpdesk.message.service.ts
|
// modules/helpdesk/helpdesk.message.service.ts
|
||||||
import { FastifyInstance } from 'fastify'
|
import { FastifyInstance } from 'fastify'
|
||||||
import { asc, eq } from "drizzle-orm";
|
|
||||||
import { helpdesk_conversations, helpdesk_messages } from "../../../db/schema";
|
|
||||||
|
|
||||||
export async function addMessage(
|
export async function addMessage(
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
@@ -25,53 +23,38 @@ export async function addMessage(
|
|||||||
) {
|
) {
|
||||||
if (!payload?.text) throw new Error('Message payload requires text content')
|
if (!payload?.text) throw new Error('Message payload requires text content')
|
||||||
|
|
||||||
const inserted = await server.db
|
const { data: message, error } = await server.supabase
|
||||||
.insert(helpdesk_messages)
|
.from('helpdesk_messages')
|
||||||
.values({
|
.insert({
|
||||||
tenantId: tenant_id,
|
tenant_id,
|
||||||
conversationId: conversation_id,
|
conversation_id,
|
||||||
authorUserId: author_user_id,
|
author_user_id,
|
||||||
direction,
|
direction,
|
||||||
payload,
|
payload,
|
||||||
rawMeta: raw_meta,
|
raw_meta,
|
||||||
externalMessageId: external_message_id,
|
created_at: new Date().toISOString(),
|
||||||
receivedAt: new Date(),
|
|
||||||
})
|
})
|
||||||
.returning()
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
const message = inserted[0]
|
if (error) throw error
|
||||||
|
|
||||||
// Letzte Nachricht aktualisieren
|
// Letzte Nachricht aktualisieren
|
||||||
await server.db
|
await server.supabase
|
||||||
.update(helpdesk_conversations)
|
.from('helpdesk_conversations')
|
||||||
.set({ lastMessageAt: new Date() })
|
.update({ last_message_at: new Date().toISOString() })
|
||||||
.where(eq(helpdesk_conversations.id, conversation_id))
|
.eq('id', conversation_id)
|
||||||
|
|
||||||
return {
|
return message
|
||||||
...message,
|
|
||||||
author_user_id: message.authorUserId,
|
|
||||||
conversation_id: message.conversationId,
|
|
||||||
created_at: message.createdAt,
|
|
||||||
external_message_id: message.externalMessageId,
|
|
||||||
raw_meta: message.rawMeta,
|
|
||||||
tenant_id: message.tenantId,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMessages(server: FastifyInstance, conversation_id: string) {
|
export async function getMessages(server: FastifyInstance, conversation_id: string) {
|
||||||
const data = await server.db
|
const { data, error } = await server.supabase
|
||||||
.select()
|
.from('helpdesk_messages')
|
||||||
.from(helpdesk_messages)
|
.select('*')
|
||||||
.where(eq(helpdesk_messages.conversationId, conversation_id))
|
.eq('conversation_id', conversation_id)
|
||||||
.orderBy(asc(helpdesk_messages.createdAt))
|
.order('created_at', { ascending: true })
|
||||||
|
|
||||||
return data.map((message) => ({
|
if (error) throw error
|
||||||
...message,
|
return data
|
||||||
author_user_id: message.authorUserId,
|
|
||||||
conversation_id: message.conversationId,
|
|
||||||
created_at: message.createdAt,
|
|
||||||
external_message_id: message.externalMessageId,
|
|
||||||
raw_meta: message.rawMeta,
|
|
||||||
tenant_id: message.tenantId,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// services/notification.service.ts
|
// services/notification.service.ts
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import {secrets} from "../utils/secrets";
|
import {secrets} from "../utils/secrets";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { notificationsEventTypes, notificationsItems } from "../../db/schema";
|
|
||||||
|
|
||||||
export type NotificationStatus = 'queued' | 'sent' | 'failed';
|
export type NotificationStatus = 'queued' | 'sent' | 'failed';
|
||||||
|
|
||||||
@@ -36,16 +34,16 @@ export class NotificationService {
|
|||||||
*/
|
*/
|
||||||
async trigger(input: TriggerInput) {
|
async trigger(input: TriggerInput) {
|
||||||
const { tenantId, userId, eventType, title, message, payload } = input;
|
const { tenantId, userId, eventType, title, message, payload } = input;
|
||||||
|
const supabase = this.server.supabase;
|
||||||
|
|
||||||
// 1) Event-Typ prüfen (aktiv?)
|
// 1) Event-Typ prüfen (aktiv?)
|
||||||
const eventTypeRows = await this.server.db
|
const { data: eventTypeRow, error: etErr } = await supabase
|
||||||
.select()
|
.from('notifications_event_types')
|
||||||
.from(notificationsEventTypes)
|
.select('event_key,is_active')
|
||||||
.where(eq(notificationsEventTypes.eventKey, eventType))
|
.eq('event_key', eventType)
|
||||||
.limit(1)
|
.maybeSingle();
|
||||||
const eventTypeRow = eventTypeRows[0]
|
|
||||||
|
|
||||||
if (!eventTypeRow || eventTypeRow.isActive !== true) {
|
if (etErr || !eventTypeRow || eventTypeRow.is_active !== true) {
|
||||||
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`);
|
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,40 +54,40 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3) Notification anlegen (status: queued)
|
// 3) Notification anlegen (status: queued)
|
||||||
const insertedRows = await this.server.db
|
const { data: inserted, error: insErr } = await supabase
|
||||||
.insert(notificationsItems)
|
.from('notifications_items')
|
||||||
.values({
|
.insert({
|
||||||
tenantId,
|
tenant_id: tenantId,
|
||||||
userId,
|
user_id: userId,
|
||||||
eventType,
|
event_type: eventType,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
payload: payload ?? null,
|
payload: payload ?? null,
|
||||||
channel: 'email',
|
channel: 'email',
|
||||||
status: 'queued'
|
status: 'queued'
|
||||||
})
|
})
|
||||||
.returning({ id: notificationsItems.id })
|
.select('id')
|
||||||
const inserted = insertedRows[0]
|
.single();
|
||||||
|
|
||||||
if (!inserted) {
|
if (insErr || !inserted) {
|
||||||
throw new Error("Fehler beim Einfügen der Notification");
|
throw new Error(`Fehler beim Einfügen der Notification: ${insErr?.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) E-Mail versenden
|
// 4) E-Mail versenden
|
||||||
try {
|
try {
|
||||||
await this.sendEmail(user.email, title, message);
|
await this.sendEmail(user.email, title, message);
|
||||||
|
|
||||||
await this.server.db
|
await supabase
|
||||||
.update(notificationsItems)
|
.from('notifications_items')
|
||||||
.set({ status: 'sent', sentAt: new Date() })
|
.update({ status: 'sent', sent_at: new Date().toISOString() })
|
||||||
.where(eq(notificationsItems.id, inserted.id));
|
.eq('id', inserted.id);
|
||||||
|
|
||||||
return { success: true, id: inserted.id };
|
return { success: true, id: inserted.id };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
await this.server.db
|
await supabase
|
||||||
.update(notificationsItems)
|
.from('notifications_items')
|
||||||
.set({ status: 'failed', error: String(err?.message || err) })
|
.update({ status: 'failed', error: String(err?.message || err) })
|
||||||
.where(eq(notificationsItems.id, inserted.id));
|
.eq('id', inserted.id);
|
||||||
|
|
||||||
this.server.log.error({ err, notificationId: inserted.id }, 'E-Mail Versand fehlgeschlagen');
|
this.server.log.error({ err, notificationId: inserted.id }, 'E-Mail Versand fehlgeschlagen');
|
||||||
return { success: false, error: err?.message || 'E-Mail Versand fehlgeschlagen' };
|
return { success: false, error: err?.message || 'E-Mail Versand fehlgeschlagen' };
|
||||||
|
|||||||
@@ -9,15 +9,13 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
"http://localhost:3001", // dein Nuxt-Frontend
|
"http://localhost:3001", // dein Nuxt-Frontend
|
||||||
"http://127.0.0.1:3000", // dein Nuxt-Frontend
|
"http://127.0.0.1:3000", // dein Nuxt-Frontend
|
||||||
"http://192.168.1.227:3001", // dein Nuxt-Frontend
|
"http://192.168.1.227:3001", // dein Nuxt-Frontend
|
||||||
"http://192.168.1.234:3000", // dein Nuxt-Frontend
|
|
||||||
"http://192.168.1.113:3000", // dein Nuxt-Frontend
|
"http://192.168.1.113:3000", // dein Nuxt-Frontend
|
||||||
"https://beta.fedeo.de", // dein Nuxt-Frontend
|
"https://beta.fedeo.de", // dein Nuxt-Frontend
|
||||||
"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","PATCH",
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"],
|
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin"],
|
||||||
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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {syncDokuboxService} from "../modules/cron/dokuboximport.service";
|
//import {initDokuboxClient, syncDokubox} 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 syncDokuboxService>;
|
//dokuboxSync: ReturnType<typeof syncDokubox>;
|
||||||
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: syncDokuboxService(server),
|
//dokuboxSync: syncDokubox(server),
|
||||||
prepareIncomingInvoices: prepareIncomingInvoices(server),
|
prepareIncomingInvoices: prepareIncomingInvoices(server),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
19
backend/src/plugins/supabase.ts
Normal file
19
backend/src/plugins/supabase.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import fp from "fastify-plugin";
|
||||||
|
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import {secrets} from "../utils/secrets";
|
||||||
|
|
||||||
|
export default fp(async (server: FastifyInstance) => {
|
||||||
|
const supabaseUrl = secrets.SUPABASE_URL
|
||||||
|
const supabaseServiceKey = secrets.SUPABASE_SERVICE_ROLE_KEY
|
||||||
|
const supabase: SupabaseClient = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
|
// Fastify um supabase erweitern
|
||||||
|
server.decorate("supabase", supabase);
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyInstance {
|
||||||
|
supabase: SupabaseClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,33 +5,26 @@ import swaggerUi from "@fastify/swagger-ui";
|
|||||||
|
|
||||||
export default fp(async (server: FastifyInstance) => {
|
export default fp(async (server: FastifyInstance) => {
|
||||||
await server.register(swagger, {
|
await server.register(swagger, {
|
||||||
mode: "dynamic",
|
mode: "dynamic", // wichtig: generiert echtes OpenAPI JSON
|
||||||
openapi: {
|
openapi: {
|
||||||
info: {
|
info: {
|
||||||
title: "FEDEO Backend API",
|
title: "Multi-Tenant API",
|
||||||
description: "OpenAPI specification for the FEDEO backend",
|
description: "API Dokumentation für dein Backend",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
},
|
},
|
||||||
servers: [{ url: "/" }],
|
servers: [{ url: "http://localhost:3000" }],
|
||||||
components: {
|
|
||||||
securitySchemes: {
|
|
||||||
bearerAuth: {
|
|
||||||
type: "http",
|
|
||||||
scheme: "bearer",
|
|
||||||
bearerFormat: "JWT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await server.register(swaggerUi, {
|
await server.register(swaggerUi, {
|
||||||
routePrefix: "/docs",
|
routePrefix: "/docs", // UI erreichbar unter http://localhost:3000/docs
|
||||||
|
swagger: {
|
||||||
|
info: {
|
||||||
|
title: "Multi-Tenant API",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exposeRoute: true,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
// Stable raw spec path
|
|
||||||
server.get("/openapi.json", async (_req, reply) => {
|
|
||||||
return reply.send(server.swagger());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyRequest } from "fastify";
|
import { FastifyInstance, FastifyRequest } from "fastify";
|
||||||
import fp from "fastify-plugin";
|
import fp from "fastify-plugin";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { tenants } from "../../db/schema";
|
|
||||||
|
|
||||||
export default fp(async (server: FastifyInstance) => {
|
export default fp(async (server: FastifyInstance) => {
|
||||||
server.addHook("preHandler", async (req, reply) => {
|
server.addHook("preHandler", async (req, reply) => {
|
||||||
@@ -11,12 +9,11 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Tenant aus DB laden
|
// Tenant aus DB laden
|
||||||
const rows = await server.db
|
const { data: tenant } = await server.supabase
|
||||||
.select()
|
.from("tenants")
|
||||||
.from(tenants)
|
.select("*")
|
||||||
.where(eq(tenants.portalDomain, host))
|
.eq("portalDomain", host)
|
||||||
.limit(1);
|
.single();
|
||||||
const tenant = rows[0];
|
|
||||||
|
|
||||||
|
|
||||||
if(!tenant) {
|
if(!tenant) {
|
||||||
@@ -41,4 +38,4 @@ declare module "fastify" {
|
|||||||
settings?: Record<string, any>;
|
settings?: Record<string, any>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,60 +1,11 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import bcrypt from "bcrypt"
|
import bcrypt from "bcrypt"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import jwt from "jsonwebtoken"
|
|
||||||
import { secrets } from "../../utils/secrets"
|
|
||||||
|
|
||||||
import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren!
|
import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren!
|
||||||
|
|
||||||
export default async function authRoutesAuthenticated(server: FastifyInstance) {
|
export default async function authRoutesAuthenticated(server: FastifyInstance) {
|
||||||
|
|
||||||
server.post("/auth/refresh", {
|
|
||||||
schema: {
|
|
||||||
tags: ["Auth"],
|
|
||||||
summary: "Refresh JWT for current authenticated user",
|
|
||||||
response: {
|
|
||||||
200: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
token: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["token"],
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
error: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["error"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, async (req, reply) => {
|
|
||||||
if (!req.user?.user_id) {
|
|
||||||
return reply.code(401).send({ error: "Unauthorized" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
user_id: req.user.user_id,
|
|
||||||
email: req.user.email,
|
|
||||||
tenant_id: req.user.tenant_id,
|
|
||||||
},
|
|
||||||
secrets.JWT_SECRET!,
|
|
||||||
{ expiresIn: "6h" }
|
|
||||||
)
|
|
||||||
|
|
||||||
reply.setCookie("token", token, {
|
|
||||||
path: "/",
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
maxAge: 60 * 60 * 6,
|
|
||||||
})
|
|
||||||
|
|
||||||
return { token }
|
|
||||||
})
|
|
||||||
|
|
||||||
server.post("/auth/password/change", {
|
server.post("/auth/password/change", {
|
||||||
schema: {
|
schema: {
|
||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export default async function authRoutes(server: FastifyInstance) {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
maxAge: 60 * 60 * 6,
|
maxAge: 60 * 60 * 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { token };
|
return { token };
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { db } from "../../../db"; // <--- PFAD ZUR DB INSTANZ ANPASSEN
|
|
||||||
import { devices } from "../../../db/schema";
|
|
||||||
|
|
||||||
// Definition, was wir vom ESP32 erwarten
|
|
||||||
interface HealthBody {
|
|
||||||
terminal_id: string;
|
|
||||||
ip_address?: string;
|
|
||||||
wifi_rssi?: number;
|
|
||||||
uptime_seconds?: number;
|
|
||||||
heap_free?: number;
|
|
||||||
[key: string]: any; // Erlaubt weitere Felder
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function devicesManagementRoutes(server: FastifyInstance) {
|
|
||||||
server.post<{ Body: HealthBody }>(
|
|
||||||
"/health",
|
|
||||||
async (req, reply) => {
|
|
||||||
try {
|
|
||||||
const data = req.body;
|
|
||||||
|
|
||||||
// 1. Validierung: Haben wir eine ID?
|
|
||||||
if (!data.terminal_id) {
|
|
||||||
console.warn("Health Check ohne terminal_id empfangen:", data);
|
|
||||||
return reply.code(400).send({ error: "terminal_id missing" });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Health Ping von Device ${data.terminal_id}`, data);
|
|
||||||
|
|
||||||
// 2. Datenbank Update
|
|
||||||
// Wir suchen das Gerät mit der passenden externalId
|
|
||||||
const result = await server.db
|
|
||||||
.update(devices)
|
|
||||||
.set({
|
|
||||||
lastSeen: new Date(), // Setzt Zeit auf JETZT
|
|
||||||
lastDebugInfo: data // Speichert das ganze JSON
|
|
||||||
})
|
|
||||||
.where(eq(devices.externalId, data.terminal_id))
|
|
||||||
.returning({ id: devices.id }); // Gibt ID zurück, falls gefunden
|
|
||||||
|
|
||||||
// 3. Checken ob Gerät gefunden wurde
|
|
||||||
if (result.length === 0) {
|
|
||||||
console.warn(`Unbekanntes Terminal versucht Health Check: ${data.terminal_id}`);
|
|
||||||
// Optional: 404 senden oder ignorieren (Sicherheit)
|
|
||||||
return reply.code(404).send({ error: "Device not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alles OK
|
|
||||||
return reply.code(200).send({ status: "ok" });
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("Health Check Error:", err);
|
|
||||||
return reply.code(500).send({ error: err.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +1,37 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import {and, desc, eq} from "drizzle-orm";
|
||||||
import { authProfiles, devices, stafftimeevents } from "../../../db/schema";
|
import {authProfiles, devices, stafftimeevents} from "../../../db/schema";
|
||||||
|
|
||||||
export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
||||||
server.post(
|
server.post(
|
||||||
"/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 { terminal_id } = req.params as { terminal_id: string };
|
const {rfid_id} = req.body as {rfid_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(eq(devices.externalId, terminal_id))
|
.where(
|
||||||
|
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,56 +44,55 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.then(rows => rows[0]);
|
.then(rows => rows[0]);
|
||||||
|
|
||||||
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(eq(stafftimeevents.user_id, profile.user_id))
|
.where(
|
||||||
.orderBy(desc(stafftimeevents.eventtime))
|
eq(stafftimeevents.user_id, profile.user_id)
|
||||||
|
)
|
||||||
|
.orderBy(desc(stafftimeevents.eventtime)) // <-- Sortierung: Neuestes zuerst
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.then(rows => rows[0]);
|
.then(rows => rows[0]);
|
||||||
|
|
||||||
// 5. Zeitstempel Logik (WICHTIG!)
|
console.log(lastEvent)
|
||||||
// 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: actualEventTime, // Hier nutzen wir die berechnete Zeit
|
eventtime: new Date(),
|
||||||
eventtype: nextEventType,
|
eventtype: lastEvent.eventtype === "work_start" ? "work_end" : "work_start",
|
||||||
source: "TERMINAL" // Habe ich von WEB auf TERMINAL geändert (optional)
|
source: "WEB"
|
||||||
};
|
}
|
||||||
|
|
||||||
console.log(`New Event for ${profile.user_id}: ${nextEventType} @ ${actualEventTime.toISOString()}`);
|
console.log(dataToInsert)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import {insertHistoryItem} from "../utils/history";
|
||||||
import {buildExportZip} from "../utils/export/datev";
|
import {buildExportZip} from "../utils/export/datev";
|
||||||
import {s3} from "../utils/s3";
|
import {s3} from "../utils/s3";
|
||||||
import {GetObjectCommand, PutObjectCommand} from "@aws-sdk/client-s3"
|
import {GetObjectCommand, PutObjectCommand} from "@aws-sdk/client-s3"
|
||||||
@@ -7,8 +9,6 @@ import dayjs from "dayjs";
|
|||||||
import {randomUUID} from "node:crypto";
|
import {randomUUID} from "node:crypto";
|
||||||
import {secrets} from "../utils/secrets";
|
import {secrets} from "../utils/secrets";
|
||||||
import {createSEPAExport} from "../utils/export/sepa";
|
import {createSEPAExport} from "../utils/export/sepa";
|
||||||
import {generatedexports} from "../../db/schema";
|
|
||||||
import {eq} from "drizzle-orm";
|
|
||||||
|
|
||||||
const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDate,beraternr,mandantennr) => {
|
const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDate,beraternr,mandantennr) => {
|
||||||
try {
|
try {
|
||||||
@@ -45,21 +45,25 @@ const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDat
|
|||||||
|
|
||||||
console.log(url)
|
console.log(url)
|
||||||
|
|
||||||
// 5) In Haupt-DB speichern
|
// 5) In Supabase-DB speichern
|
||||||
const inserted = await server.db
|
const { data, error } = await server.supabase
|
||||||
.insert(generatedexports)
|
.from("exports")
|
||||||
.values({
|
.insert([
|
||||||
tenantId: req.user.tenant_id,
|
{
|
||||||
startDate: new Date(startDate),
|
tenant_id: req.user.tenant_id,
|
||||||
endDate: new Date(endDate),
|
start_date: startDate,
|
||||||
validUntil: dayjs().add(24, "hours").toDate(),
|
end_date: endDate,
|
||||||
filePath: fileKey,
|
valid_until: dayjs().add(24,"hours").toISOString(),
|
||||||
url,
|
file_path: fileKey,
|
||||||
type: "datev",
|
url: url,
|
||||||
})
|
created_at: new Date().toISOString(),
|
||||||
.returning()
|
},
|
||||||
|
])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
console.log(inserted[0])
|
console.log(data)
|
||||||
|
console.log(error)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
@@ -116,22 +120,9 @@ export default async function exportRoutes(server: FastifyInstance) {
|
|||||||
//List Exports Available for Download
|
//List Exports Available for Download
|
||||||
|
|
||||||
server.get("/exports", async (req,reply) => {
|
server.get("/exports", async (req,reply) => {
|
||||||
const data = await server.db
|
const {data,error} = await server.supabase.from("exports").select().eq("tenant_id",req.user.tenant_id)
|
||||||
.select({
|
|
||||||
id: generatedexports.id,
|
|
||||||
created_at: generatedexports.createdAt,
|
|
||||||
tenant_id: generatedexports.tenantId,
|
|
||||||
start_date: generatedexports.startDate,
|
|
||||||
end_date: generatedexports.endDate,
|
|
||||||
valid_until: generatedexports.validUntil,
|
|
||||||
type: generatedexports.type,
|
|
||||||
url: generatedexports.url,
|
|
||||||
file_path: generatedexports.filePath,
|
|
||||||
})
|
|
||||||
.from(generatedexports)
|
|
||||||
.where(eq(generatedexports.tenantId, req.user.tenant_id))
|
|
||||||
|
|
||||||
console.log(data)
|
console.log(data,error)
|
||||||
reply.send(data)
|
reply.send(data)
|
||||||
|
|
||||||
})
|
})
|
||||||
@@ -140,4 +131,4 @@ export default async function exportRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -100,25 +100,31 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
server.get('/functions/check-zip/:zip', async (req, reply) => {
|
server.get('/functions/check-zip/:zip', async (req, reply) => {
|
||||||
const { zip } = req.params as { zip: string }
|
const { zip } = req.params as { zip: string }
|
||||||
const normalizedZip = String(zip || "").replace(/\D/g, "")
|
|
||||||
|
|
||||||
if (normalizedZip.length !== 5) {
|
if (!zip) {
|
||||||
return reply.code(400).send({ error: 'ZIP must contain exactly 5 digits' })
|
return reply.code(400).send({ error: 'ZIP is required' })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await server.db
|
//@ts-ignore
|
||||||
|
const data = await server.db.select().from(citys).where(eq(citys.zip,zip))
|
||||||
|
|
||||||
|
|
||||||
|
/*const { data, error } = await server.supabase
|
||||||
|
.from('citys')
|
||||||
.select()
|
.select()
|
||||||
.from(citys)
|
.eq('zip', zip)
|
||||||
.where(eq(citys.zip, Number(normalizedZip)))
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.log(error)
|
||||||
|
return reply.code(500).send({ error: 'Database error' })
|
||||||
|
}*/
|
||||||
|
|
||||||
if (!data.length) {
|
if (!data) {
|
||||||
return reply.code(404).send({ error: 'ZIP not found' })
|
return reply.code(404).send({ error: 'ZIP not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const city = data[0]
|
|
||||||
|
|
||||||
//districtMap
|
//districtMap
|
||||||
const bundeslaender = [
|
const bundeslaender = [
|
||||||
{ code: 'DE-BW', name: 'Baden-Württemberg' },
|
{ code: 'DE-BW', name: 'Baden-Württemberg' },
|
||||||
@@ -142,8 +148,9 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
...city,
|
...data,
|
||||||
state_code: bundeslaender.find(i => i.name === city.countryName)?.code || null
|
//@ts-ignore
|
||||||
|
state_code: bundeslaender.find(i => i.name === data.countryName)
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err)
|
console.log(err)
|
||||||
@@ -172,11 +179,6 @@ 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}
|
||||||
@@ -217,4 +219,4 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})*/
|
})*/
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,12 @@ import { FastifyInstance } from "fastify";
|
|||||||
export default async function routes(server: FastifyInstance) {
|
export default async function routes(server: FastifyInstance) {
|
||||||
server.get("/ping", async () => {
|
server.get("/ping", async () => {
|
||||||
// Testquery gegen DB
|
// Testquery gegen DB
|
||||||
const result = await server.db.execute("SELECT NOW()");
|
const { data, error } = await server.supabase.from("tenants").select("id").limit(1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: "ok",
|
status: "ok",
|
||||||
db: JSON.stringify(result.rows[0]),
|
db: error ? "not connected" : "connected",
|
||||||
|
tenant_count: data?.length ?? 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,8 @@ import { FastifyPluginAsync } from 'fastify'
|
|||||||
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
|
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
|
||||||
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
|
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
|
||||||
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
||||||
import { findCustomerOrContactByEmailOrDomain } from "../utils/helpers";
|
import {extractDomain, findCustomerOrContactByEmailOrDomain} from "../utils/helpers";
|
||||||
import { eq } from "drizzle-orm";
|
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||||
import { helpdesk_conversations, helpdesk_messages } from "../../db/schema";
|
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// 📧 Interne M2M-Route für eingehende E-Mails
|
// 📧 Interne M2M-Route für eingehende E-Mails
|
||||||
@@ -53,12 +52,12 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
|
|||||||
// 3️⃣ Konversation anhand In-Reply-To suchen
|
// 3️⃣ Konversation anhand In-Reply-To suchen
|
||||||
let conversationId: string | null = null
|
let conversationId: string | null = null
|
||||||
if (in_reply_to) {
|
if (in_reply_to) {
|
||||||
const msg = await server.db
|
const { data: msg } = await server.supabase
|
||||||
.select({ conversationId: helpdesk_messages.conversationId })
|
.from('helpdesk_messages')
|
||||||
.from(helpdesk_messages)
|
.select('conversation_id')
|
||||||
.where(eq(helpdesk_messages.externalMessageId, in_reply_to))
|
.eq('external_message_id', in_reply_to)
|
||||||
.limit(1)
|
.maybeSingle()
|
||||||
conversationId = msg[0]?.conversationId || null
|
conversationId = msg?.conversation_id || null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4️⃣ Neue Konversation anlegen falls keine existiert
|
// 4️⃣ Neue Konversation anlegen falls keine existiert
|
||||||
@@ -74,12 +73,12 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
|
|||||||
})
|
})
|
||||||
conversationId = conversation.id
|
conversationId = conversation.id
|
||||||
} else {
|
} else {
|
||||||
const rows = await server.db
|
const { data } = await server.supabase
|
||||||
.select()
|
.from('helpdesk_conversations')
|
||||||
.from(helpdesk_conversations)
|
.select('*')
|
||||||
.where(eq(helpdesk_conversations.id, conversationId))
|
.eq('id', conversationId)
|
||||||
.limit(1)
|
.single()
|
||||||
conversation = rows[0]
|
conversation = data
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5️⃣ Nachricht speichern
|
// 5️⃣ Nachricht speichern
|
||||||
@@ -97,7 +96,7 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
|
|||||||
return res.status(201).send({
|
return res.status(201).send({
|
||||||
success: true,
|
success: true,
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
ticket_number: conversation?.ticket_number || conversation?.ticketNumber,
|
ticket_number: conversation.ticket_number,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,70 @@ import { FastifyPluginAsync } from 'fastify'
|
|||||||
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
|
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
|
||||||
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
|
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
|
||||||
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
||||||
import { findCustomerOrContactByEmailOrDomain } from "../utils/helpers";
|
|
||||||
import { eq } from "drizzle-orm";
|
/**
|
||||||
import { helpdesk_channel_instances } from "../../db/schema";
|
* Öffentliche Route zum Empfang eingehender Kontaktformular-Nachrichten.
|
||||||
|
* Authentifizierung: über `public_token` aus helpdesk_channel_instances
|
||||||
|
*/
|
||||||
|
|
||||||
|
function extractDomain(email) {
|
||||||
|
if (!email) return null
|
||||||
|
const parts = email.split("@")
|
||||||
|
return parts.length === 2 ? parts[1].toLowerCase() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCustomerOrContactByEmailOrDomain(server,fromMail, tenantId) {
|
||||||
|
const sender = fromMail
|
||||||
|
const senderDomain = extractDomain(sender)
|
||||||
|
if (!senderDomain) return null
|
||||||
|
|
||||||
|
|
||||||
|
// 1️⃣ Direkter Match über contacts
|
||||||
|
const { data: contactMatch } = await server.supabase
|
||||||
|
.from("contacts")
|
||||||
|
.select("id, customer")
|
||||||
|
.eq("email", sender)
|
||||||
|
.eq("tenant", tenantId)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (contactMatch?.customer_id) return {
|
||||||
|
customer: contactMatch.customer,
|
||||||
|
contact: contactMatch.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Kunden laden, bei denen E-Mail oder Rechnungsmail passt
|
||||||
|
const { data: customers, error } = await server.supabase
|
||||||
|
.from("customers")
|
||||||
|
.select("id, infoData")
|
||||||
|
.eq("tenant", tenantId)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(`[Helpdesk] Fehler beim Laden der Kunden:`, error.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3️⃣ Durch Kunden iterieren und prüfen
|
||||||
|
for (const c of customers || []) {
|
||||||
|
const info = c.infoData || {}
|
||||||
|
const email = info.email?.toLowerCase()
|
||||||
|
const invoiceEmail = info.invoiceEmail?.toLowerCase()
|
||||||
|
|
||||||
|
const emailDomain = extractDomain(email)
|
||||||
|
const invoiceDomain = extractDomain(invoiceEmail)
|
||||||
|
|
||||||
|
// exakter Match oder Domain-Match
|
||||||
|
if (
|
||||||
|
sender === email ||
|
||||||
|
sender === invoiceEmail ||
|
||||||
|
senderDomain === emailDomain ||
|
||||||
|
senderDomain === invoiceDomain
|
||||||
|
) {
|
||||||
|
return {customer: c.id, contact:null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
|
const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
|
||||||
// Öffentliche POST-Route
|
// Öffentliche POST-Route
|
||||||
@@ -24,18 +85,17 @@ const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1️⃣ Kanalinstanz anhand des Tokens ermitteln
|
// 1️⃣ Kanalinstanz anhand des Tokens ermitteln
|
||||||
const channels = await server.db
|
const { data: channel, error: channelError } = await server.supabase
|
||||||
.select()
|
.from('helpdesk_channel_instances')
|
||||||
.from(helpdesk_channel_instances)
|
.select('*')
|
||||||
.where(eq(helpdesk_channel_instances.publicToken, public_token))
|
.eq('public_token', public_token)
|
||||||
.limit(1)
|
.single()
|
||||||
const channel = channels[0]
|
|
||||||
|
|
||||||
if (!channel) {
|
if (channelError || !channel) {
|
||||||
return res.status(404).send({ error: 'Invalid channel token' })
|
return res.status(404).send({ error: 'Invalid channel token' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenant_id = channel.tenantId
|
const tenant_id = channel.tenant_id
|
||||||
const channel_instance_id = channel.id
|
const channel_instance_id = channel.id
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -5,13 +5,6 @@ import { addMessage, getMessages } from '../modules/helpdesk/helpdesk.message.se
|
|||||||
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
||||||
import {decrypt, encrypt} from "../utils/crypt";
|
import {decrypt, encrypt} from "../utils/crypt";
|
||||||
import nodemailer from "nodemailer"
|
import nodemailer from "nodemailer"
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
helpdesk_channel_instances,
|
|
||||||
helpdesk_contacts,
|
|
||||||
helpdesk_conversations,
|
|
||||||
helpdesk_messages,
|
|
||||||
} from "../../db/schema";
|
|
||||||
|
|
||||||
const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
||||||
// 📩 1. Liste aller Konversationen
|
// 📩 1. Liste aller Konversationen
|
||||||
@@ -65,30 +58,15 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
|||||||
const tenant_id = req.user?.tenant_id
|
const tenant_id = req.user?.tenant_id
|
||||||
const {id: conversation_id} = req.params as {id: string}
|
const {id: conversation_id} = req.params as {id: string}
|
||||||
|
|
||||||
const rows = await server.db
|
const { data, error } = await server.supabase
|
||||||
.select({
|
.from('helpdesk_conversations')
|
||||||
conversation: helpdesk_conversations,
|
.select('*, helpdesk_contacts(*)')
|
||||||
contact: helpdesk_contacts
|
.eq('tenant_id', tenant_id)
|
||||||
})
|
.eq('id', conversation_id)
|
||||||
.from(helpdesk_conversations)
|
.single()
|
||||||
.leftJoin(helpdesk_contacts, eq(helpdesk_contacts.id, helpdesk_conversations.contactId))
|
|
||||||
.where(eq(helpdesk_conversations.id, conversation_id))
|
|
||||||
|
|
||||||
const data = rows[0]
|
if (error) return res.status(404).send({ error: 'Conversation not found' })
|
||||||
if (!data || data.conversation.tenantId !== tenant_id) return res.status(404).send({ error: 'Conversation not found' })
|
return res.send(data)
|
||||||
|
|
||||||
return res.send({
|
|
||||||
...data.conversation,
|
|
||||||
channel_instance_id: data.conversation.channelInstanceId,
|
|
||||||
contact_id: data.conversation.contactId,
|
|
||||||
contact_person_id: data.conversation.contactPersonId,
|
|
||||||
created_at: data.conversation.createdAt,
|
|
||||||
customer_id: data.conversation.customerId,
|
|
||||||
last_message_at: data.conversation.lastMessageAt,
|
|
||||||
tenant_id: data.conversation.tenantId,
|
|
||||||
ticket_number: data.conversation.ticketNumber,
|
|
||||||
helpdesk_contacts: data.contact,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 🔄 4. Konversation Status ändern
|
// 🔄 4. Konversation Status ändern
|
||||||
@@ -203,39 +181,36 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
|||||||
safeConfig.smtp.pass = encrypt(safeConfig.smtp.pass)
|
safeConfig.smtp.pass = encrypt(safeConfig.smtp.pass)
|
||||||
}
|
}
|
||||||
|
|
||||||
const inserted = await server.db
|
// Speichern in Supabase
|
||||||
.insert(helpdesk_channel_instances)
|
const { data, error } = await server.supabase
|
||||||
.values({
|
.from("helpdesk_channel_instances")
|
||||||
tenantId: tenant_id,
|
.insert({
|
||||||
typeId: type_id,
|
tenant_id,
|
||||||
|
type_id,
|
||||||
name,
|
name,
|
||||||
config: safeConfig,
|
config: safeConfig,
|
||||||
isActive: is_active,
|
is_active,
|
||||||
})
|
})
|
||||||
.returning()
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
const data = inserted[0]
|
if (error) throw error
|
||||||
if (!data) throw new Error("Konnte Channel nicht erstellen")
|
|
||||||
const responseConfig: any = data.config
|
|
||||||
|
|
||||||
// sensible Felder aus Response entfernen
|
// sensible Felder aus Response entfernen
|
||||||
if (responseConfig?.imap) {
|
if (data.config?.imap) {
|
||||||
delete responseConfig.imap.host
|
delete data.config.imap.host
|
||||||
delete responseConfig.imap.user
|
delete data.config.imap.user
|
||||||
delete responseConfig.imap.pass
|
delete data.config.imap.pass
|
||||||
}
|
}
|
||||||
if (responseConfig?.smtp) {
|
if (data.config?.smtp) {
|
||||||
delete responseConfig.smtp.host
|
delete data.config.smtp.host
|
||||||
delete responseConfig.smtp.user
|
delete data.config.smtp.user
|
||||||
delete responseConfig.smtp.pass
|
delete data.config.smtp.pass
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.send({
|
reply.send({
|
||||||
message: "E-Mail-Channel erfolgreich erstellt",
|
message: "E-Mail-Channel erfolgreich erstellt",
|
||||||
channel: {
|
channel: data,
|
||||||
...data,
|
|
||||||
config: responseConfig
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Fehler bei Channel-Erstellung:", err)
|
console.error("Fehler bei Channel-Erstellung:", err)
|
||||||
@@ -259,29 +234,29 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
|||||||
const { text } = req.body as { text: string }
|
const { text } = req.body as { text: string }
|
||||||
|
|
||||||
// 🔹 Konversation inkl. Channel + Kontakt laden
|
// 🔹 Konversation inkl. Channel + Kontakt laden
|
||||||
const rows = await server.db
|
const { data: conv, error: convErr } = await server.supabase
|
||||||
.select({
|
.from("helpdesk_conversations")
|
||||||
conversation: helpdesk_conversations,
|
.select(`
|
||||||
contact: helpdesk_contacts,
|
id,
|
||||||
channel: helpdesk_channel_instances,
|
tenant_id,
|
||||||
})
|
subject,
|
||||||
.from(helpdesk_conversations)
|
channel_instance_id,
|
||||||
.leftJoin(helpdesk_contacts, eq(helpdesk_contacts.id, helpdesk_conversations.contactId))
|
helpdesk_contacts(email),
|
||||||
.leftJoin(helpdesk_channel_instances, eq(helpdesk_channel_instances.id, helpdesk_conversations.channelInstanceId))
|
helpdesk_channel_instances(config, name),
|
||||||
.where(eq(helpdesk_conversations.id, conversationId))
|
ticket_number
|
||||||
.limit(1)
|
`)
|
||||||
|
.eq("id", conversationId)
|
||||||
const conv = rows[0]
|
.single()
|
||||||
|
|
||||||
console.log(conv)
|
console.log(conv)
|
||||||
|
|
||||||
if (!conv) {
|
if (convErr || !conv) {
|
||||||
reply.status(404).send({ error: "Konversation nicht gefunden" })
|
reply.status(404).send({ error: "Konversation nicht gefunden" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const contact = conv.contact as unknown as {email: string}
|
const contact = conv.helpdesk_contacts as unknown as {email: string}
|
||||||
const channel = conv.channel as unknown as {name: string, config: any}
|
const channel = conv.helpdesk_channel_instances as unknown as {name: string}
|
||||||
|
|
||||||
console.log(contact)
|
console.log(contact)
|
||||||
if (!contact?.email) {
|
if (!contact?.email) {
|
||||||
@@ -313,7 +288,7 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
|||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: `"${channel?.name}" <${user}>`,
|
from: `"${channel?.name}" <${user}>`,
|
||||||
to: contact.email,
|
to: contact.email,
|
||||||
subject: `${conv.conversation.ticketNumber} | ${conv.conversation.subject}` || `${conv.conversation.ticketNumber} | Antwort vom FEDEO Helpdesk`,
|
subject: `${conv.ticket_number} | ${conv.subject}` || `${conv.ticket_number} | Antwort vom FEDEO Helpdesk`,
|
||||||
text,
|
text,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,22 +296,24 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
|||||||
console.log(`[Helpdesk SMTP] Gesendet an ${contact.email}: ${info.messageId}`)
|
console.log(`[Helpdesk SMTP] Gesendet an ${contact.email}: ${info.messageId}`)
|
||||||
|
|
||||||
// 💾 Nachricht speichern
|
// 💾 Nachricht speichern
|
||||||
await server.db
|
const { error: insertErr } = await server.supabase
|
||||||
.insert(helpdesk_messages)
|
.from("helpdesk_messages")
|
||||||
.values({
|
.insert({
|
||||||
tenantId: conv.conversation.tenantId,
|
tenant_id: conv.tenant_id,
|
||||||
conversationId: conversationId,
|
conversation_id: conversationId,
|
||||||
direction: "outgoing",
|
direction: "outgoing",
|
||||||
payload: { type: "text", text },
|
payload: { type: "text", text },
|
||||||
externalMessageId: info.messageId,
|
external_message_id: info.messageId,
|
||||||
receivedAt: new Date(),
|
received_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (insertErr) throw insertErr
|
||||||
|
|
||||||
// 🔁 Konversation aktualisieren
|
// 🔁 Konversation aktualisieren
|
||||||
await server.db
|
await server.supabase
|
||||||
.update(helpdesk_conversations)
|
.from("helpdesk_conversations")
|
||||||
.set({ lastMessageAt: new Date() })
|
.update({ last_message_at: new Date().toISOString() })
|
||||||
.where(eq(helpdesk_conversations.id, conversationId))
|
.eq("id", conversationId)
|
||||||
|
|
||||||
reply.send({
|
reply.send({
|
||||||
message: "E-Mail erfolgreich gesendet",
|
message: "E-Mail erfolgreich gesendet",
|
||||||
|
|||||||
@@ -1,34 +1,12 @@
|
|||||||
// src/routes/resources/history.ts
|
// src/routes/resources/history.ts
|
||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
|
||||||
import { authProfiles, historyitems } from "../../db/schema";
|
|
||||||
|
|
||||||
const columnMap: Record<string, any> = {
|
const columnMap: Record<string, string> = {
|
||||||
customers: historyitems.customer,
|
|
||||||
vendors: historyitems.vendor,
|
|
||||||
projects: historyitems.project,
|
|
||||||
plants: historyitems.plant,
|
|
||||||
contacts: historyitems.contact,
|
|
||||||
tasks: historyitems.task,
|
|
||||||
vehicles: historyitems.vehicle,
|
|
||||||
events: historyitems.event,
|
|
||||||
files: historyitems.file,
|
|
||||||
products: historyitems.product,
|
|
||||||
inventoryitems: historyitems.inventoryitem,
|
|
||||||
inventoryitemgroups: historyitems.inventoryitemgroup,
|
|
||||||
checks: historyitems.check,
|
|
||||||
costcentres: historyitems.costcentre,
|
|
||||||
ownaccounts: historyitems.ownaccount,
|
|
||||||
documentboxes: historyitems.documentbox,
|
|
||||||
hourrates: historyitems.hourrate,
|
|
||||||
services: historyitems.service,
|
|
||||||
};
|
|
||||||
|
|
||||||
const insertFieldMap: Record<string, string> = {
|
|
||||||
customers: "customer",
|
customers: "customer",
|
||||||
vendors: "vendor",
|
vendors: "vendor",
|
||||||
projects: "project",
|
projects: "project",
|
||||||
plants: "plant",
|
plants: "plant",
|
||||||
|
contracts: "contract",
|
||||||
contacts: "contact",
|
contacts: "contact",
|
||||||
tasks: "task",
|
tasks: "task",
|
||||||
vehicles: "vehicle",
|
vehicles: "vehicle",
|
||||||
@@ -37,18 +15,15 @@ const insertFieldMap: Record<string, string> = {
|
|||||||
products: "product",
|
products: "product",
|
||||||
inventoryitems: "inventoryitem",
|
inventoryitems: "inventoryitem",
|
||||||
inventoryitemgroups: "inventoryitemgroup",
|
inventoryitemgroups: "inventoryitemgroup",
|
||||||
|
absencerequests: "absencerequest",
|
||||||
checks: "check",
|
checks: "check",
|
||||||
costcentres: "costcentre",
|
costcentres: "costcentre",
|
||||||
ownaccounts: "ownaccount",
|
ownaccounts: "ownaccount",
|
||||||
documentboxes: "documentbox",
|
documentboxes: "documentbox",
|
||||||
hourrates: "hourrate",
|
hourrates: "hourrate",
|
||||||
services: "service",
|
services: "service",
|
||||||
}
|
roles: "role",
|
||||||
|
};
|
||||||
const parseId = (value: string) => {
|
|
||||||
if (/^\d+$/.test(value)) return Number(value)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||||
server.get<{
|
server.get<{
|
||||||
@@ -74,36 +49,29 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(400).send({ error: `History not supported for resource '${resource}'` });
|
return reply.code(400).send({ error: `History not supported for resource '${resource}'` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await server.db
|
const { data, error } = await server.supabase
|
||||||
.select()
|
.from("historyitems")
|
||||||
.from(historyitems)
|
.select("*")
|
||||||
.where(eq(column, parseId(id)))
|
.eq(column, id)
|
||||||
.orderBy(asc(historyitems.createdAt));
|
.order("created_at", { ascending: true });
|
||||||
|
|
||||||
const userIds = Array.from(
|
if (error) {
|
||||||
new Set(data.map((item) => item.createdBy).filter(Boolean))
|
server.log.error(error);
|
||||||
) as string[]
|
return reply.code(500).send({ error: "Failed to fetch history" });
|
||||||
|
}
|
||||||
|
|
||||||
const profiles = userIds.length > 0
|
const {data:users, error:usersError} = await server.supabase
|
||||||
? await server.db
|
.from("auth_users")
|
||||||
.select()
|
.select("*, auth_profiles(*), tenants!auth_tenant_users(*)")
|
||||||
.from(authProfiles)
|
|
||||||
.where(and(
|
|
||||||
eq(authProfiles.tenant_id, req.user?.tenant_id),
|
|
||||||
inArray(authProfiles.user_id, userIds)
|
|
||||||
))
|
|
||||||
: []
|
|
||||||
|
|
||||||
const profileByUserId = new Map(
|
const filteredUsers = (users ||[]).filter(i => i.tenants.find((t:any) => t.id === req.user?.tenant_id))
|
||||||
profiles.map((profile) => [profile.user_id, profile])
|
|
||||||
)
|
|
||||||
|
|
||||||
const dataCombined = data.map((historyitem) => ({
|
const dataCombined = data.map(historyitem => {
|
||||||
...historyitem,
|
return {
|
||||||
created_at: historyitem.createdAt,
|
...historyitem,
|
||||||
created_by: historyitem.createdBy,
|
created_by_profile: filteredUsers.find(i => i.id === historyitem.created_by) ? filteredUsers.find(i => i.id === historyitem.created_by).auth_profiles[0] : null
|
||||||
created_by_profile: historyitem.createdBy ? profileByUserId.get(historyitem.createdBy) || null : null,
|
}
|
||||||
}))
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -160,33 +128,29 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
|||||||
const userId = (req.user as any)?.user_id;
|
const userId = (req.user as any)?.user_id;
|
||||||
|
|
||||||
|
|
||||||
const fkField = insertFieldMap[resource];
|
const fkField = columnMap[resource];
|
||||||
if (!fkField) {
|
if (!fkField) {
|
||||||
return reply.code(400).send({ error: `Unknown resource: ${resource}` });
|
return reply.code(400).send({ error: `Unknown resource: ${resource}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const inserted = await server.db
|
const { data, error } = await server.supabase
|
||||||
.insert(historyitems)
|
.from("historyitems")
|
||||||
.values({
|
.insert({
|
||||||
text,
|
text,
|
||||||
[fkField]: parseId(id),
|
[fkField]: id,
|
||||||
oldVal: old_val || null,
|
oldVal: old_val || null,
|
||||||
newVal: new_val || null,
|
newVal: new_val || null,
|
||||||
config: config || null,
|
config: config || null,
|
||||||
tenant: (req.user as any)?.tenant_id,
|
tenant: (req.user as any)?.tenant_id,
|
||||||
createdBy: userId
|
created_by: userId
|
||||||
})
|
})
|
||||||
.returning()
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
const data = inserted[0]
|
if (error) {
|
||||||
if (!data) {
|
return reply.code(500).send({ error: error.message });
|
||||||
return reply.code(500).send({ error: "Failed to create history entry" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.code(201).send({
|
return reply.code(201).send(data);
|
||||||
...data,
|
|
||||||
created_at: data.createdAt,
|
|
||||||
created_by: data.createdBy
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
// routes/notifications.routes.ts
|
// routes/notifications.routes.ts
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { NotificationService, UserDirectory } from '../modules/notification.service';
|
import { NotificationService, UserDirectory } from '../modules/notification.service';
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { authUsers } from "../../db/schema";
|
|
||||||
|
|
||||||
// Beispiel: E-Mail aus eigener User-Tabelle laden
|
// Beispiel: E-Mail aus eigener User-Tabelle laden
|
||||||
const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => {
|
const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => {
|
||||||
const rows = await server.db
|
const { data, error } = await server.supabase
|
||||||
.select({ email: authUsers.email })
|
.from('auth_users')
|
||||||
.from(authUsers)
|
.select('email')
|
||||||
.where(eq(authUsers.id, userId))
|
.eq('id', userId)
|
||||||
.limit(1)
|
.maybeSingle();
|
||||||
const data = rows[0]
|
if (error || !data) return null;
|
||||||
if (!data) return null;
|
|
||||||
return { email: data.email };
|
return { email: data.email };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function notificationsRoutes(server: FastifyInstance) {
|
export default async function notificationsRoutes(server: FastifyInstance) {
|
||||||
|
// wichtig: server.supabase ist über app verfügbar
|
||||||
|
|
||||||
const svc = new NotificationService(server, getUserDirectory);
|
const svc = new NotificationService(server, getUserDirectory);
|
||||||
|
|
||||||
server.post('/notifications/trigger', async (req, reply) => {
|
server.post('/notifications/trigger', async (req, reply) => {
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||||
import { publicLinkService } from '../../modules/publiclinks.service';
|
import { publicLinkService } from '../../modules/publiclinks.service';
|
||||||
import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs
|
|
||||||
|
|
||||||
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
|
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
|
||||||
server.get("/workflows/context/:token", async (req, reply) => {
|
server.get("/workflows/context/:token", async (req, reply) => {
|
||||||
const { token } = req.params as { token: string };
|
const { token } = req.params as { token: string };
|
||||||
|
|
||||||
|
// Wir lesen die PIN aus dem Header (Best Practice für Security)
|
||||||
const pin = req.headers['x-public-pin'] as string | undefined;
|
const pin = req.headers['x-public-pin'] as string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await publicLinkService.getLinkContext(server, token, pin);
|
const context = await publicLinkService.getLinkContext(server, token, pin);
|
||||||
|
|
||||||
return reply.send(context);
|
return reply.send(context);
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message === "Link_NotFound") return reply.code(404).send({ error: "Link nicht gefunden" });
|
// Spezifische Fehlercodes für das Frontend
|
||||||
if (error.message === "Pin_Required") return reply.code(401).send({ error: "PIN erforderlich", requirePin: true });
|
if (error.message === "Link_NotFound") {
|
||||||
if (error.message === "Pin_Invalid") return reply.code(403).send({ error: "PIN falsch", requirePin: true });
|
return reply.code(404).send({ error: "Link nicht gefunden oder abgelaufen" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === "Pin_Required") {
|
||||||
|
return reply.code(401).send({
|
||||||
|
error: "PIN erforderlich",
|
||||||
|
code: "PIN_REQUIRED",
|
||||||
|
requirePin: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === "Pin_Invalid") {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: "PIN falsch",
|
||||||
|
code: "PIN_INVALID",
|
||||||
|
requirePin: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
server.log.error(error);
|
server.log.error(error);
|
||||||
return reply.code(500).send({ error: "Interner Server Fehler" });
|
return reply.code(500).send({ error: "Interner Server Fehler" });
|
||||||
@@ -22,31 +43,49 @@ export default async function publiclinksNonAuthenticatedRoutes(server: FastifyI
|
|||||||
|
|
||||||
server.post("/workflows/submit/:token", async (req, reply) => {
|
server.post("/workflows/submit/:token", async (req, reply) => {
|
||||||
const { token } = req.params as { token: string };
|
const { token } = req.params as { token: string };
|
||||||
|
// PIN sicher aus dem Header lesen
|
||||||
const pin = req.headers['x-public-pin'] as string | undefined;
|
const pin = req.headers['x-public-pin'] as string | undefined;
|
||||||
const body = req.body as any;
|
// Der Body enthält { profile, project, service, ... }
|
||||||
|
const payload = req.body;
|
||||||
|
|
||||||
|
console.log(payload)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const quantity = parseFloat(body.quantity) || 0;
|
// Service aufrufen (führt die 3 Schritte aus: Lieferschein -> Zeit -> History)
|
||||||
|
|
||||||
// Wir nutzen das vom User gewählte deliveryDate
|
|
||||||
// Falls kein Datum geschickt wurde, Fallback auf Heute
|
|
||||||
const baseDate = body.deliveryDate ? dayjs(body.deliveryDate) : dayjs();
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
...body,
|
|
||||||
// Wir mappen das deliveryDate auf die Zeitstempel
|
|
||||||
// Start ist z.B. 08:00 Uhr am gewählten Tag, Ende ist Start + Menge
|
|
||||||
startDate: baseDate.hour(8).minute(0).toDate(),
|
|
||||||
endDate: baseDate.hour(8).add(quantity, 'hour').toDate(),
|
|
||||||
deliveryDate: baseDate.format('YYYY-MM-DD')
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await publicLinkService.submitFormData(server, token, payload, pin);
|
const result = await publicLinkService.submitFormData(server, token, payload, pin);
|
||||||
|
|
||||||
|
// 201 Created zurückgeben
|
||||||
return reply.code(201).send(result);
|
return reply.code(201).send(result);
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
server.log.error(error);
|
console.log(error);
|
||||||
return reply.code(500).send({ error: "Fehler beim Speichern", details: error.message });
|
|
||||||
|
// Fehler-Mapping für saubere HTTP Codes
|
||||||
|
if (error.message === "Link_NotFound") {
|
||||||
|
return reply.code(404).send({ error: "Link ungültig oder nicht aktiv" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === "Pin_Required") {
|
||||||
|
return reply.code(401).send({ error: "PIN erforderlich" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === "Pin_Invalid") {
|
||||||
|
return reply.code(403).send({ error: "PIN ist falsch" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === "Profile_Missing") {
|
||||||
|
return reply.code(400).send({ error: "Kein Mitarbeiter-Profil gefunden (weder im Link noch in der Eingabe)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === "Project not found" || error.message === "Service not found") {
|
||||||
|
return reply.code(400).send({ error: "Ausgewähltes Projekt oder Leistung existiert nicht mehr." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback für alle anderen Fehler (z.B. DB Constraints)
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: "Interner Fehler beim Speichern",
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,24 +10,30 @@ import {
|
|||||||
or
|
or
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
|
||||||
import { resourceConfig } from "../../utils/resource.config";
|
|
||||||
import { useNextNumberRangeNumber } from "../../utils/functions";
|
|
||||||
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
|
import {resourceConfig} from "../../utils/resource.config";
|
||||||
|
import {useNextNumberRangeNumber} from "../../utils/functions";
|
||||||
|
import {stafftimeentries} from "../../../db/schema";
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
|
// SQL Volltextsuche auf mehreren Feldern
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,86 +54,96 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
asc?: string
|
asc?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const { resource } = req.params as { resource: string }
|
const {resource} = req.params as {resource: string}
|
||||||
const config = resourceConfig[resource]
|
const table = resourceConfig[resource].table
|
||||||
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])
|
// 🔍 SQL Search
|
||||||
|
if(search) {
|
||||||
|
const searchCond = buildSearchCondition(
|
||||||
|
table,
|
||||||
|
resourceConfig[resource].searchColumns,
|
||||||
|
search.trim()
|
||||||
|
)
|
||||||
|
|
||||||
if (config.mtoLoad) {
|
if (searchCond) {
|
||||||
config.mtoLoad.forEach(rel => {
|
whereCond = and(whereCond, searchCond)
|
||||||
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])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
// Base Query
|
||||||
const searchCond = buildSearchCondition(searchCols, search.trim())
|
let q = server.db.select().from(table).where(whereCond)
|
||||||
if (searchCond) whereCond = and(whereCond, searchCond)
|
|
||||||
}
|
|
||||||
|
|
||||||
q = q.where(whereCond)
|
|
||||||
|
|
||||||
|
// Sortierung
|
||||||
if (sort) {
|
if (sort) {
|
||||||
const col = (table as any)[sort]
|
const col = (table as any)[sort]
|
||||||
if (col) {
|
if (col) {
|
||||||
q = ascQuery === "true" ? q.orderBy(asc(col)) : q.orderBy(desc(col))
|
//@ts-ignore
|
||||||
|
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
|
// RELATION LOADING (MANY-TO-ONE)
|
||||||
let data = [...rows]
|
|
||||||
if(config.mtoLoad) {
|
let ids = {}
|
||||||
let ids: any = {}
|
let lists = {}
|
||||||
let lists: any = {}
|
let maps = {}
|
||||||
let maps: any = {}
|
let data = [...queryData]
|
||||||
config.mtoLoad.forEach(rel => {
|
|
||||||
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
if(resourceConfig[resource].mtoLoad) {
|
||||||
|
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||||
|
ids[relation] = [...new Set(queryData.map(r => r[relation]).filter(Boolean))];
|
||||||
})
|
})
|
||||||
for await (const rel of config.mtoLoad) {
|
|
||||||
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
|
for await (const relation of resourceConfig[resource].mtoLoad ) {
|
||||||
const relTab = relConf.table
|
console.log(relation)
|
||||||
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []
|
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
|
||||||
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
|
|
||||||
}
|
}
|
||||||
data = rows.map(row => {
|
|
||||||
let toReturn = { ...row }
|
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||||
config.mtoLoad.forEach(rel => {
|
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
|
||||||
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(config.mtmListLoad) {
|
if(resourceConfig[resource].mtmListLoad) {
|
||||||
for await (const relation of config.mtmListLoad) {
|
for await (const relation of resourceConfig[resource].mtmListLoad) {
|
||||||
const relTable = resourceConfig[relation].table
|
console.log(relation)
|
||||||
const parentKey = resource.substring(0, resource.length - 1)
|
console.log(resource.substring(0,resource.length-1))
|
||||||
const relationRows = await server.db.select().from(relTable).where(inArray(relTable[parentKey], data.map(i => i.id)))
|
|
||||||
data = data.map(row => ({
|
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)))
|
||||||
...row,
|
|
||||||
[relation]: relationRows.filter(i => i[parentKey] === row.id)
|
console.log(relationRows.length)
|
||||||
}))
|
|
||||||
|
data = data.map(row => {
|
||||||
|
let toReturn = {
|
||||||
|
...row
|
||||||
|
}
|
||||||
|
|
||||||
|
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
|
||||||
|
|
||||||
|
return toReturn
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,130 +155,212 @@ 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) return reply.code(400).send({ error: "No tenant selected" });
|
if (!tenantId) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" });
|
||||||
const { resource } = req.params as { resource: string };
|
|
||||||
const config = resourceConfig[resource];
|
|
||||||
const table = config.table;
|
|
||||||
|
|
||||||
const { queryConfig } = req;
|
|
||||||
const { pagination, sort, filters } = queryConfig;
|
|
||||||
const { search, distinctColumns } = req.query as { search?: string; distinctColumns?: string; };
|
|
||||||
|
|
||||||
let whereCond: any = eq(table.tenant, tenantId);
|
|
||||||
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
|
|
||||||
|
|
||||||
let countQuery = server.db.select({ value: count(table.id) }).from(table).$dynamic();
|
|
||||||
let mainQuery = server.db.select().from(table).$dynamic();
|
|
||||||
|
|
||||||
if (config.mtoLoad) {
|
|
||||||
config.mtoLoad.forEach(rel => {
|
|
||||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
|
|
||||||
if (relConfig) {
|
|
||||||
const relTable = relConfig.table;
|
|
||||||
|
|
||||||
// FIX: Self-Reference Check
|
|
||||||
if (relTable !== table) {
|
|
||||||
countQuery = countQuery.leftJoin(relTable, eq(table[rel], relTable.id));
|
|
||||||
// @ts-ignore
|
|
||||||
mainQuery = mainQuery.leftJoin(relTable, eq(table[rel], relTable.id));
|
|
||||||
if (relConfig.searchColumns) {
|
|
||||||
relConfig.searchColumns.forEach(c => {
|
|
||||||
if (relTable[c]) searchCols.push(relTable[c]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
const {resource} = req.params as {resource: string};
|
||||||
const searchCond = buildSearchCondition(searchCols, search.trim());
|
|
||||||
if (searchCond) whereCond = and(whereCond, searchCond);
|
const {queryConfig} = req;
|
||||||
|
const {
|
||||||
|
pagination,
|
||||||
|
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);
|
||||||
|
|
||||||
|
|
||||||
|
if(search) {
|
||||||
|
const searchCond = buildSearchCondition(
|
||||||
|
table,
|
||||||
|
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;
|
||||||
|
|
||||||
mainQuery = mainQuery.where(whereCond).offset(offset).limit(limit);
|
// SORTING
|
||||||
|
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) {
|
||||||
mainQuery = s.direction === "asc" ? mainQuery.orderBy(asc(col)) : mainQuery.orderBy(desc(col));
|
orderField = col;
|
||||||
|
direction = s.direction === "asc" ? "asc" : "desc";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawRows = await mainQuery;
|
// MAIN QUERY (Paginated)
|
||||||
// Transformation für Drizzle Joins
|
let q = server.db
|
||||||
let rows = rawRows.map(r => r[resource] || r.table || r);
|
.select()
|
||||||
|
.from(table)
|
||||||
|
.where(whereCond)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
const distinctValues: Record<string, any[]> = {};
|
if (orderField) {
|
||||||
if (distinctColumns) {
|
//@ts-ignore
|
||||||
for (const colName of distinctColumns.split(",").map(c => c.trim())) {
|
q = direction === "asc"
|
||||||
const col = (table as any)[colName];
|
? q.orderBy(asc(orderField))
|
||||||
if (!col) continue;
|
: q.orderBy(desc(orderField));
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = [...rows];
|
const rows = await q;
|
||||||
if (config.mtoLoad) {
|
|
||||||
let ids: any = {};
|
if (!rows.length) {
|
||||||
let lists: any = {};
|
return {
|
||||||
let maps: any = {};
|
data: [],
|
||||||
config.mtoLoad.forEach(rel => {
|
queryConfig: {
|
||||||
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
...queryConfig,
|
||||||
});
|
total,
|
||||||
for await (const rel of config.mtoLoad) {
|
totalPages: 0,
|
||||||
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
|
distinctValues
|
||||||
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 = { ...row };
|
let toReturn = {
|
||||||
config.mtoLoad.forEach(rel => {
|
...row
|
||||||
toReturn[rel] = row[rel] ? maps[rel][row[rel]] : null;
|
}
|
||||||
});
|
|
||||||
return toReturn;
|
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||||
|
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
return toReturn
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.mtmListLoad) {
|
if(resourceConfig[resource].mtmListLoad) {
|
||||||
for await (const relation of config.mtmListLoad) {
|
for await (const relation of resourceConfig[resource].mtmListLoad) {
|
||||||
const relTable = resourceConfig[relation].table;
|
console.log(relation)
|
||||||
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)
|
||||||
[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, total, totalPages: Math.ceil(total / limit), distinctValues }
|
queryConfig: {
|
||||||
|
...queryConfig,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
distinctValues
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -271,8 +369,9 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// DETAIL
|
// DETAIL (mit JOINS)
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
server.get("/resource/:resource/:id/:no_relations?", async (req, reply) => {
|
server.get("/resource/:resource/:id/:no_relations?", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
@@ -280,7 +379,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
const tenantId = req.user?.tenant_id
|
const tenantId = req.user?.tenant_id
|
||||||
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
|
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
|
||||||
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
|
const {resource, no_relations} = req.params as { resource: string, no_relations?: boolean }
|
||||||
const table = resourceConfig[resource].table
|
const table = resourceConfig[resource].table
|
||||||
|
|
||||||
const projRows = await server.db
|
const projRows = await server.db
|
||||||
@@ -292,32 +391,40 @@ 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
|
||||||
|
// ------------------------------------
|
||||||
|
|
||||||
if (!no_relations) {
|
let ids = {}
|
||||||
if (resourceConfig[resource].mtoLoad) {
|
let lists = {}
|
||||||
for await (const relation of resourceConfig[resource].mtoLoad) {
|
let maps = {}
|
||||||
if (data[relation]) {
|
let data = {
|
||||||
const relConf = resourceConfig[relation + "s"] || resourceConfig[relation];
|
...projRows[0]
|
||||||
const relTable = relConf.table
|
}
|
||||||
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
|
|
||||||
data[relation] = relData[0] || null
|
if(!no_relations) {
|
||||||
|
if(resourceConfig[resource].mtoLoad) {
|
||||||
|
for await (const relation of resourceConfig[resource].mtoLoad ) {
|
||||||
|
if(data[relation]) {
|
||||||
|
data[relation] = (await server.db.select().from(resourceConfig[relation + "s"].table).where(eq(resourceConfig[relation + "s"].table.id, data[relation])))[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceConfig[resource].mtmLoad) {
|
if(resourceConfig[resource].mtmLoad) {
|
||||||
for await (const relation of resourceConfig[resource].mtmLoad) {
|
for await (const relation of resourceConfig[resource].mtmLoad ) {
|
||||||
const relTable = resourceConfig[relation].table
|
console.log(relation)
|
||||||
const parentKey = resource.substring(0, resource.length - 1)
|
data[relation] = await server.db.select().from(resourceConfig[relation].table).where(eq(resourceConfig[relation].table[resource.substring(0,resource.length - 1)],id))
|
||||||
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/:resource/:id", err)
|
console.error("ERROR /resource/projects/:id", err)
|
||||||
return reply.code(500).send({ error: "Internal Server Error" })
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -325,69 +432,132 @@ 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) return reply.code(400).send({ error: "No tenant selected" });
|
if (!req.user?.tenant_id) {
|
||||||
const { resource } = req.params as { resource: string };
|
return reply.code(400).send({error: "No tenant selected"});
|
||||||
const body = req.body as Record<string, any>;
|
}
|
||||||
const config = resourceConfig[resource];
|
|
||||||
const table = config.table;
|
const {resource} = req.params as { resource: string };
|
||||||
|
const body = req.body as Record<string, any>;
|
||||||
let createData = { ...body, tenant: req.user.tenant_id, archived: false };
|
|
||||||
|
const table = resourceConfig[resource].table
|
||||||
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
|
||||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, resource)
|
let createData = {
|
||||||
createData[config.numberRangeHolder] = result.usedNumber
|
...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)
|
||||||
|
console.log(result)
|
||||||
|
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.insert(table).values(createData).returning()
|
const [created] = await server.db
|
||||||
|
.insert(table)
|
||||||
|
.values(createData)
|
||||||
|
.returning()
|
||||||
|
|
||||||
if (["products", "services", "hourrates"].includes(resource)) {
|
|
||||||
await recalculateServicePricesForTenant(server, req.user.tenant_id, req.user?.user_id || null);
|
/*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.error(error);
|
console.log(error)
|
||||||
reply.status(500);
|
reply.status(500)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update
|
// UPDATE (inkl. Soft-Delete/Archive)
|
||||||
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
|
|
||||||
|
|
||||||
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
|
const tenantId = (req.user as any)?.tenant_id
|
||||||
|
const userId = (req.user as any)?.user_id
|
||||||
|
|
||||||
|
if (!tenantId || !userId) {
|
||||||
|
return reply.code(401).send({error: "Unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
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; }
|
|
||||||
|
|
||||||
let data = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
|
//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}
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
delete data.updatedBy; delete data.updatedAt;
|
delete data.updatedBy
|
||||||
|
//@ts-ignore
|
||||||
|
delete data.updatedAt
|
||||||
|
|
||||||
|
console.log(data)
|
||||||
|
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if ((key.includes("_at") || key.includes("At") || key.toLowerCase().includes("date")) && key !== "deliveryDateType") {
|
console.log(key)
|
||||||
|
|
||||||
|
if(key.includes("_at") || key.includes("At") || key.toLowerCase().includes("date")) {
|
||||||
data[key] = normalizeDate(data[key])
|
data[key] = normalizeDate(data[key])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const [updated] = await server.db.update(table).set(data).where(and(eq(table.id, id), eq(table.tenant, tenantId))).returning()
|
console.log(data)
|
||||||
|
|
||||||
if (["products", "services", "hourrates"].includes(resource)) {
|
const [updated] = await server.db
|
||||||
await recalculateServicePricesForTenant(server, tenantId, userId);
|
.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.error(err)
|
console.log("ERROR /resource/projects/:id", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default async function resourceRoutesSpecial(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
// 📌 SELECT: select-string wird in dieser Route bewusst ignoriert
|
// 📌 SELECT: wir ignorieren select string (wie Supabase)
|
||||||
// Drizzle kann kein dynamisches Select aus String!
|
// Drizzle kann kein dynamisches Select aus String!
|
||||||
// Wir geben IMMER alle Spalten zurück → kompatibel zum Frontend
|
// Wir geben IMMER alle Spalten zurück → kompatibel zum Frontend
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
|
|||||||
eventtype: "invalidated",
|
eventtype: "invalidated",
|
||||||
source: "WEB",
|
source: "WEB",
|
||||||
related_event_id: id,
|
related_event_id: id,
|
||||||
invalidates_event_id: id,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
reason: reason || "Bearbeitung",
|
reason: reason || "Bearbeitung",
|
||||||
replaced_by_edit: true
|
replaced_by_edit: true
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { FastifyInstance } from 'fastify'
|
import { FastifyInstance } from 'fastify'
|
||||||
import { StaffTimeEntryConnect } from '../../types/staff'
|
import { StaffTimeEntryConnect } from '../../types/staff'
|
||||||
import { asc, eq } from "drizzle-orm";
|
|
||||||
import { stafftimenetryconnects } from "../../../db/schema";
|
|
||||||
|
|
||||||
export default async function staffTimeConnectRoutes(server: FastifyInstance) {
|
export default async function staffTimeConnectRoutes(server: FastifyInstance) {
|
||||||
|
|
||||||
@@ -10,21 +8,16 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
|
|||||||
'/staff/time/:id/connects',
|
'/staff/time/:id/connects',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
const { started_at, stopped_at, project_id, notes } = req.body
|
const { started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes } = req.body
|
||||||
const parsedProjectId = project_id ? Number(project_id) : null
|
|
||||||
|
|
||||||
const data = await server.db
|
const { data, error } = await server.supabase
|
||||||
.insert(stafftimenetryconnects)
|
.from('staff_time_entry_connects')
|
||||||
.values({
|
.insert([{ time_entry_id: id, started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes }])
|
||||||
stafftimeentry: id,
|
.select()
|
||||||
started_at: new Date(started_at),
|
.maybeSingle()
|
||||||
stopped_at: new Date(stopped_at),
|
|
||||||
project_id: parsedProjectId,
|
|
||||||
notes
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
return reply.send(data[0])
|
if (error) return reply.code(400).send({ error: error.message })
|
||||||
|
return reply.send(data)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,12 +26,13 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
|
|||||||
'/staff/time/:id/connects',
|
'/staff/time/:id/connects',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
const data = await server.db
|
const { data, error } = await server.supabase
|
||||||
.select()
|
.from('staff_time_entry_connects')
|
||||||
.from(stafftimenetryconnects)
|
.select('*')
|
||||||
.where(eq(stafftimenetryconnects.stafftimeentry, id))
|
.eq('time_entry_id', id)
|
||||||
.orderBy(asc(stafftimenetryconnects.started_at))
|
.order('started_at', { ascending: true })
|
||||||
|
|
||||||
|
if (error) return reply.code(400).send({ error: error.message })
|
||||||
return reply.send(data)
|
return reply.send(data)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -48,20 +42,15 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
|
|||||||
'/staff/time/connects/:connectId',
|
'/staff/time/connects/:connectId',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { connectId } = req.params
|
const { connectId } = req.params
|
||||||
const patchData = { ...req.body } as any
|
const { data, error } = await server.supabase
|
||||||
if (patchData.started_at) patchData.started_at = new Date(patchData.started_at)
|
.from('staff_time_entry_connects')
|
||||||
if (patchData.stopped_at) patchData.stopped_at = new Date(patchData.stopped_at)
|
.update({ ...req.body, updated_at: new Date().toISOString() })
|
||||||
if (patchData.project_id !== undefined) {
|
.eq('id', connectId)
|
||||||
patchData.project_id = patchData.project_id ? Number(patchData.project_id) : null
|
.select()
|
||||||
}
|
.maybeSingle()
|
||||||
|
|
||||||
const data = await server.db
|
if (error) return reply.code(400).send({ error: error.message })
|
||||||
.update(stafftimenetryconnects)
|
return reply.send(data)
|
||||||
.set({ ...patchData, updated_at: new Date() })
|
|
||||||
.where(eq(stafftimenetryconnects.id, connectId))
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
return reply.send(data[0])
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,10 +59,12 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
|
|||||||
'/staff/time/connects/:connectId',
|
'/staff/time/connects/:connectId',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { connectId } = req.params
|
const { connectId } = req.params
|
||||||
await server.db
|
const { error } = await server.supabase
|
||||||
.delete(stafftimenetryconnects)
|
.from('staff_time_entry_connects')
|
||||||
.where(eq(stafftimenetryconnects.id, connectId))
|
.delete()
|
||||||
|
.eq('id', connectId)
|
||||||
|
|
||||||
|
if (error) return reply.code(400).send({ error: error.message })
|
||||||
return reply.send({ success: true })
|
return reply.send({ success: true })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
|
||||||
import { and, eq, isNull, asc, inArray } from "drizzle-orm"
|
|
||||||
// WICHTIG: Hier müssen die Schemas der Entitäten importiert werden!
|
|
||||||
import {
|
|
||||||
wikiPages,
|
|
||||||
authUsers,
|
|
||||||
// Bereits vorhanden
|
|
||||||
customers,
|
|
||||||
projects,
|
|
||||||
plants,
|
|
||||||
products,
|
|
||||||
inventoryitems,
|
|
||||||
// NEU HINZUGEFÜGT (Basierend auf deinem DataStore)
|
|
||||||
tasks,
|
|
||||||
contacts,
|
|
||||||
contracts,
|
|
||||||
vehicles,
|
|
||||||
vendors,
|
|
||||||
spaces,
|
|
||||||
inventoryitemgroups,
|
|
||||||
services,
|
|
||||||
hourrates,
|
|
||||||
events,
|
|
||||||
productcategories,
|
|
||||||
servicecategories,
|
|
||||||
ownaccounts
|
|
||||||
} from "../../db/schema/"
|
|
||||||
|
|
||||||
// Konfiguration: Welche Entitäten sollen im Wiki auftauchen?
|
|
||||||
const ENTITY_CONFIG: Record<string, { table: any, labelField: any, rootLabel: string, idField: 'id' | 'uuid' }> = {
|
|
||||||
// --- BEREITS VORHANDEN ---
|
|
||||||
'customers': { table: customers, labelField: customers.name, rootLabel: 'Kunden', idField: 'id' },
|
|
||||||
'projects': { table: projects, labelField: projects.name, rootLabel: 'Projekte', idField: 'id' },
|
|
||||||
'plants': { table: plants, labelField: plants.name, rootLabel: 'Objekte', idField: 'id' },
|
|
||||||
'products': { table: products, labelField: products.name, rootLabel: 'Artikel', idField: 'id' },
|
|
||||||
'inventoryitems': { table: inventoryitems, labelField: inventoryitems.name, rootLabel: 'Inventarartikel', idField: 'id' },
|
|
||||||
|
|
||||||
// --- NEU BASIEREND AUF DATASTORE ---
|
|
||||||
'tasks': { table: tasks, labelField: tasks.name, rootLabel: 'Aufgaben', idField: 'id' },
|
|
||||||
'contacts': { table: contacts, labelField: contacts.fullName, rootLabel: 'Kontakte', idField: 'id' },
|
|
||||||
'contracts': { table: contracts, labelField: contracts.name, rootLabel: 'Verträge', idField: 'id' },
|
|
||||||
'vehicles': { table: vehicles, labelField: vehicles.license_plate, rootLabel: 'Fahrzeuge', idField: 'id' },
|
|
||||||
'vendors': { table: vendors, labelField: vendors.name, rootLabel: 'Lieferanten', idField: 'id' },
|
|
||||||
'spaces': { table: spaces, labelField: spaces.name, rootLabel: 'Lagerplätze', idField: 'id' },
|
|
||||||
'inventoryitemgroups': { table: inventoryitemgroups, labelField: inventoryitemgroups.name, rootLabel: 'Inventarartikelgruppen', idField: 'id' },
|
|
||||||
'services': { table: services, labelField: services.name, rootLabel: 'Leistungen', idField: 'id' },
|
|
||||||
'hourrates': { table: hourrates, labelField: hourrates.name, rootLabel: 'Stundensätze', idField: 'id' },
|
|
||||||
'events': { table: events, labelField: events.name, rootLabel: 'Termine', idField: 'id' },
|
|
||||||
'productcategories': { table: productcategories, labelField: productcategories.name, rootLabel: 'Artikelkategorien', idField: 'id' },
|
|
||||||
'servicecategories': { table: servicecategories, labelField: servicecategories.name, rootLabel: 'Leistungskategorien', idField: 'id' },
|
|
||||||
'ownaccounts': { table: ownaccounts, labelField: ownaccounts.name, rootLabel: 'Zusätzliche Buchungskonten', idField: 'id' },
|
|
||||||
}
|
|
||||||
|
|
||||||
// Types
|
|
||||||
interface WikiTreeQuery {
|
|
||||||
entityType?: string
|
|
||||||
entityId?: number
|
|
||||||
entityUuid?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WikiCreateBody {
|
|
||||||
title: string
|
|
||||||
parentId?: string
|
|
||||||
isFolder?: boolean
|
|
||||||
entityType?: string
|
|
||||||
entityId?: number
|
|
||||||
entityUuid?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WikiUpdateBody {
|
|
||||||
title?: string
|
|
||||||
content?: any
|
|
||||||
parentId?: string | null
|
|
||||||
sortOrder?: number
|
|
||||||
isFolder?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function wikiRoutes(server: FastifyInstance) {
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// 1. GET /wiki/tree
|
|
||||||
// Lädt Struktur: Entweder gefiltert (Widget) oder Global (mit virtuellen Ordnern)
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
server.get<{ Querystring: WikiTreeQuery }>("/wiki/tree", async (req, reply) => {
|
|
||||||
const user = req.user
|
|
||||||
const { entityType, entityId, entityUuid } = req.query
|
|
||||||
|
|
||||||
// FALL A: WIDGET-ANSICHT (Spezifische Entität)
|
|
||||||
// Wenn wir spezifisch filtern, wollen wir nur die echten Seiten ohne virtuelle Ordner
|
|
||||||
if (entityType && (entityId || entityUuid)) {
|
|
||||||
const filters = [
|
|
||||||
eq(wikiPages.tenantId, user.tenant_id),
|
|
||||||
eq(wikiPages.entityType, entityType)
|
|
||||||
]
|
|
||||||
|
|
||||||
if (entityId) filters.push(eq(wikiPages.entityId, Number(entityId)))
|
|
||||||
else if (entityUuid) filters.push(eq(wikiPages.entityUuid, entityUuid))
|
|
||||||
|
|
||||||
return server.db
|
|
||||||
.select({
|
|
||||||
id: wikiPages.id,
|
|
||||||
parentId: wikiPages.parentId,
|
|
||||||
title: wikiPages.title,
|
|
||||||
isFolder: wikiPages.isFolder,
|
|
||||||
sortOrder: wikiPages.sortOrder,
|
|
||||||
entityType: wikiPages.entityType,
|
|
||||||
updatedAt: wikiPages.updatedAt,
|
|
||||||
})
|
|
||||||
.from(wikiPages)
|
|
||||||
.where(and(...filters))
|
|
||||||
.orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title))
|
|
||||||
}
|
|
||||||
|
|
||||||
// FALL B: GLOBALE ANSICHT (Haupt-Wiki)
|
|
||||||
// Wir laden ALLES und bauen virtuelle Ordner für die Entitäten
|
|
||||||
|
|
||||||
// 1. Alle Wiki-Seiten des Tenants laden
|
|
||||||
const allPages = await server.db
|
|
||||||
.select({
|
|
||||||
id: wikiPages.id,
|
|
||||||
parentId: wikiPages.parentId,
|
|
||||||
title: wikiPages.title,
|
|
||||||
isFolder: wikiPages.isFolder,
|
|
||||||
sortOrder: wikiPages.sortOrder,
|
|
||||||
entityType: wikiPages.entityType,
|
|
||||||
entityId: wikiPages.entityId, // Wichtig für Zuordnung
|
|
||||||
entityUuid: wikiPages.entityUuid, // Wichtig für Zuordnung
|
|
||||||
updatedAt: wikiPages.updatedAt,
|
|
||||||
})
|
|
||||||
.from(wikiPages)
|
|
||||||
.where(eq(wikiPages.tenantId, user.tenant_id))
|
|
||||||
.orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title))
|
|
||||||
|
|
||||||
// Trennen in Standard-Seiten und Entity-Seiten
|
|
||||||
const standardPages = allPages.filter(p => !p.entityType)
|
|
||||||
const entityPages = allPages.filter(p => p.entityType)
|
|
||||||
|
|
||||||
const virtualNodes: any[] = []
|
|
||||||
|
|
||||||
// 2. Virtuelle Ordner generieren
|
|
||||||
// Wir iterieren durch unsere Config (Kunden, Projekte...)
|
|
||||||
await Promise.all(Object.entries(ENTITY_CONFIG).map(async ([typeKey, config]) => {
|
|
||||||
|
|
||||||
// Haben wir überhaupt Notizen für diesen Typ?
|
|
||||||
const pagesForType = entityPages.filter(p => p.entityType === typeKey)
|
|
||||||
if (pagesForType.length === 0) return
|
|
||||||
|
|
||||||
// IDs sammeln, um Namen aus der DB zu holen
|
|
||||||
// Wir unterscheiden zwischen ID (int) und UUID
|
|
||||||
let entities: any[] = []
|
|
||||||
|
|
||||||
if (config.idField === 'id') {
|
|
||||||
const ids = [...new Set(pagesForType.map(p => p.entityId).filter((id): id is number => id !== null))]
|
|
||||||
if (ids.length > 0) {
|
|
||||||
//@ts-ignore - Drizzle Typisierung bei dynamischen Tables ist tricky
|
|
||||||
entities = await server.db.select({ id: config.table.id, label: config.labelField })
|
|
||||||
.from(config.table)
|
|
||||||
//@ts-ignore
|
|
||||||
.where(inArray(config.table.id, ids))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Falls UUID genutzt wird (z.B. IoT Devices)
|
|
||||||
const uuids = [...new Set(pagesForType.map(p => p.entityUuid).filter((uuid): uuid is string => uuid !== null))]
|
|
||||||
if (uuids.length > 0) {
|
|
||||||
//@ts-ignore
|
|
||||||
entities = await server.db.select({ id: config.table.id, label: config.labelField })
|
|
||||||
.from(config.table)
|
|
||||||
//@ts-ignore
|
|
||||||
.where(inArray(config.table.id, uuids))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entities.length === 0) return
|
|
||||||
|
|
||||||
// 3. Virtuellen Root Ordner erstellen (z.B. "Kunden")
|
|
||||||
const rootId = `virtual-root-${typeKey}`
|
|
||||||
virtualNodes.push({
|
|
||||||
id: rootId,
|
|
||||||
parentId: null, // Ganz oben im Baum
|
|
||||||
title: config.rootLabel,
|
|
||||||
isFolder: true,
|
|
||||||
isVirtual: true, // Flag fürs Frontend (read-only Folder)
|
|
||||||
sortOrder: 1000 // Ganz unten anzeigen
|
|
||||||
})
|
|
||||||
|
|
||||||
// 4. Virtuelle Entity Ordner erstellen (z.B. "Müller GmbH")
|
|
||||||
entities.forEach(entity => {
|
|
||||||
const entityNodeId = `virtual-entity-${typeKey}-${entity.id}`
|
|
||||||
|
|
||||||
virtualNodes.push({
|
|
||||||
id: entityNodeId,
|
|
||||||
parentId: rootId,
|
|
||||||
title: entity.label || 'Unbekannt',
|
|
||||||
isFolder: true,
|
|
||||||
isVirtual: true,
|
|
||||||
sortOrder: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5. Die echten Notizen verschieben
|
|
||||||
// Wir suchen alle Notizen, die zu dieser Entity gehören
|
|
||||||
const myPages = pagesForType.filter(p =>
|
|
||||||
(config.idField === 'id' && p.entityId === entity.id) ||
|
|
||||||
(config.idField === 'uuid' && p.entityUuid === entity.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
myPages.forEach(page => {
|
|
||||||
// Nur Root-Notizen der Entity verschieben.
|
|
||||||
// Sub-Pages bleiben wo sie sind (parentId zeigt ja schon auf die richtige Seite)
|
|
||||||
if (!page.parentId) {
|
|
||||||
// Wir modifizieren das Objekt für die Response (nicht in der DB!)
|
|
||||||
// Wir müssen es clonen, sonst ändern wir es für alle Referenzen
|
|
||||||
const pageClone = { ...page }
|
|
||||||
pageClone.parentId = entityNodeId
|
|
||||||
virtualNodes.push(pageClone)
|
|
||||||
} else {
|
|
||||||
// Sub-Pages einfach so hinzufügen
|
|
||||||
virtualNodes.push(page)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Ergebnis: Normale Seiten + Virtuelle Struktur
|
|
||||||
return [...standardPages, ...virtualNodes]
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// 2. GET /wiki/:id
|
|
||||||
// Lädt EINEN Eintrag komplett MIT Content
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
server.get<{ Params: { id: string } }>("/wiki/:id", async (req, reply) => {
|
|
||||||
const user = req.user
|
|
||||||
const { id } = req.params
|
|
||||||
|
|
||||||
const page = await server.db.query.wikiPages.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(wikiPages.id, id),
|
|
||||||
eq(wikiPages.tenantId, user.tenant_id)
|
|
||||||
),
|
|
||||||
with: {
|
|
||||||
author: {
|
|
||||||
columns: { id: true } // Name falls vorhanden
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!page) return reply.code(404).send({ error: "Page not found" })
|
|
||||||
return page
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// 3. POST /wiki
|
|
||||||
// Erstellt neuen Eintrag
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
server.post<{ Body: WikiCreateBody }>("/wiki", async (req, reply) => {
|
|
||||||
const user = req.user
|
|
||||||
const body = req.body
|
|
||||||
|
|
||||||
if (!body.title) return reply.code(400).send({ error: "Title required" })
|
|
||||||
|
|
||||||
const hasEntity = !!body.entityType
|
|
||||||
|
|
||||||
const [newPage] = await server.db
|
|
||||||
.insert(wikiPages)
|
|
||||||
.values({
|
|
||||||
tenantId: user.tenant_id,
|
|
||||||
title: body.title,
|
|
||||||
parentId: body.parentId || null,
|
|
||||||
isFolder: body.isFolder ?? false,
|
|
||||||
entityType: hasEntity ? body.entityType : null,
|
|
||||||
entityId: hasEntity && body.entityId ? body.entityId : null,
|
|
||||||
entityUuid: hasEntity && body.entityUuid ? body.entityUuid : null,
|
|
||||||
//@ts-ignore
|
|
||||||
createdBy: user.id,
|
|
||||||
//@ts-ignore
|
|
||||||
updatedBy: user.id
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
return newPage
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// 4. PATCH /wiki/:id
|
|
||||||
// Universal-Update
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
server.patch<{ Params: { id: string }; Body: WikiUpdateBody }>(
|
|
||||||
"/wiki/:id",
|
|
||||||
async (req, reply) => {
|
|
||||||
const user = req.user
|
|
||||||
const { id } = req.params
|
|
||||||
const body = req.body
|
|
||||||
|
|
||||||
const existing = await server.db.query.wikiPages.findFirst({
|
|
||||||
where: and(eq(wikiPages.id, id), eq(wikiPages.tenantId, user.tenant_id)),
|
|
||||||
columns: { id: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!existing) return reply.code(404).send({ error: "Not found" })
|
|
||||||
|
|
||||||
const [updatedPage] = await server.db
|
|
||||||
.update(wikiPages)
|
|
||||||
.set({
|
|
||||||
title: body.title,
|
|
||||||
content: body.content,
|
|
||||||
parentId: body.parentId,
|
|
||||||
sortOrder: body.sortOrder,
|
|
||||||
isFolder: body.isFolder,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
//@ts-ignore
|
|
||||||
updatedBy: user.id
|
|
||||||
})
|
|
||||||
.where(eq(wikiPages.id, id))
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
return updatedPage
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
// 5. DELETE /wiki/:id
|
|
||||||
// Löscht Eintrag
|
|
||||||
// ---------------------------------------------------------
|
|
||||||
server.delete<{ Params: { id: string } }>("/wiki/:id", async (req, reply) => {
|
|
||||||
const user = req.user
|
|
||||||
const { id } = req.params
|
|
||||||
|
|
||||||
const result = await server.db
|
|
||||||
.delete(wikiPages)
|
|
||||||
.where(and(
|
|
||||||
eq(wikiPages.id, id),
|
|
||||||
eq(wikiPages.tenantId, user.tenant_id)
|
|
||||||
))
|
|
||||||
.returning({ id: wikiPages.id })
|
|
||||||
|
|
||||||
if (result.length === 0) return reply.code(404).send({ error: "Not found" })
|
|
||||||
|
|
||||||
return { success: true, deletedId: result[0].id }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -301,7 +301,7 @@ export async function buildExportZip(
|
|||||||
else if(account.taxType === '7I') buschluessel = "18";
|
else if(account.taxType === '7I') buschluessel = "18";
|
||||||
else buschluessel = "-";
|
else buschluessel = "-";
|
||||||
|
|
||||||
let amountGross =/* account.amountGross ? account.amountGross : */(account.amountNet || 0) + (account.amountTax || 0);
|
let amountGross = account.amountGross ? account.amountGross : (account.amountNet || 0) + (account.amountTax || 0);
|
||||||
let shSelector = Math.sign(amountGross) === -1 ? "H" : "S";
|
let shSelector = Math.sign(amountGross) === -1 ? "H" : "S";
|
||||||
let text = `ER ${ii.reference}: ${escapeString(ii.description)}`.substring(0,59);
|
let text = `ER ${ii.reference}: ${escapeString(ii.description)}`.substring(0,59);
|
||||||
const vend = ii.vendor; // durch Mapping verfügbar
|
const vend = ii.vendor; // durch Mapping verfügbar
|
||||||
@@ -325,27 +325,27 @@ export async function buildExportZip(
|
|||||||
if(alloc.createddocument && alloc.createddocument.customer) {
|
if(alloc.createddocument && alloc.createddocument.customer) {
|
||||||
const cd = alloc.createddocument;
|
const cd = alloc.createddocument;
|
||||||
const cust = cd.customer;
|
const cust = cd.customer;
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"H";;;;;${cust?.customerNumber};${datevKonto};"3";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`ZE${alloc.description}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust?.name}";"Kundennummer";"${cust?.customerNumber}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"H";;;;;${cust?.customerNumber};${datevKonto};"3";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`ZE${alloc.description}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust?.name}";"Kundennummer";"${cust?.customerNumber}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(cd.documentDate).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
|
||||||
} else if(alloc.incominginvoice && alloc.incominginvoice.vendor) {
|
} else if(alloc.incominginvoice && alloc.incominginvoice.vendor) {
|
||||||
const ii = alloc.incominginvoice;
|
const ii = alloc.incominginvoice;
|
||||||
const vend = ii.vendor;
|
const vend = ii.vendor;
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${dayjs(ii.date).format("DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${dayjs(ii.date).format("DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
|
||||||
} else if(alloc.account) {
|
} else if(alloc.account) {
|
||||||
const acc = alloc.account;
|
const acc = alloc.account;
|
||||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${acc.number};"";${dateVal};"";;;"${`${vorzeichen} ${acc.number} - ${escapeString(acc.label)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${bs.credName || ''}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${acc.number};"";${dateVal};"";;;"${`${vorzeichen} ${acc.number} - ${escapeString(acc.label)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${bs.credName || ''}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
|
||||||
} else if(alloc.vendor) {
|
} else if(alloc.vendor) {
|
||||||
const vend = alloc.vendor;
|
const vend = alloc.vendor;
|
||||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend.vendorNumber};"";${dateVal};"";;;"${`${vorzeichen} ${vend.vendorNumber} - ${escapeString(vend.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend.vendorNumber};"";${dateVal};"";;;"${`${vorzeichen} ${vend.vendorNumber} - ${escapeString(vend.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
|
||||||
} else if(alloc.customer) {
|
} else if(alloc.customer) {
|
||||||
const cust = alloc.customer;
|
const cust = alloc.customer;
|
||||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${cust.customerNumber};"";${dateVal};"";;;"${`${vorzeichen} ${cust.customerNumber} - ${escapeString(cust.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${cust.customerNumber};"";${dateVal};"";;;"${`${vorzeichen} ${cust.customerNumber} - ${escapeString(cust.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
|
||||||
} else if(alloc.ownaccount) {
|
} else if(alloc.ownaccount) {
|
||||||
const own = alloc.ownaccount;
|
const own = alloc.ownaccount;
|
||||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${own.number};"";${dateVal};"";;;"${`${vorzeichen} ${own.number} - ${escapeString(own.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${own.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${own.number};"";${dateVal};"";;;"${`${vorzeichen} ${own.number} - ${escapeString(own.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${own.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,12 @@
|
|||||||
import xmlbuilder from "xmlbuilder";
|
import xmlbuilder from "xmlbuilder";
|
||||||
import {randomUUID} from "node:crypto";
|
import {randomUUID} from "node:crypto";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
|
||||||
import { createddocuments, tenants } from "../../../db/schema";
|
|
||||||
|
|
||||||
export const createSEPAExport = async (server,idsToExport, tenant_id) => {
|
export const createSEPAExport = async (server,idsToExport, tenant_id) => {
|
||||||
const data = await server.db
|
const {data,error} = await server.supabase.from("createddocuments").select().eq("tenant", tenant_id).in("id", idsToExport)
|
||||||
.select()
|
const {data:tenantData,error:tenantError} = await server.supabase.from("tenants").select().eq("id", tenant_id).single()
|
||||||
.from(createddocuments)
|
|
||||||
.where(and(
|
|
||||||
eq(createddocuments.tenant, tenant_id),
|
|
||||||
inArray(createddocuments.id, idsToExport)
|
|
||||||
))
|
|
||||||
|
|
||||||
const tenantRows = await server.db
|
|
||||||
.select()
|
|
||||||
.from(tenants)
|
|
||||||
.where(eq(tenants.id, tenant_id))
|
|
||||||
.limit(1)
|
|
||||||
const tenantData = tenantRows[0]
|
|
||||||
console.log(tenantData)
|
console.log(tenantData)
|
||||||
|
console.log(tenantError)
|
||||||
|
|
||||||
console.log(data)
|
console.log(data)
|
||||||
|
|
||||||
@@ -124,4 +111,4 @@ export const createSEPAExport = async (server,idsToExport, tenant_id) => {
|
|||||||
|
|
||||||
console.log(doc.end({pretty:true}))
|
console.log(doc.end({pretty:true}))
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -86,13 +86,12 @@ const InstructionFormat = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// MAIN FUNCTION
|
// MAIN FUNCTION – REPLACES SUPABASE VERSION
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
export const getInvoiceDataFromGPT = async function (
|
export const getInvoiceDataFromGPT = async function (
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
file: any,
|
file: any,
|
||||||
tenantId: number,
|
tenantId: number
|
||||||
learningContext?: string
|
|
||||||
) {
|
) {
|
||||||
await initOpenAi();
|
await initOpenAi();
|
||||||
|
|
||||||
@@ -189,13 +188,8 @@ export const getInvoiceDataFromGPT = async function (
|
|||||||
"You extract structured invoice data.\n\n" +
|
"You extract structured invoice data.\n\n" +
|
||||||
`VENDORS: ${JSON.stringify(vendorList)}\n` +
|
`VENDORS: ${JSON.stringify(vendorList)}\n` +
|
||||||
`ACCOUNTS: ${JSON.stringify(accountList)}\n\n` +
|
`ACCOUNTS: ${JSON.stringify(accountList)}\n\n` +
|
||||||
(learningContext
|
|
||||||
? `HISTORICAL_PATTERNS: ${learningContext}\n\n`
|
|
||||||
: "") +
|
|
||||||
"Match issuer by name to vendor.id.\n" +
|
"Match issuer by name to vendor.id.\n" +
|
||||||
"Match invoice items to account id based on label/number.\n" +
|
"Match invoice items to account id based on label/number.\n" +
|
||||||
"Use historical patterns as soft hints for vendor/account/payment mapping.\n" +
|
|
||||||
"Do not invent values when the invoice text contradicts the hints.\n" +
|
|
||||||
"Convert dates to YYYY-MM-DD.\n" +
|
"Convert dates to YYYY-MM-DD.\n" +
|
||||||
"Keep invoice items in original order.\n",
|
"Keep invoice items in original order.\n",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { historyitems } from "../../db/schema";
|
|
||||||
|
|
||||||
export async function insertHistoryItem(
|
export async function insertHistoryItem(
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
@@ -64,5 +63,8 @@ export async function insertHistoryItem(
|
|||||||
newVal: params.newVal ? JSON.stringify(params.newVal) : null
|
newVal: params.newVal ? JSON.stringify(params.newVal) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
await server.db.insert(historyitems).values(entry as any)
|
const { error } = await server.supabase.from("historyitems").insert([entry])
|
||||||
|
if (error) { // @ts-ignore
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import {PDFDocument, StandardFonts, rgb} from "pdf-lib"
|
|||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import {renderAsCurrency, splitStringBySpace} from "./stringRendering";
|
import {renderAsCurrency, splitStringBySpace} from "./stringRendering";
|
||||||
import {FastifyInstance} from "fastify";
|
import {FastifyInstance} from "fastify";
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
||||||
import { s3 } from "./s3";
|
|
||||||
import { secrets } from "./secrets";
|
|
||||||
|
|
||||||
const getCoordinatesForPDFLib = (x:number ,y:number, page:any) => {
|
const getCoordinatesForPDFLib = (x:number ,y:number, page:any) => {
|
||||||
/*
|
/*
|
||||||
@@ -28,21 +25,9 @@ const getCoordinatesForPDFLib = (x:number ,y:number, page:any) => {
|
|||||||
|
|
||||||
const getBackgroundSourceBuffer = async (server:FastifyInstance, path:string) => {
|
const getBackgroundSourceBuffer = async (server:FastifyInstance, path:string) => {
|
||||||
|
|
||||||
console.log(path)
|
const {data:backgroundPDFData,error:backgroundPDFError} = await server.supabase.storage.from("files").download(path)
|
||||||
|
|
||||||
const { Body } = await s3.send(
|
return backgroundPDFData.arrayBuffer()
|
||||||
new GetObjectCommand({
|
|
||||||
Bucket: secrets.S3_BUCKET,
|
|
||||||
Key: path
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const chunks: Buffer[] = []
|
|
||||||
for await (const chunk of Body as any) {
|
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
||||||
}
|
|
||||||
|
|
||||||
return Buffer.concat(chunks)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDuration = (time) => {
|
const getDuration = (time) => {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
|
|
||||||
export const resourceConfig = {
|
export const resourceConfig = {
|
||||||
projects: {
|
projects: {
|
||||||
searchColumns: ["name","customerRef","projectNumber","notes"],
|
searchColumns: ["name"],
|
||||||
mtoLoad: ["customer","plant","contract","projecttype"],
|
mtoLoad: ["customer","plant","contract","projecttype"],
|
||||||
mtmLoad: ["tasks", "files","createddocuments"],
|
mtmLoad: ["tasks", "files","createddocuments"],
|
||||||
table: projects,
|
table: projects,
|
||||||
@@ -61,7 +61,6 @@ export const resourceConfig = {
|
|||||||
},
|
},
|
||||||
plants: {
|
plants: {
|
||||||
table: plants,
|
table: plants,
|
||||||
searchColumns: ["name"],
|
|
||||||
mtoLoad: ["customer"],
|
mtoLoad: ["customer"],
|
||||||
mtmLoad: ["projects","tasks","files"],
|
mtmLoad: ["projects","tasks","files"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export let secrets = {
|
|||||||
PORT: number
|
PORT: number
|
||||||
HOST: string
|
HOST: string
|
||||||
DATABASE_URL: string
|
DATABASE_URL: string
|
||||||
|
SUPABASE_URL: string
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: string
|
||||||
S3_BUCKET: string
|
S3_BUCKET: string
|
||||||
ENCRYPTION_KEY: string
|
ENCRYPTION_KEY: string
|
||||||
MAILER_SMTP_HOST: string
|
MAILER_SMTP_HOST: string
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
ref="el"
|
|
||||||
:style="style"
|
|
||||||
class="fixed z-[999] w-72 bg-white dark:bg-gray-900 shadow-2xl rounded-xl border border-gray-200 dark:border-gray-800 p-4 select-none touch-none"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between mb-4 cursor-move border-b pb-2 dark:border-gray-800">
|
|
||||||
<div class="flex items-center gap-2 text-gray-500">
|
|
||||||
<UIcon name="i-heroicons-calculator" />
|
|
||||||
<span class="text-xs font-bold uppercase tracking-wider">Kalkulator</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<UTooltip text="Verlauf">
|
|
||||||
<UButton
|
|
||||||
color="gray"
|
|
||||||
variant="ghost"
|
|
||||||
:icon="showHistory ? 'i-heroicons-clock-solid' : 'i-heroicons-clock'"
|
|
||||||
size="xs"
|
|
||||||
@click="showHistory = !showHistory"
|
|
||||||
/>
|
|
||||||
</UTooltip>
|
|
||||||
<UTooltip text="Schließen (Esc)">
|
|
||||||
<UButton
|
|
||||||
color="gray"
|
|
||||||
variant="ghost"
|
|
||||||
icon="i-heroicons-x-mark"
|
|
||||||
size="xs"
|
|
||||||
@click="store.isOpen = false"
|
|
||||||
/>
|
|
||||||
</UTooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!showHistory">
|
|
||||||
<div
|
|
||||||
class="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg mb-4 text-right border border-gray-200 dark:border-gray-700 cursor-pointer group relative"
|
|
||||||
@click="copyDisplay"
|
|
||||||
>
|
|
||||||
<div class="text-[10px] text-gray-500 h-4 font-mono uppercase tracking-tighter">
|
|
||||||
Speicher: {{ Number(store.memory).toFixed(2).replace('.', ',') }} €
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-mono truncate tracking-tighter">{{ store.display }}</div>
|
|
||||||
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center bg-primary-500/10 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg">
|
|
||||||
<span class="text-[10px] font-bold text-primary-600 uppercase">
|
|
||||||
{{ copied ? 'Kopiert!' : 'Klicken zum Kopieren' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-4 gap-2">
|
|
||||||
<UTooltip text="Brutto (+19%)"><UButton color="green" variant="soft" block size="xs" @click="applyTax(19)">+19%</UButton></UTooltip>
|
|
||||||
<UTooltip text="Brutto (+7%)"><UButton color="green" variant="soft" block size="xs" @click="applyTax(7)">+7%</UButton></UTooltip>
|
|
||||||
<UTooltip text="Netto (-19%)"><UButton color="rose" variant="soft" block size="xs" @click="removeTax(19)">-19%</UButton></UTooltip>
|
|
||||||
<UTooltip text="Netto (-7%)"><UButton color="rose" variant="soft" block size="xs" @click="removeTax(7)">-7%</UButton></UTooltip>
|
|
||||||
|
|
||||||
<UTooltip text="Löschen"><UButton color="gray" variant="ghost" block @click="clear">C</UButton></UTooltip>
|
|
||||||
<UTooltip text="Speicher +"><UButton color="gray" variant="ghost" block @click="addToSum">M+</UButton></UTooltip>
|
|
||||||
<UTooltip text="Speicher Reset"><UButton color="gray" variant="ghost" block @click="store.memory = 0">MC</UButton></UTooltip>
|
|
||||||
<UButton color="primary" variant="soft" @click="setOperator('/')">/</UButton>
|
|
||||||
|
|
||||||
<UButton v-for="n in [7, 8, 9]" :key="n" color="white" @click="appendNumber(n)">{{ n }}</UButton>
|
|
||||||
<UButton color="primary" variant="soft" @click="setOperator('*')">×</UButton>
|
|
||||||
|
|
||||||
<UButton v-for="n in [4, 5, 6]" :key="n" color="white" @click="appendNumber(n)">{{ n }}</UButton>
|
|
||||||
<UButton color="primary" variant="soft" @click="setOperator('-')">-</UButton>
|
|
||||||
|
|
||||||
<UButton v-for="n in [1, 2, 3]" :key="n" color="white" @click="appendNumber(n)">{{ n }}</UButton>
|
|
||||||
<UButton color="primary" variant="soft" @click="setOperator('+')">+</UButton>
|
|
||||||
|
|
||||||
<UButton color="white" class="col-span-2" @click="appendNumber(0)">0</UButton>
|
|
||||||
<UButton color="white" @click="addComma">,</UButton>
|
|
||||||
<UButton color="primary" block @click="calculate">=</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="h-[270px] flex flex-col animate-in fade-in duration-200">
|
|
||||||
<div class="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
|
||||||
<div v-if="store.history.length === 0" class="text-center text-gray-400 text-xs mt-10 italic">
|
|
||||||
Keine Berechnungen im Verlauf
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="(item, i) in store.history" :key="i"
|
|
||||||
class="p-2 bg-gray-50 dark:bg-gray-800 rounded text-right border-l-2 border-primary-500 cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"
|
|
||||||
@click="useHistoryItem(item.result)"
|
|
||||||
>
|
|
||||||
<div class="text-[10px] text-gray-400">{{ item.expression }} =</div>
|
|
||||||
<div class="text-sm font-bold">{{ item.result }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UButton
|
|
||||||
color="gray"
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
block
|
|
||||||
class="mt-2"
|
|
||||||
icon="i-heroicons-trash"
|
|
||||||
@click="store.history = []"
|
|
||||||
>
|
|
||||||
Verlauf leeren
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useDraggable, useClipboard } from '@vueuse/core'
|
|
||||||
import { useCalculatorStore } from '~/stores/calculator'
|
|
||||||
|
|
||||||
const store = useCalculatorStore()
|
|
||||||
const { copy, copied } = useClipboard()
|
|
||||||
|
|
||||||
const el = ref(null)
|
|
||||||
const { style } = useDraggable(el, {
|
|
||||||
initialValue: { x: window.innerWidth - 350, y: 150 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const shouldResetDisplay = ref(false)
|
|
||||||
const showHistory = ref(false)
|
|
||||||
const previousValue = ref(null)
|
|
||||||
const lastOperator = ref(null)
|
|
||||||
|
|
||||||
// --- Logik ---
|
|
||||||
const appendNumber = (num) => {
|
|
||||||
if (store.display === '0' || shouldResetDisplay.value) {
|
|
||||||
store.display = String(num)
|
|
||||||
shouldResetDisplay.value = false
|
|
||||||
} else {
|
|
||||||
store.display += String(num)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addComma = () => {
|
|
||||||
if (!store.display.includes(',')) {
|
|
||||||
store.display += ','
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setOperator = (op) => {
|
|
||||||
previousValue.value = parseFloat(store.display.replace(',', '.'))
|
|
||||||
lastOperator.value = op
|
|
||||||
shouldResetDisplay.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculate = () => {
|
|
||||||
if (lastOperator.value === null) return
|
|
||||||
const currentVal = parseFloat(store.display.replace(',', '.'))
|
|
||||||
const prevVal = previousValue.value
|
|
||||||
let result = 0
|
|
||||||
|
|
||||||
switch (lastOperator.value) {
|
|
||||||
case '+': result = prevVal + currentVal; break
|
|
||||||
case '-': result = prevVal - currentVal; break
|
|
||||||
case '*': result = prevVal * currentVal; break
|
|
||||||
case '/': result = currentVal !== 0 ? prevVal / currentVal : 0; break
|
|
||||||
}
|
|
||||||
|
|
||||||
const expression = `${prevVal} ${lastOperator.value} ${currentVal}`
|
|
||||||
const resultString = String(Number(result.toFixed(4))).replace('.', ',')
|
|
||||||
|
|
||||||
store.addHistory(expression, resultString)
|
|
||||||
store.display = resultString
|
|
||||||
lastOperator.value = null
|
|
||||||
shouldResetDisplay.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const clear = () => {
|
|
||||||
store.display = '0'
|
|
||||||
previousValue.value = null
|
|
||||||
lastOperator.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyTax = (percent) => {
|
|
||||||
const current = parseFloat(store.display.replace(',', '.'))
|
|
||||||
store.display = (current * (1 + percent / 100)).toFixed(2).replace('.', ',')
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeTax = (percent) => {
|
|
||||||
const current = parseFloat(store.display.replace(',', '.'))
|
|
||||||
store.display = (current / (1 + percent / 100)).toFixed(2).replace('.', ',')
|
|
||||||
}
|
|
||||||
|
|
||||||
const addToSum = () => {
|
|
||||||
store.memory += parseFloat(store.display.replace(',', '.'))
|
|
||||||
shouldResetDisplay.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyDisplay = () => {
|
|
||||||
copy(store.display)
|
|
||||||
}
|
|
||||||
|
|
||||||
const useHistoryItem = (val) => {
|
|
||||||
store.display = val
|
|
||||||
showHistory.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Shortcuts ---
|
|
||||||
defineShortcuts({
|
|
||||||
'0': () => appendNumber(0),
|
|
||||||
'1': () => appendNumber(1),
|
|
||||||
'2': () => appendNumber(2),
|
|
||||||
'3': () => appendNumber(3),
|
|
||||||
'4': () => appendNumber(4),
|
|
||||||
'5': () => appendNumber(5),
|
|
||||||
'6': () => appendNumber(6),
|
|
||||||
'7': () => appendNumber(7),
|
|
||||||
'8': () => appendNumber(8),
|
|
||||||
'9': () => appendNumber(9),
|
|
||||||
'comma': addComma,
|
|
||||||
'plus': () => setOperator('+'),
|
|
||||||
'minus': () => setOperator('-'),
|
|
||||||
'enter': { usingInput: true, handler: calculate },
|
|
||||||
'backspace': () => {
|
|
||||||
store.display = store.display.length > 1 ? store.display.slice(0, -1) : '0'
|
|
||||||
},
|
|
||||||
// Escape schließt nun das Fenster via Store
|
|
||||||
'escape': {
|
|
||||||
usingInput: true,
|
|
||||||
whenever: [computed(() => store.isOpen)],
|
|
||||||
handler: () => { store.isOpen = false }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
|
||||||
@apply bg-transparent;
|
|
||||||
}
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
@apply bg-gray-200 dark:bg-gray-700 rounded-full;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -29,6 +29,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(["returnData"])
|
const emit = defineEmits(["returnData"])
|
||||||
|
|
||||||
|
|
||||||
const {type} = props
|
const {type} = props
|
||||||
|
|
||||||
defineShortcuts({
|
defineShortcuts({
|
||||||
@@ -52,10 +53,11 @@ 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 = () => {
|
||||||
@@ -64,39 +66,6 @@ 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(".")){
|
||||||
@@ -109,7 +78,10 @@ const setupCreate = () => {
|
|||||||
} else {
|
} else {
|
||||||
item.value[datapoint.key] = {}
|
item.value[datapoint.key] = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setupCreate()
|
setupCreate()
|
||||||
@@ -119,45 +91,49 @@ 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 => {
|
||||||
if (dataType.templateColumns.find(i => i.key === key)) {
|
if(dataType.templateColumns.find(i => i.key === key)) {
|
||||||
if (["customer", "contract", "plant", "contact", "project"].includes(key)) {
|
if (["customer", "contract", "plant", "contact", "project"].includes(key)) {
|
||||||
item.value[key] = Number(data[key])
|
item.value[key] = Number(data[key])
|
||||||
} else {
|
} else {
|
||||||
item.value[key] = data[key]
|
item.value[key] = data[key]
|
||||||
}
|
}
|
||||||
} else if (key === "resources") {
|
} else if(key === "resources") {
|
||||||
/*item.value[key] = data[key]*/
|
/*item.value[key] = data[key]*/
|
||||||
JSON.parse(data[key]).forEach(async (i) => {
|
JSON.parse(data[key]).forEach(async (i) => {
|
||||||
console.log(i)
|
console.log(i)
|
||||||
let type = i.substring(0, 1)
|
let type = i.substring(0,1)
|
||||||
let id = i.substring(2, i.length)
|
let id = i.substring(2,i.length)
|
||||||
console.log(type)
|
console.log(type)
|
||||||
console.log(id)
|
console.log(id)
|
||||||
let holder = ""
|
let holder = ""
|
||||||
if (type === "P") {
|
if(type === "P"){
|
||||||
holder = "profiles"
|
holder = "profiles"
|
||||||
} else if (type === "F") {
|
} else if(type === "F"){
|
||||||
holder = "vehicles"
|
holder = "vehicles"
|
||||||
id = Number(id)
|
id = Number(id)
|
||||||
} else if (type === "I") {
|
} else if(type === "I"){
|
||||||
holder = "inventoryitems"
|
holder = "inventoryitems"
|
||||||
id = Number(id)
|
id = Number(id)
|
||||||
} else if (type === "G") {
|
} else if(type === "G"){
|
||||||
holder = "inventoryitemgroups"
|
holder = "inventoryitemgroups"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof item.value[holder] === "object") {
|
if(typeof item.value[holder] === "object") {
|
||||||
item.value[holder].push(id)
|
item.value[holder].push(id)
|
||||||
} else {
|
} else {
|
||||||
item.value[holder] = [id]
|
item.value[holder] = [id]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// calcSaveAllowed() -> Entfernt, da computed automatisch reagiert
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setupQuery()
|
setupQuery()
|
||||||
@@ -172,14 +148,14 @@ const loadOptions = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for await(const option of optionsToLoad) {
|
for await(const option of optionsToLoad) {
|
||||||
if (option.option === "countrys") {
|
if(option.option === "countrys") {
|
||||||
loadedOptions.value[option.option] = useEntities("countrys").selectSpecial()
|
loadedOptions.value[option.option] = useEntities("countrys").selectSpecial()
|
||||||
} else if (option.option === "units") {
|
} else if(option.option === "units") {
|
||||||
loadedOptions.value[option.option] = useEntities("units").selectSpecial()
|
loadedOptions.value[option.option] = useEntities("units").selectSpecial()
|
||||||
} else {
|
} else {
|
||||||
loadedOptions.value[option.option] = (await useEntities(option.option).select())
|
loadedOptions.value[option.option] = (await useEntities(option.option).select())
|
||||||
|
|
||||||
if (dataType.templateColumns.find(x => x.key === option.key).selectDataTypeFilter) {
|
if(dataType.templateColumns.find(x => x.key === option.key).selectDataTypeFilter){
|
||||||
loadedOptions.value[option.option] = loadedOptions.value[option.option].filter(i => dataType.templateColumns.find(x => x.key === option.key).selectDataTypeFilter(i, item))
|
loadedOptions.value[option.option] = loadedOptions.value[option.option].filter(i => dataType.templateColumns.find(x => x.key === option.key).selectDataTypeFilter(i, item))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,23 +165,47 @@ const loadOptions = async () => {
|
|||||||
loadOptions()
|
loadOptions()
|
||||||
|
|
||||||
const contentChanged = (content, datapoint) => {
|
const contentChanged = (content, datapoint) => {
|
||||||
if (datapoint.key.includes(".")) {
|
if(datapoint.key.includes(".")){
|
||||||
item.value[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].html = content.html
|
item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].html = content.html
|
||||||
item.value[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].text = content.text
|
item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].text = content.text
|
||||||
item.value[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].json = content.json
|
item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]].json = content.json
|
||||||
} else {
|
} else {
|
||||||
item.value[datapoint.key].html = content.html
|
item[datapoint.key].html = content.html
|
||||||
item.value[datapoint.key].text = content.text
|
item[datapoint.key].text = content.text
|
||||||
item.value[datapoint.key].json = content.json
|
item[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
|
||||||
|
|
||||||
if (props.inModal) {
|
if(props.inModal) {
|
||||||
ret = await useEntities(type).create(item.value, true)
|
ret = await useEntities(type).create(item.value, true)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
ret = await useEntities(type).create(item.value)//dataStore.createNewItem(type,item.value)
|
ret = await useEntities(type).create(item.value)//dataStore.createNewItem(type,item.value)
|
||||||
@@ -218,7 +218,7 @@ const createItem = async () => {
|
|||||||
const updateItem = async () => {
|
const updateItem = async () => {
|
||||||
let ret = null
|
let ret = null
|
||||||
|
|
||||||
if (props.inModal) {
|
if(props.inModal) {
|
||||||
ret = await useEntities(type).update(item.value.id, item.value, true)
|
ret = await useEntities(type).update(item.value.id, item.value, true)
|
||||||
emit('returnData', ret)
|
emit('returnData', ret)
|
||||||
modal.close()
|
modal.close()
|
||||||
@@ -226,7 +226,11 @@ 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>
|
||||||
@@ -241,15 +245,16 @@ const updateItem = async () => {
|
|||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-chevron-left"
|
icon="i-heroicons-chevron-left"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@click="router.back()"
|
@click="router.back()/*router.push(`/standardEntity/${type}`)*/"
|
||||||
>
|
>
|
||||||
|
<!-- {{dataType.label}}-->
|
||||||
</UButton>
|
</UButton>
|
||||||
</template>
|
</template>
|
||||||
<template #center>
|
<template #center>
|
||||||
<h1
|
<h1
|
||||||
v-if="item"
|
v-if="item"
|
||||||
:class="['text-xl','font-medium', 'text-center']"
|
:class="['text-xl','font-medium', 'text-center']"
|
||||||
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
>{{item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
||||||
</template>
|
</template>
|
||||||
<template #right>
|
<template #right>
|
||||||
<ArchiveButton
|
<ArchiveButton
|
||||||
@@ -290,7 +295,7 @@ const updateItem = async () => {
|
|||||||
<h1
|
<h1
|
||||||
v-if="item"
|
v-if="item"
|
||||||
:class="['text-xl','font-medium']"
|
:class="['text-xl','font-medium']"
|
||||||
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
>{{item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
||||||
</template>
|
</template>
|
||||||
<template #right>
|
<template #right>
|
||||||
<UButton
|
<UButton
|
||||||
@@ -325,7 +330,11 @@ const updateItem = async () => {
|
|||||||
v-for="(columnName,index) in dataType.inputColumns"
|
v-for="(columnName,index) in dataType.inputColumns"
|
||||||
:class="platform === 'mobile' ? ['w-full'] : [`w-1/${dataType.inputColumns.length}`, ... index < dataType.inputColumns.length -1 ? ['mr-5'] : []]"
|
:class="platform === 'mobile' ? ['w-full'] : [`w-1/${dataType.inputColumns.length}`, ... index < dataType.inputColumns.length -1 ? ['mr-5'] : []]"
|
||||||
>
|
>
|
||||||
<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)"
|
||||||
@@ -353,7 +362,7 @@ const updateItem = async () => {
|
|||||||
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
|
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
|
||||||
>
|
>
|
||||||
<template #trailing v-if="datapoint.inputTrailing">
|
<template #trailing v-if="datapoint.inputTrailing">
|
||||||
<span class="text-gray-500 dark:text-gray-400 text-xs">{{ datapoint.inputTrailing }}</span>
|
<span class="text-gray-500 dark:text-gray-400 text-xs">{{datapoint.inputTrailing}}</span>
|
||||||
</template>
|
</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
<UToggle
|
<UToggle
|
||||||
@@ -427,6 +436,7 @@ 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)"
|
||||||
@@ -453,7 +463,7 @@ const updateItem = async () => {
|
|||||||
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
|
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
|
||||||
>
|
>
|
||||||
<template #trailing v-if="datapoint.inputTrailing">
|
<template #trailing v-if="datapoint.inputTrailing">
|
||||||
{{ datapoint.inputTrailing }}
|
{{datapoint.inputTrailing}}
|
||||||
</template>
|
</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
<UToggle
|
<UToggle
|
||||||
@@ -527,6 +537,7 @@ 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)"
|
||||||
@@ -551,8 +562,35 @@ 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
|
||||||
@@ -578,7 +616,7 @@ const updateItem = async () => {
|
|||||||
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
|
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
|
||||||
>
|
>
|
||||||
<template #trailing v-if="datapoint.inputTrailing">
|
<template #trailing v-if="datapoint.inputTrailing">
|
||||||
<span class="text-gray-500 dark:text-gray-400 text-xs">{{ datapoint.inputTrailing }}</span>
|
<span class="text-gray-500 dark:text-gray-400 text-xs">{{datapoint.inputTrailing}}</span>
|
||||||
</template>
|
</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
<UToggle
|
<UToggle
|
||||||
@@ -652,6 +690,7 @@ 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)"
|
||||||
@@ -678,7 +717,7 @@ const updateItem = async () => {
|
|||||||
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
|
:placeholder="datapoint.inputIsNumberRange ? 'Leer lassen für automatisch generierte Nummer' : ''"
|
||||||
>
|
>
|
||||||
<template #trailing v-if="datapoint.inputTrailing">
|
<template #trailing v-if="datapoint.inputTrailing">
|
||||||
{{ datapoint.inputTrailing }}
|
{{datapoint.inputTrailing}}
|
||||||
</template>
|
</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
<UToggle
|
<UToggle
|
||||||
@@ -752,6 +791,7 @@ 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)"
|
||||||
@@ -760,8 +800,8 @@ const updateItem = async () => {
|
|||||||
|
|
||||||
|
|
||||||
<MaterialComposing
|
<MaterialComposing
|
||||||
v-else-if="datapoint.inputType === 'materialComposing'"
|
v-else-if="datapoint.inputType === 'materialComposing'"
|
||||||
:item="item"
|
:item="item"
|
||||||
/>
|
/>
|
||||||
<PersonalComposing
|
<PersonalComposing
|
||||||
v-else-if="datapoint.inputType === 'personalComposing'"
|
v-else-if="datapoint.inputType === 'personalComposing'"
|
||||||
@@ -776,6 +816,30 @@ 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>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
import EntityShowSubTimes from "~/components/EntityShowSubTimes.vue";
|
import EntityShowSubTimes from "~/components/EntityShowSubTimes.vue";
|
||||||
import WikiEntityWidget from "~/components/wiki/WikiEntityWidget.vue";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
type: {
|
type: {
|
||||||
@@ -289,13 +288,6 @@ const changePinned = async () => {
|
|||||||
v-else-if="tab.label === 'Zeiten'"
|
v-else-if="tab.label === 'Zeiten'"
|
||||||
:platform="platform"
|
:platform="platform"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="tab.label === 'Wiki'" class="h-[600px] w-full overflow-hidden">
|
|
||||||
<WikiEntityWidget
|
|
||||||
:entity-type="type"
|
|
||||||
:entity-id="typeof props.item.id === 'number' ? props.item.id : undefined"
|
|
||||||
:entity-uuid="typeof props.item.id === 'string' ? props.item.id : undefined"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<EntityShowSub
|
<EntityShowSub
|
||||||
:item="props.item"
|
:item="props.item"
|
||||||
:query-string-data="getAvailableQueryStringData()"
|
:query-string-data="getAvailableQueryStringData()"
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const router = useRouter()
|
|||||||
const createddocuments = ref([])
|
const createddocuments = ref([])
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
|
//createddocuments.value = (await useSupabaseSelect("createddocuments")).filter(i => !i.archived)
|
||||||
createddocuments.value = (await useEntities("createddocuments").select()).filter(i => !i.archived)
|
createddocuments.value = (await useEntities("createddocuments").select()).filter(i => !i.archived)
|
||||||
}
|
}
|
||||||
setup()
|
setup()
|
||||||
@@ -77,6 +78,9 @@ const templateColumns = [
|
|||||||
},{
|
},{
|
||||||
key: 'state',
|
key: 'state',
|
||||||
label: "Status"
|
label: "Status"
|
||||||
|
},{
|
||||||
|
key: 'paid',
|
||||||
|
label: "Bezahlt"
|
||||||
},{
|
},{
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
label: "Betrag"
|
label: "Betrag"
|
||||||
@@ -279,12 +283,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>
|
||||||
@@ -307,4 +311,4 @@ const selectItem = (item) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -25,6 +28,8 @@ const statementallocations = ref([])
|
|||||||
const incominginvoices = ref([])
|
const incominginvoices = ref([])
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
|
//statementallocations.value = (await supabase.from("statementallocations").select("*, bs_id(*)").eq("account", route.params.id).eq("tenant",profileStore.currentTenant).order("created_at",{ascending: true})).data
|
||||||
|
//incominginvoices.value = (await useSupabaseSelect("incominginvoices", "*, vendor(*)")).filter(i => i.accounts.find(x => x.account == route.params.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
setup()
|
setup()
|
||||||
@@ -100,4 +105,4 @@ const renderedAllocations = computed(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -24,6 +24,7 @@ const emit = defineEmits(["updateNeeded"]);
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
|
||||||
const renderedPhases = computed(() => {
|
const renderedPhases = computed(() => {
|
||||||
if(props.topLevelType === "projects" && props.item.phases) {
|
if(props.topLevelType === "projects" && props.item.phases) {
|
||||||
@@ -76,6 +77,17 @@ const changeActivePhase = async (key) => {
|
|||||||
|
|
||||||
const res = await useEntities("projects").update(item.id, {phases:item.phases,active_phase: item.phases.find(i => i.active).label})
|
const res = await useEntities("projects").update(item.id, {phases:item.phases,active_phase: item.phases.find(i => i.active).label})
|
||||||
|
|
||||||
|
//const {error:updateError} = await supabase.from("projects").update({phases: item.phases}).eq("id",item.id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*const {error} = await supabase.from("historyitems").insert({
|
||||||
|
createdBy: profileStore.activeProfile.id,
|
||||||
|
tenant: profileStore.currentTenant,
|
||||||
|
text: `Aktive Phase zu "${phaseLabel}" gewechselt`,
|
||||||
|
project: item.id
|
||||||
|
})*/
|
||||||
|
|
||||||
emit("updateNeeded")
|
emit("updateNeeded")
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -154,4 +166,4 @@ const changeActivePhase = async (key) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
queryStringData: {
|
queryStringData: {
|
||||||
@@ -109,4 +114,4 @@ const columns = [
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
const selectedItem = ref(0)
|
const selectedItem = ref(0)
|
||||||
const sort = ref({
|
const sort = ref({
|
||||||
column: dataType.sortColumn || "date",
|
column: dataType.supabaseSortColumn || "date",
|
||||||
direction: 'desc'
|
direction: 'desc'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
|
|
||||||
const globalMessages = ref([])
|
const globalMessages = ref([])
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
let data = []
|
let {data} = await supabase.from("globalmessages").select("*, profiles(id)")
|
||||||
try {
|
|
||||||
data = await useNuxtApp().$api("/api/resource/globalmessages")
|
|
||||||
} catch (e) {
|
|
||||||
data = []
|
|
||||||
}
|
|
||||||
|
|
||||||
data = (data || []).filter((message) => !message.profiles || message.profiles.length === 0)
|
data = data.filter((message) => message.profiles.length === 0)
|
||||||
|
|
||||||
globalMessages.value = data
|
globalMessages.value = data
|
||||||
|
|
||||||
@@ -32,17 +29,10 @@ const showMessage = (message) => {
|
|||||||
showMessageModal.value = true
|
showMessageModal.value = true
|
||||||
}
|
}
|
||||||
const markMessageAsRead = async () => {
|
const markMessageAsRead = async () => {
|
||||||
try {
|
await supabase.from("globalmessagesseen").insert({
|
||||||
await useNuxtApp().$api("/api/resource/globalmessagesseen", {
|
profile: profileStore.activeProfile.id,
|
||||||
method: "POST",
|
message: messageToShow.value.id,
|
||||||
body: {
|
})
|
||||||
profile: profileStore.activeProfile.id,
|
|
||||||
message: messageToShow.value.id,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
// noop: endpoint optional in newer backend versions
|
|
||||||
}
|
|
||||||
showMessageModal.value = false
|
showMessageModal.value = false
|
||||||
setup()
|
setup()
|
||||||
|
|
||||||
@@ -96,4 +86,4 @@ setup()
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -3,7 +3,10 @@ const { isHelpSlideoverOpen } = useDashboard()
|
|||||||
const { metaSymbol } = useShortcuts()
|
const { metaSymbol } = useShortcuts()
|
||||||
|
|
||||||
const shortcuts = ref(false)
|
const shortcuts = ref(false)
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -225,4 +228,4 @@ const resetContactRequest = () => {
|
|||||||
</div>
|
</div>
|
||||||
<UProgress class="mt-5" animation="carousel" v-else/>-->
|
<UProgress class="mt-5" animation="carousel" v-else/>-->
|
||||||
</UDashboardSlideover>
|
</UDashboardSlideover>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,39 +1,42 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const { has } = usePermission()
|
|
||||||
|
|
||||||
// Lokaler State für den Taschenrechner
|
const {has} = usePermission()
|
||||||
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 => {
|
||||||
if (pin.type === "external") {
|
if(pin.type === "external") {
|
||||||
return {
|
return {
|
||||||
label: pin.label,
|
label: pin.label,
|
||||||
to: pin.link,
|
to: pin.link,
|
||||||
icon: pin.icon,
|
icon: pin.icon,
|
||||||
target: "_blank",
|
target: "_blank",
|
||||||
pinned: true
|
pinned: true
|
||||||
|
}
|
||||||
|
}else if(pin.type === "standardEntity") {
|
||||||
|
return {
|
||||||
|
label: pin.label,
|
||||||
|
to: `/standardEntity/${pin.datatype}/show/${pin.id}`,
|
||||||
|
icon: pin.icon,
|
||||||
|
pinned: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (pin.type === "standardEntity") {
|
}),
|
||||||
return {
|
|
||||||
label: pin.label,
|
|
||||||
to: `/standardEntity/${pin.datatype}/show/${pin.id}`,
|
|
||||||
icon: pin.icon,
|
|
||||||
pinned: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
|
... false ? [{
|
||||||
|
label: "Support Tickets",
|
||||||
|
to: "/support",
|
||||||
|
icon: "i-heroicons-rectangle-stack",
|
||||||
|
}] : [],
|
||||||
{
|
{
|
||||||
id: 'dashboard',
|
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",
|
||||||
@@ -45,16 +48,31 @@ const links = computed(() => {
|
|||||||
icon: "i-heroicons-rectangle-stack",
|
icon: "i-heroicons-rectangle-stack",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
children: [
|
children: [
|
||||||
...has("tasks") ? [{
|
... has("tasks") ? [{
|
||||||
label: "Aufgaben",
|
label: "Aufgaben",
|
||||||
to: "/standardEntity/tasks",
|
to: "/standardEntity/tasks",
|
||||||
icon: "i-heroicons-rectangle-stack"
|
icon: "i-heroicons-rectangle-stack"
|
||||||
}] : [],
|
}] : [],
|
||||||
...true ? [{
|
/*... true ? [{
|
||||||
label: "Wiki",
|
label: "Plantafel",
|
||||||
to: "/wiki",
|
to: "/calendar/timeline",
|
||||||
icon: "i-heroicons-book-open"
|
icon: "i-heroicons-calendar-days"
|
||||||
}] : [],
|
}] : [],
|
||||||
|
... true ? [{
|
||||||
|
label: "Kalender",
|
||||||
|
to: "/calendar/grid",
|
||||||
|
icon: "i-heroicons-calendar-days"
|
||||||
|
}] : [],
|
||||||
|
... true ? [{
|
||||||
|
label: "Termine",
|
||||||
|
to: "/standardEntity/events",
|
||||||
|
icon: "i-heroicons-calendar-days"
|
||||||
|
}] : [],*/
|
||||||
|
/*{
|
||||||
|
label: "Dateien",
|
||||||
|
to: "/files",
|
||||||
|
icon: "i-heroicons-document"
|
||||||
|
},*/
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,12 +84,12 @@ const links = computed(() => {
|
|||||||
label: "Dateien",
|
label: "Dateien",
|
||||||
to: "/files",
|
to: "/files",
|
||||||
icon: "i-heroicons-document"
|
icon: "i-heroicons-document"
|
||||||
}, {
|
},{
|
||||||
label: "Anschreiben",
|
label: "Anschreiben",
|
||||||
to: "/createdletters",
|
to: "/createdletters",
|
||||||
icon: "i-heroicons-document",
|
icon: "i-heroicons-document",
|
||||||
disabled: true
|
disabled: true
|
||||||
}, {
|
},{
|
||||||
label: "Boxen",
|
label: "Boxen",
|
||||||
to: "/standardEntity/documentboxes",
|
to: "/standardEntity/documentboxes",
|
||||||
icon: "i-heroicons-archive-box",
|
icon: "i-heroicons-archive-box",
|
||||||
@@ -95,44 +113,62 @@ 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")) ? [{
|
||||||
label: "Kontakte",
|
label: "Kontakte",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
icon: "i-heroicons-user-group",
|
icon: "i-heroicons-user-group",
|
||||||
children: [
|
children: [
|
||||||
...has("customers") ? [{
|
... has("customers") ? [{
|
||||||
label: "Kunden",
|
label: "Kunden",
|
||||||
to: "/standardEntity/customers",
|
to: "/standardEntity/customers",
|
||||||
icon: "i-heroicons-user-group"
|
icon: "i-heroicons-user-group"
|
||||||
}] : [],
|
}] : [],
|
||||||
...has("vendors") ? [{
|
... has("vendors") ? [{
|
||||||
label: "Lieferanten",
|
label: "Lieferanten",
|
||||||
to: "/standardEntity/vendors",
|
to: "/standardEntity/vendors",
|
||||||
icon: "i-heroicons-truck"
|
icon: "i-heroicons-truck"
|
||||||
}] : [],
|
}] : [],
|
||||||
...has("contacts") ? [{
|
... has("contacts") ? [{
|
||||||
label: "Ansprechpartner",
|
label: "Ansprechpartner",
|
||||||
to: "/standardEntity/contacts",
|
to: "/standardEntity/contacts",
|
||||||
icon: "i-heroicons-user-group"
|
icon: "i-heroicons-user-group"
|
||||||
}] : [],
|
}] : [],
|
||||||
]
|
]
|
||||||
}] : [],
|
},] : [],
|
||||||
{
|
{
|
||||||
label: "Mitarbeiter",
|
label: "Mitarbeiter",
|
||||||
defaultOpen: false,
|
defaultOpen:false,
|
||||||
icon: "i-heroicons-user-group",
|
icon: "i-heroicons-user-group",
|
||||||
children: [
|
children: [
|
||||||
...true ? [{
|
... true ? [{
|
||||||
label: "Zeiten",
|
label: "Zeiten",
|
||||||
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"
|
||||||
|
},*/
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
...[{
|
... [{
|
||||||
label: "Buchhaltung",
|
label: "Buchhaltung",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
icon: "i-heroicons-chart-bar-square",
|
icon: "i-heroicons-chart-bar-square",
|
||||||
@@ -141,23 +177,23 @@ const links = computed(() => {
|
|||||||
label: "Ausgangsbelege",
|
label: "Ausgangsbelege",
|
||||||
to: "/createDocument",
|
to: "/createDocument",
|
||||||
icon: "i-heroicons-document-text"
|
icon: "i-heroicons-document-text"
|
||||||
}, {
|
},{
|
||||||
label: "Serienvorlagen",
|
label: "Serienvorlagen",
|
||||||
to: "/createDocument/serialInvoice",
|
to: "/createDocument/serialInvoice",
|
||||||
icon: "i-heroicons-document-text"
|
icon: "i-heroicons-document-text"
|
||||||
}, {
|
},{
|
||||||
label: "Eingangsbelege",
|
label: "Eingangsbelege",
|
||||||
to: "/incomingInvoices",
|
to: "/incomingInvoices",
|
||||||
icon: "i-heroicons-document-text",
|
icon: "i-heroicons-document-text",
|
||||||
}, {
|
},{
|
||||||
label: "Kostenstellen",
|
label: "Kostenstellen",
|
||||||
to: "/standardEntity/costcentres",
|
to: "/standardEntity/costcentres",
|
||||||
icon: "i-heroicons-document-currency-euro"
|
icon: "i-heroicons-document-currency-euro"
|
||||||
}, {
|
},{
|
||||||
label: "Buchungskonten",
|
label: "Buchungskonten",
|
||||||
to: "/accounts",
|
to: "/accounts",
|
||||||
icon: "i-heroicons-document-text",
|
icon: "i-heroicons-document-text",
|
||||||
}, {
|
},{
|
||||||
label: "zusätzliche Buchungskonten",
|
label: "zusätzliche Buchungskonten",
|
||||||
to: "/standardEntity/ownaccounts",
|
to: "/standardEntity/ownaccounts",
|
||||||
icon: "i-heroicons-document-text"
|
icon: "i-heroicons-document-text"
|
||||||
@@ -169,39 +205,48 @@ const links = computed(() => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}],
|
}],
|
||||||
...has("inventory") ? [{
|
... has("inventory") ? [{
|
||||||
label: "Lager",
|
label: "Lager",
|
||||||
icon: "i-heroicons-puzzle-piece",
|
icon: "i-heroicons-puzzle-piece",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
children: [
|
children: [
|
||||||
...has("spaces") ? [{
|
/*{
|
||||||
|
label: "Vorgänge",
|
||||||
|
to: "/inventory",
|
||||||
|
icon: "i-heroicons-square-3-stack-3d"
|
||||||
|
},{
|
||||||
|
label: "Bestände",
|
||||||
|
to: "/inventory/stocks",
|
||||||
|
icon: "i-heroicons-square-3-stack-3d"
|
||||||
|
},*/
|
||||||
|
... has("spaces") ? [{
|
||||||
label: "Lagerplätze",
|
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,
|
||||||
icon: "i-heroicons-clipboard-document",
|
icon: "i-heroicons-clipboard-document",
|
||||||
children: [
|
children: [
|
||||||
...has("products") ? [{
|
... has("products") ? [{
|
||||||
label: "Artikel",
|
label: "Artikel",
|
||||||
to: "/standardEntity/products",
|
to: "/standardEntity/products",
|
||||||
icon: "i-heroicons-puzzle-piece"
|
icon: "i-heroicons-puzzle-piece"
|
||||||
}] : [],
|
}] : [],
|
||||||
...has("productcategories") ? [{
|
... has("productcategories") ? [{
|
||||||
label: "Artikelkategorien",
|
label: "Artikelkategorien",
|
||||||
to: "/standardEntity/productcategories",
|
to: "/standardEntity/productcategories",
|
||||||
icon: "i-heroicons-puzzle-piece"
|
icon: "i-heroicons-puzzle-piece"
|
||||||
}] : [],
|
}] : [],
|
||||||
...has("services") ? [{
|
... has("services") ? [{
|
||||||
label: "Leistungen",
|
label: "Leistungen",
|
||||||
to: "/standardEntity/services",
|
to: "/standardEntity/services",
|
||||||
icon: "i-heroicons-wrench-screwdriver"
|
icon: "i-heroicons-wrench-screwdriver"
|
||||||
}] : [],
|
}] : [],
|
||||||
...has("servicecategories") ? [{
|
... has("servicecategories") ? [{
|
||||||
label: "Leistungskategorien",
|
label: "Leistungskategorien",
|
||||||
to: "/standardEntity/servicecategories",
|
to: "/standardEntity/servicecategories",
|
||||||
icon: "i-heroicons-wrench-screwdriver"
|
icon: "i-heroicons-wrench-screwdriver"
|
||||||
@@ -216,17 +261,17 @@ const links = computed(() => {
|
|||||||
to: "/standardEntity/hourrates",
|
to: "/standardEntity/hourrates",
|
||||||
icon: "i-heroicons-user-group"
|
icon: "i-heroicons-user-group"
|
||||||
},
|
},
|
||||||
...has("vehicles") ? [{
|
... has("vehicles") ? [{
|
||||||
label: "Fahrzeuge",
|
label: "Fahrzeuge",
|
||||||
to: "/standardEntity/vehicles",
|
to: "/standardEntity/vehicles",
|
||||||
icon: "i-heroicons-truck"
|
icon: "i-heroicons-truck"
|
||||||
}] : [],
|
}] : [],
|
||||||
...has("inventoryitems") ? [{
|
... has("inventoryitems") ? [{
|
||||||
label: "Inventar",
|
label: "Inventar",
|
||||||
to: "/standardEntity/inventoryitems",
|
to: "/standardEntity/inventoryitems",
|
||||||
icon: "i-heroicons-puzzle-piece"
|
icon: "i-heroicons-puzzle-piece"
|
||||||
}] : [],
|
}] : [],
|
||||||
...has("inventoryitems") ? [{
|
... has("inventoryitems") ? [{
|
||||||
label: "Inventargruppen",
|
label: "Inventargruppen",
|
||||||
to: "/standardEntity/inventoryitemgroups",
|
to: "/standardEntity/inventoryitemgroups",
|
||||||
icon: "i-heroicons-puzzle-piece"
|
icon: "i-heroicons-puzzle-piece"
|
||||||
@@ -234,21 +279,26 @@ const links = computed(() => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
...has("projects") ? [{
|
... has("projects") ? [{
|
||||||
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",
|
||||||
icon: "i-heroicons-clipboard-document"
|
icon: "i-heroicons-clipboard-document"
|
||||||
}] : [],
|
}] : [],
|
||||||
...has("plants") ? [{
|
... has("plants") ? [{
|
||||||
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,
|
||||||
@@ -258,57 +308,67 @@ 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",
|
||||||
}, {
|
},{
|
||||||
label: "Bankkonten",
|
label: "Bankkonten",
|
||||||
to: "/settings/banking",
|
to: "/settings/banking",
|
||||||
icon: "i-heroicons-currency-euro",
|
icon: "i-heroicons-currency-euro",
|
||||||
}, {
|
},{
|
||||||
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",
|
||||||
}, {
|
},{
|
||||||
label: "Projekttypen",
|
label: "Projekttypen",
|
||||||
to: "/projecttypes",
|
to: "/projecttypes",
|
||||||
icon: "i-heroicons-clipboard-document-list",
|
icon: "i-heroicons-clipboard-document-list",
|
||||||
}, {
|
},{
|
||||||
label: "Export",
|
label: "Export",
|
||||||
to: "/export",
|
to: "/export",
|
||||||
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="ghost"
|
:variant="item.pinned ? 'ghost' : '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"
|
||||||
@@ -318,9 +378,8 @@ const buttonItems = computed(() =>
|
|||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
<UDivider/>
|
||||||
<UDivider class="my-2"/>
|
<!-- Accordion für die Items mit Children -->
|
||||||
|
|
||||||
<UAccordion
|
<UAccordion
|
||||||
:items="accordionItems"
|
:items="accordionItems"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
@@ -328,7 +387,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"
|
||||||
@@ -356,13 +415,56 @@ 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>
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {formatTimeAgo} from '@vueuse/core'
|
import {formatTimeAgo} from '@vueuse/core'
|
||||||
|
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
|
||||||
|
|
||||||
const { isNotificationsSlideoverOpen } = useDashboard()
|
const { isNotificationsSlideoverOpen } = useDashboard()
|
||||||
|
|
||||||
watch(isNotificationsSlideoverOpen, async (newVal,oldVal) => {
|
watch(isNotificationsSlideoverOpen, async (newVal,oldVal) => {
|
||||||
@@ -12,24 +15,18 @@ watch(isNotificationsSlideoverOpen, async (newVal,oldVal) => {
|
|||||||
const notifications = ref([])
|
const notifications = ref([])
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
try {
|
notifications.value = (await supabase.from("notifications").select()).data
|
||||||
notifications.value = await useNuxtApp().$api("/api/resource/notifications_items")
|
|
||||||
} catch (e) {
|
|
||||||
notifications.value = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setup()
|
setup()
|
||||||
|
|
||||||
const setNotificationAsRead = async (notification) => {
|
const setNotificationAsRead = async (notification) => {
|
||||||
try {
|
console.log(notification)
|
||||||
await useNuxtApp().$api(`/api/resource/notifications_items/${notification.id}`, {
|
|
||||||
method: "PUT",
|
const {data,error} = await supabase.from("notifications").update({read: true}).eq("id", notification.id)
|
||||||
body: { readAt: new Date() }
|
|
||||||
})
|
console.log(error)
|
||||||
} catch (e) {
|
|
||||||
// noop: endpoint optional in older/newer backend variants
|
|
||||||
}
|
|
||||||
setup()
|
setup()
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -44,7 +41,7 @@ const setNotificationAsRead = async (notification) => {
|
|||||||
class="p-3 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer flex items-center gap-3 relative"
|
class="p-3 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer flex items-center gap-3 relative"
|
||||||
@click="setNotificationAsRead(notification)"
|
@click="setNotificationAsRead(notification)"
|
||||||
>
|
>
|
||||||
<UChip color="primary" :show="!notification.read && !notification.readAt" inset>
|
<UChip color="primary" :show="!notification.read" inset>
|
||||||
<UAvatar alt="FEDEO" size="md" />
|
<UAvatar alt="FEDEO" size="md" />
|
||||||
</UChip>
|
</UChip>
|
||||||
|
|
||||||
@@ -52,7 +49,7 @@ const setNotificationAsRead = async (notification) => {
|
|||||||
<p class="flex items-center justify-between">
|
<p class="flex items-center justify-between">
|
||||||
<span class="text-gray-900 dark:text-white font-medium">{{notification.title}}</span>
|
<span class="text-gray-900 dark:text-white font-medium">{{notification.title}}</span>
|
||||||
|
|
||||||
<time :datetime="notification.date || notification.createdAt || notification.created_at" class="text-gray-500 dark:text-gray-400 text-xs" v-text="formatTimeAgo(new Date(notification.createdAt || notification.created_at))" />
|
<time :datetime="notification.date" class="text-gray-500 dark:text-gray-400 text-xs" v-text="formatTimeAgo(new Date(notification.created_at))" />
|
||||||
</p>
|
</p>
|
||||||
<p class="text-gray-500 dark:text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{{ notification.message }}
|
{{ notification.message }}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
|||||||
Nein, bleiben
|
Nein, bleiben
|
||||||
</button>
|
</button>
|
||||||
<button @click="confirmLeave" class="btn-confirm">
|
<button @click="confirmLeave" class="btn-confirm">
|
||||||
Ja, verlassen
|
Ja, verwerfen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ 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 toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const config = computed(() => props.context.config)
|
const config = computed(() => props.context.config)
|
||||||
@@ -16,11 +16,12 @@ const data = computed(() => props.context.data)
|
|||||||
|
|
||||||
// Initiale Werte setzen
|
// Initiale Werte setzen
|
||||||
const form = ref({
|
const form = ref({
|
||||||
deliveryDate: dayjs().format('YYYY-MM-DD'), // Standard: Heute
|
|
||||||
profile: props.context.meta?.defaultProfileId || null,
|
profile: props.context.meta?.defaultProfileId || null,
|
||||||
project: null,
|
project: null,
|
||||||
service: config.value?.defaults?.serviceId || null,
|
service: config.value?.defaults?.serviceId || null,
|
||||||
quantity: config.value?.features?.timeTracking?.defaultDurationHours || 1,
|
// Wenn manualTime erlaubt, setze Startzeit auf jetzt, sonst null (wird im Backend gesetzt)
|
||||||
|
startDate: config.value?.features?.timeTracking?.allowManualTime ? new Date() : null,
|
||||||
|
endDate: config.value?.features?.timeTracking?.allowManualTime ? dayjs().add(1, 'hour').toDate() : null,
|
||||||
dieselUsage: 0,
|
dieselUsage: 0,
|
||||||
description: ''
|
description: ''
|
||||||
})
|
})
|
||||||
@@ -28,17 +29,13 @@ const form = ref({
|
|||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const errors = ref({})
|
const errors = ref({})
|
||||||
|
|
||||||
// Validierung basierend auf JSON Config & neuen Anforderungen
|
// Validierung basierend auf JSON Config
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
errors.value = {}
|
errors.value = {}
|
||||||
let isValid = true
|
let isValid = true
|
||||||
const validationRules = config.value.validation || {}
|
const validationRules = config.value.validation || {}
|
||||||
|
|
||||||
if (!form.value.deliveryDate) {
|
// Standard-Validierung
|
||||||
errors.value.deliveryDate = 'Datum erforderlich'
|
|
||||||
isValid = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!form.value.project && data.value.projects?.length > 0) {
|
if (!form.value.project && data.value.projects?.length > 0) {
|
||||||
errors.value.project = 'Pflichtfeld'
|
errors.value.project = 'Pflichtfeld'
|
||||||
isValid = false
|
isValid = false
|
||||||
@@ -49,18 +46,13 @@ const validate = () => {
|
|||||||
isValid = false
|
isValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.value.quantity || form.value.quantity <= 0) {
|
// Profil nur validieren, wenn Auswahl möglich ist
|
||||||
errors.value.quantity = 'Menge erforderlich'
|
|
||||||
isValid = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Profil nur validieren, wenn Auswahl nötig und möglich ist
|
|
||||||
if (!form.value.profile && data.value.profiles?.length > 0 && !props.context.meta.defaultProfileId) {
|
if (!form.value.profile && data.value.profiles?.length > 0 && !props.context.meta.defaultProfileId) {
|
||||||
errors.value.profile = 'Bitte Mitarbeiter wählen'
|
errors.value.profile = 'Bitte Mitarbeiter wählen'
|
||||||
isValid = false
|
isValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feature: Agriculture Diesel
|
// Feature: Agriculture
|
||||||
if (config.value.features?.agriculture?.showDieselUsage && validationRules.requireDiesel) {
|
if (config.value.features?.agriculture?.showDieselUsage && validationRules.requireDiesel) {
|
||||||
if (!form.value.dieselUsage || form.value.dieselUsage <= 0) {
|
if (!form.value.dieselUsage || form.value.dieselUsage <= 0) {
|
||||||
errors.value.diesel = 'Dieselverbrauch erforderlich'
|
errors.value.diesel = 'Dieselverbrauch erforderlich'
|
||||||
@@ -78,10 +70,12 @@ const submit = async () => {
|
|||||||
try {
|
try {
|
||||||
const payload = { ...form.value }
|
const payload = { ...form.value }
|
||||||
|
|
||||||
|
// Headers vorbereiten (PIN mitsenden!)
|
||||||
const headers = {}
|
const headers = {}
|
||||||
if (props.pin) headers['x-public-pin'] = props.pin
|
if (props.pin) headers['x-public-pin'] = props.pin
|
||||||
|
|
||||||
await $fetch(`${runtimeConfig.public.apiBase}/workflows/submit/${props.token}`, {
|
// An den Submit-Endpunkt senden (den müssen wir im Backend noch bauen!)
|
||||||
|
await $fetch(`http://localhost:3100/workflows/submit/${props.token}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: payload,
|
body: payload,
|
||||||
headers
|
headers
|
||||||
@@ -90,18 +84,11 @@ const submit = async () => {
|
|||||||
emit('success')
|
emit('success')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast.add({ title: 'Fehler beim Speichern', color: 'red', icon: 'i-heroicons-exclamation-triangle' })
|
toast.add({ title: 'Fehler beim Speichern', color: 'red' })
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hilfsfunktion für die dynamische Einheiten-Anzeige
|
|
||||||
const currentUnit = computed(() => {
|
|
||||||
if (!form.value.service) return data.value?.units?.[0]?.symbol || 'h'
|
|
||||||
const selectedService = data.value.services?.find(s => s.id === form.value.service)
|
|
||||||
return selectedService?.unitSymbol || data.value?.units?.[0]?.symbol || 'h'
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -115,19 +102,6 @@ const currentUnit = computed(() => {
|
|||||||
|
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
|
|
||||||
<UFormGroup
|
|
||||||
label="Datum der Ausführung"
|
|
||||||
:error="errors.deliveryDate"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<UInput
|
|
||||||
v-model="form.deliveryDate"
|
|
||||||
type="date"
|
|
||||||
size="lg"
|
|
||||||
icon="i-heroicons-calendar-days"
|
|
||||||
/>
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<UFormGroup
|
<UFormGroup
|
||||||
v-if="!context?.meta?.defaultProfileId && data?.profiles?.length > 0"
|
v-if="!context?.meta?.defaultProfileId && data?.profiles?.length > 0"
|
||||||
label="Mitarbeiter"
|
label="Mitarbeiter"
|
||||||
@@ -141,13 +115,13 @@ const currentUnit = computed(() => {
|
|||||||
value-attribute="id"
|
value-attribute="id"
|
||||||
placeholder="Name auswählen..."
|
placeholder="Name auswählen..."
|
||||||
searchable
|
searchable
|
||||||
size="lg"
|
searchable-placeholder="Suchen..."
|
||||||
/>
|
/>
|
||||||
</UFormGroup>
|
</UFormGroup>
|
||||||
|
|
||||||
<UFormGroup
|
<UFormGroup
|
||||||
v-if="data?.projects?.length > 0"
|
v-if="data?.projects?.length > 0"
|
||||||
:label="config.ui?.labels?.project || 'Projekt / Auftrag'"
|
:label="config.ui?.labels?.project || 'Projekt'"
|
||||||
:error="errors.project"
|
:error="errors.project"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -158,13 +132,12 @@ const currentUnit = computed(() => {
|
|||||||
value-attribute="id"
|
value-attribute="id"
|
||||||
placeholder="Wählen..."
|
placeholder="Wählen..."
|
||||||
searchable
|
searchable
|
||||||
size="lg"
|
|
||||||
/>
|
/>
|
||||||
</UFormGroup>
|
</UFormGroup>
|
||||||
|
|
||||||
<UFormGroup
|
<UFormGroup
|
||||||
v-if="data?.services?.length > 0"
|
v-if="data?.services?.length > 0"
|
||||||
:label="config?.ui?.labels?.service || 'Tätigkeit'"
|
:label="config?.ui?.labels?.service || 'Leistung'"
|
||||||
:error="errors.service"
|
:error="errors.service"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -174,28 +147,36 @@ const currentUnit = computed(() => {
|
|||||||
option-attribute="name"
|
option-attribute="name"
|
||||||
value-attribute="id"
|
value-attribute="id"
|
||||||
placeholder="Wählen..."
|
placeholder="Wählen..."
|
||||||
searchable
|
|
||||||
size="lg"
|
|
||||||
/>
|
/>
|
||||||
</UFormGroup>
|
</UFormGroup>
|
||||||
|
|
||||||
<UFormGroup
|
<div v-if="config?.features?.timeTracking?.allowManualTime" class="grid grid-cols-2 gap-3">
|
||||||
label="Menge / Dauer"
|
<UFormGroup label="Start">
|
||||||
:error="errors.quantity"
|
<input
|
||||||
required
|
type="datetime-local"
|
||||||
>
|
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-primary-600 sm:text-sm sm:leading-6 px-2"
|
||||||
<UInput
|
:value="dayjs(form.startDate).format('YYYY-MM-DDTHH:mm')"
|
||||||
v-model="form.quantity"
|
@input="e => form.startDate = new Date(e.target.value)"
|
||||||
type="number"
|
/>
|
||||||
step="0.25"
|
</UFormGroup>
|
||||||
size="lg"
|
<UFormGroup label="Dauer (Stunden)">
|
||||||
placeholder="0.00"
|
<input
|
||||||
>
|
type="number"
|
||||||
<template #trailing>
|
step="0.25"
|
||||||
<span class="text-gray-500 text-sm pr-2">{{ currentUnit }}</span>
|
placeholder="z.B. 1.5"
|
||||||
</template>
|
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-primary-600 sm:text-sm sm:leading-6 px-2"
|
||||||
</UInput>
|
@input="e => form.endDate = dayjs(form.startDate).add(parseFloat(e.target.value), 'hour').toDate()"
|
||||||
</UFormGroup>
|
/>
|
||||||
|
</UFormGroup>
|
||||||
|
<UFormGroup label="Ende" class="col-span-2">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-primary-600 sm:text-sm sm:leading-6 px-2"
|
||||||
|
:value="dayjs(form.endDate).format('YYYY-MM-DDTHH:mm')"
|
||||||
|
@input="e => form.endDate = new Date(e.target.value)"
|
||||||
|
/>
|
||||||
|
</UFormGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
<UFormGroup
|
<UFormGroup
|
||||||
v-if="config?.features?.agriculture?.showDieselUsage"
|
v-if="config?.features?.agriculture?.showDieselUsage"
|
||||||
@@ -203,15 +184,15 @@ const currentUnit = computed(() => {
|
|||||||
:error="errors.diesel"
|
:error="errors.diesel"
|
||||||
:required="config?.validation?.requireDiesel"
|
:required="config?.validation?.requireDiesel"
|
||||||
>
|
>
|
||||||
<UInput v-model="form.dieselUsage" type="number" step="0.1" placeholder="0.0" size="lg">
|
<UInput v-model="form.dieselUsage" type="number" step="0.1" placeholder="0.0">
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
<span class="text-gray-500 text-xs">Liter</span>
|
<span class="text-gray-500 text-xs">Liter</span>
|
||||||
</template>
|
</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
</UFormGroup>
|
</UFormGroup>
|
||||||
|
|
||||||
<UFormGroup :label="config?.ui?.labels?.description || 'Notiz / Vorkommnisse'">
|
<UFormGroup :label="config?.ui?.labels?.description || 'Notiz'">
|
||||||
<UTextarea v-model="form.description" :rows="3" placeholder="Optional..." />
|
<UTextarea v-model="form.description" :rows="3" />
|
||||||
</UFormGroup>
|
</UFormGroup>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
const remainingMinutes = computed(() => Math.floor(auth.sessionWarningRemainingSeconds / 60))
|
|
||||||
const remainingSeconds = computed(() => auth.sessionWarningRemainingSeconds % 60)
|
|
||||||
const remainingTimeLabel = computed(() => `${remainingMinutes.value}:${String(remainingSeconds.value).padStart(2, "0")}`)
|
|
||||||
|
|
||||||
const onRefresh = async () => {
|
|
||||||
await auth.refreshSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLogout = async () => {
|
|
||||||
auth.sessionWarningVisible = false
|
|
||||||
await auth.logout()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UModal v-model="auth.sessionWarningVisible" prevent-close>
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-semibold">Sitzung läuft bald ab</h3>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Deine Sitzung endet in
|
|
||||||
<span class="font-semibold">{{ remainingTimeLabel }}</span>.
|
|
||||||
Bitte bestätige, um eingeloggt zu bleiben.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<UButton variant="outline" color="gray" @click="onLogout">
|
|
||||||
Abmelden
|
|
||||||
</UButton>
|
|
||||||
<UButton color="primary" @click="onRefresh">
|
|
||||||
Eingeloggt bleiben
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UCard>
|
|
||||||
</UModal>
|
|
||||||
</template>
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const dataStore = useDataStore()
|
const dataStore = useDataStore()
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
const modal = useModal()
|
const modal = useModal()
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
type: {
|
type: {
|
||||||
@@ -34,10 +35,11 @@ const item = ref({})
|
|||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
if(props.mode === "show") {
|
if(props.mode === "show") {
|
||||||
//Load Data for Show
|
//Load Data for Show
|
||||||
item.value = await useEntities(props.type).selectSingle(props.id, dataType.selectWithInformation || "*")
|
item.value = await useEntities(props.type).selectSingle(props.id, dataType.supabaseSelectWithInformation || "*")
|
||||||
} else if(props.mode === "edit") {
|
} else if(props.mode === "edit") {
|
||||||
//Load Data for Edit
|
//Load Data for Edit
|
||||||
const data = JSON.stringify(await useEntities(props.type).selectSingle(props.id))
|
const data = JSON.stringify(await useEntities(props.type).selectSingle(props.id)/*(await supabase.from(props.type).select().eq("id", props.id).single()).data*/)
|
||||||
|
//await useSupabaseSelectSingle(type, route.params.id)
|
||||||
item.value = data
|
item.value = data
|
||||||
|
|
||||||
} else if(props.mode === "create") {
|
} else if(props.mode === "create") {
|
||||||
@@ -46,7 +48,7 @@ const setupPage = async () => {
|
|||||||
|
|
||||||
} else if(props.mode === "list") {
|
} else if(props.mode === "list") {
|
||||||
//Load Data for List
|
//Load Data for List
|
||||||
items.value = await useEntities(props.type).select(dataType.selectWithInformation || "*", dataType.sortColumn,dataType.sortAscending || false)
|
items.value = await useEntities(props.type).select(dataType.supabaseSelectWithInformation || "*", dataType.supabaseSortColumn,dataType.supabaseSortAscending || false)
|
||||||
}
|
}
|
||||||
|
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
@@ -93,4 +95,4 @@ setupPage()
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
const { isHelpSlideoverOpen } = useDashboard()
|
const { isHelpSlideoverOpen } = useDashboard()
|
||||||
const { isDashboardSearchModalOpen } = useUIState()
|
const { isDashboardSearchModalOpen } = useUIState()
|
||||||
const { metaSymbol } = useShortcuts()
|
const { metaSymbol } = useShortcuts()
|
||||||
|
const user = useSupabaseUser()
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const items = computed(() => [
|
const items = computed(() => [
|
||||||
@@ -54,4 +59,4 @@ const items = computed(() => [
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UDropdown>
|
</UDropdown>
|
||||||
</template>
|
</template>
|
||||||
@@ -7,9 +7,11 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
|
||||||
|
|
||||||
let inventoryitemgroups = await Promise.all(props.row.inventoryitemgroups.map(async (i) => {
|
let inventoryitemgroups = await Promise.all(props.row.inventoryitemgroups.map(async (i) => {
|
||||||
const group = await useEntities("inventoryitemgroups").selectSingle(i)
|
return (await supabase.from("inventoryitemgroups").select("id,name").eq("id",i).single()).data.name
|
||||||
return group?.name
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
|
||||||
|
|
||||||
let inventoryitems = await Promise.all(props.row.inventoryitems.map(async (i) => {
|
let inventoryitems = await Promise.all(props.row.inventoryitems.map(async (i) => {
|
||||||
const item = await useEntities("inventoryitems").selectSingle(i)
|
return (await supabase.from("inventoryitems").select("id,name").eq("id",i).single()).data.name
|
||||||
return item?.name
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
|
||||||
|
|
||||||
let vehicles = await Promise.all(props.row.vehicles.map(async (i) => {
|
let vehicles = await Promise.all(props.row.vehicles.map(async (i) => {
|
||||||
const vehicle = await useEntities("vehicles").selectSingle(i)
|
return (await supabase.from("vehicles").select("id,licensePlate").eq("id",i).single()).data.licensePlate
|
||||||
return vehicle?.licensePlate
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: {
|
item: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -23,4 +27,4 @@ setupPage()
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -8,12 +8,15 @@ let incomeData = ref({})
|
|||||||
let expenseData = ref({})
|
let expenseData = ref({})
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
|
//let incomeRawData = (await supabase.from("createddocuments").select().eq("tenant",profileStore.currentTenant).eq("state","Gebucht").in('type',['invoices','advanceInvoices','cancellationInvoices'])).data
|
||||||
let incomeRawData = (await useEntities("createddocuments").select()).filter(i => i.state === "Gebucht" && ['invoices','advanceInvoices','cancellationInvoices'].includes(i.type))
|
let incomeRawData = (await useEntities("createddocuments").select()).filter(i => i.state === "Gebucht" && ['invoices','advanceInvoices','cancellationInvoices'].includes(i.type))
|
||||||
|
|
||||||
let incomeRawFilteredData = incomeRawData.filter(x => x.state === 'Gebucht' && incomeRawData.find(i => i.linkedDocument && i.linkedDocument.id === x.id && i.type === 'cancellationInvoices') && ['invoices','advanceInvoices'].includes(row.type))
|
let incomeRawFilteredData = incomeRawData.filter(x => x.state === 'Gebucht' && incomeRawData.find(i => i.linkedDocument && i.linkedDocument.id === x.id && i.type === 'cancellationInvoices') && ['invoices','advanceInvoices'].includes(row.type))
|
||||||
|
|
||||||
|
|
||||||
|
//let expenseRawData =(await supabase.from("incominginvoices").select().eq("tenant",profileStore.currentTenant)).data
|
||||||
let expenseRawData =(await useEntities("incominginvoices").select())
|
let expenseRawData =(await useEntities("incominginvoices").select())
|
||||||
|
//let withoutInvoiceRawData = (await supabase.from("statementallocations").select().eq("tenant",profileStore.currentTenant).not("account","is",null)).data
|
||||||
let withoutInvoiceRawData = (await useEntities("statementallocations").select()).filter(i => i.account)
|
let withoutInvoiceRawData = (await useEntities("statementallocations").select()).filter(i => i.account)
|
||||||
|
|
||||||
let withoutInvoiceRawDataExpenses = []
|
let withoutInvoiceRawDataExpenses = []
|
||||||
@@ -238,4 +241,4 @@ setup()
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const profileStore = useProfileStore();
|
const profileStore = useProfileStore();
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const staffTime = useStaffTime()
|
|
||||||
|
|
||||||
const runningTimeInfo = ref({})
|
const runningTimeInfo = ref({})
|
||||||
|
|
||||||
@@ -11,9 +11,12 @@ const projects = ref([])
|
|||||||
const platform = ref("default")
|
const platform = ref("default")
|
||||||
|
|
||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
const rows = await staffTime.list({ user_id: profileStore.activeProfile?.user_id || profileStore.activeProfile?.id })
|
runningTimeInfo.value = (await supabase.from("times").select().eq("profile", profileStore.activeProfile.id).is("endDate", null).single()).data || {}
|
||||||
runningTimeInfo.value = rows.find((r) => !r.stopped_at && r.type === "work") || {}
|
|
||||||
projects.value = await useEntities("projects").select("*")
|
//projects.value = (await useSupabaseSelect("projects"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupPage()
|
setupPage()
|
||||||
@@ -23,25 +26,47 @@ setupPage()
|
|||||||
}*/
|
}*/
|
||||||
|
|
||||||
const startTime = async () => {
|
const startTime = async () => {
|
||||||
try {
|
console.log("started")
|
||||||
await staffTime.start("Arbeitszeit")
|
runningTimeInfo.value = {
|
||||||
toast.add({title: "Projektzeit erfolgreich gestartet"})
|
profile: profileStore.activeProfile.id,
|
||||||
await setupPage()
|
startDate: dayjs(),
|
||||||
} catch (error) {
|
tenant: profileStore.currentTenant,
|
||||||
|
state: platform.value === "mobile" ? "In der App gestartet" : "Im Web gestartet",
|
||||||
|
source: "Dashboard"
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data,error} = await supabase
|
||||||
|
.from("times")
|
||||||
|
.insert([runningTimeInfo.value])
|
||||||
|
.select()
|
||||||
|
if(error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
toast.add({title: "Fehler beim starten der Projektzeit",color:"rose"})
|
toast.add({title: "Fehler beim starten der Projektzeit",color:"rose"})
|
||||||
|
} else if(data) {
|
||||||
|
toast.add({title: "Projektzeit erfolgreich gestartet"})
|
||||||
|
runningTimeInfo.value = data[0]
|
||||||
|
//console.log(runningTimeInfo.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopStartedTime = async () => {
|
const stopStartedTime = async () => {
|
||||||
try {
|
runningTimeInfo.value.endDate = dayjs()
|
||||||
await staffTime.stop()
|
runningTimeInfo.value.state = platform.value === "mobile" ? "In der App gestoppt" : "Im Web gestoppt"
|
||||||
|
|
||||||
|
const {error,status} = await supabase
|
||||||
|
.from("times")
|
||||||
|
.update(runningTimeInfo.value)
|
||||||
|
.eq('id',runningTimeInfo.value.id)
|
||||||
|
|
||||||
|
if(error) {
|
||||||
|
console.log(error)
|
||||||
|
let errorId = await useError().logError(`${status} - ${JSON.stringify(error)}`)
|
||||||
|
toast.add({title: errorId ? `Fehler beim stoppen der Projektzeit (Fehler ID: ${errorId})` : `Fehler beim stoppen der Projektzeit`,color:"rose"})
|
||||||
|
|
||||||
|
|
||||||
|
} else {
|
||||||
toast.add({title: "Projektzeit erfolgreich gestoppt"})
|
toast.add({title: "Projektzeit erfolgreich gestoppt"})
|
||||||
runningTimeInfo.value = {}
|
runningTimeInfo.value = {}
|
||||||
} catch (error) {
|
|
||||||
console.log(error)
|
|
||||||
let errorId = await useError().logError(`${JSON.stringify(error)}`)
|
|
||||||
toast.add({title: errorId ? `Fehler beim stoppen der Projektzeit (Fehler ID: ${errorId})` : `Fehler beim stoppen der Projektzeit`,color:"rose"})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,9 +74,9 @@ const stopStartedTime = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="runningTimeInfo.started_at">
|
<div v-if="runningTimeInfo.startDate">
|
||||||
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
|
<p>Start: {{dayjs(runningTimeInfo.startDate).format("HH:mm")}}</p>
|
||||||
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p>
|
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') + ' min' }}</p>
|
||||||
|
|
||||||
<UFormGroup
|
<UFormGroup
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
@@ -94,4 +119,4 @@ const stopStartedTime = async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const profileStore = useProfileStore();
|
const profileStore = useProfileStore();
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const staffTime = useStaffTime()
|
|
||||||
|
|
||||||
const runningTimeInfo = ref({})
|
const runningTimeInfo = ref({})
|
||||||
|
|
||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
const rows = await staffTime.list({ user_id: profileStore.activeProfile?.user_id || profileStore.activeProfile?.id })
|
runningTimeInfo.value = (await supabase.from("workingtimes").select().eq("profile", profileStore.activeProfile.id).is("endDate", null).single()).data || {}
|
||||||
runningTimeInfo.value = rows.find((r) => !r.stopped_at && r.type === "work") || {}
|
console.log(runningTimeInfo.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
setupPage()
|
setupPage()
|
||||||
@@ -19,25 +19,47 @@ setupPage()
|
|||||||
}*/
|
}*/
|
||||||
|
|
||||||
const startTime = async () => {
|
const startTime = async () => {
|
||||||
try {
|
console.log("started")
|
||||||
await staffTime.start("Arbeitszeit")
|
runningTimeInfo.value = {
|
||||||
toast.add({title: "Anwesenheit erfolgreich gestartet"})
|
profile: profileStore.activeProfile.id,
|
||||||
await setupPage()
|
startDate: dayjs(),
|
||||||
} catch (error) {
|
tenant: profileStore.currentTenant,
|
||||||
|
state: "Im Web gestartet",
|
||||||
|
source: "Dashboard"
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data,error} = await supabase
|
||||||
|
.from("workingtimes")
|
||||||
|
.insert([runningTimeInfo.value])
|
||||||
|
.select()
|
||||||
|
if(error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
toast.add({title: "Fehler beim starten der Zeit",color:"rose"})
|
toast.add({title: "Fehler beim starten der Zeit",color:"rose"})
|
||||||
|
} else if(data) {
|
||||||
|
toast.add({title: "Anwesenheit erfolgreich gestartet"})
|
||||||
|
runningTimeInfo.value = data[0]
|
||||||
|
console.log(runningTimeInfo.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopStartedTime = async () => {
|
const stopStartedTime = async () => {
|
||||||
try {
|
runningTimeInfo.value.endDate = dayjs()
|
||||||
await staffTime.stop()
|
runningTimeInfo.value.state = "Im Web gestoppt"
|
||||||
|
|
||||||
|
const {error,status} = await supabase
|
||||||
|
.from("workingtimes")
|
||||||
|
.update(runningTimeInfo.value)
|
||||||
|
.eq('id',runningTimeInfo.value.id)
|
||||||
|
|
||||||
|
if(error) {
|
||||||
|
console.log(error)
|
||||||
|
let errorId = await useError().logError(`${status} - ${JSON.stringify(error)}`)
|
||||||
|
toast.add({title: errorId ? `Fehler beim stoppen der Anwesenheit (Fehler ID: ${errorId})` : `Fehler beim stoppen der Anwesenheit`,color:"rose"})
|
||||||
|
|
||||||
|
|
||||||
|
} else {
|
||||||
toast.add({title: "Anwesenheit erfolgreich gestoppt"})
|
toast.add({title: "Anwesenheit erfolgreich gestoppt"})
|
||||||
runningTimeInfo.value = {}
|
runningTimeInfo.value = {}
|
||||||
} catch (error) {
|
|
||||||
console.log(error)
|
|
||||||
let errorId = await useError().logError(`${JSON.stringify(error)}`)
|
|
||||||
toast.add({title: errorId ? `Fehler beim stoppen der Anwesenheit (Fehler ID: ${errorId})` : `Fehler beim stoppen der Anwesenheit`,color:"rose"})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,9 +67,9 @@ const stopStartedTime = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="runningTimeInfo.started_at">
|
<div v-if="runningTimeInfo.startDate">
|
||||||
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
|
<p>Start: {{dayjs(runningTimeInfo.startDate).format("HH:mm")}}</p>
|
||||||
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p>
|
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.startDate),'minutes') + ' min' }}</p>
|
||||||
|
|
||||||
<UFormGroup
|
<UFormGroup
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
@@ -76,4 +98,4 @@ const stopStartedTime = async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="email-editor">
|
|
||||||
<div v-if="editor" class="toolbar">
|
|
||||||
<div class="toolbar-group">
|
|
||||||
<button
|
|
||||||
class="toolbar-btn"
|
|
||||||
:class="{ 'is-active': editor.isActive('bold') }"
|
|
||||||
type="button"
|
|
||||||
title="Fett"
|
|
||||||
@click="editor.chain().focus().toggleBold().run()"
|
|
||||||
>
|
|
||||||
B
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="toolbar-btn italic"
|
|
||||||
:class="{ 'is-active': editor.isActive('italic') }"
|
|
||||||
type="button"
|
|
||||||
title="Kursiv"
|
|
||||||
@click="editor.chain().focus().toggleItalic().run()"
|
|
||||||
>
|
|
||||||
I
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="toolbar-btn line-through"
|
|
||||||
:class="{ 'is-active': editor.isActive('strike') }"
|
|
||||||
type="button"
|
|
||||||
title="Durchgestrichen"
|
|
||||||
@click="editor.chain().focus().toggleStrike().run()"
|
|
||||||
>
|
|
||||||
S
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toolbar-group">
|
|
||||||
<button
|
|
||||||
class="toolbar-btn"
|
|
||||||
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
|
||||||
type="button"
|
|
||||||
title="Überschrift"
|
|
||||||
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
|
||||||
>
|
|
||||||
H2
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="toolbar-btn"
|
|
||||||
:class="{ 'is-active': editor.isActive('bulletList') }"
|
|
||||||
type="button"
|
|
||||||
title="Liste"
|
|
||||||
@click="editor.chain().focus().toggleBulletList().run()"
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="toolbar-btn"
|
|
||||||
:class="{ 'is-active': editor.isActive('orderedList') }"
|
|
||||||
type="button"
|
|
||||||
title="Nummerierte Liste"
|
|
||||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
|
||||||
>
|
|
||||||
1.
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toolbar-group">
|
|
||||||
<button
|
|
||||||
class="toolbar-btn"
|
|
||||||
:class="{ 'is-active': editor.isActive('link') }"
|
|
||||||
type="button"
|
|
||||||
title="Link"
|
|
||||||
@click="setLink"
|
|
||||||
>
|
|
||||||
Link
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="toolbar-btn"
|
|
||||||
type="button"
|
|
||||||
title="Link entfernen"
|
|
||||||
@click="unsetLink"
|
|
||||||
>
|
|
||||||
Unlink
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EditorContent :editor="editor" class="editor-content" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onBeforeUnmount, watch } from 'vue'
|
|
||||||
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
|
||||||
import Link from '@tiptap/extension-link'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
preloadedContent?: string
|
|
||||||
}>(), {
|
|
||||||
preloadedContent: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
updateContent: [payload: { json: Record<string, any>; html: string; text: string }]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emitContent = () => {
|
|
||||||
if (!editor.value) return
|
|
||||||
emit('updateContent', {
|
|
||||||
json: editor.value.getJSON(),
|
|
||||||
html: editor.value.getHTML(),
|
|
||||||
text: editor.value.getText(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
content: props.preloadedContent,
|
|
||||||
extensions: [
|
|
||||||
StarterKit,
|
|
||||||
Placeholder.configure({ placeholder: 'E-Mail Inhalt eingeben...' }),
|
|
||||||
Link.configure({ openOnClick: false, autolink: true }),
|
|
||||||
],
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
class: 'prosemirror-email',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onCreate: emitContent,
|
|
||||||
onUpdate: emitContent,
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.preloadedContent,
|
|
||||||
(value) => {
|
|
||||||
if (!editor.value) return
|
|
||||||
if (value === editor.value.getHTML()) return
|
|
||||||
editor.value.commands.setContent(value || '', false)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const setLink = () => {
|
|
||||||
if (!editor.value) return
|
|
||||||
const previousUrl = editor.value.getAttributes('link').href
|
|
||||||
const url = window.prompt('URL eingeben:', previousUrl)
|
|
||||||
if (url === null) return
|
|
||||||
if (!url.trim()) {
|
|
||||||
editor.value.chain().focus().extendMarkRange('link').unsetLink().run()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsetLink = () => {
|
|
||||||
if (!editor.value) return
|
|
||||||
editor.value.chain().focus().extendMarkRange('link').unsetLink().run()
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
editor.value?.destroy()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.email-editor {
|
|
||||||
border: 1px solid #69c350;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding-right: 0.5rem;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
border-right: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-group:last-child {
|
|
||||||
border-right: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn {
|
|
||||||
min-width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
background: #fff;
|
|
||||||
color: #374151;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn:hover {
|
|
||||||
background: #f3f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn.is-active {
|
|
||||||
background: #dcfce7;
|
|
||||||
border-color: #69c350;
|
|
||||||
color: #14532d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-content {
|
|
||||||
min-height: 320px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.prosemirror-email) {
|
|
||||||
outline: none;
|
|
||||||
min-height: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.prosemirror-email p) {
|
|
||||||
margin: 0.3rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.prosemirror-email ul) {
|
|
||||||
list-style-type: disc;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
margin: 0.4rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.prosemirror-email ol) {
|
|
||||||
list-style-type: decimal;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
margin: 0.4rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.prosemirror-email li) {
|
|
||||||
display: list-item;
|
|
||||||
margin: 0.2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.prosemirror-email a) {
|
|
||||||
color: #2563eb;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -32,7 +32,15 @@ const default_data = {
|
|||||||
const newProjectDescription = ref(data|| default_data.value);
|
const newProjectDescription = ref(data|| default_data.value);
|
||||||
|
|
||||||
const saveProjectDescription = async () => {
|
const saveProjectDescription = async () => {
|
||||||
|
//Update Project Description
|
||||||
|
/*const {data:updateData,error:updateError} = await supabase
|
||||||
|
.from("projects")
|
||||||
|
.update({description: newProjectDescription.value})
|
||||||
|
.eq('id',currentProject.id)
|
||||||
|
.select()
|
||||||
|
|
||||||
|
console.log(updateData)
|
||||||
|
console.log(updateError)*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -53,4 +61,4 @@ const saveProjectDescription = async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,20 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
|
|
||||||
const workingtimes = ref([])
|
const workingtimes = ref([])
|
||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
const profiles = profileStore.profiles || []
|
workingtimes.value = (await supabase.from("workingtimes").select().eq("tenant",profileStore.currentTenant).is("endDate",null)).data
|
||||||
const checks = await Promise.all(profiles.map(async (profile) => {
|
|
||||||
try {
|
|
||||||
const spans = await useNuxtApp().$api(`/api/staff/time/spans?targetUserId=${profile.user_id || profile.id}`)
|
|
||||||
const openSpan = (spans || []).find((s) => !s.endedAt && s.type === "work")
|
|
||||||
if (openSpan) return { profile: profile.id }
|
|
||||||
} catch (e) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}))
|
|
||||||
workingtimes.value = checks.filter(Boolean)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupPage()
|
setupPage()
|
||||||
@@ -31,4 +21,4 @@ setupPage()
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: {
|
item: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<template>
|
|
||||||
<node-view-wrapper as="li" class="flex flex-row items-start gap-2 mb-2 !p-0 !m-0 bg-transparent">
|
|
||||||
<label class="flex-none pt-[0.15rem] cursor-pointer select-none" contenteditable="false">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="node.attrs.checked"
|
|
||||||
@change="onChange"
|
|
||||||
class="w-4 h-4 text-primary-600 rounded border-gray-300 focus:ring-primary-500 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<node-view-content class="flex-1 min-w-0 [&>p]:!m-0 [&>p]:!inline-block" />
|
|
||||||
</node-view-wrapper>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { NodeViewWrapper, NodeViewContent, nodeViewProps } from '@tiptap/vue-3'
|
|
||||||
|
|
||||||
const props = defineProps(nodeViewProps)
|
|
||||||
|
|
||||||
function onChange(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
props.updateAttributes({
|
|
||||||
checked: target.checked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="select-none text-sm text-gray-700 dark:text-gray-200 relative">
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="group flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors mb-0.5 relative pr-8"
|
|
||||||
:class="[
|
|
||||||
isActive ? 'bg-primary-50 dark:bg-primary-900/10 text-primary-600 dark:text-primary-400 font-medium' : 'hover:bg-gray-100 dark:hover:bg-gray-800',
|
|
||||||
item.isVirtual ? 'opacity-90' : ''
|
|
||||||
]"
|
|
||||||
:style="{ paddingLeft: `${indent}rem` }"
|
|
||||||
@click.stop="handleClick"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-if="item.isFolder"
|
|
||||||
class="h-4 w-4 flex items-center justify-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-transform duration-200"
|
|
||||||
:class="{ 'rotate-90': isOpen }"
|
|
||||||
@click.stop="toggleFolder"
|
|
||||||
>
|
|
||||||
<UIcon name="i-heroicons-chevron-right-20-solid" class="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
<span v-else class="w-4"></span>
|
|
||||||
|
|
||||||
<span class="shrink-0 flex items-center">
|
|
||||||
<UIcon
|
|
||||||
v-if="item.isFolder && item.isVirtual"
|
|
||||||
name="i-heroicons-folder"
|
|
||||||
class="w-4 h-4 text-primary-500 dark:text-primary-400"
|
|
||||||
/>
|
|
||||||
<UIcon
|
|
||||||
v-else-if="item.isFolder"
|
|
||||||
name="i-heroicons-folder-solid"
|
|
||||||
class="w-4 h-4 text-yellow-400"
|
|
||||||
/>
|
|
||||||
<UIcon
|
|
||||||
v-else
|
|
||||||
name="i-heroicons-document-text"
|
|
||||||
class="w-4 h-4 text-gray-400"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="truncate flex-1" :class="{'italic text-gray-500': item.isVirtual && !item.parentId}">
|
|
||||||
{{ item.title }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div v-if="!item.isVirtual" class="absolute right-1 top-1 opacity-0 group-hover:opacity-100 transition-opacity" :class="{ '!opacity-100': showMenu }">
|
|
||||||
<button @click.stop="showMenu = !showMenu" class="p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-500">
|
|
||||||
<UIcon name="i-heroicons-ellipsis-horizontal" class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="absolute right-1 top-1 opacity-0 group-hover:opacity-50 transition-opacity flex items-center justify-center pt-0.5 pr-0.5">
|
|
||||||
<UTooltip text="Automatisch generiert">
|
|
||||||
<UIcon name="i-heroicons-lock-closed" class="w-3 h-3 text-gray-300" />
|
|
||||||
</UTooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showMenu" v-on-click-outside="() => showMenu = false" class="absolute right-0 top-8 z-50 w-40 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-100 dark:border-gray-700 py-1 text-xs text-gray-700 dark:text-gray-200 animate-in fade-in slide-in-from-top-1 duration-100">
|
|
||||||
|
|
||||||
<template v-if="item.isFolder">
|
|
||||||
<button @click.stop="triggerCreate(false)" class="w-full text-left px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2">
|
|
||||||
<UIcon name="i-heroicons-plus" class="w-3 h-3" /> Neue Seite
|
|
||||||
</button>
|
|
||||||
<button @click.stop="triggerCreate(true)" class="w-full text-left px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2">
|
|
||||||
<UIcon name="i-heroicons-folder-plus" class="w-3 h-3 text-yellow-500" /> Neuer Ordner
|
|
||||||
</button>
|
|
||||||
<div class="h-px bg-gray-100 dark:bg-gray-700 my-1"></div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<button @click.stop="triggerDelete" class="w-full text-left px-3 py-1.5 hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600 flex items-center gap-2">
|
|
||||||
<UIcon name="i-heroicons-trash" class="w-3 h-3" /> Löschen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="item.isFolder && isOpen">
|
|
||||||
<WikiTreeItem v-for="child in item.children" :key="child.id" :item="child" :depth="depth + 1" />
|
|
||||||
<div v-if="!item.children?.length" class="text-xs text-gray-400 py-1 italic" :style="{ paddingLeft: `${indent + 1.8}rem` }">Leer</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { vOnClickOutside } from '@vueuse/components'
|
|
||||||
import type { WikiPageItem } from '~/composables/useWikiTree'
|
|
||||||
|
|
||||||
const props = defineProps<{ item: WikiPageItem; depth?: number }>()
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
const { searchQuery } = useWikiTree()
|
|
||||||
|
|
||||||
const openWikiAction = inject('openWikiAction') as (action: 'create' | 'delete', contextItem: WikiPageItem | null, isFolder?: boolean) => void
|
|
||||||
|
|
||||||
const depth = props.depth ?? 0
|
|
||||||
const indent = computed(() => 0.5 + (depth * 0.7))
|
|
||||||
const isOpen = ref(false)
|
|
||||||
const showMenu = ref(false)
|
|
||||||
const isActive = computed(() => route.params.id === props.item.id)
|
|
||||||
|
|
||||||
// Auto-Open: Active Page
|
|
||||||
watch(() => route.params.id, (newId) => {
|
|
||||||
if (props.item.isFolder && hasActiveChild(props.item, newId as string)) isOpen.value = true
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// Auto-Open: Search
|
|
||||||
watch(searchQuery, (newVal) => {
|
|
||||||
if (newVal.trim().length > 0 && props.item.isFolder) {
|
|
||||||
isOpen.value = true
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
function hasActiveChild(node: WikiPageItem, targetId: string): boolean {
|
|
||||||
if (node.id === targetId) return true
|
|
||||||
return node.children?.some(c => hasActiveChild(c, targetId)) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
if (props.item.isFolder) {
|
|
||||||
isOpen.value = !isOpen.value
|
|
||||||
} else {
|
|
||||||
// Falls es eine virtuelle Seite ist (Read only Entity View?), leiten wir trotzdem weiter
|
|
||||||
// oder verhindern es, je nach Wunsch. Aktuell erlauben wir Navigation:
|
|
||||||
router.push(`/wiki/${props.item.id}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFolder() { isOpen.value = !isOpen.value }
|
|
||||||
|
|
||||||
function triggerCreate(isFolder: boolean) {
|
|
||||||
// Sicherheitscheck: Virtuelle Ordner dürfen nichts erstellen
|
|
||||||
if (props.item.isVirtual) return
|
|
||||||
|
|
||||||
showMenu.value = false
|
|
||||||
isOpen.value = true
|
|
||||||
openWikiAction('create', props.item, isFolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerDelete() {
|
|
||||||
// Sicherheitscheck
|
|
||||||
if (props.item.isVirtual) return
|
|
||||||
|
|
||||||
showMenu.value = false
|
|
||||||
openWikiAction('delete', props.item)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col h-full bg-white dark:bg-gray-900 relative">
|
|
||||||
|
|
||||||
<div v-if="editor" class="border-b border-gray-100 dark:border-gray-800 px-4 py-2 flex flex-wrap gap-1 items-center sticky top-0 bg-white dark:bg-gray-900 z-20 shadow-sm select-none">
|
|
||||||
|
|
||||||
<div class="flex gap-0.5 border-r border-gray-200 dark:border-gray-700 pr-2 mr-2">
|
|
||||||
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }" class="toolbar-btn font-bold" title="Fett">B</button>
|
|
||||||
<button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }" class="toolbar-btn italic" title="Kursiv">I</button>
|
|
||||||
<button @click="editor.chain().focus().toggleStrike().run()" :class="{ 'is-active': editor.isActive('strike') }" class="toolbar-btn line-through" title="Durchgestrichen">S</button>
|
|
||||||
<button @click="editor.chain().focus().toggleHighlight().run()" :class="{ 'is-active': editor.isActive('highlight') }" class="toolbar-btn text-yellow-500 font-bold" title="Markieren">M</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-0.5 border-r border-gray-200 dark:border-gray-700 pr-2 mr-2">
|
|
||||||
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }" class="toolbar-btn" title="Überschrift 1">H1</button>
|
|
||||||
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" class="toolbar-btn" title="Überschrift 2">H2</button>
|
|
||||||
<button @click="editor.chain().focus().toggleCodeBlock().run()" :class="{ 'is-active': editor.isActive('codeBlock') }" class="toolbar-btn font-mono text-xs" title="Code Block"></></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-0.5 border-r border-gray-200 dark:border-gray-700 pr-2 mr-2">
|
|
||||||
<button @click="editor.chain().focus().toggleBulletList().run()" :class="{ 'is-active': editor.isActive('bulletList') }" class="toolbar-btn" title="Liste">•</button>
|
|
||||||
<button @click="editor.chain().focus().toggleTaskList().run()" :class="{ 'is-active': editor.isActive('taskList') }" class="toolbar-btn" title="Aufgabenliste">☑</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-0.5 items-center">
|
|
||||||
<button @click="editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()" class="toolbar-btn" title="Tabelle">▦</button>
|
|
||||||
<button @click="addVideo" class="toolbar-btn text-red-600" title="YouTube Video">
|
|
||||||
<UIcon name="i-heroicons-video-camera" class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BubbleMenu
|
|
||||||
v-if="editor"
|
|
||||||
:editor="editor"
|
|
||||||
:tippy-options="{ duration: 200, placement: 'top-start', animation: 'scale' }"
|
|
||||||
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl flex items-center p-1 gap-1 z-50"
|
|
||||||
>
|
|
||||||
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }" class="bubble-btn font-bold">B</button>
|
|
||||||
<button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }" class="bubble-btn italic">I</button>
|
|
||||||
<button @click="editor.chain().focus().toggleHighlight().run()" :class="{ 'is-active': editor.isActive('highlight') }" class="bubble-btn text-yellow-500 font-bold">M</button>
|
|
||||||
<div class="w-px h-4 bg-gray-200 mx-1"></div>
|
|
||||||
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" class="bubble-btn font-bold text-sm">H2</button>
|
|
||||||
<div class="w-px h-4 bg-gray-200 mx-1"></div>
|
|
||||||
<button @click="setLink" :class="{ 'is-active': editor.isActive('link') }" class="bubble-btn text-sm">Link</button>
|
|
||||||
</BubbleMenu>
|
|
||||||
|
|
||||||
<FloatingMenu
|
|
||||||
v-if="editor"
|
|
||||||
:editor="editor"
|
|
||||||
:tippy-options="{ duration: 100, placement: 'right-start' }"
|
|
||||||
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg flex items-center p-1 gap-1 -ml-4"
|
|
||||||
>
|
|
||||||
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" class="bubble-btn text-xs font-bold">H1</button>
|
|
||||||
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" class="bubble-btn text-xs font-bold">H2</button>
|
|
||||||
<button @click="editor.chain().focus().toggleBulletList().run()" class="bubble-btn">•</button>
|
|
||||||
<button @click="editor.chain().focus().toggleTaskList().run()" class="bubble-btn">☑</button>
|
|
||||||
<button @click="editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()" class="bubble-btn">▦</button>
|
|
||||||
<button @click="editor.chain().focus().toggleCodeBlock().run()" class="bubble-btn font-mono text-xs"><></button>
|
|
||||||
</FloatingMenu>
|
|
||||||
|
|
||||||
<editor-content
|
|
||||||
:editor="editor"
|
|
||||||
class="flex-1 overflow-y-auto px-8 py-6 prose prose-slate dark:prose-invert max-w-none focus:outline-none custom-editor-area"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="editor" class="border-t border-gray-100 dark:border-gray-800 px-4 py-1 text-xs text-gray-400 flex justify-end bg-gray-50 dark:bg-gray-900/50">
|
|
||||||
{{ editor.storage.characterCount.words() }} Wörter
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { watch } from 'vue'
|
|
||||||
import { useRouter } from '#app'
|
|
||||||
import { useEditor, EditorContent, VueNodeViewRenderer } from '@tiptap/vue-3'
|
|
||||||
// @ts-ignore
|
|
||||||
import { BubbleMenu, FloatingMenu } from '@tiptap/vue-3/menus'
|
|
||||||
|
|
||||||
// Tiptap Extensions
|
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
|
||||||
import Link from '@tiptap/extension-link'
|
|
||||||
import TaskList from '@tiptap/extension-task-list'
|
|
||||||
import TaskItem from '@tiptap/extension-task-item'
|
|
||||||
import Image from '@tiptap/extension-image'
|
|
||||||
import Highlight from '@tiptap/extension-highlight'
|
|
||||||
import Youtube from '@tiptap/extension-youtube'
|
|
||||||
import Typography from '@tiptap/extension-typography'
|
|
||||||
import CharacterCount from '@tiptap/extension-character-count'
|
|
||||||
import CodeBlock from '@tiptap/extension-code-block'
|
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// WICHTIGE ÄNDERUNG: NUR Table, KEINE Row/Cell/Header Imports
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
import { Table } from '@tiptap/extension-table'
|
|
||||||
import { TableRow } from '@tiptap/extension-table-row'
|
|
||||||
import { TableCell } from '@tiptap/extension-table-cell'
|
|
||||||
import { TableHeader } from '@tiptap/extension-table-header'
|
|
||||||
|
|
||||||
import BubbleMenuExtension from '@tiptap/extension-bubble-menu'
|
|
||||||
import FloatingMenuExtension from '@tiptap/extension-floating-menu'
|
|
||||||
import Mention from '@tiptap/extension-mention'
|
|
||||||
|
|
||||||
import TiptapTaskItem from './TiptapTaskItem.vue'
|
|
||||||
import wikiSuggestion from '~/composables/useWikiSuggestion'
|
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: any }>()
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// Damit wir TaskItem nicht ständig neu initialisieren
|
|
||||||
const CustomTaskItem = TaskItem.extend({
|
|
||||||
addNodeView() {
|
|
||||||
return VueNodeViewRenderer(TiptapTaskItem)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
content: props.modelValue,
|
|
||||||
extensions: [
|
|
||||||
StarterKit.configure({ codeBlock: false }),
|
|
||||||
Placeholder.configure({ placeholder: 'Tippe "/" für Befehle oder "#" für Seiten...' }),
|
|
||||||
|
|
||||||
BubbleMenuExtension.configure({ shouldShow: ({ editor, from, to }) => !editor.state.selection.empty && (to - from > 0) }),
|
|
||||||
FloatingMenuExtension,
|
|
||||||
|
|
||||||
Link.configure({ openOnClick: false, autolink: true }),
|
|
||||||
Image, Highlight, Typography, CharacterCount, CodeBlock,
|
|
||||||
Youtube.configure({ width: 640, height: 480 }),
|
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// WICHTIGE ÄNDERUNG: Nur Table.configure()
|
|
||||||
// TableRow, TableHeader, TableCell wurden HIER ENTFERNT
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
Table.configure({ resizable: true }),
|
|
||||||
TableRow,
|
|
||||||
TableHeader,
|
|
||||||
TableCell,
|
|
||||||
|
|
||||||
// TASK LIST
|
|
||||||
TaskList,
|
|
||||||
CustomTaskItem.configure({
|
|
||||||
nested: true,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// INTERNAL LINKING
|
|
||||||
Mention.configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: 'wiki-mention',
|
|
||||||
},
|
|
||||||
suggestion: {
|
|
||||||
...wikiSuggestion,
|
|
||||||
char: '#'
|
|
||||||
},
|
|
||||||
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
class: 'min-h-[500px] pb-40',
|
|
||||||
},
|
|
||||||
handleClick: (view, pos, event) => {
|
|
||||||
const target = event.target as HTMLElement
|
|
||||||
if (target.closest('.wiki-mention')) {
|
|
||||||
const id = target.getAttribute('data-id')
|
|
||||||
if (id) {
|
|
||||||
router.push(`/wiki/${id}`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onUpdate: ({ editor }) => {
|
|
||||||
emit('update:modelValue', editor.getJSON())
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync Model -> Editor
|
|
||||||
watch(() => props.modelValue, (val) => {
|
|
||||||
if (editor.value && JSON.stringify(val) !== JSON.stringify(editor.value.getJSON())) {
|
|
||||||
//@ts-ignore
|
|
||||||
editor.value.commands.setContent(val, false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ACTIONS
|
|
||||||
const setLink = () => {
|
|
||||||
if (!editor.value) return
|
|
||||||
const previousUrl = editor.value.getAttributes('link').href
|
|
||||||
const url = window.prompt('URL eingeben:', previousUrl)
|
|
||||||
if (url === null) return
|
|
||||||
if (url === '') {
|
|
||||||
editor.value.chain().focus().extendMarkRange('link').unsetLink().run()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
|
||||||
}
|
|
||||||
|
|
||||||
const addVideo = () => {
|
|
||||||
const url = prompt('Video URL:')
|
|
||||||
if (url && editor.value) editor.value.commands.setYoutubeVideo({ src: url })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Toolbar & Buttons */
|
|
||||||
.toolbar-btn {
|
|
||||||
@apply p-1.5 rounded text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm font-medium min-w-[28px] h-[28px] flex items-center justify-center;
|
|
||||||
}
|
|
||||||
.toolbar-btn.is-active {
|
|
||||||
@apply bg-gray-200 dark:bg-gray-600 text-black dark:text-white shadow-inner;
|
|
||||||
}
|
|
||||||
.bubble-btn {
|
|
||||||
@apply px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors text-sm min-w-[24px] h-[24px] flex items-center justify-center text-gray-700 dark:text-gray-200;
|
|
||||||
}
|
|
||||||
.bubble-btn.is-active {
|
|
||||||
@apply bg-gray-200 dark:bg-gray-600 text-black dark:text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* GLOBAL EDITOR STYLES */
|
|
||||||
:deep(.prose) {
|
|
||||||
.ProseMirror {
|
|
||||||
outline: none;
|
|
||||||
caret-color: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* LIST RESET */
|
|
||||||
ul[data-type="taskList"] {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* MENTION */
|
|
||||||
.wiki-mention {
|
|
||||||
/* Pill-Shape, grau/neutral statt knallig blau */
|
|
||||||
@apply bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 px-1.5 py-0.5 rounded-md text-sm font-medium no-underline inline-block mx-0.5 align-middle border border-gray-200 dark:border-gray-700;
|
|
||||||
|
|
||||||
box-decoration-break: clone;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-mention::before {
|
|
||||||
@apply text-gray-400 dark:text-gray-500 mr-0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-mention:hover {
|
|
||||||
@apply bg-primary-50 dark:bg-primary-900/30 border-primary-200 dark:border-primary-800 text-primary-700 dark:text-primary-400;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TABLE */
|
|
||||||
table { width: 100% !important; border-collapse: collapse; table-layout: fixed; margin: 1rem 0; }
|
|
||||||
th, td { border: 1px solid #d1d5db; padding: 0.5rem; vertical-align: top; box-sizing: border-box; min-width: 1em; }
|
|
||||||
.dark th, .dark td { border-color: #374151; }
|
|
||||||
th { background-color: #f3f4f6; font-weight: 600; text-align: left; }
|
|
||||||
.dark th { background-color: #1f2937; }
|
|
||||||
.column-resize-handle { background-color: #3b82f6; width: 4px; }
|
|
||||||
|
|
||||||
/* CODE */
|
|
||||||
pre { background: #0d1117; color: #c9d1d9; font-family: 'JetBrains Mono', monospace; padding: 0.75rem 1rem; border-radius: 0.5rem; margin: 1rem 0; overflow-x: auto; }
|
|
||||||
code { color: inherit; padding: 0; background: none; font-size: 0.9rem; }
|
|
||||||
|
|
||||||
/* IMG */
|
|
||||||
img { max-width: 100%; height: auto; border-radius: 6px; display: block; margin: 1rem 0; }
|
|
||||||
|
|
||||||
/* MISC */
|
|
||||||
p.is-editor-empty:first-child::before { color: #9ca3af; content: attr(data-placeholder); float: left; height: 0; pointer-events: none; }
|
|
||||||
mark { background-color: #fef08a; padding: 0.1rem 0.2rem; border-radius: 0.2rem; }
|
|
||||||
.ProseMirror-selectednode { outline: 2px solid #3b82f6; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex h-full w-full min-h-[500px] border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 shadow-sm relative isolate overflow-hidden">
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-col border-r border-gray-200 dark:border-gray-800 bg-gray-50/80 dark:bg-gray-900/50 transition-all duration-300"
|
|
||||||
:class="selectedPage ? 'w-64 hidden md:flex shrink-0' : 'w-full'"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between px-3 py-3 border-b border-gray-200 dark:border-gray-800 shrink-0">
|
|
||||||
<span class="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-1">
|
|
||||||
<UIcon name="i-heroicons-list-bullet" class="w-3 h-3" />
|
|
||||||
Inhalte
|
|
||||||
</span>
|
|
||||||
<UTooltip text="Neue Notiz erstellen">
|
|
||||||
<UButton size="2xs" color="primary" variant="ghost" icon="i-heroicons-plus" @click="isCreateModalOpen = true" />
|
|
||||||
</UTooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-2 space-y-1 custom-scrollbar">
|
|
||||||
<div v-if="loadingList" class="space-y-2 pt-2">
|
|
||||||
<USkeleton class="h-8 w-full" />
|
|
||||||
<USkeleton class="h-8 w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else-if="pages.length">
|
|
||||||
<div
|
|
||||||
v-for="page in pages"
|
|
||||||
:key="page.id"
|
|
||||||
class="flex items-center gap-2 px-2.5 py-2 rounded-md cursor-pointer text-sm transition-all border border-transparent"
|
|
||||||
:class="selectedPage?.id === page.id ? 'bg-white dark:bg-gray-800 text-primary-600 shadow-sm border-gray-200 dark:border-gray-700 font-medium' : 'text-gray-600 hover:bg-white dark:hover:bg-gray-800 hover:shadow-sm'"
|
|
||||||
@click="selectPage(page.id)"
|
|
||||||
>
|
|
||||||
<UIcon :name="page.isFolder ? 'i-heroicons-folder' : 'i-heroicons-document-text'" class="w-4 h-4 shrink-0" />
|
|
||||||
<span class="truncate flex-1">{{ page.title }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-else class="flex flex-col items-center justify-center py-12 px-4 text-center">
|
|
||||||
<p class="text-xs text-gray-500">Keine Notizen vorhanden.</p>
|
|
||||||
<UButton variant="link" size="xs" color="primary" @click="isCreateModalOpen = true" class="mt-1">Erstellen</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col bg-white dark:bg-gray-900 h-full relative min-w-0 w-full overflow-hidden">
|
|
||||||
|
|
||||||
<div v-if="selectedPage" class="flex flex-col h-full w-full">
|
|
||||||
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800 z-10 shrink-0 bg-white dark:bg-gray-900">
|
|
||||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
<UButton icon="i-heroicons-arrow-left" variant="ghost" color="gray" class="md:hidden shrink-0" @click="selectedPage = null" />
|
|
||||||
<input
|
|
||||||
v-model="selectedPage.title"
|
|
||||||
@input="onInputTitle"
|
|
||||||
class="bg-transparent font-semibold text-gray-900 dark:text-white focus:outline-none w-full truncate"
|
|
||||||
placeholder="Titel..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 text-xs text-gray-400 shrink-0 ml-2">
|
|
||||||
<span class="hidden sm:inline">{{ isSaving ? 'Speichert...' : 'Gespeichert' }}</span>
|
|
||||||
<UButton icon="i-heroicons-trash" color="gray" variant="ghost" size="xs" @click="deleteCurrentPage"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-hidden relative w-full">
|
|
||||||
<div v-if="loadingContent" class="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-900/80 z-20">
|
|
||||||
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin text-primary-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<WikiEditor
|
|
||||||
v-model="selectedPage.content"
|
|
||||||
class="h-full w-full"
|
|
||||||
:page-id="selectedPage.id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="hidden md:flex flex-1 flex-col items-center justify-center w-full h-full bg-gray-50/30 dark:bg-gray-900 p-6 text-center">
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 p-4 rounded-full shadow-sm ring-1 ring-gray-100 dark:ring-gray-700 mb-4">
|
|
||||||
<UIcon name="i-heroicons-book-open" class="w-10 h-10 text-primary-200 dark:text-primary-800" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
|
|
||||||
Wiki & Dokumentation
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto mb-6">
|
|
||||||
Wähle links eine Notiz aus oder erstelle einen neuen Eintrag für diesen Datensatz.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
icon="i-heroicons-plus"
|
|
||||||
color="primary"
|
|
||||||
@click="isCreateModalOpen = true"
|
|
||||||
>
|
|
||||||
Neue Seite erstellen
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UModal v-model="isCreateModalOpen">
|
|
||||||
<div class="p-5">
|
|
||||||
<h3 class="font-bold mb-4">Neue Seite</h3>
|
|
||||||
<form @submit.prevent="createPage">
|
|
||||||
<UInput v-model="newTitle" placeholder="Titel..." autofocus />
|
|
||||||
<div class="mt-4 flex justify-end gap-2">
|
|
||||||
<UButton color="gray" variant="ghost" @click="isCreateModalOpen = false">Abbrechen</UButton>
|
|
||||||
<UButton type="submit" color="primary" :loading="isCreating">Erstellen</UButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</UModal>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, watch } from 'vue'
|
|
||||||
import WikiEditor from '~/components/wiki/WikiEditor.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
entityType: string
|
|
||||||
entityId?: number | null
|
|
||||||
entityUuid?: string | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { $api } = useNuxtApp()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const loadingList = ref(false)
|
|
||||||
const loadingContent = ref(false)
|
|
||||||
const isCreateModalOpen = ref(false)
|
|
||||||
const isCreating = ref(false)
|
|
||||||
const isSaving = ref(false)
|
|
||||||
const pages = ref<any[]>([])
|
|
||||||
const selectedPage = ref<any>(null)
|
|
||||||
const newTitle = ref('')
|
|
||||||
let saveTimeout: any = null
|
|
||||||
|
|
||||||
const getParams = () => {
|
|
||||||
const p: any = { entityType: props.entityType }
|
|
||||||
if (props.entityId) p.entityId = props.entityId
|
|
||||||
else if (props.entityUuid) p.entityUuid = props.entityUuid
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchList() {
|
|
||||||
loadingList.value = true
|
|
||||||
try {
|
|
||||||
const data = await $api('/api/wiki/tree', { method: 'GET', params: getParams() })
|
|
||||||
pages.value = data
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Fetch Error:", e)
|
|
||||||
} finally {
|
|
||||||
loadingList.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectPage(id: string) {
|
|
||||||
if (selectedPage.value?.id === id) return
|
|
||||||
loadingContent.value = true
|
|
||||||
try {
|
|
||||||
const data = await $api(`/api/wiki/${id}`, { method: 'GET' })
|
|
||||||
selectedPage.value = data
|
|
||||||
} catch (e) {
|
|
||||||
toast.add({ title: 'Fehler beim Laden', color: 'red' })
|
|
||||||
} finally {
|
|
||||||
loadingContent.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerSave() {
|
|
||||||
if (saveTimeout) clearTimeout(saveTimeout)
|
|
||||||
isSaving.value = true
|
|
||||||
saveTimeout = setTimeout(async () => {
|
|
||||||
if (!selectedPage.value) return
|
|
||||||
try {
|
|
||||||
await $api(`/api/wiki/${selectedPage.value.id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: {
|
|
||||||
title: selectedPage.value.title,
|
|
||||||
content: selectedPage.value.content
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const item = pages.value.find(p => p.id === selectedPage.value.id)
|
|
||||||
if (item) item.title = selectedPage.value.title
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInputTitle() {
|
|
||||||
triggerSave()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => selectedPage.value?.content, (newVal, oldVal) => {
|
|
||||||
if (newVal && oldVal && !loadingContent.value) triggerSave()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
async function createPage() {
|
|
||||||
if (!newTitle.value) return
|
|
||||||
isCreating.value = true
|
|
||||||
try {
|
|
||||||
const res = await $api('/api/wiki', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { title: newTitle.value, parentId: null, isFolder: false, ...getParams() }
|
|
||||||
})
|
|
||||||
await fetchList()
|
|
||||||
newTitle.value = ''
|
|
||||||
isCreateModalOpen.value = false
|
|
||||||
await selectPage(res.id)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
isCreating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteCurrentPage() {
|
|
||||||
if(!confirm('Löschen?')) return
|
|
||||||
await $api(`/api/wiki/${selectedPage.value.id}`, { method: 'DELETE' })
|
|
||||||
selectedPage.value = null
|
|
||||||
fetchList()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(fetchList)
|
|
||||||
watch(() => [props.entityId, props.entityUuid], fetchList)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb { background-color: #ddd; border-radius: 4px; }
|
|
||||||
</style>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl overflow-hidden min-w-[12rem] flex flex-col p-1 gap-0.5">
|
|
||||||
<template v-if="items.length">
|
|
||||||
<button
|
|
||||||
v-for="(item, index) in items"
|
|
||||||
:key="item.id"
|
|
||||||
class="flex items-center gap-2 px-2 py-1.5 text-sm rounded text-left transition-colors w-full"
|
|
||||||
:class="{ 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-400': index === selectedIndex, 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200': index !== selectedIndex }"
|
|
||||||
@click="selectItem(index)"
|
|
||||||
>
|
|
||||||
<UIcon :name="item.isFolder ? 'i-heroicons-folder' : 'i-heroicons-document-text'" class="w-4 h-4 text-gray-400" />
|
|
||||||
<span class="truncate">{{ item.title }}</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<div v-else class="px-3 py-2 text-xs text-gray-400 text-center">
|
|
||||||
Keine Seite gefunden
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
items: { type: Array, required: true },
|
|
||||||
command: { type: Function, required: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedIndex = ref(0)
|
|
||||||
|
|
||||||
// Wenn sich die Liste ändert, Reset Selection
|
|
||||||
watch(() => props.items, () => { selectedIndex.value = 0 })
|
|
||||||
|
|
||||||
function selectItem(index: number) {
|
|
||||||
const item = props.items[index]
|
|
||||||
if (item) props.command({ id: item.id, label: item.title })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyDown({ event }: { event: KeyboardEvent }) {
|
|
||||||
if (event.key === 'ArrowUp') {
|
|
||||||
selectedIndex.value = (selectedIndex.value + props.items.length - 1) % props.items.length
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (event.key === 'ArrowDown') {
|
|
||||||
selectedIndex.value = (selectedIndex.value + 1) % props.items.length
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
selectItem(selectedIndex.value)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose für Tiptap Render Logic
|
|
||||||
defineExpose({ onKeyDown })
|
|
||||||
</script>
|
|
||||||
25
frontend/composables/useErrorLogging.js
Normal file
25
frontend/composables/useErrorLogging.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
export const useErrorLogging = (resourceType) => {
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
const toast = useToast()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
|
||||||
|
const logError = async (error) => {
|
||||||
|
let errorData = {
|
||||||
|
message: error,
|
||||||
|
tenant: profileStore.currentTenant,
|
||||||
|
profile: profileStore.activeProfile.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data:supabaseData,error:supabaseError} = await supabase.from("errors").insert(errorData).select().single()
|
||||||
|
|
||||||
|
if(supabaseError) {
|
||||||
|
console.error(supabaseError)
|
||||||
|
} else if(supabaseData) {
|
||||||
|
return supabaseData.id
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return { logError}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
export const useFiles = () => {
|
export const useFiles = () => {
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import dayjs from "dayjs";
|
|||||||
const baseURL = /*"http://192.168.1.129:3333"*/ /*"http://localhost:3333"*/ "https://functions.fedeo.io"
|
const baseURL = /*"http://192.168.1.129:3333"*/ /*"http://localhost:3333"*/ "https://functions.fedeo.io"
|
||||||
|
|
||||||
export const useFunctions = () => {
|
export const useFunctions = () => {
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
|
||||||
const getWorkingTimesEvaluationData = async (user_id, startDate, endDate) => {
|
const getWorkingTimesEvaluationData = async (user_id, startDate, endDate) => {
|
||||||
// Der neue Endpunkt ist /staff/time/evaluation und erwartet die Benutzer-ID als targetUserId Query-Parameter.
|
// Der neue Endpunkt ist /staff/time/evaluation und erwartet die Benutzer-ID als targetUserId Query-Parameter.
|
||||||
@@ -29,6 +30,26 @@ export const useFunctions = () => {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useCreateTicket = async (subject,message,url,source) => {
|
||||||
|
const {data:{session:{access_token}}} = await supabase.auth.getSession()
|
||||||
|
|
||||||
|
const {data} = await axios({
|
||||||
|
method: "POST",
|
||||||
|
url: `${baseURL}/functions/createticket`,
|
||||||
|
data: {
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
source,
|
||||||
|
url
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${access_token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return !!data.ticket_created;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
const useBankingGenerateLink = async (institutionId) => {
|
const useBankingGenerateLink = async (institutionId) => {
|
||||||
return (await useNuxtApp().$api(`/api/banking/link/${institutionId}`)).link
|
return (await useNuxtApp().$api(`/api/banking/link/${institutionId}`)).link
|
||||||
@@ -50,29 +71,56 @@ export const useFunctions = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const useZipCheck = async (zip) => {
|
const useZipCheck = async (zip) => {
|
||||||
const normalizedZip = String(zip || "").replace(/\D/g, "")
|
const returnData = await useNuxtApp().$api(`/api/functions/check-zip/${zip}`, {
|
||||||
if (!normalizedZip || normalizedZip.length > 5) {
|
method: "GET",
|
||||||
return null
|
})
|
||||||
}
|
|
||||||
const lookupZip = normalizedZip.padStart(5, "0")
|
return returnData
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await useNuxtApp().$api(`/api/functions/check-zip/${lookupZip}`, {
|
|
||||||
method: "GET",
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
zip: String(data?.zip ?? lookupZip).replace(/\D/g, "").padStart(5, "0")
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const useGetInvoiceData = async (file) => {
|
||||||
|
const {data:{session:{access_token}}} = await supabase.auth.getSession()
|
||||||
|
|
||||||
|
const {data} = await axios({
|
||||||
|
method: "POST",
|
||||||
|
url: `${baseURL}/functions/getinvoicedatafromgpt`,
|
||||||
|
data: {
|
||||||
|
file
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${access_token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(data)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSendTelegramNotification = async (message) => {
|
||||||
|
const {data:{session:{access_token}}} = await supabase.auth.getSession()
|
||||||
|
|
||||||
|
const {data,error} = await axios({
|
||||||
|
method: "POST",
|
||||||
|
url: `${baseURL}/functions/sendtelegramnotification`,
|
||||||
|
data: {
|
||||||
|
message: message
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${access_token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if(error){
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const useBankingCheckInstitutions = async (bic) => {
|
const useBankingCheckInstitutions = async (bic) => {
|
||||||
|
|
||||||
@@ -86,5 +134,5 @@ export const useFunctions = () => {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useCreatePDF}
|
return {getWorkingTimesEvaluationData, useNextNumber, useCreateTicket, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useCreatePDF,useGetInvoiceData, useSendTelegramNotification}
|
||||||
}
|
}
|
||||||
61
frontend/composables/useNumberRange.js
Normal file
61
frontend/composables/useNumberRange.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
|
||||||
|
export const useNumberRange = (resourceType) => {
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
|
||||||
|
const numberRanges = profileStore.ownTenant.numberRanges
|
||||||
|
|
||||||
|
const numberRange = numberRanges[resourceType]
|
||||||
|
|
||||||
|
const useNextNumber = async () => {
|
||||||
|
|
||||||
|
let nextNumber = numberRange.nextNumber
|
||||||
|
|
||||||
|
let newNumberRanges = numberRanges
|
||||||
|
|
||||||
|
newNumberRanges[resourceType].nextNumber += 1
|
||||||
|
|
||||||
|
const {data,error} = await supabase
|
||||||
|
.from("tenants")
|
||||||
|
.update({numberRanges: newNumberRanges})
|
||||||
|
.eq('id',profileStore.currentTenant)
|
||||||
|
|
||||||
|
|
||||||
|
await profileStore.fetchOwnTenant()
|
||||||
|
|
||||||
|
return (numberRange.prefix ? numberRange.prefix : "") + nextNumber + (numberRange.suffix ? numberRange.suffix : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { useNextNumber}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*export const useNumberRange = (resourceType) => {
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
|
||||||
|
const {numberRanges} = storeToRefs(useDataStore())
|
||||||
|
const {fetchNumberRanges} = useDataStore()
|
||||||
|
|
||||||
|
const numberRange = numberRanges.value.find(range => range.resourceType === resourceType)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const useNextNumber = async () => {
|
||||||
|
|
||||||
|
let nextNumber = numberRange.nextNumber
|
||||||
|
|
||||||
|
const {data,error} = await supabase
|
||||||
|
.from("numberranges")
|
||||||
|
.update({nextNumber: nextNumber + 1})
|
||||||
|
.eq('id',numberRange.id)
|
||||||
|
|
||||||
|
fetchNumberRanges()
|
||||||
|
|
||||||
|
return (numberRange.prefix ? numberRange.prefix : "") + nextNumber + (numberRange.suffix ? numberRange.suffix : "")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return { useNextNumber}
|
||||||
|
}*/
|
||||||
27
frontend/composables/usePrintLabel.js
Normal file
27
frontend/composables/usePrintLabel.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import Handlebars from "handlebars";
|
||||||
|
|
||||||
|
export const usePrintLabel = async (printServerId,printerName , rawZPL ) => {
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
|
||||||
|
await supabase.from("printJobs").insert({
|
||||||
|
tenant: profileStore.currentTenant,
|
||||||
|
rawContent: rawZPL,
|
||||||
|
printerName: printerName,
|
||||||
|
printServer: printServerId
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGenerateZPL = (rawZPL,data) => {
|
||||||
|
let template = Handlebars.compile(rawZPL)
|
||||||
|
|
||||||
|
return template(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -41,23 +41,18 @@ export const useStaffTime = () => {
|
|||||||
* aber wir nutzen dafür besser die createEntry Funktion unten.
|
* aber wir nutzen dafür besser die createEntry Funktion unten.
|
||||||
*/
|
*/
|
||||||
const start = async (description = "Arbeitszeit", time?: string) => {
|
const start = async (description = "Arbeitszeit", time?: string) => {
|
||||||
|
|
||||||
console.log(auth.user)
|
|
||||||
|
|
||||||
await $api('/api/staff/time/event', {
|
await $api('/api/staff/time/event', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
eventtype: 'work_start',
|
eventtype: 'work_start',
|
||||||
eventtime: time || new Date().toISOString(), // 💡 Fix: Zeit akzeptieren
|
eventtime: time || new Date().toISOString(), // 💡 Fix: Zeit akzeptieren
|
||||||
payload: { description },
|
payload: { description }
|
||||||
user_id: auth.user?.id
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = async () => {
|
const stop = async () => {
|
||||||
await $api('/api/staff/time/event', { method: 'POST', body: { eventtype: 'work_end', eventtime: new Date().toISOString(),
|
await $api('/api/staff/time/event', { method: 'POST', body: { eventtype: 'work_end', eventtime: new Date().toISOString() } })
|
||||||
user_id: auth.user?.id } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = async (entry: any) => {
|
const submit = async (entry: any) => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
|
||||||
export const useSum = () => {
|
export const useSum = () => {
|
||||||
|
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
|
||||||
const getIncomingInvoiceSum = (invoice) => {
|
const getIncomingInvoiceSum = (invoice) => {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
invoice.accounts.forEach(account => {
|
invoice.accounts.forEach(account => {
|
||||||
@@ -132,4 +135,4 @@ export const useSum = () => {
|
|||||||
|
|
||||||
return {getIncomingInvoiceSum, getCreatedDocumentSum, getCreatedDocumentSumDetailed, getIsPaid}
|
return {getIncomingInvoiceSum, getCreatedDocumentSum, getCreatedDocumentSumDetailed, getIsPaid}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { VueRenderer } from '@tiptap/vue-3'
|
|
||||||
import tippy from 'tippy.js'
|
|
||||||
import WikiPageList from '~/components/wiki/WikiPageList.vue'
|
|
||||||
|
|
||||||
// Wir brauchen Zugriff auf die rohen Items aus useWikiTree
|
|
||||||
// Da wir hier ausserhalb von setup() sind, müssen wir den State direkt holen oder übergeben.
|
|
||||||
// Einfacher: Wir nutzen useNuxtApp() oder übergeben die Items in der Config.
|
|
||||||
|
|
||||||
export default {
|
|
||||||
items: ({ query }: { query: string }) => {
|
|
||||||
// 1. Zugriff auf unsere Wiki Items
|
|
||||||
const { items } = useWikiTree()
|
|
||||||
|
|
||||||
// 2. Filtern
|
|
||||||
const allItems = items.value || []
|
|
||||||
return allItems
|
|
||||||
.filter(item => item.title.toLowerCase().includes(query.toLowerCase()))
|
|
||||||
.slice(0, 10) // Max 10 Vorschläge
|
|
||||||
},
|
|
||||||
|
|
||||||
render: () => {
|
|
||||||
let component: any
|
|
||||||
let popup: any
|
|
||||||
|
|
||||||
return {
|
|
||||||
onStart: (props: any) => {
|
|
||||||
component = new VueRenderer(WikiPageList, {
|
|
||||||
props,
|
|
||||||
editor: props.editor,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!props.clientRect) return
|
|
||||||
|
|
||||||
popup = tippy('body', {
|
|
||||||
getReferenceClientRect: props.clientRect,
|
|
||||||
appendTo: () => document.body,
|
|
||||||
content: component.element,
|
|
||||||
showOnCreate: true,
|
|
||||||
interactive: true,
|
|
||||||
trigger: 'manual',
|
|
||||||
placement: 'bottom-start',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
onUpdate(props: any) {
|
|
||||||
component.updateProps(props)
|
|
||||||
if (!props.clientRect) return
|
|
||||||
popup[0].setProps({ getReferenceClientRect: props.clientRect })
|
|
||||||
},
|
|
||||||
|
|
||||||
onKeyDown(props: any) {
|
|
||||||
if (props.event.key === 'Escape') {
|
|
||||||
popup[0].hide()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return component.ref?.onKeyDown(props)
|
|
||||||
},
|
|
||||||
|
|
||||||
onExit() {
|
|
||||||
popup[0].destroy()
|
|
||||||
component.destroy()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
export interface WikiPageItem {
|
|
||||||
id: string
|
|
||||||
parentId: string | null
|
|
||||||
title: string
|
|
||||||
isFolder: boolean
|
|
||||||
sortOrder: number
|
|
||||||
entityType?: string | null
|
|
||||||
children?: WikiPageItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useWikiTree = () => {
|
|
||||||
const { $api } = useNuxtApp()
|
|
||||||
|
|
||||||
// STATE
|
|
||||||
const items = useState<WikiPageItem[]>('wiki-items', () => [])
|
|
||||||
const isLoading = useState<boolean>('wiki-loading', () => false)
|
|
||||||
const isSidebarOpen = useState<boolean>('wiki-sidebar-open', () => true)
|
|
||||||
|
|
||||||
// NEU: Suchbegriff State
|
|
||||||
const searchQuery = useState<string>('wiki-search-query', () => '')
|
|
||||||
|
|
||||||
// 1. Basis-Baum bauen (Hierarchie & Sortierung)
|
|
||||||
const baseTree = computed(() => {
|
|
||||||
const rawItems = items.value || []
|
|
||||||
if (!rawItems.length) return []
|
|
||||||
|
|
||||||
const roots: WikiPageItem[] = []
|
|
||||||
const lookup: Record<string, WikiPageItem> = {}
|
|
||||||
|
|
||||||
// Init Lookup (Shallow Copy um Originaldaten nicht zu mutieren)
|
|
||||||
rawItems.forEach(item => {
|
|
||||||
lookup[item.id] = { ...item, children: [] }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build Hierarchy
|
|
||||||
rawItems.forEach(item => {
|
|
||||||
const node = lookup[item.id]
|
|
||||||
if (item.parentId && lookup[item.parentId]) {
|
|
||||||
lookup[item.parentId].children?.push(node)
|
|
||||||
} else {
|
|
||||||
roots.push(node)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort Helper
|
|
||||||
const sortNodes = (nodes: WikiPageItem[]) => {
|
|
||||||
nodes.sort((a, b) => {
|
|
||||||
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1
|
|
||||||
return a.title.localeCompare(b.title)
|
|
||||||
})
|
|
||||||
nodes.forEach(n => {
|
|
||||||
if (n.children?.length) sortNodes(n.children)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sortNodes(roots)
|
|
||||||
return roots
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2. NEU: Gefilterter Baum (basiert auf baseTree + searchQuery)
|
|
||||||
const filteredTree = computed(() => {
|
|
||||||
const query = searchQuery.value.toLowerCase().trim()
|
|
||||||
|
|
||||||
// Wenn keine Suche: Gib originalen Baum zurück
|
|
||||||
if (!query) return baseTree.value
|
|
||||||
|
|
||||||
// Rekursive Filterfunktion
|
|
||||||
const filterNodes = (nodes: WikiPageItem[]): WikiPageItem[] => {
|
|
||||||
return nodes.reduce((acc: WikiPageItem[], node) => {
|
|
||||||
// Matcht der Knoten selbst?
|
|
||||||
const matchesSelf = node.title.toLowerCase().includes(query)
|
|
||||||
|
|
||||||
// Matchen Kinder? (Rekursion)
|
|
||||||
const filteredChildren = node.children ? filterNodes(node.children) : []
|
|
||||||
|
|
||||||
// Wenn selbst matcht ODER Kinder matchen -> behalten
|
|
||||||
if (matchesSelf || filteredChildren.length > 0) {
|
|
||||||
// Wir erstellen eine Kopie des Knotens mit den gefilterten Kindern
|
|
||||||
acc.push({
|
|
||||||
...node,
|
|
||||||
children: filteredChildren
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
return filterNodes(baseTree.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ACTIONS
|
|
||||||
const loadTree = async () => {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
const data = await $api<WikiPageItem[]>('/api/wiki/tree', { method: 'GET' })
|
|
||||||
items.value = data
|
|
||||||
} catch (e) { console.error(e) }
|
|
||||||
finally { isLoading.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
const createItem = async (title: string, parentId: string | null, isFolder: boolean) => {
|
|
||||||
try {
|
|
||||||
const newItem = await $api('/api/wiki', { method: 'POST', body: { title, parentId, isFolder } })
|
|
||||||
await loadTree()
|
|
||||||
return newItem
|
|
||||||
} catch (e) { throw e }
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteItem = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await $api(`/api/wiki/${id}`, { method: 'DELETE' })
|
|
||||||
await loadTree()
|
|
||||||
return true
|
|
||||||
} catch (e) { throw e }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
tree: filteredTree, // Wir geben jetzt immer den (evtl. gefilterten) Baum zurück
|
|
||||||
searchQuery, // Damit die UI das Input-Feld binden kann
|
|
||||||
items,
|
|
||||||
isLoading,
|
|
||||||
isSidebarOpen,
|
|
||||||
loadTree,
|
|
||||||
createItem,
|
|
||||||
deleteItem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
<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'
|
|
||||||
import SessionRefreshModal from "~/components/SessionRefreshModal.vue";
|
|
||||||
|
|
||||||
const dataStore = useDataStore()
|
const dataStore = useDataStore()
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
const {isHelpSlideoverOpen} = useDashboard()
|
const { isHelpSlideoverOpen } = useDashboard()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
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")
|
||||||
|
|
||||||
@@ -24,114 +24,96 @@ const actions = [
|
|||||||
id: 'new-customer',
|
id: 'new-customer',
|
||||||
label: 'Kunde hinzufügen',
|
label: 'Kunde hinzufügen',
|
||||||
icon: 'i-heroicons-user-group',
|
icon: 'i-heroicons-user-group',
|
||||||
to: "/customers/create",
|
to: "/customers/create" ,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'new-vendor',
|
id: 'new-vendor',
|
||||||
label: 'Lieferant hinzufügen',
|
label: 'Lieferant hinzufügen',
|
||||||
icon: 'i-heroicons-truck',
|
icon: 'i-heroicons-truck',
|
||||||
to: "/vendors/create",
|
to: "/vendors/create" ,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'new-contact',
|
id: 'new-contact',
|
||||||
label: 'Ansprechpartner hinzufügen',
|
label: 'Ansprechpartner hinzufügen',
|
||||||
icon: 'i-heroicons-user-group',
|
icon: 'i-heroicons-user-group',
|
||||||
to: "/contacts/create",
|
to: "/contacts/create" ,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'new-task',
|
id: 'new-task',
|
||||||
label: 'Aufgabe hinzufügen',
|
label: 'Aufgabe hinzufügen',
|
||||||
icon: 'i-heroicons-rectangle-stack',
|
icon: 'i-heroicons-rectangle-stack',
|
||||||
to: "/tasks/create",
|
to: "/tasks/create" ,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'new-plant',
|
id: 'new-plant',
|
||||||
label: 'Objekt hinzufügen',
|
label: 'Objekt hinzufügen',
|
||||||
icon: 'i-heroicons-clipboard-document',
|
icon: 'i-heroicons-clipboard-document',
|
||||||
to: "/plants/create",
|
to: "/plants/create" ,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'new-product',
|
id: 'new-product',
|
||||||
label: 'Artikel hinzufügen',
|
label: 'Artikel hinzufügen',
|
||||||
icon: 'i-heroicons-puzzle-piece',
|
icon: 'i-heroicons-puzzle-piece',
|
||||||
to: "/products/create",
|
to: "/products/create" ,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'new-project',
|
id: 'new-project',
|
||||||
label: 'Projekt hinzufügen',
|
label: 'Projekt hinzufügen',
|
||||||
icon: 'i-heroicons-clipboard-document-check',
|
icon: 'i-heroicons-clipboard-document-check',
|
||||||
to: "/projects/create",
|
to: "/projects/create" ,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const groups = computed(() => [
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
commands: actions
|
|
||||||
}, {
|
|
||||||
key: "customers",
|
|
||||||
label: "Kunden",
|
|
||||||
commands: dataStore.customers.map(item => {
|
|
||||||
return {id: item.id, label: item.name, to: `/customers/show/${item.id}`}
|
|
||||||
})
|
|
||||||
}, {
|
|
||||||
key: "vendors",
|
|
||||||
label: "Lieferanten",
|
|
||||||
commands: dataStore.vendors.map(item => {
|
|
||||||
return {id: item.id, label: item.name, to: `/vendors/show/${item.id}`}
|
|
||||||
})
|
|
||||||
}, {
|
|
||||||
key: "contacts",
|
|
||||||
label: "Ansprechpartner",
|
|
||||||
commands: dataStore.contacts.map(item => {
|
|
||||||
return {id: item.id, label: item.fullName, to: `/contacts/show/${item.id}`}
|
|
||||||
})
|
|
||||||
}, {
|
|
||||||
key: "products",
|
|
||||||
label: "Artikel",
|
|
||||||
commands: dataStore.products.map(item => {
|
|
||||||
return {id: item.id, label: item.name, to: `/products/show/${item.id}`}
|
|
||||||
})
|
|
||||||
}, {
|
|
||||||
key: "tasks",
|
|
||||||
label: "Aufgaben",
|
|
||||||
commands: dataStore.tasks.map(item => {
|
|
||||||
return {id: item.id, label: item.name, to: `/tasks/show/${item.id}`}
|
|
||||||
})
|
|
||||||
}, {
|
|
||||||
key: "plants",
|
|
||||||
label: "Objekte",
|
|
||||||
commands: dataStore.plants.map(item => {
|
|
||||||
return {id: item.id, label: item.name, to: `/plants/show/${item.id}`}
|
|
||||||
})
|
|
||||||
}, {
|
|
||||||
key: "projects",
|
|
||||||
label: "Projekte",
|
|
||||||
commands: dataStore.projects.map(item => {
|
|
||||||
return {id: item.id, label: item.name, to: `/projects/show/${item.id}`}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
].filter(Boolean))
|
|
||||||
|
|
||||||
// --- Footer Links nutzen jetzt den zentralen Calculator Store ---
|
const groups = computed(() => [
|
||||||
const footerLinks = computed(() => [
|
{
|
||||||
{
|
key: 'actions',
|
||||||
label: 'Taschenrechner',
|
commands: actions
|
||||||
icon: 'i-heroicons-calculator',
|
},{
|
||||||
click: () => calculatorStore.toggle()
|
key: "customers",
|
||||||
},
|
label: "Kunden",
|
||||||
{
|
commands: dataStore.customers.map(item => { return {id: item.id, label: item.name, to: `/customers/show/${item.id}`}})
|
||||||
label: 'Hilfe & Info',
|
},{
|
||||||
icon: 'i-heroicons-question-mark-circle',
|
key: "vendors",
|
||||||
click: () => isHelpSlideoverOpen.value = true
|
label: "Lieferanten",
|
||||||
}
|
commands: dataStore.vendors.map(item => { return {id: item.id, label: item.name, to: `/vendors/show/${item.id}`}})
|
||||||
])
|
},{
|
||||||
|
key: "contacts",
|
||||||
|
label: "Ansprechpartner",
|
||||||
|
commands: dataStore.contacts.map(item => { return {id: item.id, label: item.fullName, to: `/contacts/show/${item.id}`}})
|
||||||
|
},{
|
||||||
|
key: "products",
|
||||||
|
label: "Artikel",
|
||||||
|
commands: dataStore.products.map(item => { return {id: item.id, label: item.name, to: `/products/show/${item.id}`}})
|
||||||
|
},{
|
||||||
|
key: "tasks",
|
||||||
|
label: "Aufgaben",
|
||||||
|
commands: dataStore.tasks.map(item => { return {id: item.id, label: item.name, to: `/tasks/show/${item.id}`}})
|
||||||
|
},{
|
||||||
|
key: "plants",
|
||||||
|
label: "Objekte",
|
||||||
|
commands: dataStore.plants.map(item => { return {id: item.id, label: item.name, to: `/plants/show/${item.id}`}})
|
||||||
|
},{
|
||||||
|
key: "projects",
|
||||||
|
label: "Projekte",
|
||||||
|
commands: dataStore.projects.map(item => { return {id: item.id, label: item.name, to: `/projects/show/${item.id}`}})
|
||||||
|
}
|
||||||
|
].filter(Boolean))
|
||||||
|
const footerLinks = [
|
||||||
|
/*{
|
||||||
|
label: 'Invite people',
|
||||||
|
icon: 'i-heroicons-plus',
|
||||||
|
to: '/settings/members'
|
||||||
|
}, */{
|
||||||
|
label: 'Hilfe & Info',
|
||||||
|
icon: 'i-heroicons-question-mark-circle',
|
||||||
|
click: () => isHelpSlideoverOpen.value = true
|
||||||
|
}]
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!auth.loading">
|
<div v-if="!auth.loading">
|
||||||
<SessionRefreshModal />
|
|
||||||
<div v-if="auth.activeTenantData?.locked === 'maintenance_tenant'">
|
<div v-if="auth.activeTenantData?.locked === 'maintenance_tenant'">
|
||||||
<UContainer class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
<UContainer class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
<UCard class="max-w-lg text-center p-10">
|
<UCard class="max-w-lg text-center p-10">
|
||||||
@@ -148,24 +130,24 @@ const footerLinks = computed(() => [
|
|||||||
v-else
|
v-else
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-center mb-6">
|
<div class="flex justify-center mb-6">
|
||||||
<UIcon name="i-heroicons-exclamation-triangle-solid" class="w-16 h-16 text-yellow-500"/>
|
<UIcon name="i-heroicons-exclamation-triangle-solid" class="w-16 h-16 text-yellow-500" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
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
|
Dieser FEDEO Mandant wird derzeit gewartet. Bitte versuche es in einigen Minuten erneut oder verwende einen anderen Mandanten.
|
||||||
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
|
>Wählen</UButton>
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</UCard>
|
</UCard>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,7 +167,7 @@ const footerLinks = computed(() => [
|
|||||||
v-else
|
v-else
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-center mb-6">
|
<div class="flex justify-center mb-6">
|
||||||
<UIcon name="i-heroicons-exclamation-triangle-solid" class="w-16 h-16 text-yellow-500"/>
|
<UIcon name="i-heroicons-exclamation-triangle-solid" class="w-16 h-16 text-yellow-500" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
@@ -194,6 +176,8 @@ const footerLinks = computed(() => [
|
|||||||
<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>
|
||||||
@@ -213,33 +197,32 @@ const footerLinks = computed(() => [
|
|||||||
v-else
|
v-else
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-center mb-6">
|
<div class="flex justify-center mb-6">
|
||||||
<UIcon name="i-heroicons-credit-card" class="w-16 h-16 text-red-600"/>
|
<UIcon name="i-heroicons-credit-card" class="w-16 h-16 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
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
|
Bitte wenden Sie sich an den FEDEO Support um ein Abonnement zu erhalten oder verwenden Sie einen anderen Mandanten.
|
||||||
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
|
>Wählen</UButton>
|
||||||
</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;"
|
<UDashboardNavbar style="margin-top: env(safe-area-inset-top, 10px) !important;" :class="['!border-transparent']" :ui="{ left: 'flex-1' }">
|
||||||
:class="['!border-transparent']" :ui="{ left: 'flex-1' }">
|
|
||||||
<template #left>
|
<template #left>
|
||||||
<TenantDropdown class="w-full"/>
|
<TenantDropdown class="w-full" />
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
@@ -247,19 +230,25 @@ const footerLinks = computed(() => [
|
|||||||
|
|
||||||
<MainNav/>
|
<MainNav/>
|
||||||
|
|
||||||
<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">
|
|
||||||
|
|
||||||
<UColorModeToggle class="ml-3"/>
|
<UColorModeButton />
|
||||||
<LabelPrinterButton class="w-full"/>
|
<LabelPrinterButton/>
|
||||||
|
|
||||||
<UDashboardSidebarLinks :links="footerLinks" class="w-full"/>
|
|
||||||
|
|
||||||
<UDivider class="sticky bottom-0 w-full"/>
|
|
||||||
<UserDropdown style="margin-bottom: env(safe-area-inset-bottom, 10px) !important;" class="w-full"/>
|
<!-- Footer Links -->
|
||||||
|
<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>
|
||||||
@@ -267,13 +256,13 @@ const footerLinks = computed(() => [
|
|||||||
|
|
||||||
<UDashboardPage>
|
<UDashboardPage>
|
||||||
<UDashboardPanel grow>
|
<UDashboardPanel grow>
|
||||||
<slot/>
|
<slot />
|
||||||
</UDashboardPanel>
|
</UDashboardPanel>
|
||||||
</UDashboardPage>
|
</UDashboardPage>
|
||||||
|
|
||||||
<HelpSlideover/>
|
|
||||||
|
|
||||||
<Calculator v-if="calculatorStore.isOpen"/>
|
|
||||||
|
<HelpSlideover/>
|
||||||
|
|
||||||
</UDashboardLayout>
|
</UDashboardLayout>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,32 +278,37 @@ const footerLinks = computed(() => [
|
|||||||
v-if="month === '12'"
|
v-if="month === '12'"
|
||||||
/>
|
/>
|
||||||
<UColorModeImage
|
<UColorModeImage
|
||||||
light="/Logo.png"
|
light="/Logo.png"
|
||||||
dark="/Logo_Dark.png"
|
dark="/Logo_Dark.png"
|
||||||
class="w-1/3 mx-auto my-10"
|
class="w-1/3 mx-auto my-10"
|
||||||
v-else
|
v-else
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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">
|
||||||
<h3 class="text-center font-bold text-2xl mb-5">Kein Aktiver Mandant. Bitte wählen Sie ein Mandant.</h3>
|
<!-- Tenant Selection -->
|
||||||
<div class="mx-auto w-1/2 flex flex-row justify-between my-3" v-for="tenant in auth.tenants">
|
<h3 class="text-center font-bold text-2xl mb-5">Kein Aktiver Mandant. Bitte wählen Sie ein Mandant.</h3>
|
||||||
<span class="text-left">{{ tenant.name }}</span>
|
<div class="mx-auto w-1/2 flex flex-row justify-between my-3" v-for="tenant in auth.tenants">
|
||||||
<UButton
|
<span class="text-left">{{tenant.name}}</span>
|
||||||
|
<UButton
|
||||||
@click="auth.switchTenant(tenant.id)"
|
@click="auth.switchTenant(tenant.id)"
|
||||||
>Wählen
|
>Wählen</UButton>
|
||||||
</UButton>
|
</div>
|
||||||
</div>
|
<UButton
|
||||||
<UButton
|
variant="outline"
|
||||||
variant="outline"
|
color="rose"
|
||||||
color="rose"
|
|
||||||
@click="auth.logout()"
|
@click="auth.logout()"
|
||||||
>Abmelden
|
>Abmelden</UButton>
|
||||||
</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" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -7,7 +7,7 @@ export default defineNuxtConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
modules: ['@vite-pwa/nuxt','@pinia/nuxt', '@nuxt/ui', "nuxt-editorjs", '@nuxtjs/fontaine', 'nuxt-viewport', '@nuxtjs/leaflet', '@vueuse/nuxt'],
|
modules: ['@pinia/nuxt', '@nuxt/ui', '@nuxtjs/supabase', "nuxt-editorjs", '@nuxtjs/fontaine', 'nuxt-viewport', 'nuxt-tiptap-editor', '@nuxtjs/leaflet', '@vueuse/nuxt'],
|
||||||
|
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
||||||
@@ -24,8 +24,7 @@ export default defineNuxtConfig({
|
|||||||
}],
|
}],
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
transpile: ['@vuepic/vue-datepicker','@tiptap/vue-3','@tiptap/extension-code-block-lowlight',
|
transpile: ['@vuepic/vue-datepicker']
|
||||||
'lowlight',]
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
@@ -34,43 +33,15 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
supabase: {
|
||||||
|
key: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV3cHB2Y3hmbHJjc2lidXpzYmlsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDA5MzgxOTQsImV4cCI6MjAxNjUxNDE5NH0.CkxYSQH0uLfwx9GVUlO6AYMU2FMLAxGMrwEKvyPv7Oo",
|
||||||
|
url: "https://uwppvcxflrcsibuzsbil.supabase.co",
|
||||||
|
redirect: false
|
||||||
|
},
|
||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
resolve: {
|
|
||||||
dedupe: [
|
|
||||||
'vue',
|
|
||||||
'@tiptap/vue-3',
|
|
||||||
'prosemirror-model',
|
|
||||||
'prosemirror-view',
|
|
||||||
'prosemirror-state',
|
|
||||||
'prosemirror-commands',
|
|
||||||
'prosemirror-schema-list',
|
|
||||||
'prosemirror-transform',
|
|
||||||
'prosemirror-history',
|
|
||||||
'prosemirror-gapcursor',
|
|
||||||
'prosemirror-dropcursor',
|
|
||||||
'prosemirror-tables'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: [
|
include: ["@editorjs/editorjs", "dayjs"],
|
||||||
"@editorjs/editorjs",
|
|
||||||
"dayjs",
|
|
||||||
'@tiptap/vue-3',
|
|
||||||
'@tiptap/extension-code-block-lowlight',
|
|
||||||
'lowlight',
|
|
||||||
'vue',
|
|
||||||
'@tiptap/extension-task-item',
|
|
||||||
'@tiptap/extension-task-list',
|
|
||||||
'@tiptap/extension-table',
|
|
||||||
'@tiptap/extension-mention',
|
|
||||||
'prosemirror-model',
|
|
||||||
'prosemirror-view',
|
|
||||||
'prosemirror-state',
|
|
||||||
'prosemirror-commands',
|
|
||||||
'prosemirror-transform',
|
|
||||||
'tippy.js',
|
|
||||||
'prosemirror-tables',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -82,6 +53,11 @@ export default defineNuxtConfig({
|
|||||||
preference: 'system'
|
preference: 'system'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
tiptap: {
|
||||||
|
prefix: "Tiptap"
|
||||||
|
},
|
||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
|
|
||||||
public: {
|
public: {
|
||||||
@@ -90,68 +66,5 @@ export default defineNuxtConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
pwa: {
|
|
||||||
/* Automatische Updates des Service Workers (optional, aber empfohlen) */
|
|
||||||
registerType: 'autoUpdate',
|
|
||||||
|
|
||||||
manifest: {
|
|
||||||
name: 'FEDEO',
|
|
||||||
short_name: 'FEDEO',
|
|
||||||
description: 'FEDEO',
|
|
||||||
theme_color: '#69c350',
|
|
||||||
background_color: '#ffffff',
|
|
||||||
|
|
||||||
/* WICHTIG: Dies sorgt dafür, dass die URL-Leiste verschwindet */
|
|
||||||
display: 'standalone',
|
|
||||||
|
|
||||||
/* Icons sind essentiell für den Home Screen */
|
|
||||||
icons: [
|
|
||||||
{
|
|
||||||
src: '192.png',
|
|
||||||
sizes: '192x192',
|
|
||||||
type: 'image/png'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: '512.png',
|
|
||||||
sizes: '512x512',
|
|
||||||
type: 'image/png'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: '512.png',
|
|
||||||
sizes: '512x512',
|
|
||||||
type: 'image/png',
|
|
||||||
purpose: 'any maskable'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
/* WICHTIG FÜR SAFARI / iOS */
|
|
||||||
workbox: {
|
|
||||||
navigateFallback: '/',
|
|
||||||
},
|
|
||||||
|
|
||||||
devOptions: {
|
|
||||||
enabled: true, // Damit du es auch lokal testen kannst
|
|
||||||
type: 'module',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
app: {
|
|
||||||
head: {
|
|
||||||
meta: [
|
|
||||||
// Sagt iOS, dass es eine WebApp ist
|
|
||||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
|
||||||
// Steuert die Farbe der Statusleiste (weiß, schwarz oder transparent)
|
|
||||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
|
|
||||||
// Name der App unter dem Icon
|
|
||||||
{ name: 'apple-mobile-web-app-title', content: 'FEDEO' },
|
|
||||||
],
|
|
||||||
link: [
|
|
||||||
// Wichtig: Das Icon für den Home Screen
|
|
||||||
{ rel: 'apple-touch-icon', href: '/512.png' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
compatibilityDate: '2024-12-18'
|
compatibilityDate: '2024-12-18'
|
||||||
})
|
})
|
||||||
@@ -12,10 +12,11 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "^7.0.0",
|
"@capacitor/cli": "^7.0.0",
|
||||||
"@nuxtjs/leaflet": "^1.2.3",
|
"@nuxtjs/leaflet": "^1.2.3",
|
||||||
"@vite-pwa/nuxt": "^1.1.0",
|
"@nuxtjs/supabase": "^1.1.4",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"@vueuse/nuxt": "^14.1.0",
|
"@vueuse/nuxt": "^14.1.0",
|
||||||
"nuxt": "^3.14.1592",
|
"nuxt": "^3.14.1592",
|
||||||
|
"nuxt-tiptap-editor": "^1.2.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.2.5"
|
"vue-router": "^4.2.5"
|
||||||
},
|
},
|
||||||
@@ -45,31 +46,14 @@
|
|||||||
"@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-bubble-menu": "^3.17.1",
|
"@tiptap/extension-underline": "^2.1.15",
|
||||||
"@tiptap/extension-character-count": "^3.17.1",
|
"@tiptap/pm": "^2.1.15",
|
||||||
"@tiptap/extension-code-block": "^3.17.1",
|
"@tiptap/starter-kit": "^2.1.15",
|
||||||
"@tiptap/extension-floating-menu": "^3.17.1",
|
"@tiptap/vue-3": "^2.1.15",
|
||||||
"@tiptap/extension-highlight": "^3.17.1",
|
|
||||||
"@tiptap/extension-image": "^3.17.1",
|
|
||||||
"@tiptap/extension-link": "^3.17.1",
|
|
||||||
"@tiptap/extension-mention": "^3.17.1",
|
|
||||||
"@tiptap/extension-placeholder": "^3.17.1",
|
|
||||||
"@tiptap/extension-table": "^3.17.1",
|
|
||||||
"@tiptap/extension-table-cell": "^3.17.1",
|
|
||||||
"@tiptap/extension-table-header": "^3.17.1",
|
|
||||||
"@tiptap/extension-table-row": "^3.17.1",
|
|
||||||
"@tiptap/extension-task-item": "^3.17.1",
|
|
||||||
"@tiptap/extension-task-list": "^3.17.1",
|
|
||||||
"@tiptap/extension-typography": "^3.17.1",
|
|
||||||
"@tiptap/extension-youtube": "^3.17.1",
|
|
||||||
"@tiptap/pm": "^3.17.1",
|
|
||||||
"@tiptap/starter-kit": "^3.17.1",
|
|
||||||
"@tiptap/vue-3": "^3.17.1",
|
|
||||||
"@vicons/ionicons5": "^0.12.0",
|
"@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",
|
||||||
@@ -95,7 +79,6 @@
|
|||||||
"sass": "^1.69.7",
|
"sass": "^1.69.7",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"tailwindcss-safe-area-capacitor": "^0.5.1",
|
"tailwindcss-safe-area-capacitor": "^0.5.1",
|
||||||
"tippy.js": "^6.3.7",
|
|
||||||
"uuid": "^11.0.3",
|
"uuid": "^11.0.3",
|
||||||
"uuidv4": "^6.2.13",
|
"uuidv4": "^6.2.13",
|
||||||
"v-calendar": "^3.1.2",
|
"v-calendar": "^3.1.2",
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const {$api, $dayjs} = useNuxtApp()
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
// 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()
|
||||||
@@ -13,280 +18,238 @@ 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 () => {
|
||||||
loadingDocs.value = true
|
bankstatements.value = (await useEntities("bankstatements").select("*, statementallocations(*)", "date", false))
|
||||||
try {
|
bankaccounts.value = await useEntities("bankaccounts").select()
|
||||||
const [statements, accounts] = await Promise.all([
|
if(bankaccounts.value.length > 0) filterAccount.value = bankaccounts.value
|
||||||
useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false),
|
|
||||||
useEntities("bankaccounts").select()
|
|
||||||
])
|
|
||||||
|
|
||||||
bankstatements.value = statements
|
|
||||||
bankaccounts.value = accounts
|
|
||||||
|
|
||||||
if (bankaccounts.value.length > 0 && filterAccount.value.length === 0) {
|
|
||||||
filterAccount.value = bankaccounts.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Erst nach dem Laden der Daten die Store-Werte anwenden
|
|
||||||
const savedBanking = tempStore.settings?.['banking'] || {}
|
|
||||||
if (savedBanking.periodKey) {
|
|
||||||
const found = periodOptions.find(p => p.key === savedBanking.periodKey)
|
|
||||||
if (found) selectedPeriod.value = found
|
|
||||||
}
|
|
||||||
if (savedBanking.range) {
|
|
||||||
dateRange.value = savedBanking.range
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Setup Error:", err)
|
|
||||||
} finally {
|
|
||||||
loadingDocs.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watcher für Schnellwahlen & Persistenz
|
// Funktion für den Bankabruf
|
||||||
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) {
|
||||||
toast.add({title: 'Fehler', description: 'Fehler beim Abruf.', color: 'red'})
|
console.error(error)
|
||||||
|
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: "valueDate", label: "Valuta"},
|
key: "account",
|
||||||
{key: "amount", label: "Betrag"},
|
label: "Konto"
|
||||||
{key: "openAmount", label: "Offen"},
|
},{
|
||||||
{key: "partner", label: "Name"},
|
key: "valueDate",
|
||||||
{key: "text", label: "Beschreibung"}
|
label: "Valuta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 selectedFilters = ref(tempStore.filters?.["banking"]?.["main"] || ['Nur offene anzeigen'])
|
|
||||||
|
|
||||||
const shouldShowMonthDivider = (row, index) => {
|
const searchString = ref(tempStore.searchStrings["bankstatements"] ||'')
|
||||||
if (index === 0) return true;
|
|
||||||
const prevRow = filteredRows.value[index - 1];
|
const clearSearchString = () => {
|
||||||
return $dayjs(row.valueDate).format('MMMM YYYY') !== $dayjs(prevRow.valueDate).format('MMMM YYYY');
|
tempStore.clearSearchString("bankstatements")
|
||||||
|
searchString.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const displayCurrency = (value, currency = "€") => {
|
||||||
|
return `${Number(value).toFixed(2).replace(".",",")} ${currency}`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const calculateOpenSum = (statement) => {
|
const calculateOpenSum = (statement) => {
|
||||||
const allocated = statement.statementallocations?.reduce((acc, curr) => acc + curr.amount, 0) || 0;
|
let startingAmount = 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(() => {
|
||||||
if (!bankstatements.value.length) return []
|
let temp = bankstatements.value
|
||||||
|
|
||||||
let temp = [...bankstatements.value]
|
if(route.query.filter) {
|
||||||
|
console.log(route.query.filter)
|
||||||
|
temp = temp.filter(i => JSON.parse(route.query.filter).includes(i.id))
|
||||||
|
} else {
|
||||||
|
if(selectedFilters.value.includes("Nur offene anzeigen")){
|
||||||
|
temp = temp.filter(i => Number(calculateOpenSum(i)) !== 0)
|
||||||
|
}
|
||||||
|
|
||||||
// Filterung nach Datum
|
if(selectedFilters.value.includes("Nur positive anzeigen")){
|
||||||
if (dateRange.value.start) {
|
temp = temp.filter(i => i.amount >= 0)
|
||||||
temp = temp.filter(i => $dayjs(i.valueDate).isSameOrAfter($dayjs(dateRange.value.start), 'day'))
|
}
|
||||||
}
|
|
||||||
if (dateRange.value.end) {
|
if(selectedFilters.value.includes("Nur negative anzeigen")){
|
||||||
temp = temp.filter(i => $dayjs(i.valueDate).isSameOrBefore($dayjs(dateRange.value.end), 'day'))
|
temp = temp.filter(i => i.amount < 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status Filter
|
return useSearch(searchString.value, temp.filter(i => filterAccount.value.find(x => x.id === i.account)))
|
||||||
if (selectedFilters.value.includes("Nur offene anzeigen")) {
|
|
||||||
temp = temp.filter(i => Number(calculateOpenSum(i)) !== 0)
|
|
||||||
}
|
|
||||||
if (selectedFilters.value.includes("Nur positive anzeigen")) {
|
|
||||||
temp = temp.filter(i => i.amount >= 0)
|
|
||||||
}
|
|
||||||
if (selectedFilters.value.includes("Nur negative anzeigen")) {
|
|
||||||
temp = temp.filter(i => i.amount < 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Konto Filter & Suche
|
|
||||||
let results = temp.filter(i => filterAccount.value.find(x => x.id === i.account))
|
|
||||||
|
|
||||||
if (searchString.value) {
|
|
||||||
results = useSearch(searchString.value, results)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results.sort((a, b) => $dayjs(b.valueDate).unix() - $dayjs(a.valueDate).unix())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayCurrency = (value) => `${Number(value).toFixed(2).replace(".", ",")} €`
|
setupPage()
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
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-magnifying-glass"
|
icon="i-heroicons-funnel"
|
||||||
|
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"
|
icon="i-heroicons-adjustments-horizontal-solid"
|
||||||
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' }"
|
||||||
|
>
|
||||||
|
|
||||||
<div class="overflow-y-auto relative" style="height: calc(100vh - 200px)">
|
<template #account-data="{row}">
|
||||||
<div v-if="loadingDocs" class="p-20 flex flex-col items-center justify-center">
|
{{row.account ? bankaccounts.find(i => i.id === row.account).iban : ""}}
|
||||||
<UProgress animation="carousel" class="w-1/3 mb-4" />
|
</template>
|
||||||
<span class="text-sm text-gray-500 italic">Bankbuchungen werden geladen...</span>
|
<template #valueDate-data="{row}">
|
||||||
</div>
|
{{dayjs(row.valueDate).format("DD.MM.YY")}}
|
||||||
|
</template>
|
||||||
<table v-else class="w-full text-left border-collapse">
|
<template #amount-data="{row}">
|
||||||
<thead class="sticky top-0 bg-white dark:bg-gray-900 z-10 shadow-sm">
|
<span
|
||||||
<tr class="text-xs font-semibold text-gray-500 uppercase">
|
v-if="row.amount >= 0"
|
||||||
<th v-for="col in templateColumns" :key="col.key" class="p-4 border-b dark:border-gray-800">
|
class="text-primary-500"
|
||||||
{{ col.label }}
|
>{{String(row.amount.toFixed(2)).replace(".",",")}} €</span>
|
||||||
</th>
|
<span
|
||||||
</tr>
|
v-else-if="row.amount < 0"
|
||||||
</thead>
|
class="text-rose-500"
|
||||||
<tbody>
|
>{{String(row.amount.toFixed(2)).replace(".",",")}} €</span>
|
||||||
<template v-for="(row, index) in filteredRows" :key="row.id">
|
</template>
|
||||||
<tr v-if="shouldShowMonthDivider(row, index)">
|
<template #openAmount-data="{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">
|
{{displayCurrency(calculateOpenSum(row))}}
|
||||||
<div class="flex items-center gap-2">
|
</template>
|
||||||
<UIcon name="i-heroicons-calendar" class="w-4 h-4"/>
|
<template #partner-data="{row}">
|
||||||
{{ $dayjs(row.valueDate).format('MMMM YYYY') }}
|
<span
|
||||||
</div>
|
v-if="row.amount < 0"
|
||||||
</td>
|
>
|
||||||
</tr>
|
{{row.credName}}
|
||||||
<tr
|
</span>
|
||||||
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 cursor-pointer border-b dark:border-gray-800 text-sm group"
|
<span
|
||||||
@click="router.push(`/banking/statements/edit/${row.id}`)"
|
v-else-if="row.amount > 0"
|
||||||
>
|
>
|
||||||
<td class="p-4 text-[10px] text-gray-400 font-mono truncate max-w-[150px]">
|
{{row.debName}}
|
||||||
{{ row.account ? bankaccounts.find(i => i.id === row.account)?.iban : "" }}
|
</span>
|
||||||
</td>
|
</template>
|
||||||
<td class="p-4 whitespace-nowrap">{{ $dayjs(row.valueDate).format("DD.MM.YY") }}</td>
|
</UTable>
|
||||||
<td class="p-4 font-semibold">
|
|
||||||
<span :class="row.amount >= 0 ? 'text-green-600 dark:text-green-400' : 'text-rose-600 dark:text-rose-400'">
|
|
||||||
{{ displayCurrency(row.amount) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="p-4 text-gray-400 italic text-xs">
|
|
||||||
{{ Number(calculateOpenSum(row)) !== 0 ? displayCurrency(calculateOpenSum(row)) : '-' }}
|
|
||||||
</td>
|
|
||||||
<td class="p-4 truncate max-w-[180px] font-medium">
|
|
||||||
{{ row.amount < 0 ? row.credName : row.debName }}
|
|
||||||
</td>
|
|
||||||
<td class="p-4 text-gray-500 truncate max-w-[350px] text-xs">
|
|
||||||
{{ row.text }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
<tr v-if="filteredRows.length === 0">
|
|
||||||
<td colspan="6" class="p-32 text-center text-gray-400">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<UIcon name="i-heroicons-magnifying-glass-circle" class="w-12 h-12 mb-3 opacity-20"/>
|
|
||||||
<p class="font-medium">Keine Buchungen gefunden</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<PageLeaveGuard :when="isSyncing"/>
|
<PageLeaveGuard :when="isSyncing"/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user