Compare commits

..

28 Commits

Author SHA1 Message Date
52c182cb5f Fixes
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 3m8s
Build and Push Docker Images / build-frontend (push) Successful in 1m15s
2026-03-04 20:44:19 +01:00
9cef3964e9 Serienrechnungen ausführung sowie Anwahl und liste 2026-03-04 19:54:12 +01:00
cf0fb724a2 Fix #126 2026-02-22 19:33:56 +01:00
bbb893dd6c Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m9s
2026-02-21 22:41:23 +01:00
724f152d70 Fix #116 2026-02-21 22:41:07 +01:00
27be8241bf Initial for #123 2026-02-21 22:23:32 +01:00
d27e437ba6 Fix Error in IcomingInvoice Opening of Drafts 2026-02-21 22:23:32 +01:00
f5253b29f4 Fix #113 2026-02-21 22:23:31 +01:00
0141a243ce Initial for #123
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 31s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
2026-02-21 22:21:10 +01:00
a0e1b8c0eb Fix Error in IcomingInvoice Opening of Drafts 2026-02-21 22:19:45 +01:00
45fb45845a Fix #116 2026-02-21 22:17:58 +01:00
409db82368 Mobile Dev
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m50s
Build and Push Docker Images / build-frontend (push) Successful in 1m13s
2026-02-21 21:21:39 +01:00
30d761f899 fix memberrlation 2026-02-21 21:21:27 +01:00
70636f6ac5 Fixed FinalInvoice
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 32s
Build and Push Docker Images / build-frontend (push) Successful in 1m9s
2026-02-20 09:20:55 +01:00
59392a723c Time Page 2026-02-19 18:33:24 +01:00
c782492ab5 Initial Mobile 2026-02-19 18:29:06 +01:00
844af30b18 Search und Save Function
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 33s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-02-18 15:04:16 +01:00
6fded3993a New CustomerInventory,
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 32s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
New Mitgliederverwaltung für Vereine
New Bank Auto Complete
2026-02-17 12:38:39 +01:00
f26d6bd4f3 Load Fix
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 31s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-02-16 13:56:45 +01:00
2621cc0d8d DB Fix
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 31s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-02-16 12:57:29 +01:00
a8238dc9ba Added IBAN Saving, Automatic Saving, added Mitglieder
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 1m11s
2026-02-16 12:43:52 +01:00
49d35f080d Added IBAN Saving, Automatic Saving, added Mitglieder
Some checks failed
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has started running
2026-02-16 12:43:07 +01:00
189a52b3cd Added IBAN Saving, Automatic Saving, added Mitglieder
Some checks failed
Build and Push Docker Images / build-backend (push) Failing after 1m25s
Build and Push Docker Images / build-frontend (push) Failing after 38s
2026-02-16 12:40:07 +01:00
3f8ce5daf7 Tasks und Vertragstyp fix #17
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 30s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
2026-02-15 22:02:16 +01:00
087ba1126e Fix #105
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m9s
2026-02-15 20:50:52 +01:00
db4e9612a0 Logbuch Überarbeitung
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 29s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-02-15 20:43:01 +01:00
cb4917c536 DB Restructuring 2026-02-15 13:30:19 +01:00
9f32eb5439 M2M Api 2026-02-15 13:29:26 +01:00
145 changed files with 30228 additions and 859 deletions

View File

@@ -2,11 +2,12 @@
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg"; import { Pool } from "pg";
import * as schema from "./schema"; import * as schema from "./schema";
import {secrets} from "../src/utils/secrets";
console.log("[DB INIT] 1. Suche Connection String..."); console.log("[DB INIT] 1. Suche Connection String...");
// Checken woher die URL kommt // Checken woher die URL kommt
let connectionString = process.env.DATABASE_URL; let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL;
if (connectionString) { if (connectionString) {
console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL"); console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL");
} else { } else {

View File

@@ -0,0 +1 @@
ALTER TABLE "customers" ADD COLUMN "customTaxType" text;

View File

@@ -0,0 +1,16 @@
CREATE TABLE "contracttypes" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "contracttypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"paymentType" text,
"recurring" boolean DEFAULT false NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "contracts" ADD COLUMN "contracttype" bigint;
--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_contracttype_contracttypes_id_fk" FOREIGN KEY ("contracttype") REFERENCES "public"."contracttypes"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "contracttypes" ADD COLUMN "billingInterval" text;
--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "billingInterval" text;

View File

@@ -0,0 +1,16 @@
CREATE TABLE "entitybankaccounts" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "entitybankaccounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"iban_encrypted" jsonb NOT NULL,
"bic_encrypted" jsonb NOT NULL,
"bank_name_encrypted" jsonb NOT NULL,
"description" text,
"updated_at" timestamp with time zone,
"updated_by" uuid,
"archived" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,73 @@
CREATE TABLE "customerspaces" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerspaces_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,
"type" text NOT NULL,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"spaceNumber" text NOT NULL,
"parentSpace" bigint,
"infoData" jsonb DEFAULT '{"zip":"","city":"","streetNumber":""}'::jsonb NOT NULL,
"description" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerinventoryitems" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerinventoryitems_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,
"description" text,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"customerspace" bigint,
"customerInventoryId" text NOT NULL,
"serialNumber" text,
"quantity" bigint DEFAULT 0 NOT NULL,
"manufacturer" text,
"manufacturerNumber" text,
"purchaseDate" date,
"purchasePrice" double precision DEFAULT 0,
"currentValue" double precision,
"product" bigint,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_parentSpace_customerspaces_id_fk" FOREIGN KEY ("parentSpace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_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 "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_product_products_id_fk" FOREIGN KEY ("product") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_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 UNIQUE INDEX "customerinventoryitems_tenant_customerInventoryId_idx" ON "customerinventoryitems" USING btree ("tenant","customerInventoryId");
--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerspace" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerinventoryitem" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerinventoryitem_customerinventoryitems_id_fk" FOREIGN KEY ("customerinventoryitem") REFERENCES "public"."customerinventoryitems"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000}}'::jsonb;
--> statement-breakpoint
UPDATE "tenants"
SET "numberRanges" = COALESCE("numberRanges", '{}'::jsonb) || jsonb_build_object(
'customerspaces', COALESCE("numberRanges"->'customerspaces', '{"prefix":"KLP-","suffix":"","nextNumber":1000}'::jsonb),
'customerinventoryitems', COALESCE("numberRanges"->'customerinventoryitems', '{"prefix":"KIA-","suffix":"","nextNumber":1000}'::jsonb)
);

View File

@@ -0,0 +1,3 @@
ALTER TABLE "customerinventoryitems" ADD COLUMN "vendor" bigint;
--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_vendor_vendors_id_fk" FOREIGN KEY ("vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,20 @@
CREATE TABLE "memberrelations" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "memberrelations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"type" text NOT NULL,
"billingInterval" text NOT NULL,
"billingAmount" double precision DEFAULT 0 NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "memberrelation" bigint;
--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_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 "customers" ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,4 @@
ALTER TABLE "historyitems" ADD COLUMN "memberrelation" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,33 @@
ALTER TABLE "customers" ADD COLUMN IF NOT EXISTS "memberrelation" bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'customers_memberrelation_memberrelations_id_fk'
) THEN
ALTER TABLE "customers"
ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk"
FOREIGN KEY ("memberrelation")
REFERENCES "public"."memberrelations"("id")
ON DELETE no action
ON UPDATE no action;
END IF;
END $$;
UPDATE "customers"
SET "memberrelation" = ("infoData"->>'memberrelation')::bigint
WHERE
"memberrelation" IS NULL
AND "type" = 'Mitglied'
AND jsonb_typeof(COALESCE("infoData", '{}'::jsonb)) = 'object'
AND COALESCE("infoData", '{}'::jsonb) ? 'memberrelation'
AND ("infoData"->>'memberrelation') ~ '^[0-9]+$';
UPDATE "customers"
SET "infoData" = COALESCE("infoData", '{}'::jsonb) - 'memberrelation'
WHERE
"type" = 'Mitglied'
AND jsonb_typeof(COALESCE("infoData", '{}'::jsonb)) = 'object'
AND COALESCE("infoData", '{}'::jsonb) ? 'memberrelation';

View File

@@ -0,0 +1,108 @@
CREATE TABLE "contracttypes" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "contracttypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"paymentType" text,
"recurring" boolean DEFAULT false NOT NULL,
"billingInterval" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerinventoryitems" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerinventoryitems_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,
"description" text,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"customerspace" bigint,
"customerInventoryId" text NOT NULL,
"serialNumber" text,
"quantity" bigint DEFAULT 0 NOT NULL,
"manufacturer" text,
"manufacturerNumber" text,
"purchaseDate" date,
"purchasePrice" double precision DEFAULT 0,
"currentValue" double precision,
"product" bigint,
"vendor" bigint,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerspaces" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerspaces_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,
"type" text NOT NULL,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"spaceNumber" text NOT NULL,
"parentSpace" bigint,
"infoData" jsonb DEFAULT '{"zip":"","city":"","streetNumber":""}'::jsonb NOT NULL,
"description" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "entitybankaccounts" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "entitybankaccounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"iban_encrypted" jsonb NOT NULL,
"bic_encrypted" jsonb NOT NULL,
"bank_name_encrypted" jsonb NOT NULL,
"description" text,
"updated_at" timestamp with time zone,
"updated_by" uuid,
"archived" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memberrelations" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "memberrelations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"type" text NOT NULL,
"billingInterval" text NOT NULL,
"billingAmount" double precision DEFAULT 0 NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000}}'::jsonb;--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "contracttype" bigint;--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "billingInterval" text;--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "customTaxType" text;--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerspace" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerinventoryitem" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
ALTER TABLE "services" ADD COLUMN "priceUpdateLocked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_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 "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_product_products_id_fk" FOREIGN KEY ("product") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_vendor_vendors_id_fk" FOREIGN KEY ("vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_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 "customerspaces" ADD CONSTRAINT "customerspaces_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_parentSpace_customerspaces_id_fk" FOREIGN KEY ("parentSpace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_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 "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_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 "memberrelations" ADD CONSTRAINT "memberrelations_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_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 "contracts" ADD CONSTRAINT "contracts_contracttype_contracttypes_id_fk" FOREIGN KEY ("contracttype") REFERENCES "public"."contracttypes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customers" ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerinventoryitem_customerinventoryitems_id_fk" FOREIGN KEY ("customerinventoryitem") REFERENCES "public"."customerinventoryitems"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "accounts" ADD COLUMN "accountChart" text DEFAULT 'skr03' NOT NULL;
--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "accountChart" text DEFAULT 'skr03' NOT NULL;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "createddocuments"
ALTER COLUMN "customSurchargePercentage" TYPE double precision
USING "customSurchargePercentage"::double precision;

View File

@@ -57,6 +57,83 @@
"when": 1772000100000, "when": 1772000100000,
"tag": "0007_bright_default_tax_type", "tag": "0007_bright_default_tax_type",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1773000000000,
"tag": "0008_quick_contracttypes",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1773000100000,
"tag": "0009_heavy_contract_contracttype",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1773000200000,
"tag": "0010_sudden_billing_interval",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1773000300000,
"tag": "0011_mighty_member_bankaccounts",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1773000400000,
"tag": "0012_shiny_customer_inventory",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1773000500000,
"tag": "0013_brisk_customer_inventory_vendor",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1773000600000,
"tag": "0014_smart_memberrelations",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1773000700000,
"tag": "0015_wise_memberrelation_history",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1773000800000,
"tag": "0016_fix_memberrelation_column_usage",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1771704862789,
"tag": "0017_slow_the_hood",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773000900000,
"tag": "0018_account_chart",
"breakpoints": true
} }
] ]
} }

View File

@@ -16,6 +16,7 @@ export const accounts = pgTable("accounts", {
number: text("number").notNull(), number: text("number").notNull(),
label: text("label").notNull(), label: text("label").notNull(),
accountChart: text("accountChart").notNull().default("skr03"),
description: text("description"), description: text("description"),
}) })

View File

@@ -11,6 +11,7 @@ import {
import { tenants } from "./tenants" import { tenants } from "./tenants"
import { customers } from "./customers" import { customers } from "./customers"
import { contacts } from "./contacts" import { contacts } from "./contacts"
import { contracttypes } from "./contracttypes"
import { authUsers } from "./auth_users" import { authUsers } from "./auth_users"
export const contracts = pgTable( export const contracts = pgTable(
@@ -48,6 +49,9 @@ export const contracts = pgTable(
contact: bigint("contact", { mode: "number" }).references( contact: bigint("contact", { mode: "number" }).references(
() => contacts.id () => contacts.id
), ),
contracttype: bigint("contracttype", { mode: "number" }).references(
() => contracttypes.id
),
bankingIban: text("bankingIban"), bankingIban: text("bankingIban"),
bankingBIC: text("bankingBIC"), bankingBIC: text("bankingBIC"),
@@ -57,6 +61,7 @@ export const contracts = pgTable(
sepaDate: timestamp("sepaDate", { withTimezone: true }), sepaDate: timestamp("sepaDate", { withTimezone: true }),
paymentType: text("paymentType"), paymentType: text("paymentType"),
billingInterval: text("billingInterval"),
invoiceDispatch: text("invoiceDispatch"), invoiceDispatch: text("invoiceDispatch"),
ownFields: jsonb("ownFields").notNull().default({}), ownFields: jsonb("ownFields").notNull().default({}),

View File

@@ -0,0 +1,40 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const contracttypes = pgTable("contracttypes", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
description: text("description"),
paymentType: text("paymentType"),
recurring: boolean("recurring").notNull().default(false),
billingInterval: text("billingInterval"),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type ContractType = typeof contracttypes.$inferSelect
export type NewContractType = typeof contracttypes.$inferInsert

View File

@@ -6,6 +6,7 @@ import {
jsonb, jsonb,
boolean, boolean,
smallint, smallint,
doublePrecision,
uuid, uuid,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
@@ -96,7 +97,7 @@ export const createddocuments = pgTable("createddocuments", {
taxType: text("taxType"), taxType: text("taxType"),
customSurchargePercentage: smallint("customSurchargePercentage") customSurchargePercentage: doublePrecision("customSurchargePercentage")
.notNull() .notNull()
.default(0), .default(0),

View File

@@ -0,0 +1,66 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
doublePrecision,
uuid,
date,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { customers } from "./customers"
import { customerspaces } from "./customerspaces"
import { products } from "./products"
import { vendors } from "./vendors"
import { authUsers } from "./auth_users"
export const customerinventoryitems = pgTable("customerinventoryitems", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
description: text("description"),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
customer: bigint("customer", { mode: "number" })
.notNull()
.references(() => customers.id),
customerspace: bigint("customerspace", { mode: "number" }).references(
() => customerspaces.id
),
customerInventoryId: text("customerInventoryId").notNull(),
serialNumber: text("serialNumber"),
quantity: bigint("quantity", { mode: "number" }).notNull().default(0),
manufacturer: text("manufacturer"),
manufacturerNumber: text("manufacturerNumber"),
purchaseDate: date("purchaseDate"),
purchasePrice: doublePrecision("purchasePrice").default(0),
currentValue: doublePrecision("currentValue"),
product: bigint("product", { mode: "number" }).references(() => products.id),
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type CustomerInventoryItem = typeof customerinventoryitems.$inferSelect
export type NewCustomerInventoryItem = typeof customerinventoryitems.$inferInsert

View File

@@ -10,6 +10,7 @@ import {
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
import { tenants } from "./tenants" import { tenants } from "./tenants"
import { authUsers } from "./auth_users" import { authUsers } from "./auth_users"
import { memberrelations } from "./memberrelations"
export const customers = pgTable( export const customers = pgTable(
"customers", "customers",
@@ -63,6 +64,7 @@ export const customers = pgTable(
customPaymentType: text("custom_payment_type"), // ENUM payment_types separat? customPaymentType: text("custom_payment_type"), // ENUM payment_types separat?
customTaxType: text("customTaxType"), customTaxType: text("customTaxType"),
memberrelation: bigint("memberrelation", { mode: "number" }).references(() => memberrelations.id),
} }
) )

View File

@@ -0,0 +1,54 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
jsonb,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { customers } from "./customers"
import { authUsers } from "./auth_users"
export const customerspaces = pgTable("customerspaces", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
type: text("type").notNull(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
customer: bigint("customer", { mode: "number" })
.notNull()
.references(() => customers.id),
space_number: text("spaceNumber").notNull(),
parentSpace: bigint("parentSpace", { mode: "number" }).references(
() => customerspaces.id
),
info_data: jsonb("infoData")
.notNull()
.default({ zip: "", city: "", streetNumber: "" }),
description: text("description"),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type CustomerSpace = typeof customerspaces.$inferSelect
export type NewCustomerSpace = typeof customerspaces.$inferInsert

View File

@@ -0,0 +1,39 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
jsonb,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const entitybankaccounts = pgTable("entitybankaccounts", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
ibanEncrypted: jsonb("iban_encrypted").notNull(),
bicEncrypted: jsonb("bic_encrypted").notNull(),
bankNameEncrypted: jsonb("bank_name_encrypted").notNull(),
description: text("description"),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
archived: boolean("archived").notNull().default(false),
})
export type EntityBankAccount = typeof entitybankaccounts.$inferSelect
export type NewEntityBankAccount = typeof entitybankaccounts.$inferInsert

View File

@@ -20,6 +20,8 @@ import { tasks } from "./tasks"
import { vehicles } from "./vehicles" import { vehicles } from "./vehicles"
import { bankstatements } from "./bankstatements" import { bankstatements } from "./bankstatements"
import { spaces } from "./spaces" import { spaces } from "./spaces"
import { customerspaces } from "./customerspaces"
import { customerinventoryitems } from "./customerinventoryitems"
import { costcentres } from "./costcentres" import { costcentres } from "./costcentres"
import { ownaccounts } from "./ownaccounts" import { ownaccounts } from "./ownaccounts"
import { createddocuments } from "./createddocuments" import { createddocuments } from "./createddocuments"
@@ -32,6 +34,7 @@ import { events } from "./events"
import { inventoryitemgroups } from "./inventoryitemgroups" import { inventoryitemgroups } from "./inventoryitemgroups"
import { authUsers } from "./auth_users" import { authUsers } from "./auth_users"
import {files} from "./files"; import {files} from "./files";
import { memberrelations } from "./memberrelations";
export const historyitems = pgTable("historyitems", { export const historyitems = pgTable("historyitems", {
id: bigint("id", { mode: "number" }) id: bigint("id", { mode: "number" })
@@ -99,6 +102,12 @@ export const historyitems = pgTable("historyitems", {
space: bigint("space", { mode: "number" }).references(() => spaces.id), space: bigint("space", { mode: "number" }).references(() => spaces.id),
customerspace: bigint("customerspace", { mode: "number" }).references(() => customerspaces.id),
customerinventoryitem: bigint("customerinventoryitem", { mode: "number" }).references(() => customerinventoryitems.id),
memberrelation: bigint("memberrelation", { mode: "number" }).references(() => memberrelations.id),
config: jsonb("config"), config: jsonb("config"),
projecttype: bigint("projecttype", { mode: "number" }).references( projecttype: bigint("projecttype", { mode: "number" }).references(

View File

@@ -13,15 +13,19 @@ export * from "./checks"
export * from "./citys" export * from "./citys"
export * from "./contacts" export * from "./contacts"
export * from "./contracts" export * from "./contracts"
export * from "./contracttypes"
export * from "./costcentres" export * from "./costcentres"
export * from "./countrys" export * from "./countrys"
export * from "./createddocuments" export * from "./createddocuments"
export * from "./createdletters" export * from "./createdletters"
export * from "./customers" export * from "./customers"
export * from "./customerspaces"
export * from "./customerinventoryitems"
export * from "./devices" export * from "./devices"
export * from "./documentboxes" export * from "./documentboxes"
export * from "./enums" export * from "./enums"
export * from "./events" export * from "./events"
export * from "./entitybankaccounts"
export * from "./files" export * from "./files"
export * from "./filetags" export * from "./filetags"
export * from "./folders" export * from "./folders"
@@ -42,7 +46,9 @@ export * from "./incominginvoices"
export * from "./inventoryitemgroups" export * from "./inventoryitemgroups"
export * from "./inventoryitems" export * from "./inventoryitems"
export * from "./letterheads" export * from "./letterheads"
export * from "./memberrelations"
export * from "./movements" export * from "./movements"
export * from "./m2m_api_keys"
export * from "./notifications_event_types" export * from "./notifications_event_types"
export * from "./notifications_items" export * from "./notifications_items"
export * from "./notifications_preferences" export * from "./notifications_preferences"
@@ -72,4 +78,4 @@ export * from "./staff_time_events"
export * from "./serialtypes" export * from "./serialtypes"
export * from "./serialexecutions" export * from "./serialexecutions"
export * from "./public_links" export * from "./public_links"
export * from "./wikipages" export * from "./wikipages"

View File

@@ -0,0 +1,48 @@
import {
pgTable,
uuid,
bigint,
text,
timestamp,
boolean,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const m2mApiKeys = pgTable("m2m_api_keys", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }),
createdBy: uuid("created_by").references(() => authUsers.id, {
onDelete: "set null",
onUpdate: "cascade",
}),
name: text("name").notNull(),
keyPrefix: text("key_prefix").notNull(),
keyHash: text("key_hash").notNull().unique(),
active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }),
})
export type M2mApiKey = typeof m2mApiKeys.$inferSelect
export type NewM2mApiKey = typeof m2mApiKeys.$inferInsert

View File

@@ -0,0 +1,39 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
uuid,
doublePrecision,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const memberrelations = pgTable("memberrelations", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
type: text("type").notNull(),
billingInterval: text("billingInterval").notNull(),
billingAmount: doublePrecision("billingAmount").notNull().default(0),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type MemberRelation = typeof memberrelations.$inferSelect
export type NewMemberRelation = typeof memberrelations.$inferInsert

View File

@@ -74,6 +74,48 @@ export const tenants = pgTable(
timeTracking: true, timeTracking: true,
planningBoard: true, planningBoard: true,
workingTimeTracking: true, workingTimeTracking: true,
dashboard: true,
historyitems: true,
tasks: true,
wiki: true,
files: true,
createdletters: true,
documentboxes: true,
helpdesk: true,
email: true,
members: true,
customers: true,
vendors: true,
contactsList: true,
staffTime: true,
createDocument: true,
serialInvoice: true,
incomingInvoices: true,
costcentres: true,
accounts: true,
ownaccounts: true,
banking: true,
spaces: true,
customerspaces: true,
customerinventoryitems: true,
inventoryitems: true,
inventoryitemgroups: true,
products: true,
productcategories: true,
services: true,
servicecategories: true,
memberrelations: true,
staffProfiles: true,
hourrates: true,
projecttypes: true,
contracttypes: true,
plants: true,
settingsNumberRanges: true,
settingsEmailAccounts: true,
settingsBanking: true,
settingsTexttemplates: true,
settingsTenant: true,
export: true,
}), }),
ownFields: jsonb("ownFields"), ownFields: jsonb("ownFields"),
@@ -88,10 +130,13 @@ export const tenants = pgTable(
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 }, confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 }, invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 },
spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 }, spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 },
customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 },
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 }, inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },
customerinventoryitems: { prefix: "KIA-", suffix: "", nextNumber: 1000 },
projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 }, projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 },
costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 }, costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 },
}), }),
accountChart: text("accountChart").notNull().default("skr03"),
standardEmailForInvoices: text("standardEmailForInvoices"), standardEmailForInvoices: text("standardEmailForInvoices"),

View File

@@ -6,6 +6,6 @@ export default defineConfig({
schema: "./db/schema", schema: "./db/schema",
out: "./db/migrations", out: "./db/migrations",
dbCredentials: { dbCredentials: {
url: secrets.DATABASE_URL, url: secrets.DATABASE_URL || "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo",
}, },
}) })

View File

@@ -9,7 +9,10 @@
"dev:dav": "tsx watch src/webdav/server.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",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
"members:import:csv": "tsx scripts/import-members-csv.ts",
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -0,0 +1,95 @@
import fs from "node:fs/promises"
import path from "node:path"
import https from "node:https"
const DEFAULT_SOURCE_URL =
"https://www.bundesbank.de/resource/blob/602632/bec25ca5df1eb62fefadd8325dafe67c/472B63F073F071307366337C94F8C870/blz-aktuell-txt-data.txt"
const OUTPUT_NAME_FILE = path.resolve("src/utils/deBankCodes.ts")
const OUTPUT_BIC_FILE = path.resolve("src/utils/deBankBics.ts")
function fetchBuffer(url: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
https
.get(url, (res) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return resolve(fetchBuffer(res.headers.location))
}
if (res.statusCode !== 200) {
return reject(new Error(`Download failed with status ${res.statusCode}`))
}
const chunks: Buffer[] = []
res.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
res.on("end", () => resolve(Buffer.concat(chunks)))
res.on("error", reject)
})
.on("error", reject)
})
}
function escapeTsString(value: string) {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
}
async function main() {
const source = process.env.BLZ_SOURCE_URL || DEFAULT_SOURCE_URL
const sourceFile = process.env.BLZ_SOURCE_FILE
let raw: Buffer
if (sourceFile) {
console.log(`Reading BLZ source file: ${sourceFile}`)
raw = await fs.readFile(sourceFile)
} else {
console.log(`Downloading BLZ source: ${source}`)
raw = await fetchBuffer(source)
}
const content = raw.toString("latin1")
const lines = content.split(/\r?\n/)
const nameMap = new Map<string, string>()
const bicMap = new Map<string, string>()
for (const line of lines) {
if (!line || line.length < 150) continue
const blz = line.slice(0, 8).trim()
const name = line.slice(9, 67).trim()
const bic = line.slice(139, 150).trim()
if (!/^\d{8}$/.test(blz) || !name) continue
if (!nameMap.has(blz)) nameMap.set(blz, name)
if (bic && !bicMap.has(blz)) bicMap.set(blz, bic)
}
const sortedNames = [...nameMap.entries()].sort(([a], [b]) => a.localeCompare(b))
const sortedBics = [...bicMap.entries()].sort(([a], [b]) => a.localeCompare(b))
const nameOutputLines = [
"// Lokale Bankleitzahl-zu-Institut Zuordnung (DE).",
"// Quelle: Deutsche Bundesbank, BLZ-Datei (vollstaendig).",
"export const DE_BANK_CODE_TO_NAME: Record<string, string> = {",
...sortedNames.map(([blz, name]) => ` "${blz}": "${escapeTsString(name)}",`),
"}",
"",
]
const bicOutputLines = [
"// Lokale Bankleitzahl-zu-BIC Zuordnung (DE).",
"// Quelle: Deutsche Bundesbank, BLZ-Datei (vollstaendig).",
"export const DE_BANK_CODE_TO_BIC: Record<string, string> = {",
...sortedBics.map(([blz, bic]) => ` "${blz}": "${escapeTsString(bic)}",`),
"}",
"",
]
await fs.writeFile(OUTPUT_NAME_FILE, nameOutputLines.join("\n"), "utf8")
await fs.writeFile(OUTPUT_BIC_FILE, bicOutputLines.join("\n"), "utf8")
console.log(`Wrote ${sortedNames.length} bank names to ${OUTPUT_NAME_FILE}`)
console.log(`Wrote ${sortedBics.length} bank BICs to ${OUTPUT_BIC_FILE}`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -42,6 +42,7 @@ import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email";
import deviceRoutes from "./routes/internal/devices"; import deviceRoutes from "./routes/internal/devices";
import tenantRoutesInternal from "./routes/internal/tenant"; import tenantRoutesInternal from "./routes/internal/tenant";
import staffTimeRoutesInternal from "./routes/internal/time"; import staffTimeRoutesInternal from "./routes/internal/time";
import authM2mInternalRoutes from "./routes/internal/auth.m2m";
//Devices //Devices
import devicesRFIDRoutes from "./routes/devices/rfid"; import devicesRFIDRoutes from "./routes/devices/rfid";
@@ -107,6 +108,7 @@ async function main() {
await app.register(async (m2mApp) => { await app.register(async (m2mApp) => {
await m2mApp.register(authM2m) await m2mApp.register(authM2m)
await m2mApp.register(authM2mInternalRoutes)
await m2mApp.register(helpdeskInboundEmailRoutes) await m2mApp.register(helpdeskInboundEmailRoutes)
await m2mApp.register(deviceRoutes) await m2mApp.register(deviceRoutes)
await m2mApp.register(tenantRoutesInternal) await m2mApp.register(tenantRoutesInternal)
@@ -167,4 +169,4 @@ async function main() {
} }
} }
main(); main();

View File

@@ -38,6 +38,11 @@ function normalizeUuid(value: unknown): string | null {
return trimmed.length ? trimmed : null; return trimmed.length ? trimmed : null;
} }
function sanitizeCompositionRows(value: unknown): CompositionRow[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is CompositionRow => !!entry && typeof entry === "object");
}
export async function recalculateServicePricesForTenant(server: FastifyInstance, tenantId: number, updatedBy?: string | null) { export async function recalculateServicePricesForTenant(server: FastifyInstance, tenantId: number, updatedBy?: string | null) {
const [services, products, hourrates] = await Promise.all([ const [services, products, hourrates] = await Promise.all([
server.db.select().from(schema.services).where(eq(schema.services.tenant, tenantId)), server.db.select().from(schema.services).where(eq(schema.services.tenant, tenantId)),
@@ -88,94 +93,111 @@ export async function recalculateServicePricesForTenant(server: FastifyInstance,
materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"), materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"), workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"), workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
materialComposition: Array.isArray(service.materialComposition) ? service.materialComposition as CompositionRow[] : [], materialComposition: sanitizeCompositionRows(service.materialComposition),
personalComposition: Array.isArray(service.personalComposition) ? service.personalComposition as CompositionRow[] : [], personalComposition: sanitizeCompositionRows(service.personalComposition),
}; };
memo.set(serviceId, lockedResult); memo.set(serviceId, lockedResult);
return lockedResult; return lockedResult;
} }
stack.add(serviceId); stack.add(serviceId);
try {
const materialComposition = sanitizeCompositionRows(service.materialComposition);
const personalComposition = sanitizeCompositionRows(service.personalComposition);
const hasMaterialComposition = materialComposition.length > 0;
const hasPersonalComposition = personalComposition.length > 0;
const materialComposition: CompositionRow[] = Array.isArray(service.materialComposition) // Ohne Zusammensetzung keine automatische Überschreibung:
? (service.materialComposition as CompositionRow[]) // manuell gepflegte Preise sollen erhalten bleiben.
: []; if (!hasMaterialComposition && !hasPersonalComposition) {
const personalComposition: CompositionRow[] = Array.isArray(service.personalComposition) const manualResult = {
? (service.personalComposition as CompositionRow[]) sellingTotal: getJsonNumber(service.sellingPriceComposed, "total") || toNumber(service.sellingPrice),
: []; purchaseTotal: getJsonNumber(service.purchasePriceComposed, "total"),
materialTotal: getJsonNumber(service.sellingPriceComposed, "material"),
let materialTotal = 0; materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
let materialPurchaseTotal = 0; workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
const normalizedMaterialComposition = materialComposition.map((entry) => { materialComposition,
const quantity = toNumber(entry.quantity); personalComposition,
const productId = normalizeId(entry.product); };
const childServiceId = normalizeId(entry.service); memo.set(serviceId, manualResult);
return manualResult;
let sellingPrice = toNumber(entry.price);
let purchasePrice = toNumber(entry.purchasePrice);
if (productId) {
const product = productMap.get(productId);
sellingPrice = toNumber(product?.selling_price);
purchasePrice = toNumber(product?.purchase_price);
} else if (childServiceId) {
const child = calculateService(childServiceId);
sellingPrice = toNumber(child.sellingTotal);
purchasePrice = toNumber(child.purchaseTotal);
} }
materialTotal += quantity * sellingPrice; let materialTotal = 0;
materialPurchaseTotal += quantity * purchasePrice; let materialPurchaseTotal = 0;
return { const normalizedMaterialComposition = materialComposition.map((entry) => {
...entry, const quantity = toNumber(entry.quantity);
price: round2(sellingPrice), const productId = normalizeId(entry.product);
purchasePrice: round2(purchasePrice), const childServiceId = normalizeId(entry.service);
};
});
let workerTotal = 0; let sellingPrice = toNumber(entry.price);
let workerPurchaseTotal = 0; let purchasePrice = toNumber(entry.purchasePrice);
const normalizedPersonalComposition = personalComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const hourrateId = normalizeUuid(entry.hourrate);
let sellingPrice = toNumber(entry.price); if (productId) {
let purchasePrice = toNumber(entry.purchasePrice); const product = productMap.get(productId);
sellingPrice = toNumber(product?.selling_price);
if (hourrateId) { purchasePrice = toNumber(product?.purchase_price);
const hourrate = hourrateMap.get(hourrateId); } else if (childServiceId) {
if (hourrate) { const child = calculateService(childServiceId);
sellingPrice = toNumber(hourrate.sellingPrice); sellingPrice = toNumber(child.sellingTotal);
purchasePrice = toNumber(hourrate.purchase_price); purchasePrice = toNumber(child.purchaseTotal);
} }
}
workerTotal += quantity * sellingPrice; materialTotal += quantity * sellingPrice;
workerPurchaseTotal += quantity * purchasePrice; materialPurchaseTotal += quantity * purchasePrice;
return { return {
...entry, ...entry,
price: round2(sellingPrice), price: round2(sellingPrice),
purchasePrice: round2(purchasePrice), purchasePrice: round2(purchasePrice),
};
});
let workerTotal = 0;
let workerPurchaseTotal = 0;
const normalizedPersonalComposition = personalComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const hourrateId = normalizeUuid(entry.hourrate);
let sellingPrice = toNumber(entry.price);
let purchasePrice = toNumber(entry.purchasePrice);
if (hourrateId) {
const hourrate = hourrateMap.get(hourrateId);
if (hourrate) {
sellingPrice = toNumber(hourrate.sellingPrice);
purchasePrice = toNumber(hourrate.purchase_price);
}
}
workerTotal += quantity * sellingPrice;
workerPurchaseTotal += quantity * purchasePrice;
return {
...entry,
price: round2(sellingPrice),
purchasePrice: round2(purchasePrice),
};
});
const result = {
sellingTotal: round2(materialTotal + workerTotal),
purchaseTotal: round2(materialPurchaseTotal + workerPurchaseTotal),
materialTotal: round2(materialTotal),
materialPurchaseTotal: round2(materialPurchaseTotal),
workerTotal: round2(workerTotal),
workerPurchaseTotal: round2(workerPurchaseTotal),
materialComposition: normalizedMaterialComposition,
personalComposition: normalizedPersonalComposition,
}; };
});
const result = { memo.set(serviceId, result);
sellingTotal: round2(materialTotal + workerTotal), return result;
purchaseTotal: round2(materialPurchaseTotal + workerPurchaseTotal), } finally {
materialTotal: round2(materialTotal), stack.delete(serviceId);
materialPurchaseTotal: round2(materialPurchaseTotal), }
workerTotal: round2(workerTotal),
workerPurchaseTotal: round2(workerPurchaseTotal),
materialComposition: normalizedMaterialComposition,
personalComposition: normalizedPersonalComposition,
};
memo.set(serviceId, result);
stack.delete(serviceId);
return result;
}; };
for (const service of services) { for (const service of services) {

View File

@@ -1,6 +1,9 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import fp from "fastify-plugin"; import fp from "fastify-plugin";
import { secrets } from "../utils/secrets"; import { secrets } from "../utils/secrets";
import { and, eq } from "drizzle-orm";
import { authUsers, m2mApiKeys } from "../../db/schema";
import { createHash } from "node:crypto";
/** /**
* Fastify Plugin für Machine-to-Machine Authentifizierung. * Fastify Plugin für Machine-to-Machine Authentifizierung.
@@ -12,26 +15,99 @@ import { secrets } from "../utils/secrets";
* server.register(m2mAuthPlugin, { allowedPrefix: '/internal' }) * server.register(m2mAuthPlugin, { allowedPrefix: '/internal' })
*/ */
export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string } = {}) => { export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string } = {}) => {
//const allowedPrefix = opts.allowedPrefix || "/internal"; const hashApiKey = (apiKey: string) =>
createHash("sha256").update(apiKey, "utf8").digest("hex")
server.addHook("preHandler", async (req, reply) => { server.addHook("preHandler", async (req, reply) => {
try { try {
// Nur prüfen, wenn Route unterhalb des Prefix liegt const apiKeyHeader = req.headers["x-api-key"];
//if (!req.url.startsWith(allowedPrefix)) return; const apiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader;
const apiKey = req.headers["x-api-key"]; if (!apiKey) {
if (!apiKey || apiKey !== secrets.M2M_API_KEY) {
server.log.warn(`[M2M Auth] Ungültiger oder fehlender API-Key bei ${req.url}`); server.log.warn(`[M2M Auth] Ungültiger oder fehlender API-Key bei ${req.url}`);
return reply.status(401).send({ error: "Unauthorized" }); return reply.status(401).send({ error: "Unauthorized" });
} }
// Zusatzinformationen im Request (z. B. interne Kennung) const keyHash = hashApiKey(apiKey);
const keyRows = await server.db
.select({
id: m2mApiKeys.id,
tenantId: m2mApiKeys.tenantId,
userId: m2mApiKeys.userId,
active: m2mApiKeys.active,
expiresAt: m2mApiKeys.expiresAt,
name: m2mApiKeys.name,
userEmail: authUsers.email,
})
.from(m2mApiKeys)
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
.where(and(
eq(m2mApiKeys.keyHash, keyHash),
eq(m2mApiKeys.active, true)
))
.limit(1)
let key = keyRows[0]
if (!key) {
const fallbackValid = apiKey === secrets.M2M_API_KEY
if (!fallbackValid) {
server.log.warn(`[M2M Auth] Ungültiger API-Key bei ${req.url}`)
return reply.status(401).send({ error: "Unauthorized" })
}
// Backward compatibility mode for one global key.
// The caller must provide user/tenant identifiers in headers.
const tenantIdHeader = req.headers["x-tenant-id"]
const userIdHeader = req.headers["x-user-id"]
const tenantId = Number(Array.isArray(tenantIdHeader) ? tenantIdHeader[0] : tenantIdHeader)
const userId = Array.isArray(userIdHeader) ? userIdHeader[0] : userIdHeader
if (!tenantId || !userId) {
return reply.status(401).send({ error: "Missing x-tenant-id or x-user-id for legacy M2M key" })
}
const users = await server.db
.select({ email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
if (!users[0]) {
return reply.status(401).send({ error: "Unknown user for legacy M2M key" })
}
req.user = {
user_id: userId,
email: users[0].email,
tenant_id: tenantId
}
} else {
if (key.expiresAt && new Date(key.expiresAt).getTime() < Date.now()) {
return reply.status(401).send({ error: "Expired API key" })
}
req.user = {
user_id: key.userId,
email: key.userEmail,
tenant_id: key.tenantId
}
await server.db
.update(m2mApiKeys)
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
.where(eq(m2mApiKeys.id, key.id))
}
(req as any).m2m = { (req as any).m2m = {
verified: true, verified: true,
type: "internal", type: "internal",
key: apiKey, key: apiKey,
}; };
req.role = "m2m"
req.permissions = []
req.hasPermission = () => false
} catch (err) { } catch (err) {
// @ts-ignore // @ts-ignore
server.log.error("[M2M Auth] Fehler beim Prüfen des API-Keys:", err); server.log.error("[M2M Auth] Fehler beim Prüfen des API-Keys:", err);

View File

@@ -1,31 +1,25 @@
import fp from "fastify-plugin" // src/plugins/db.ts
import {drizzle, NodePgDatabase} from "drizzle-orm/node-postgres" import fp from "fastify-plugin";
import * as schema from "../../db/schema" import { NodePgDatabase } from "drizzle-orm/node-postgres";
import {secrets} from "../utils/secrets"; import * as schema from "../../db/schema";
import { Pool } from "pg" import { db, pool } from "../../db"; // <--- Importiert jetzt die globale Instanz
export default fp(async (server, opts) => { export default fp(async (server, opts) => {
const pool = new Pool({ // Wir nutzen die db, die wir in src/db/index.ts erstellt haben
connectionString: secrets.DATABASE_URL, server.decorate("db", db);
max: 10, // je nach Last
})
const db = drizzle(pool , {schema}) // Graceful Shutdown: Wenn Fastify ausgeht, schließen wir den Pool
// Dekorieren -> überall server.db
server.decorate("db", db)
// Graceful Shutdown
server.addHook("onClose", async () => { server.addHook("onClose", async () => {
await pool.end() console.log("[DB] Closing connection pool...");
}) await pool.end();
});
console.log("Drizzle database connected") console.log("[Fastify] Database attached from shared instance");
}) });
declare module "fastify" { declare module "fastify" {
interface FastifyInstance { interface FastifyInstance {
db:NodePgDatabase<typeof schema> db: NodePgDatabase<typeof schema>
} }
} }

View File

@@ -58,8 +58,6 @@ const queryConfigPlugin: FastifyPluginAsync<QueryConfigPluginOptions> = async (
const query = req.query as Record<string, any> const query = req.query as Record<string, any>
console.log(query)
// Pagination deaktivieren? // Pagination deaktivieren?
const disablePagination = const disablePagination =
query.noPagination === 'true' || query.noPagination === 'true' ||

View File

@@ -94,6 +94,7 @@ export default async function adminRoutes(server: FastifyInstance) {
short: tenants.short, short: tenants.short,
locked: tenants.locked, locked: tenants.locked,
numberRanges: tenants.numberRanges, numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart,
extraModules: tenants.extraModules, extraModules: tenants.extraModules,
}) })
.from(authTenantUsers) .from(authTenantUsers)

View File

@@ -51,9 +51,11 @@ export default async function meRoutes(server: FastifyInstance) {
name: tenants.name, name: tenants.name,
short: tenants.short, short: tenants.short,
locked: tenants.locked, locked: tenants.locked,
features: tenants.features,
extraModules: tenants.extraModules, extraModules: tenants.extraModules,
businessInfo: tenants.businessInfo, businessInfo: tenants.businessInfo,
numberRanges: tenants.numberRanges, numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart,
dokuboxkey: tenants.dokuboxkey, dokuboxkey: tenants.dokuboxkey,
standardEmailForInvoices: tenants.standardEmailForInvoices, standardEmailForInvoices: tenants.standardEmailForInvoices,
standardPaymentDays: tenants.standardPaymentDays, standardPaymentDays: tenants.standardPaymentDays,

View File

@@ -4,10 +4,19 @@ import dayjs from "dayjs"
import { secrets } from "../utils/secrets" import { secrets } from "../utils/secrets"
import { insertHistoryItem } from "../utils/history" import { insertHistoryItem } from "../utils/history"
import { decrypt, encrypt } from "../utils/crypt"
import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes"
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
import { import {
bankrequisitions, bankrequisitions,
bankstatements,
createddocuments,
customers,
entitybankaccounts,
incominginvoices,
statementallocations, statementallocations,
vendors,
} from "../../db/schema" } from "../../db/schema"
import { import {
@@ -17,6 +26,322 @@ import {
export default async function bankingRoutes(server: FastifyInstance) { export default async function bankingRoutes(server: FastifyInstance) {
const normalizeIban = (value?: string | null) =>
String(value || "").replace(/\s+/g, "").toUpperCase()
const pickPartnerBankData = (statement: any, partnerType: "customer" | "vendor") => {
if (!statement) return null
const prefersDebit = partnerType === "customer"
? Number(statement.amount) >= 0
: Number(statement.amount) > 0
const primary = prefersDebit
? { iban: statement.debIban }
: { iban: statement.credIban }
const fallback = prefersDebit
? { iban: statement.credIban }
: { iban: statement.debIban }
const primaryIban = normalizeIban(primary.iban)
if (primaryIban) {
return {
iban: primaryIban,
}
}
const fallbackIban = normalizeIban(fallback.iban)
if (fallbackIban) {
return {
iban: fallbackIban,
}
}
return null
}
const mergePartnerIban = (infoData: Record<string, any>, iban: string, bankAccountId?: number | null) => {
if (!iban && !bankAccountId) return infoData || {}
const info = infoData && typeof infoData === "object" ? { ...infoData } : {}
if (iban) {
const existing = Array.isArray(info.bankingIbans) ? info.bankingIbans : []
const merged = [...new Set([...existing.map((i: string) => normalizeIban(i)), iban])]
info.bankingIbans = merged
if (!info.bankingIban) info.bankingIban = iban
}
if (bankAccountId) {
const existingIds = Array.isArray(info.bankAccountIds) ? info.bankAccountIds : []
if (!existingIds.includes(bankAccountId)) {
info.bankAccountIds = [...existingIds, bankAccountId]
}
}
return info
}
const ibanLengthByCountry: Record<string, number> = {
DE: 22,
AT: 20,
CH: 21,
NL: 18,
BE: 16,
FR: 27,
ES: 24,
IT: 27,
LU: 20,
}
const isValidIbanLocal = (iban: string) => {
const normalized = normalizeIban(iban)
if (!normalized || normalized.length < 15 || normalized.length > 34) return false
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(normalized)) return false
const country = normalized.slice(0, 2)
const expectedLength = ibanLengthByCountry[country]
if (expectedLength && normalized.length !== expectedLength) return false
const rearranged = normalized.slice(4) + normalized.slice(0, 4)
let numeric = ""
for (const ch of rearranged) {
if (ch >= "A" && ch <= "Z") numeric += (ch.charCodeAt(0) - 55).toString()
else numeric += ch
}
let remainder = 0
for (const digit of numeric) {
remainder = (remainder * 10 + Number(digit)) % 97
}
return remainder === 1
}
const resolveGermanBankDataFromIbanLocal = (iban: string) => {
const normalized = normalizeIban(iban)
if (!isValidIbanLocal(normalized)) return null
// Für DE-IBANs kann die BLZ aus Position 5-12 lokal gelesen werden.
if (normalized.startsWith("DE") && normalized.length === 22) {
const bankCode = normalized.slice(4, 12)
const bankName = DE_BANK_CODE_TO_NAME[bankCode] || `Unbekannt (BLZ ${bankCode})`
const bic = DE_BANK_CODE_TO_BIC[bankCode] || null
return {
bankName,
bic,
bankCode,
}
}
return null
}
const resolveEntityBankAccountId = async (
tenantId: number,
userId: string,
iban: string
) => {
const normalizedIban = normalizeIban(iban)
if (!normalizedIban) return null
const bankData = resolveGermanBankDataFromIbanLocal(normalizedIban)
const allAccounts = await server.db
.select({
id: entitybankaccounts.id,
ibanEncrypted: entitybankaccounts.ibanEncrypted,
bankNameEncrypted: entitybankaccounts.bankNameEncrypted,
bicEncrypted: entitybankaccounts.bicEncrypted,
})
.from(entitybankaccounts)
.where(eq(entitybankaccounts.tenant, tenantId))
const existing = allAccounts.find((row) => {
if (!row.ibanEncrypted) return false
try {
const decryptedIban = decrypt(row.ibanEncrypted as any)
return normalizeIban(decryptedIban) === normalizedIban
} catch {
return false
}
})
if (existing?.id) {
if (bankData) {
let currentBankName = ""
let currentBic = ""
try {
currentBankName = String(decrypt(existing.bankNameEncrypted as any) || "").trim()
} catch {
currentBankName = ""
}
try {
currentBic = String(decrypt((existing as any).bicEncrypted as any) || "").trim()
} catch {
currentBic = ""
}
const nextBankName = bankData?.bankName || "Unbekannt"
const nextBic = bankData?.bic || "UNBEKANNT"
if (currentBankName !== nextBankName || currentBic !== nextBic) {
await server.db
.update(entitybankaccounts)
.set({
bankNameEncrypted: encrypt(nextBankName),
bicEncrypted: encrypt(nextBic),
updatedAt: new Date(),
updatedBy: userId,
})
.where(and(eq(entitybankaccounts.id, Number(existing.id)), eq(entitybankaccounts.tenant, tenantId)))
}
}
return Number(existing.id)
}
const [created] = await server.db
.insert(entitybankaccounts)
.values({
tenant: tenantId,
ibanEncrypted: encrypt(normalizedIban),
bicEncrypted: encrypt(bankData?.bic || "UNBEKANNT"),
bankNameEncrypted: encrypt(bankData?.bankName || "Unbekannt"),
description: "Automatisch aus Bankbuchung übernommen",
updatedAt: new Date(),
updatedBy: userId,
})
.returning({ id: entitybankaccounts.id })
return created?.id ? Number(created.id) : null
}
server.get("/banking/iban/:iban", async (req, reply) => {
try {
const { iban } = req.params as { iban: string }
const normalized = normalizeIban(iban)
if (!normalized) {
return reply.code(400).send({ error: "IBAN missing" })
}
const valid = isValidIbanLocal(normalized)
const bankData = resolveGermanBankDataFromIbanLocal(normalized)
return reply.send({
iban: normalized,
valid,
bic: bankData?.bic || null,
bankName: bankData?.bankName || null,
bankCode: bankData?.bankCode || null,
})
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to resolve IBAN data" })
}
})
const assignIbanFromStatementToCustomer = async (tenantId: number, userId: string, statementId: number, createdDocumentId?: number) => {
if (!createdDocumentId) return
const [statement] = await server.db
.select()
.from(bankstatements)
.where(and(eq(bankstatements.id, statementId), eq(bankstatements.tenant, tenantId)))
.limit(1)
if (!statement) return
const [doc] = await server.db
.select({ customer: createddocuments.customer })
.from(createddocuments)
.where(and(eq(createddocuments.id, createdDocumentId), eq(createddocuments.tenant, tenantId)))
.limit(1)
const customerId = doc?.customer
if (!customerId) return
const partnerBank = pickPartnerBankData(statement, "customer")
if (!partnerBank?.iban) return
const [customer] = await server.db
.select({ id: customers.id, infoData: customers.infoData })
.from(customers)
.where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId)))
.limit(1)
if (!customer) return
const bankAccountId = await resolveEntityBankAccountId(
tenantId,
userId,
partnerBank.iban
)
const newInfoData = mergePartnerIban(
(customer.infoData || {}) as Record<string, any>,
partnerBank.iban,
bankAccountId
)
await server.db
.update(customers)
.set({
infoData: newInfoData,
updatedAt: new Date(),
updatedBy: userId,
})
.where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId)))
}
const assignIbanFromStatementToVendor = async (tenantId: number, userId: string, statementId: number, incomingInvoiceId?: number) => {
if (!incomingInvoiceId) return
const [statement] = await server.db
.select()
.from(bankstatements)
.where(and(eq(bankstatements.id, statementId), eq(bankstatements.tenant, tenantId)))
.limit(1)
if (!statement) return
const [invoice] = await server.db
.select({ vendor: incominginvoices.vendor })
.from(incominginvoices)
.where(and(eq(incominginvoices.id, incomingInvoiceId), eq(incominginvoices.tenant, tenantId)))
.limit(1)
const vendorId = invoice?.vendor
if (!vendorId) return
const partnerBank = pickPartnerBankData(statement, "vendor")
if (!partnerBank?.iban) return
const [vendor] = await server.db
.select({ id: vendors.id, infoData: vendors.infoData })
.from(vendors)
.where(and(eq(vendors.id, vendorId), eq(vendors.tenant, tenantId)))
.limit(1)
if (!vendor) return
const bankAccountId = await resolveEntityBankAccountId(
tenantId,
userId,
partnerBank.iban
)
const newInfoData = mergePartnerIban(
(vendor.infoData || {}) as Record<string, any>,
partnerBank.iban,
bankAccountId
)
await server.db
.update(vendors)
.set({
infoData: newInfoData,
updatedAt: new Date(),
updatedBy: userId,
})
.where(and(eq(vendors.id, vendorId), eq(vendors.tenant, tenantId)))
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 🔐 GoCardLess Token Handling // 🔐 GoCardLess Token Handling
@@ -171,9 +496,35 @@ export default async function bankingRoutes(server: FastifyInstance) {
const createdRecord = inserted[0] const createdRecord = inserted[0]
if (createdRecord?.createddocument) {
try {
await assignIbanFromStatementToCustomer(
req.user.tenant_id,
req.user.user_id,
Number(createdRecord.bankstatement),
Number(createdRecord.createddocument)
)
} catch (err) {
server.log.warn({ err, allocationId: createdRecord.id }, "Konnte IBAN nicht automatisch beim Kunden hinterlegen")
}
}
if (createdRecord?.incominginvoice) {
try {
await assignIbanFromStatementToVendor(
req.user.tenant_id,
req.user.user_id,
Number(createdRecord.bankstatement),
Number(createdRecord.incominginvoice)
)
} catch (err) {
server.log.warn({ err, allocationId: createdRecord.id }, "Konnte IBAN nicht automatisch beim Lieferanten hinterlegen")
}
}
await insertHistoryItem(server, { await insertHistoryItem(server, {
entity: "bankstatements", entity: "bankstatements",
entityId: createdRecord.id, entityId: Number(createdRecord.bankstatement),
action: "created", action: "created",
created_by: req.user.user_id, created_by: req.user.user_id,
tenant_id: req.user.tenant_id, tenant_id: req.user.tenant_id,
@@ -216,7 +567,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
await insertHistoryItem(server, { await insertHistoryItem(server, {
entity: "bankstatements", entity: "bankstatements",
entityId: id, entityId: Number(old.bankstatement),
action: "deleted", action: "deleted",
created_by: req.user.user_id, created_by: req.user.user_id,
tenant_id: req.user.tenant_id, tenant_id: req.user.tenant_id,

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf"; import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
//import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions"; import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
import dayjs from "dayjs"; import dayjs from "dayjs";
//import { ready as zplReady } from 'zpl-renderer-js' //import { ready as zplReady } from 'zpl-renderer-js'
//import { renderZPL } from "zpl-image"; //import { renderZPL } from "zpl-image";
@@ -15,7 +15,6 @@ import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service"; import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys} from "../../db/schema"; import {citys} from "../../db/schema";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import {useNextNumberRangeNumber} from "../utils/functions";
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service"; import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(isoWeek) dayjs.extend(isoWeek)
@@ -177,44 +176,20 @@ export default async function functionRoutes(server: FastifyInstance) {
await server.services.dokuboxSync.run() await server.services.dokuboxSync.run()
}) })
/*server.post('/print/zpl/preview', async (req, reply) => {
const { zpl, widthMm = 50, heightMm = 30, dpmm = 8, asBase64 = false } = req.body as {zpl:string,widthMm:number,heightMm:number,dpmm:number,asBase64:string}
console.log(widthMm,heightMm,dpmm)
if (!zpl) {
return reply.code(400).send({ error: 'Missing ZPL string' })
}
try {
// 1⃣ Renderer initialisieren
const { api } = await zplReady
// 2⃣ Rendern (liefert base64-encoded PNG)
const base64Png = await api.zplToBase64Async(zpl, widthMm, heightMm, dpmm)
return await encodeBase64ToNiimbot(base64Png, 'top')
} catch (err) {
console.error('[ZPL Preview Error]', err)
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
}
})
server.post('/print/label', async (req, reply) => { server.post('/print/label', async (req, reply) => {
const { context, width=584, heigth=354 } = req.body as {context:any,width:number,heigth:number} const { context, width = 584, height = 354 } = req.body as {context:any,width:number,height:number}
try { try {
const base64 = await generateLabel(context,width,heigth) const base64 = await generateLabel(context,width,height)
return { return {
encoded: await encodeBase64ToNiimbot(base64, 'top'), encoded: await encodeBase64ToNiimbot(base64, 'top'),
base64: base64 base64: base64
} }
} catch (err) { } catch (err) {
console.error('[ZPL Preview Error]', err) console.error('[Label Render Error]', err)
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' }) return reply.code(500).send({ error: err.message || 'Failed to render label' })
} }
})*/ })
} }

View File

@@ -5,6 +5,7 @@ import { authProfiles, historyitems } from "../../db/schema";
const columnMap: Record<string, any> = { const columnMap: Record<string, any> = {
customers: historyitems.customer, customers: historyitems.customer,
members: historyitems.customer,
vendors: historyitems.vendor, vendors: historyitems.vendor,
projects: historyitems.project, projects: historyitems.project,
plants: historyitems.plant, plants: historyitems.plant,
@@ -22,10 +23,14 @@ const columnMap: Record<string, any> = {
documentboxes: historyitems.documentbox, documentboxes: historyitems.documentbox,
hourrates: historyitems.hourrate, hourrates: historyitems.hourrate,
services: historyitems.service, services: historyitems.service,
customerspaces: historyitems.customerspace,
customerinventoryitems: historyitems.customerinventoryitem,
memberrelations: historyitems.memberrelation,
}; };
const insertFieldMap: Record<string, string> = { const insertFieldMap: Record<string, string> = {
customers: "customer", customers: "customer",
members: "customer",
vendors: "vendor", vendors: "vendor",
projects: "project", projects: "project",
plants: "plant", plants: "plant",
@@ -43,6 +48,9 @@ const insertFieldMap: Record<string, string> = {
documentboxes: "documentbox", documentboxes: "documentbox",
hourrates: "hourrate", hourrates: "hourrate",
services: "service", services: "service",
customerspaces: "customerspace",
customerinventoryitems: "customerinventoryitem",
memberrelations: "memberrelation",
} }
const parseId = (value: string) => { const parseId = (value: string) => {
@@ -51,6 +59,44 @@ const parseId = (value: string) => {
} }
export default async function resourceHistoryRoutes(server: FastifyInstance) { export default async function resourceHistoryRoutes(server: FastifyInstance) {
server.get("/history", {
schema: {
tags: ["History"],
summary: "Get all history entries for the active tenant",
},
}, async (req: any) => {
const data = await server.db
.select()
.from(historyitems)
.where(eq(historyitems.tenant, req.user?.tenant_id))
.orderBy(asc(historyitems.createdAt));
const userIds = Array.from(
new Set(data.map((item) => item.createdBy).filter(Boolean))
) as string[];
const profiles = userIds.length > 0
? await server.db
.select()
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, req.user?.tenant_id),
inArray(authProfiles.user_id, userIds)
))
: [];
const profileByUserId = new Map(
profiles.map((profile) => [profile.user_id, profile])
);
return data.map((historyitem) => ({
...historyitem,
created_at: historyitem.createdAt,
created_by: historyitem.createdBy,
created_by_profile: historyitem.createdBy ? profileByUserId.get(historyitem.createdBy) || null : null,
}));
});
server.get<{ server.get<{
Params: { resource: string; id: string } Params: { resource: string; id: string }
}>("/resource/:resource/:id/history", { }>("/resource/:resource/:id/history", {

View File

@@ -0,0 +1,63 @@
import { FastifyInstance } from "fastify"
import jwt from "jsonwebtoken"
import { and, eq } from "drizzle-orm"
import { authTenantUsers } from "../../../db/schema"
import { secrets } from "../../utils/secrets"
export default async function authM2mInternalRoutes(server: FastifyInstance) {
server.post("/auth/m2m/token", {
schema: {
tags: ["Auth"],
summary: "Exchange M2M API key for a short-lived JWT",
body: {
type: "object",
properties: {
expires_in_seconds: { type: "number" }
}
}
}
}, async (req, reply) => {
try {
if (!req.user?.user_id || !req.user?.tenant_id || !req.user?.email) {
return reply.code(401).send({ error: "Unauthorized" })
}
const membership = await server.db
.select()
.from(authTenantUsers)
.where(and(
eq(authTenantUsers.user_id, req.user.user_id),
eq(authTenantUsers.tenant_id, Number(req.user.tenant_id))
))
.limit(1)
if (!membership[0]) {
return reply.code(403).send({ error: "User is not assigned to tenant" })
}
const requestedTtl = Number((req.body as any)?.expires_in_seconds ?? 900)
const ttlSeconds = Math.min(3600, Math.max(60, requestedTtl))
const token = jwt.sign(
{
user_id: req.user.user_id,
email: req.user.email,
tenant_id: req.user.tenant_id,
},
secrets.JWT_SECRET!,
{ expiresIn: ttlSeconds }
)
return {
token_type: "Bearer",
access_token: token,
expires_in_seconds: ttlSeconds,
user_id: req.user.user_id,
tenant_id: req.user.tenant_id
}
} catch (err) {
console.error("POST /internal/auth/m2m/token ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
}

View File

@@ -7,12 +7,16 @@ import {
and, and,
count, count,
inArray, inArray,
or or,
sql,
} from "drizzle-orm" } from "drizzle-orm"
import { resourceConfig } from "../../utils/resource.config"; import { resourceConfig } from "../../utils/resource.config";
import { useNextNumberRangeNumber } from "../../utils/functions"; import { useNextNumberRangeNumber } from "../../utils/functions";
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
import { diffObjects } from "../../utils/diff";
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"; import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
import { decrypt, encrypt } from "../../utils/crypt";
// ------------------------------------------------------------- // -------------------------------------------------------------
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen) // SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
@@ -20,15 +24,202 @@ import { recalculateServicePricesForTenant } from "../../modules/service-price-r
function buildSearchCondition(columns: any[], search: string) { function buildSearchCondition(columns: any[], search: string) {
if (!search || !columns.length) return null if (!search || !columns.length) return null
const term = `%${search.toLowerCase()}%` const normalizeForSearch = (value: string) =>
value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ß/g, "ss")
const conditions = columns const searchTermsRaw = search
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean) .filter(Boolean)
.map((col) => ilike(col, term))
if (conditions.length === 0) return null const searchTermsNormalized = searchTermsRaw.map(normalizeForSearch)
return or(...conditions) const normalizeSqlExpr = (valueExpr: any) => sql`
lower(
replace(
replace(
replace(
replace(
replace(
replace(
replace(cast(${valueExpr} as text), 'Ä', 'A'),
'Ö', 'O'
),
'Ü', 'U'
),
'ä', 'a'
),
'ö', 'o'
),
'ü', 'u'
),
'ß', 'ss'
)
)
`
const validColumns = columns.filter(Boolean)
if (validColumns.length === 0) return null
// Alle Suchspalten zu einem String zusammenführen, damit Vor-/Nachname zuverlässig
// gemeinsam durchsuchbar sind (auch wenn in getrennten Feldern gespeichert).
const combinedRawExpr = sql`concat_ws(' ', ${sql.join(validColumns.map((col) => sql`coalesce(cast(${col} as text), '')`), sql`, `)})`
const combinedNormalizedExpr = normalizeSqlExpr(combinedRawExpr)
const perTermConditions = searchTermsRaw.map((rawTerm, idx) => {
const normalizedTerm = searchTermsNormalized[idx]
const rawLike = `%${rawTerm}%`
const normalizedLike = `%${normalizedTerm}%`
const rawCondition = ilike(combinedRawExpr, rawLike)
const normalizedCondition = sql`${combinedNormalizedExpr} like ${normalizedLike}`
return or(rawCondition, normalizedCondition)
})
if (perTermConditions.length === 0) return null
return and(...perTermConditions)
}
function formatDiffValue(value: any): string {
if (value === null || value === undefined) return "-"
if (typeof value === "boolean") return value ? "Ja" : "Nein"
if (typeof value === "object") {
try {
return JSON.stringify(value)
} catch {
return "[Objekt]"
}
}
return String(value)
}
const TECHNICAL_HISTORY_KEYS = new Set([
"id",
"tenant",
"tenant_id",
"createdAt",
"created_at",
"createdBy",
"created_by",
"updatedAt",
"updated_at",
"updatedBy",
"updated_by",
"archived",
])
function getUserVisibleChanges(oldRecord: Record<string, any>, updated: Record<string, any>) {
return diffObjects(oldRecord, updated).filter((c) => !TECHNICAL_HISTORY_KEYS.has(c.key))
}
function buildFieldUpdateHistoryText(resource: string, label: string, oldValue: any, newValue: any) {
const resourceLabel = getHistoryEntityLabel(resource)
return `${resourceLabel}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"`
}
function applyResourceWhereFilters(resource: string, table: any, whereCond: any) {
if (resource === "members") {
return and(whereCond, eq(table.type, "Mitglied"))
}
return whereCond
}
function isDateLikeField(key: string) {
if (key === "deliveryDateType") return false
if (key.includes("_at") || key.endsWith("At")) return true
if (/Date$/.test(key)) return true
return /(^|_|-)date($|_|-)/i.test(key)
}
function normalizeMemberPayload(payload: Record<string, any>) {
const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
const normalized = {
...payload,
type: "Mitglied",
isCompany: false,
infoData,
}
return normalized
}
function validateMemberPayload(payload: Record<string, any>) {
const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.filter(Boolean) : []
const firstname = typeof payload.firstname === "string" ? payload.firstname.trim() : ""
const lastname = typeof payload.lastname === "string" ? payload.lastname.trim() : ""
if (!firstname || !lastname) {
return "Für Mitglieder sind Vorname und Nachname erforderlich."
}
if (!bankAccountIds.length) {
return "Für Mitglieder muss mindestens ein Bankkonto hinterlegt werden."
}
if (infoData.hasSEPA && !infoData.sepaSignedAt) {
return "Wenn ein SEPA-Mandat hinterlegt ist, muss ein Unterschriftsdatum gesetzt werden."
}
return null
}
function maskIban(iban: string) {
if (!iban) return ""
const cleaned = iban.replace(/\s+/g, "")
if (cleaned.length <= 8) return cleaned
return `${cleaned.slice(0, 4)} **** **** ${cleaned.slice(-4)}`
}
function decryptEntityBankAccount(row: Record<string, any>) {
const iban = row.ibanEncrypted ? decrypt(row.ibanEncrypted as any) : null
const bic = row.bicEncrypted ? decrypt(row.bicEncrypted as any) : null
const bankName = row.bankNameEncrypted ? decrypt(row.bankNameEncrypted as any) : null
return {
...row,
iban,
bic,
bankName,
displayLabel: `${maskIban(iban || "")}${bankName ? ` | ${bankName}` : ""}${row.description ? ` (${row.description})` : ""}`.trim(),
}
}
function prepareEntityBankAccountPayload(payload: Record<string, any>, requireAll: boolean) {
const iban = typeof payload.iban === "string" ? payload.iban.trim() : ""
const bic = typeof payload.bic === "string" ? payload.bic.trim() : ""
const bankName = typeof payload.bankName === "string" ? payload.bankName.trim() : ""
const hasAnyPlainField = Object.prototype.hasOwnProperty.call(payload, "iban")
|| Object.prototype.hasOwnProperty.call(payload, "bic")
|| Object.prototype.hasOwnProperty.call(payload, "bankName")
if (!hasAnyPlainField && !requireAll) {
return { data: payload }
}
if (!iban || !bic || !bankName) {
return { error: "IBAN, BIC und Bankinstitut sind Pflichtfelder." }
}
const result: Record<string, any> = {
...payload,
ibanEncrypted: encrypt(iban),
bicEncrypted: encrypt(bic),
bankNameEncrypted: encrypt(bankName),
}
delete result.iban
delete result.bic
delete result.bankName
return { data: result }
} }
export default async function resourceRoutes(server: FastifyInstance) { export default async function resourceRoutes(server: FastifyInstance) {
@@ -53,6 +244,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const table = config.table const table = config.table
let whereCond: any = eq(table.tenant, tenantId) let whereCond: any = eq(table.tenant, tenantId)
whereCond = applyResourceWhereFilters(resource, table, whereCond)
let q = server.db.select().from(table).$dynamic() let q = server.db.select().from(table).$dynamic()
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]) const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
@@ -122,7 +314,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
if(config.mtmListLoad) { if(config.mtmListLoad) {
for await (const relation of config.mtmListLoad) { for await (const relation of config.mtmListLoad) {
const relTable = resourceConfig[relation].table const relTable = resourceConfig[relation].table
const parentKey = resource.substring(0, resource.length - 1) const parentKey = config.relationKey || 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(relTable).where(inArray(relTable[parentKey], data.map(i => i.id)))
data = data.map(row => ({ data = data.map(row => ({
...row, ...row,
@@ -131,6 +323,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
} }
} }
if (resource === "entitybankaccounts") {
return data.map((row) => decryptEntityBankAccount(row))
}
return data return data
} catch (err) { } catch (err) {
@@ -156,7 +352,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
const { search, distinctColumns } = req.query as { search?: string; distinctColumns?: string; }; const { search, distinctColumns } = req.query as { search?: string; distinctColumns?: string; };
let whereCond: any = eq(table.tenant, tenantId); let whereCond: any = eq(table.tenant, tenantId);
whereCond = applyResourceWhereFilters(resource, table, whereCond)
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]); const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
const parsedFilters: Array<{ key: string; value: any }> = []
let countQuery = server.db.select({ value: count(table.id) }).from(table).$dynamic(); let countQuery = server.db.select({ value: count(table.id) }).from(table).$dynamic();
let mainQuery = server.db.select().from(table).$dynamic(); let mainQuery = server.db.select().from(table).$dynamic();
@@ -174,7 +373,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
mainQuery = mainQuery.leftJoin(relTable, eq(table[rel], relTable.id)); mainQuery = mainQuery.leftJoin(relTable, eq(table[rel], relTable.id));
if (relConfig.searchColumns) { if (relConfig.searchColumns) {
relConfig.searchColumns.forEach(c => { relConfig.searchColumns.forEach(c => {
if (relTable[c]) searchCols.push(relTable[c]); if (relTable[c]) {
searchCols.push(relTable[c]);
debugSearchColumnNames.push(`${rel}.${c}`);
}
}); });
} }
} }
@@ -183,6 +385,23 @@ export default async function resourceRoutes(server: FastifyInstance) {
} }
if (search) { if (search) {
if (resource === "customers") {
const rawSearch = search.trim()
const terms = rawSearch.toLowerCase().split(/\s+/).filter(Boolean)
const normalizedTerms = terms
.map((t) => t.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ß/g, "ss"))
server.log.info({
tag: "customer-search-debug",
search: rawSearch,
terms,
normalizedTerms,
searchColumns: debugSearchColumnNames,
page: pagination?.page ?? 1,
limit: pagination?.limit ?? 100,
}, "Paginated customer search request")
}
const searchCond = buildSearchCondition(searchCols, search.trim()); const searchCond = buildSearchCondition(searchCols, search.trim());
if (searchCond) whereCond = and(whereCond, searchCond); if (searchCond) whereCond = and(whereCond, searchCond);
} }
@@ -191,6 +410,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
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;
parsedFilters.push({ key, value: val })
whereCond = Array.isArray(val) ? and(whereCond, inArray(col, val)) : and(whereCond, eq(col, val as any)); whereCond = Array.isArray(val) ? and(whereCond, inArray(col, val)) : and(whereCond, eq(col, val as any));
} }
} }
@@ -220,7 +440,35 @@ export default async function resourceRoutes(server: FastifyInstance) {
for (const colName of distinctColumns.split(",").map(c => c.trim())) { for (const colName of distinctColumns.split(",").map(c => c.trim())) {
const col = (table as any)[colName]; const col = (table as any)[colName];
if (!col) continue; if (!col) continue;
const dRows = await server.db.select({ v: col }).from(table).where(eq(table.tenant, tenantId)); let distinctQuery = server.db.select({ v: col }).from(table).$dynamic();
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
if (!relConfig) return;
const relTable = relConfig.table;
if (relTable !== table) {
distinctQuery = distinctQuery.leftJoin(relTable, eq(table[rel], relTable.id));
}
});
}
let distinctWhereCond: any = eq(table.tenant, tenantId)
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
if (search) {
const searchCond = buildSearchCondition(searchCols, search.trim())
if (searchCond) distinctWhereCond = and(distinctWhereCond, searchCond)
}
for (const f of parsedFilters) {
if (f.key === colName) continue
const filterCol = (table as any)[f.key]
if (!filterCol) continue
distinctWhereCond = Array.isArray(f.value)
? and(distinctWhereCond, inArray(filterCol, f.value))
: and(distinctWhereCond, eq(filterCol, f.value as any))
}
const dRows = await distinctQuery.where(distinctWhereCond);
distinctValues[colName] = [...new Set(dRows.map(r => r.v).filter(v => v != null && v !== ""))].sort(); distinctValues[colName] = [...new Set(dRows.map(r => r.v).filter(v => v != null && v !== ""))].sort();
} }
} }
@@ -251,7 +499,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (config.mtmListLoad) { if (config.mtmListLoad) {
for await (const relation of config.mtmListLoad) { for await (const relation of config.mtmListLoad) {
const relTable = resourceConfig[relation].table; const relTable = resourceConfig[relation].table;
const parentKey = resource.substring(0, resource.length - 1); const parentKey = config.relationKey || 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(relTable).where(inArray(relTable[parentKey], data.map(i => i.id)));
data = data.map(row => ({ data = data.map(row => ({
...row, ...row,
@@ -260,6 +508,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
} }
} }
if (resource === "entitybankaccounts") {
data = data.map((row) => decryptEntityBankAccount(row))
}
return { return {
data, data,
queryConfig: { ...queryConfig, total, totalPages: Math.ceil(total / limit), distinctValues } queryConfig: { ...queryConfig, total, totalPages: Math.ceil(total / limit), distinctValues }
@@ -283,10 +535,13 @@ export default async function resourceRoutes(server: FastifyInstance) {
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
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
whereCond = applyResourceWhereFilters(resource, table, whereCond)
const projRows = await server.db const projRows = await server.db
.select() .select()
.from(table) .from(table)
.where(and(eq(table.id, id), eq(table.tenant, tenantId))) .where(whereCond)
.limit(1) .limit(1)
if (!projRows.length) if (!projRows.length)
@@ -309,12 +564,16 @@ export default async function resourceRoutes(server: FastifyInstance) {
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 const relTable = resourceConfig[relation].table
const parentKey = resource.substring(0, resource.length - 1) const parentKey = resourceConfig[resource].relationKey || resource.substring(0, resource.length - 1)
data[relation] = await server.db.select().from(relTable).where(eq(relTable[parentKey], id)) data[relation] = await server.db.select().from(relTable).where(eq(relTable[parentKey], id))
} }
} }
} }
if (resource === "entitybankaccounts") {
return decryptEntityBankAccount(data)
}
return data return data
} catch (err) { } catch (err) {
console.error("ERROR /resource/:resource/:id", err) console.error("ERROR /resource/:resource/:id", err)
@@ -327,14 +586,32 @@ export default async function resourceRoutes(server: FastifyInstance) {
try { try {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" }); if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
const { resource } = req.params as { resource: string }; const { resource } = req.params as { resource: string };
if (resource === "accounts") {
return reply.code(403).send({ error: "Accounts are read-only" })
}
const body = req.body as Record<string, any>; const body = req.body as Record<string, any>;
const config = resourceConfig[resource]; const config = resourceConfig[resource];
const table = config.table; const table = config.table;
let createData = { ...body, tenant: req.user.tenant_id, archived: false }; let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false };
if (resource === "members") {
createData = normalizeMemberPayload(createData)
const validationError = validateMemberPayload(createData)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (resource === "entitybankaccounts") {
const prepared = prepareEntityBankAccountPayload(createData, true)
if (prepared.error) return reply.code(400).send({ error: prepared.error })
createData = prepared.data!
}
if (config.numberRangeHolder && !body[config.numberRangeHolder]) { if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, resource) const numberRangeResource = resource === "members" ? "customers" : resource
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
createData[config.numberRangeHolder] = result.usedNumber createData[config.numberRangeHolder] = result.usedNumber
} }
@@ -349,6 +626,28 @@ export default async function resourceRoutes(server: FastifyInstance) {
await recalculateServicePricesForTenant(server, req.user.tenant_id, req.user?.user_id || null); await recalculateServicePricesForTenant(server, req.user.tenant_id, req.user?.user_id || null);
} }
if (created) {
try {
const resourceLabel = getHistoryEntityLabel(resource)
await insertHistoryItem(server, {
tenant_id: req.user.tenant_id,
created_by: req.user?.user_id || null,
entity: resource,
entityId: created.id,
action: "created",
oldVal: null,
newVal: created,
text: `Neuer Eintrag in ${resourceLabel} erstellt`,
})
} catch (historyError) {
server.log.warn({ err: historyError, resource }, "Failed to write create history entry")
}
}
if (resource === "entitybankaccounts") {
return decryptEntityBankAccount(created as Record<string, any>)
}
return created; return created;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -360,6 +659,9 @@ export default async function resourceRoutes(server: FastifyInstance) {
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 }
if (resource === "accounts") {
return reply.code(403).send({ error: "Accounts are read-only" })
}
const body = req.body as Record<string, any> const body = req.body as Record<string, any>
const tenantId = req.user?.tenant_id const tenantId = req.user?.tenant_id
const userId = req.user?.user_id const userId = req.user?.user_id
@@ -369,22 +671,93 @@ export default async function resourceRoutes(server: FastifyInstance) {
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; } 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 } const [oldRecord] = await server.db
.select()
.from(table)
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
.limit(1)
let data: Record<string, any> = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
//@ts-ignore //@ts-ignore
delete data.updatedBy; delete data.updatedAt; delete data.updatedBy; delete data.updatedAt;
if (resource === "members") {
data = normalizeMemberPayload(data)
const validationError = validateMemberPayload(data)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (resource === "entitybankaccounts") {
const prepared = prepareEntityBankAccountPayload(data, false)
if (prepared.error) return reply.code(400).send({ error: prepared.error })
data = {
...prepared.data,
updated_at: data.updated_at,
updated_by: data.updated_by,
}
}
Object.keys(data).forEach((key) => { Object.keys(data).forEach((key) => {
if ((key.includes("_at") || key.includes("At") || key.toLowerCase().includes("date")) && key !== "deliveryDateType") { const value = data[key]
data[key] = normalizeDate(data[key]) const shouldNormalize =
isDateLikeField(key) &&
value !== null &&
value !== undefined &&
(typeof value === "string" || typeof value === "number" || value instanceof Date)
if (shouldNormalize) {
data[key] = normalizeDate(value)
} }
}) })
const [updated] = await server.db.update(table).set(data).where(and(eq(table.id, id), eq(table.tenant, tenantId))).returning() let updateWhereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond)
const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning()
if (["products", "services", "hourrates"].includes(resource)) { if (["products", "services", "hourrates"].includes(resource)) {
await recalculateServicePricesForTenant(server, tenantId, userId); await recalculateServicePricesForTenant(server, tenantId, userId);
} }
if (updated) {
try {
const resourceLabel = getHistoryEntityLabel(resource)
const changes = oldRecord ? getUserVisibleChanges(oldRecord, updated) : []
if (!changes.length) {
await insertHistoryItem(server, {
tenant_id: tenantId,
created_by: userId,
entity: resource,
entityId: updated.id,
action: "updated",
oldVal: oldRecord || null,
newVal: updated,
text: `Eintrag in ${resourceLabel} geändert`,
})
} else {
for (const change of changes) {
await insertHistoryItem(server, {
tenant_id: tenantId,
created_by: userId,
entity: resource,
entityId: updated.id,
action: "updated",
oldVal: change.oldValue,
newVal: change.newValue,
text: buildFieldUpdateHistoryText(resource, change.label, change.oldValue, change.newValue),
})
}
}
} catch (historyError) {
server.log.warn({ err: historyError, resource, id }, "Failed to write update history entry")
}
}
if (resource === "entitybankaccounts") {
return decryptEntityBankAccount(updated as Record<string, any>)
}
return updated return updated
} catch (err) { } catch (err) {
console.error(err) console.error(err)

View File

@@ -1,9 +1,9 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { asc, desc } from "drizzle-orm" import { asc, desc, eq } from "drizzle-orm"
import { sortData } from "../utils/sort" import { sortData } from "../utils/sort"
// Schema imports // Schema imports
import { accounts, units,countrys } from "../../db/schema" import { accounts, units, countrys, tenants } from "../../db/schema"
const TABLE_MAP: Record<string, any> = { const TABLE_MAP: Record<string, any> = {
accounts, accounts,
@@ -40,6 +40,44 @@ export default async function resourceRoutesSpecial(server: FastifyInstance) {
// Wir geben IMMER alle Spalten zurück → kompatibel zum Frontend // Wir geben IMMER alle Spalten zurück → kompatibel zum Frontend
// --------------------------------------- // ---------------------------------------
if (resource === "accounts") {
const [tenant] = await server.db
.select({
accountChart: tenants.accountChart,
})
.from(tenants)
.where(eq(tenants.id, Number(req.user.tenant_id)))
.limit(1)
const activeAccountChart = tenant?.accountChart || "skr03"
let data
if (sort && (accounts as any)[sort]) {
const col = (accounts as any)[sort]
data = ascQuery === "true"
? await server.db
.select()
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart))
.orderBy(asc(col))
: await server.db
.select()
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart))
.orderBy(desc(col))
} else {
data = await server.db
.select()
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart))
}
return sortData(
data,
sort as any,
ascQuery === "true"
)
}
let query = server.db.select().from(table) let query = server.db.select().from(table)
// --------------------------------------- // ---------------------------------------

View File

@@ -1,18 +1,26 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import jwt from "jsonwebtoken" import jwt from "jsonwebtoken"
import { secrets } from "../utils/secrets" import { secrets } from "../utils/secrets"
import { createHash, randomBytes } from "node:crypto"
import { import {
authTenantUsers, authTenantUsers,
authUsers, authUsers,
authProfiles, authProfiles,
tenants tenants,
m2mApiKeys
} from "../../db/schema" } from "../../db/schema"
import {and, eq, inArray} from "drizzle-orm" import {and, desc, eq, inArray} from "drizzle-orm"
export default async function tenantRoutes(server: FastifyInstance) { export default async function tenantRoutes(server: FastifyInstance) {
const generateApiKey = () => {
const raw = randomBytes(32).toString("base64url")
return `fedeo_m2m_${raw}`
}
const hashApiKey = (apiKey: string) =>
createHash("sha256").update(apiKey, "utf8").digest("hex")
// ------------------------------------------------------------- // -------------------------------------------------------------
@@ -73,7 +81,7 @@ export default async function tenantRoutes(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 * 3, maxAge: 60 * 60 * 6,
}) })
return { token } return { token }
@@ -241,4 +249,172 @@ export default async function tenantRoutes(server: FastifyInstance) {
} }
}) })
// -------------------------------------------------------------
// M2M API KEYS
// -------------------------------------------------------------
server.get("/tenant/api-keys", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const keys = await server.db
.select({
id: m2mApiKeys.id,
name: m2mApiKeys.name,
tenant_id: m2mApiKeys.tenantId,
user_id: m2mApiKeys.userId,
active: m2mApiKeys.active,
key_prefix: m2mApiKeys.keyPrefix,
created_at: m2mApiKeys.createdAt,
updated_at: m2mApiKeys.updatedAt,
expires_at: m2mApiKeys.expiresAt,
last_used_at: m2mApiKeys.lastUsedAt,
})
.from(m2mApiKeys)
.where(eq(m2mApiKeys.tenantId, tenantId))
.orderBy(desc(m2mApiKeys.createdAt))
return keys
} catch (err) {
console.error("/tenant/api-keys GET ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
server.post("/tenant/api-keys", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
const creatorUserId = req.user?.user_id
if (!tenantId || !creatorUserId) {
return reply.code(401).send({ error: "Unauthorized" })
}
const { name, user_id, expires_at } = req.body as {
name: string
user_id: string
expires_at?: string | null
}
if (!name || !user_id) {
return reply.code(400).send({ error: "name and user_id are required" })
}
const userMembership = await server.db
.select()
.from(authTenantUsers)
.where(and(
eq(authTenantUsers.tenant_id, tenantId),
eq(authTenantUsers.user_id, user_id)
))
.limit(1)
if (!userMembership[0]) {
return reply.code(400).send({ error: "user_id is not assigned to this tenant" })
}
const plainApiKey = generateApiKey()
const keyPrefix = plainApiKey.slice(0, 16)
const keyHash = hashApiKey(plainApiKey)
const inserted = await server.db
.insert(m2mApiKeys)
.values({
tenantId,
userId: user_id,
createdBy: creatorUserId,
name,
keyPrefix,
keyHash,
expiresAt: expires_at ? new Date(expires_at) : null,
})
.returning({
id: m2mApiKeys.id,
name: m2mApiKeys.name,
tenant_id: m2mApiKeys.tenantId,
user_id: m2mApiKeys.userId,
key_prefix: m2mApiKeys.keyPrefix,
created_at: m2mApiKeys.createdAt,
expires_at: m2mApiKeys.expiresAt,
active: m2mApiKeys.active,
})
return reply.code(201).send({
...inserted[0],
api_key: plainApiKey, // only returned once
})
} catch (err) {
console.error("/tenant/api-keys POST ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
server.patch("/tenant/api-keys/:id", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
const { name, active, expires_at } = req.body as {
name?: string
active?: boolean
expires_at?: string | null
}
const updateData: any = {
updatedAt: new Date()
}
if (name !== undefined) updateData.name = name
if (active !== undefined) updateData.active = active
if (expires_at !== undefined) updateData.expiresAt = expires_at ? new Date(expires_at) : null
const updated = await server.db
.update(m2mApiKeys)
.set(updateData)
.where(and(
eq(m2mApiKeys.id, id),
eq(m2mApiKeys.tenantId, tenantId)
))
.returning({
id: m2mApiKeys.id,
name: m2mApiKeys.name,
tenant_id: m2mApiKeys.tenantId,
user_id: m2mApiKeys.userId,
active: m2mApiKeys.active,
key_prefix: m2mApiKeys.keyPrefix,
updated_at: m2mApiKeys.updatedAt,
expires_at: m2mApiKeys.expiresAt,
last_used_at: m2mApiKeys.lastUsedAt,
})
if (!updated[0]) {
return reply.code(404).send({ error: "API key not found" })
}
return updated[0]
} catch (err) {
console.error("/tenant/api-keys PATCH ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
server.delete("/tenant/api-keys/:id", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
await server.db
.delete(m2mApiKeys)
.where(and(
eq(m2mApiKeys.id, id),
eq(m2mApiKeys.tenantId, tenantId)
))
return { success: true }
} catch (err) {
console.error("/tenant/api-keys DELETE ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
} }

View File

@@ -10,6 +10,8 @@ import {
plants, plants,
products, products,
inventoryitems, inventoryitems,
customerinventoryitems,
customerspaces,
// NEU HINZUGEFÜGT (Basierend auf deinem DataStore) // NEU HINZUGEFÜGT (Basierend auf deinem DataStore)
tasks, tasks,
contacts, contacts,
@@ -34,6 +36,8 @@ const ENTITY_CONFIG: Record<string, { table: any, labelField: any, rootLabel: st
'plants': { table: plants, labelField: plants.name, rootLabel: 'Objekte', idField: 'id' }, 'plants': { table: plants, labelField: plants.name, rootLabel: 'Objekte', idField: 'id' },
'products': { table: products, labelField: products.name, rootLabel: 'Artikel', idField: 'id' }, 'products': { table: products, labelField: products.name, rootLabel: 'Artikel', idField: 'id' },
'inventoryitems': { table: inventoryitems, labelField: inventoryitems.name, rootLabel: 'Inventarartikel', idField: 'id' }, 'inventoryitems': { table: inventoryitems, labelField: inventoryitems.name, rootLabel: 'Inventarartikel', idField: 'id' },
'customerinventoryitems': { table: customerinventoryitems, labelField: customerinventoryitems.name, rootLabel: 'Kundeninventar', idField: 'id' },
'customerspaces': { table: customerspaces, labelField: customerspaces.name, rootLabel: 'Kundenlagerplätze', idField: 'id' },
// --- NEU BASIEREND AUF DATASTORE --- // --- NEU BASIEREND AUF DATASTORE ---
'tasks': { table: tasks, labelField: tasks.name, rootLabel: 'Aufgaben', idField: 'id' }, 'tasks': { table: tasks, labelField: tasks.name, rootLabel: 'Aufgaben', idField: 'id' },
@@ -337,4 +341,4 @@ export default async function wikiRoutes(server: FastifyInstance) {
return { success: true, deletedId: result[0].id } return { success: true, deletedId: result[0].id }
}) })
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import {diffTranslations} from "./diffTranslations"; import {diffTranslations, getDiffLabel} from "./diffTranslations";
export type DiffChange = { export type DiffChange = {
key: string; key: string;
@@ -43,8 +43,6 @@ export function diffObjects(
const oldVal = obj1?.[key]; const oldVal = obj1?.[key];
const newVal = obj2?.[key]; const newVal = obj2?.[key];
console.log(oldVal, key, newVal);
// Wenn beides null/undefined → ignorieren // Wenn beides null/undefined → ignorieren
if ( if (
(oldVal === null || oldVal === undefined || oldVal === "" || JSON.stringify(oldVal) === "[]") && (oldVal === null || oldVal === undefined || oldVal === "" || JSON.stringify(oldVal) === "[]") &&
@@ -72,12 +70,11 @@ export function diffObjects(
if (type === "unchanged") continue; if (type === "unchanged") continue;
const translation = diffTranslations[key]; const translation = diffTranslations[key];
let label = key; let label = getDiffLabel(key);
let resolvedOld = oldVal; let resolvedOld = oldVal;
let resolvedNew = newVal; let resolvedNew = newVal;
if (translation) { if (translation) {
label = translation.label;
if (translation.resolve) { if (translation.resolve) {
const { oldVal: resOld, newVal: resNew } = translation.resolve( const { oldVal: resOld, newVal: resNew } = translation.resolve(
oldVal, oldVal,
@@ -100,4 +97,4 @@ export function diffObjects(
} }
return diffs; return diffs;
} }

View File

@@ -6,6 +6,149 @@ type ValueResolver = (
ctx?: Record<string, any> ctx?: Record<string, any>
) => { oldVal: any; newVal: any }; ) => { oldVal: any; newVal: any };
const TOKEN_TRANSLATIONS: Record<string, string> = {
account: "Konto",
active: "Aktiv",
address: "Adresse",
amount: "Betrag",
archived: "Archiviert",
article: "Artikel",
bank: "Bank",
barcode: "Barcode",
birthday: "Geburtstag",
category: "Kategorie",
city: "Ort",
color: "Farbe",
comment: "Kommentar",
company: "Firma",
contact: "Kontakt",
contract: "Vertrag",
cost: "Kosten",
country: "Land",
created: "Erstellt",
customer: "Kunde",
date: "Datum",
default: "Standard",
deleted: "Gelöscht",
delivery: "Lieferung",
description: "Beschreibung",
document: "Dokument",
driver: "Fahrer",
due: "Fällig",
duration: "Dauer",
email: "E-Mail",
employee: "Mitarbeiter",
enabled: "Aktiviert",
end: "Ende",
event: "Ereignis",
file: "Datei",
first: "Vorname",
fixed: "Festgeschrieben",
group: "Gruppe",
hour: "Stunde",
iban: "IBAN",
id: "ID",
incoming: "Eingang",
invoice: "Rechnung",
item: "Eintrag",
language: "Sprache",
last: "Nachname",
license: "Kennzeichen",
link: "Link",
list: "Liste",
location: "Standort",
manufacturer: "Hersteller",
markup: "Verkaufsaufschlag",
message: "Nachricht",
mobile: "Mobil",
name: "Name",
note: "Notiz",
notes: "Notizen",
number: "Nummer",
order: "Bestellung",
own: "Eigen",
payment: "Zahlung",
phone: "Telefon",
plant: "Objekt",
postal: "Post",
price: "Preis",
percentage: "%",
product: "Produkt",
profile: "Profil",
project: "Projekt",
purchase: "Kauf",
quantity: "Menge",
rate: "Satz",
reference: "Referenz",
requisition: "Anfrage",
resource: "Ressource",
role: "Rolle",
serial: "Serien",
service: "Leistung",
selling: "Verkauf",
sellign: "Verkauf",
space: "Lagerplatz",
start: "Start",
statement: "Buchung",
status: "Status",
street: "Straße",
surcharge: "Aufschlag",
tax: "Steuer",
tel: "Telefon",
tenant: "Mandant",
time: "Zeit",
title: "Titel",
total: "Gesamt",
type: "Typ",
unit: "Einheit",
updated: "Aktualisiert",
user: "Benutzer",
ustid: "USt-ID",
value: "Wert",
vendor: "Lieferant",
vehicle: "Fahrzeug",
weekly: "Wöchentlich",
working: "Arbeits",
zip: "Postleitzahl",
composed: "Zusammensetzung",
material: "Material",
worker: "Arbeit",
};
function tokenizeKey(key: string): string[] {
return key
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/[^a-zA-Z0-9]+/g, "_")
.split("_")
.filter(Boolean)
.map((p) => p.toLowerCase());
}
function capitalize(word: string) {
if (!word) return word;
return word.charAt(0).toUpperCase() + word.slice(1);
}
function fallbackLabelFromKey(key: string): string {
const parts = tokenizeKey(key);
if (!parts.length) return key;
if (parts.length > 1 && parts[parts.length - 1] === "id") {
const base = parts.slice(0, -1).map((p) => TOKEN_TRANSLATIONS[p] || capitalize(p)).join(" ");
return `${base} ID`.trim();
}
return parts
.map((p) => TOKEN_TRANSLATIONS[p] || capitalize(p))
.join(" ")
.replace(/\s+/g, " ")
.trim();
}
export function getDiffLabel(key: string): string {
return diffTranslations[key]?.label || fallbackLabelFromKey(key);
}
export const diffTranslations: Record< export const diffTranslations: Record<
string, string,
{ label: string; resolve?: ValueResolver } { label: string; resolve?: ValueResolver }
@@ -44,7 +187,7 @@ export const diffTranslations: Record<
}), }),
}, },
resources: { resources: {
label: "Resourcen", label: "Ressourcen",
resolve: (o, n) => ({ resolve: (o, n) => ({
oldVal: Array.isArray(o) ? o.map((i: any) => i.title).join(", ") : "-", oldVal: Array.isArray(o) ? o.map((i: any) => i.title).join(", ") : "-",
newVal: Array.isArray(n) ? n.map((i: any) => i.title).join(", ") : "-", newVal: Array.isArray(n) ? n.map((i: any) => i.title).join(", ") : "-",
@@ -86,10 +229,18 @@ export const diffTranslations: Record<
approved: { label: "Genehmigt" }, approved: { label: "Genehmigt" },
manufacturer: { label: "Hersteller" }, manufacturer: { label: "Hersteller" },
purchasePrice: { label: "Kaufpreis" }, purchasePrice: { label: "Kaufpreis" },
markupPercentage: { label: "Verkaufsaufschlag in %" },
markup_percentage: { label: "Verkaufsaufschlag in %" },
sellingPrice: { label: "Verkaufspreis" },
selling_price: { label: "Verkaufspreis" },
sellingPriceComposed: { label: "Verkaufspreis Zusammensetzung" },
purchaseDate: { label: "Kaufdatum" }, purchaseDate: { label: "Kaufdatum" },
serialNumber: { label: "Seriennummer" }, serialNumber: { label: "Seriennummer" },
customerInventoryId: { label: "Kundeninventar-ID" },
customerinventoryitems: { label: "Kundeninventar" },
usePlanning: { label: "In Plantafel verwenden" }, usePlanning: { label: "In Plantafel verwenden" },
currentSpace: { label: "Lagerplatz" }, currentSpace: { label: "Lagerplatz" },
customerspace: { label: "Kundenlagerplatz" },
customer: { customer: {
label: "Kunde", label: "Kunde",
@@ -108,6 +259,7 @@ export const diffTranslations: Record<
description: { label: "Beschreibung" }, description: { label: "Beschreibung" },
categorie: { label: "Kategorie" }, categorie: { label: "Kategorie" },
category: { label: "Kategorie" },
profile: { profile: {
label: "Mitarbeiter", label: "Mitarbeiter",
@@ -147,6 +299,8 @@ export const diffTranslations: Record<
}, },
projecttype: { label: "Projekttyp" }, projecttype: { label: "Projekttyp" },
contracttype: { label: "Vertragstyp" },
billingInterval: { label: "Abrechnungsintervall" },
fixed: { fixed: {
label: "Festgeschrieben", label: "Festgeschrieben",

View File

@@ -1,11 +1,8 @@
import {FastifyInstance} from "fastify"; import { FastifyInstance } from "fastify"
// import { PNG } from 'pngjs' import { PNG } from "pngjs"
// import { ready as zplReady } from 'zpl-renderer-js' import { Utils } from "@mmote/niimbluelib"
// import { Utils } from '@mmote/niimbluelib' import bwipjs from "bwip-js"
// import { createCanvas } from 'canvas' import Sharp from "sharp"
// import bwipjs from 'bwip-js'
// import Sharp from 'sharp'
// import fs from 'fs'
import { tenants } from "../../db/schema" import { tenants } from "../../db/schema"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
@@ -15,7 +12,6 @@ export const useNextNumberRangeNumber = async (
tenantId: number, tenantId: number,
numberRange: string numberRange: string
) => { ) => {
// 1⃣ Tenant laden
const [tenant] = await server.db const [tenant] = await server.db
.select() .select()
.from(tenants) .from(tenants)
@@ -33,23 +29,20 @@ export const useNextNumberRangeNumber = async (
const current = numberRanges[numberRange] const current = numberRanges[numberRange]
// 2⃣ Used Number generieren
const usedNumber = const usedNumber =
(current.prefix || "") + (current.prefix || "") +
current.nextNumber + current.nextNumber +
(current.suffix || "") (current.suffix || "")
// 3⃣ nextNumber erhöhen
const updatedRanges = { const updatedRanges = {
// @ts-ignore // @ts-ignore
...numberRanges, ...numberRanges,
[numberRange]: { [numberRange]: {
...current, ...current,
nextNumber: current.nextNumber + 1 nextNumber: current.nextNumber + 1,
} },
} }
// 4⃣ Tenant aktualisieren
await server.db await server.db
.update(tenants) .update(tenants)
.set({ numberRanges: updatedRanges }) .set({ numberRanges: updatedRanges })
@@ -58,24 +51,17 @@ export const useNextNumberRangeNumber = async (
return { usedNumber } return { usedNumber }
} }
export async function encodeBase64ToNiimbot(base64Png: string, printDirection: "top" | "left" = "top") {
/* const buffer = Buffer.from(base64Png, "base64")
export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') { const png = PNG.sync.read(buffer)
// 1⃣ PNG dekodieren
const buffer = Buffer.from(base64Png, 'base64')
const png = PNG.sync.read(buffer) // liefert {width, height, data: Uint8Array(RGBA)}
const { width, height, data } = png const { width, height, data } = png
console.log(width, height, data) const cols = printDirection === "left" ? height : width
const cols = printDirection === 'left' ? height : width const rows = printDirection === "left" ? width : height
const rows = printDirection === 'left' ? width : height const rowsData: any[] = []
const rowsData = []
console.log(cols) if (cols % 8 !== 0) throw new Error("Column count must be multiple of 8")
if (cols % 8 !== 0) throw new Error('Column count must be multiple of 8')
// 2⃣ Zeilenweise durchgehen und Bits bilden
for (let row = 0; row < rows; row++) { for (let row = 0; row < rows; row++) {
let isVoid = true let isVoid = true
let blackPixelsCount = 0 let blackPixelsCount = 0
@@ -84,8 +70,8 @@ export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') {
for (let colOct = 0; colOct < cols / 8; colOct++) { for (let colOct = 0; colOct < cols / 8; colOct++) {
let pixelsOctet = 0 let pixelsOctet = 0
for (let colBit = 0; colBit < 8; colBit++) { for (let colBit = 0; colBit < 8; colBit++) {
const x = printDirection === 'left' ? row : colOct * 8 + colBit const x = printDirection === "left" ? row : colOct * 8 + colBit
const y = printDirection === 'left' ? height - 1 - (colOct * 8 + colBit) : row const y = printDirection === "left" ? height - 1 - (colOct * 8 + colBit) : row
const idx = (y * width + x) * 4 const idx = (y * width + x) * 4
const lum = 0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2] const lum = 0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2]
const isBlack = lum < 128 const isBlack = lum < 128
@@ -99,7 +85,7 @@ export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') {
} }
const newPart = { const newPart = {
dataType: isVoid ? 'void' : 'pixels', dataType: isVoid ? "void" : "pixels",
rowNumber: row, rowNumber: row,
repeat: 1, repeat: 1,
rowData: isVoid ? undefined : rowData, rowData: isVoid ? undefined : rowData,
@@ -111,14 +97,15 @@ export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') {
} else { } else {
const last = rowsData[rowsData.length - 1] const last = rowsData[rowsData.length - 1]
let same = newPart.dataType === last.dataType let same = newPart.dataType === last.dataType
if (same && newPart.dataType === 'pixels') { if (same && newPart.dataType === "pixels") {
same = Utils.u8ArraysEqual(newPart.rowData, last.rowData) same = Utils.u8ArraysEqual(newPart.rowData, last.rowData)
} }
if (same) last.repeat++ if (same) last.repeat++
else rowsData.push(newPart) else rowsData.push(newPart)
if (row % 200 === 199) { if (row % 200 === 199) {
rowsData.push({ rowsData.push({
dataType: 'check', dataType: "check",
rowNumber: row, rowNumber: row,
repeat: 0, repeat: 0,
rowData: undefined, rowData: undefined,
@@ -131,44 +118,69 @@ export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') {
return { cols, rows, rowsData } return { cols, rows, rowsData }
} }
export async function generateLabel(context,width,height) { function escapeXml(value: string) {
// Canvas für Hintergrund & Text return String(value)
const canvas = createCanvas(width, height) .replace(/&/g, "&amp;")
const ctx = canvas.getContext('2d') .replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&apos;")
}
// Hintergrund weiß export async function generateLabel(context: any = {}, width = 584, height = 354) {
ctx.fillStyle = '#FFFFFF' const normalizedWidth = Math.ceil(Number(width) / 8) * 8
ctx.fillRect(0, 0, width, height) const normalizedHeight = Math.max(1, Number(height) || 203)
// Überschrift const idFont = Math.max(24, Math.round(normalizedHeight * 0.125))
ctx.fillStyle = '#000000' const nameFont = Math.max(17, Math.round(normalizedHeight * 0.078))
ctx.font = '32px Arial' const customerFont = Math.max(14, Math.round(normalizedHeight * 0.06))
ctx.fillText(context.text, 20, 40) const serialFont = Math.max(12, Math.round(normalizedHeight * 0.052))
const labelId = context.customerInventoryId || context.datamatrix || context.id || "N/A"
const labelName = context.name || context.text || "Kundeninventarartikel"
const customerName = context.customerName || ""
const serial = context.serialNumber ? `SN: ${context.serialNumber}` : ""
const nameLine1 = String(labelName).slice(0, 30)
const nameLine2 = String(labelName).slice(30, 60)
// 3) DataMatrix
const dataMatrixPng = await bwipjs.toBuffer({ const dataMatrixPng = await bwipjs.toBuffer({
bcid: 'datamatrix', bcid: "datamatrix",
text: context.datamatrix, text: String(labelId),
scale: 6, scale: normalizedWidth >= 560 ? 7 : 5,
includetext: false,
}) })
const dataMatrixMeta = await Sharp(dataMatrixPng).metadata()
const dataMatrixWidth = dataMatrixMeta.width || 0
const dataMatrixHeight = dataMatrixMeta.height || 0
const dmLeft = Math.max(8, normalizedWidth - dataMatrixWidth - 28)
const dmTop = Math.max(8, Math.floor((normalizedHeight - dataMatrixHeight) / 2))
const textMaxWidth = Math.max(120, dmLeft - 20)
// Basisbild aus Canvas const textSvg = `
const base = await Sharp(canvas.toBuffer()) <svg width="${normalizedWidth}" height="${normalizedHeight}" xmlns="http://www.w3.org/2000/svg">
.png() <rect width="100%" height="100%" fill="white"/>
.toBuffer() <text x="12" y="${Math.round(normalizedHeight * 0.15)}" font-size="${idFont}" font-family="Arial, Helvetica, sans-serif" font-weight="700" fill="black">${escapeXml(String(labelId).slice(0, 26))}</text>
<text x="12" y="${Math.round(normalizedHeight * 0.29)}" font-size="${nameFont}" font-family="Arial, Helvetica, sans-serif" fill="black">${escapeXml(nameLine1)}</text>
<text x="12" y="${Math.round(normalizedHeight * 0.37)}" font-size="${nameFont}" font-family="Arial, Helvetica, sans-serif" fill="black">${escapeXml(nameLine2)}</text>
<text x="12" y="${Math.round(normalizedHeight * 0.49)}" font-size="${customerFont}" font-family="Arial, Helvetica, sans-serif" fill="black">${escapeXml(String(customerName).slice(0, 40))}</text>
<text x="12" y="${Math.round(normalizedHeight * 0.58)}" font-size="${serialFont}" font-family="Arial, Helvetica, sans-serif" fill="black">${escapeXml(String(serial).slice(0, 42))}</text>
<rect x="0" y="0" width="${textMaxWidth}" height="${normalizedHeight}" fill="none"/>
</svg>`.trim()
// Alles zusammen compositen const final = await Sharp({
const final = await Sharp(base) create: {
width: normalizedWidth,
height: normalizedHeight,
channels: 3,
background: { r: 255, g: 255, b: 255 },
},
})
.composite([ .composite([
{ input: dataMatrixPng, top: 60, left: 20 }, { input: Buffer.from(textSvg), top: 0, left: 0 },
{ input: dataMatrixPng, top: dmTop, left: dmLeft },
]) ])
.png() .png()
.toBuffer() .toBuffer()
fs.writeFileSync('label.png', final) return final.toString("base64")
}
// Optional: Base64 zurückgeben (z.B. für API)
const base64 = final.toString('base64')
return base64
}*/

View File

@@ -11,7 +11,7 @@ import { s3 } from "./s3";
import { secrets } from "./secrets"; import { secrets } from "./secrets";
// Drizzle schema // Drizzle schema
import { vendors, accounts } from "../../db/schema"; import { vendors, accounts, tenants } from "../../db/schema";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
let openai: OpenAI | null = null; let openai: OpenAI | null = null;
@@ -163,13 +163,22 @@ export const getInvoiceDataFromGPT = async function (
.from(vendors) .from(vendors)
.where(eq(vendors.tenant,tenantId)); .where(eq(vendors.tenant,tenantId));
const [tenant] = await server.db
.select({ accountChart: tenants.accountChart })
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1)
const activeAccountChart = tenant?.accountChart || "skr03"
const accountList = await server.db const accountList = await server.db
.select({ .select({
id: accounts.id, id: accounts.id,
label: accounts.label, label: accounts.label,
number: accounts.number, number: accounts.number,
}) })
.from(accounts); .from(accounts)
.where(eq(accounts.accountChart, activeAccountChart));
// --------------------------------------------------------- // ---------------------------------------------------------
// 4) GPT ANALYSIS // 4) GPT ANALYSIS

View File

@@ -1,6 +1,43 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { historyitems } from "../../db/schema"; import { historyitems } from "../../db/schema";
const HISTORY_ENTITY_LABELS: Record<string, string> = {
customers: "Kunden",
members: "Mitglieder",
vendors: "Lieferanten",
projects: "Projekte",
plants: "Objekte",
contacts: "Kontakte",
inventoryitems: "Inventarartikel",
customerinventoryitems: "Kundeninventar",
products: "Artikel",
profiles: "Mitarbeiter",
absencerequests: "Abwesenheiten",
events: "Termine",
tasks: "Aufgaben",
vehicles: "Fahrzeuge",
costcentres: "Kostenstellen",
ownaccounts: "zusätzliche Buchungskonten",
documentboxes: "Dokumentenboxen",
hourrates: "Stundensätze",
services: "Leistungen",
roles: "Rollen",
checks: "Überprüfungen",
spaces: "Lagerplätze",
customerspaces: "Kundenlagerplätze",
trackingtrips: "Fahrten",
createddocuments: "Dokumente",
inventoryitemgroups: "Inventarartikelgruppen",
bankstatements: "Buchungen",
incominginvoices: "Eingangsrechnungen",
files: "Dateien",
memberrelations: "Mitgliedsverhältnisse",
}
export function getHistoryEntityLabel(entity: string) {
return HISTORY_ENTITY_LABELS[entity] || entity
}
export async function insertHistoryItem( export async function insertHistoryItem(
server: FastifyInstance, server: FastifyInstance,
params: { params: {
@@ -14,15 +51,18 @@ export async function insertHistoryItem(
text?: string text?: string
} }
) { ) {
const entityLabel = getHistoryEntityLabel(params.entity)
const textMap = { const textMap = {
created: `Neuer Eintrag in ${params.entity} erstellt`, created: `Neuer Eintrag in ${entityLabel} erstellt`,
updated: `Eintrag in ${params.entity} geändert`, updated: `Eintrag in ${entityLabel} geändert`,
archived: `Eintrag in ${params.entity} archiviert`, unchanged: `Eintrag in ${entityLabel} unverändert`,
deleted: `Eintrag in ${params.entity} gelöscht` archived: `Eintrag in ${entityLabel} archiviert`,
deleted: `Eintrag in ${entityLabel} gelöscht`
} }
const columnMap: Record<string, string> = { const columnMap: Record<string, string> = {
customers: "customer", customers: "customer",
members: "customer",
vendors: "vendor", vendors: "vendor",
projects: "project", projects: "project",
plants: "plant", plants: "plant",
@@ -42,10 +82,15 @@ export async function insertHistoryItem(
roles: "role", roles: "role",
checks: "check", checks: "check",
spaces: "space", spaces: "space",
customerspaces: "customerspace",
customerinventoryitems: "customerinventoryitem",
trackingtrips: "trackingtrip", trackingtrips: "trackingtrip",
createddocuments: "createddocument", createddocuments: "createddocument",
inventoryitemgroups: "inventoryitemgroup", inventoryitemgroups: "inventoryitemgroup",
bankstatements: "bankstatement" bankstatements: "bankstatement",
incominginvoices: "incomingInvoice",
files: "file",
memberrelations: "memberrelation",
} }
const fkColumn = columnMap[params.entity] const fkColumn = columnMap[params.entity]
@@ -54,14 +99,19 @@ export async function insertHistoryItem(
return return
} }
const stringifyHistoryValue = (value: any) => {
if (value === undefined || value === null) return null
return typeof value === "string" ? value : JSON.stringify(value)
}
const entry = { const entry = {
tenant: params.tenant_id, tenant: params.tenant_id,
created_by: params.created_by, createdBy: params.created_by,
text: params.text || textMap[params.action], text: params.text || textMap[params.action],
action: params.action, action: params.action,
[fkColumn]: params.entityId, [fkColumn]: params.entityId,
oldVal: params.oldVal ? JSON.stringify(params.oldVal) : null, oldVal: stringifyHistoryValue(params.oldVal),
newVal: params.newVal ? JSON.stringify(params.newVal) : null newVal: stringifyHistoryValue(params.newVal)
} }
await server.db.insert(historyitems).values(entry as any) await server.db.insert(historyitems).values(entry as any)

View File

@@ -3,10 +3,14 @@ import {
bankaccounts, bankaccounts,
bankrequisitions, bankrequisitions,
bankstatements, bankstatements,
entitybankaccounts,
contacts, contacts,
contracts, contracts,
contracttypes,
costcentres, costcentres,
createddocuments, createddocuments,
customerinventoryitems,
customerspaces,
customers, customers,
files, files,
filetags, filetags,
@@ -16,6 +20,7 @@ import {
inventoryitemgroups, inventoryitemgroups,
inventoryitems, inventoryitems,
letterheads, letterheads,
memberrelations,
ownaccounts, ownaccounts,
plants, plants,
productcategories, productcategories,
@@ -43,10 +48,21 @@ export const resourceConfig = {
numberRangeHolder: "projectNumber" numberRangeHolder: "projectNumber"
}, },
customers: { customers: {
searchColumns: ["name", "customerNumber", "firstname", "lastname", "notes"], searchColumns: ["name", "nameAddition", "customerNumber", "firstname", "lastname", "notes"],
mtmLoad: ["contacts","projects","plants","createddocuments","contracts","customerinventoryitems","customerspaces"],
table: customers,
numberRangeHolder: "customerNumber",
},
members: {
searchColumns: ["name", "nameAddition", "customerNumber", "firstname", "lastname", "notes"],
mtmLoad: ["contacts","projects","plants","createddocuments","contracts"], mtmLoad: ["contacts","projects","plants","createddocuments","contracts"],
table: customers, table: customers,
numberRangeHolder: "customerNumber", numberRangeHolder: "customerNumber",
relationKey: "customer",
},
memberrelations: {
table: memberrelations,
searchColumns: ["type", "billingInterval"],
}, },
contacts: { contacts: {
searchColumns: ["firstName", "lastName", "email", "phone", "notes"], searchColumns: ["firstName", "lastName", "email", "phone", "notes"],
@@ -55,9 +71,13 @@ export const resourceConfig = {
}, },
contracts: { contracts: {
table: contracts, table: contracts,
searchColumns: ["name", "notes", "contractNumber", "paymentType", "sepaRef", "bankingName"], searchColumns: ["name", "notes", "contractNumber", "paymentType", "billingInterval", "sepaRef", "bankingName"],
numberRangeHolder: "contractNumber", numberRangeHolder: "contractNumber",
mtoLoad: ["customer"], mtoLoad: ["customer", "contracttype"],
},
contracttypes: {
table: contracttypes,
searchColumns: ["name", "description", "paymentType", "billingInterval"],
}, },
plants: { plants: {
table: plants, table: plants,
@@ -86,6 +106,12 @@ export const resourceConfig = {
table: inventoryitems, table: inventoryitems,
numberRangeHolder: "articleNumber", numberRangeHolder: "articleNumber",
}, },
customerinventoryitems: {
table: customerinventoryitems,
numberRangeHolder: "customerInventoryId",
mtoLoad: ["customer", "customerspace", "product", "vendor"],
searchColumns: ["name", "customerInventoryId", "serialNumber", "description", "manufacturer", "manufacturerNumber"],
},
inventoryitemgroups: { inventoryitemgroups: {
table: inventoryitemgroups table: inventoryitemgroups
}, },
@@ -120,6 +146,13 @@ export const resourceConfig = {
searchColumns: ["name","space_number","type","info_data"], searchColumns: ["name","space_number","type","info_data"],
numberRangeHolder: "spaceNumber", numberRangeHolder: "spaceNumber",
}, },
customerspaces: {
table: customerspaces,
searchColumns: ["name","space_number","type","info_data","description"],
numberRangeHolder: "space_number",
mtoLoad: ["customer"],
mtmLoad: ["customerinventoryitems"],
},
ownaccounts: { ownaccounts: {
table: ownaccounts, table: ownaccounts,
searchColumns: ["name","description","number"], searchColumns: ["name","description","number"],
@@ -170,7 +203,11 @@ export const resourceConfig = {
bankrequisitions: { bankrequisitions: {
table: bankrequisitions, table: bankrequisitions,
}, },
entitybankaccounts: {
table: entitybankaccounts,
searchColumns: ["description"],
},
serialexecutions: { serialexecutions: {
table: serialExecutions table: serialExecutions
} }
} }

View File

@@ -1,37 +1,70 @@
version: "3"
services: services:
web: frontend:
image: reg.federspiel.software/fedeo/software:beta image: git.federspiel.tech/flfeders/fedeo/frontend:dev
restart: always restart: always
environment: environment:
- INFISICAL_CLIENT_ID=abc - NUXT_PUBLIC_API_BASE=https://app.fedeo.de/backend
- INFISICAL_CLIENT_SECRET=abc - NUXT_PUBLIC_PDF_LICENSE=eyJkYXRhIjoiZXlKMElqb2laR1YyWld4dmNHVnlJaXdpWVhaMUlqb3hOemt3TmpNNU9UazVMQ0prYlNJNkltRndjQzVtWldSbGJ5NWtaU0lzSW00aU9pSXpOemt3Wm1Vek5UazBZbVU0TlRRNElpd2laWGh3SWpveE56a3dOak01T1RrNUxDSmtiWFFpT2lKemNHVmphV1pwWXlJc0luQWlPaUoyYVdWM1pYSWlmUT09Iiwic2lnbmF0dXJlIjoicWU4K0ZxQUJDNUp5bEJUU094Vkd5RTJMbk9UNmpyc2EyRStsN2tNNWhkM21KK2ZvVjYwaTFKeFdhZGtqSDRNWXZxQklMc0dpdWh5d2pMbUFjRHZuWGxOcTRMcXFLRm53dzVtaG1LK3lTeDRXbzVaS1loK1VZdFBzWUZjV3oyUHVGMmJraGJrVjJ6RzRlTGtRU09wdmJKY3JUZU1rN0N1VkN6Q1UraHF5T0ZVVXllWnRmaHlmcWswZEFFL0RMR1hvTDFSQXFjNkNkYU9FTDRTdC9Idy9DQnFieTE2aisvT3RxQUlLcy9NWTR6SVk3RTI3bWo4RUx5VjhXNkdXNXhqc0VUVzNKN0RRMUVlb3RhVlNLT29kc3pVRlhUYzVlbHVuSm04ZlcwM1ErMUhtSnpmWGoyS1dwM1dnamJDazZYSHozamFML2lOdUYvZFZNaWYvc2FoR3NnPT0ifQ==
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=3000"
# Middlewares
- "traefik.http.middlewares.fedeo-frontend-redirect-web-secure.redirectscheme.scheme=https"
# Web Entrypoint
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
- "traefik.http.routers.fedeo-frontend.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
backend: backend:
image: reg.federspiel.software/fedeo/backend:main image: git.federspiel.tech/flfeders/fedeo/backend:dev
restart: always restart: always
environment: environment:
- NUXT_PUBLIC_API_BASE= - INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf
- NUXT_PUBLIC_PDF_LICENSE= - INFISICAL_CLIENT_SECRET=4e3441acc0adbffd324aa50e668a95a556a3f55ec6bb85954e176e35a3392003
db: - NODE_ENV=production
image: postgres networks:
restart: always - traefik
shm_size: 128mb labels:
environment: - "traefik.enable=true"
POSTGRES_PASSWORD: abc - "traefik.docker.network=traefik"
POSTGRES_USER: sandelcom - "traefik.port=3100"
POSTGRES_DB: sensorfy # Middlewares
volumes: - "traefik.http.middlewares.fedeo-backend-redirect-web-secure.redirectscheme.scheme=https"
- ./pg-data:/var/lib/postgresql/data - "traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend"
ports: # Web Entrypoint
- "5432:5432" - "traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-redirect-web-secure"
- "traefik.http.routers.fedeo-backend.rule=Host(`app.fedeo.de`) && PathPrefix(`/backend`)"
- "traefik.http.routers.fedeo-backend.entrypoints=web"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-backend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/backend`)"
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
# db:
# image: postgres
# restart: always
# shm_size: 128mb
# environment:
# POSTGRES_PASSWORD: abc
# POSTGRES_USER: sandelcom
# POSTGRES_DB: sensorfy
# volumes:
# - ./pg-data:/var/lib/postgresql/data
# ports:
# - "5432:5432"
traefik: traefik:
image: traefik:v2.2 image: traefik:v2.11
restart: unless-stopped restart: unless-stopped
container_name: traefik container_name: traefik
command: command:
- "--api.insecure=false" - "--api.insecure=false"
- "--api.dashboard=true" - "--api.dashboard=false"
- "--api.debug=false" - "--api.debug=false"
- "--providers.docker=true" - "--providers.docker=true"
- "--providers.docker.exposedbydefault=false" - "--providers.docker.exposedbydefault=false"
@@ -43,19 +76,18 @@ services:
- "--accesslog.bufferingsize=5000" - "--accesslog.bufferingsize=5000"
- "--accesslog.fields.defaultMode=keep" - "--accesslog.fields.defaultMode=keep"
- "--accesslog.fields.headers.defaultMode=keep" - "--accesslog.fields.headers.defaultMode=keep"
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" # <== Enable TLS-ALPN-01 to generate and renew ACME certs - "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" #
- "--certificatesresolvers.mytlschallenge.acme.email=info@sandelcom.de" # <== Setting email for certs - "--certificatesresolvers.mytlschallenge.acme.email=moin@fedeo.de"
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json" # <== Defining acme file to store cert information - "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
ports: ports:
- 80:80 - 80:80
- 8080:8080
- 443:443 - 443:443
volumes: volumes:
- "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS) - "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS)
- "/var/run/docker.sock:/var/run/docker.sock:ro" - "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./traefik/logs:/logs" - "./traefik/logs:/logs"
labels: networks:
#### Labels define the behavior and rules of the traefik proxy for this container #### - traefik
- "traefik.enable=true" # <== Enable traefik on itself to view dashboard and assign subdomain to view it networks:
- "traefik.http.routers.api.rule=Host(`srv1.drinkingteam.de`)" # <== Setting the domain for the dashboard traefik:
- "traefik.http.routers.api.service=api@internal" # <== Enabling the api to be a service to access external: false

View File

@@ -0,0 +1,182 @@
<script setup>
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(["update:modelValue"])
const toast = useToast()
const accounts = ref([])
const ibanSearch = ref("")
const showCreate = ref(false)
const resolvingIban = ref(false)
const createPayload = ref({
iban: "",
bic: "",
bankName: "",
description: ""
})
const normalizeIban = (value) => String(value || "").replace(/\s+/g, "").toUpperCase()
const loadAccounts = async () => {
accounts.value = await useEntities("entitybankaccounts").select()
}
const assignedIds = computed(() => {
return Array.isArray(props.modelValue) ? props.modelValue : []
})
const assignedAccounts = computed(() => {
return accounts.value.filter((a) => assignedIds.value.includes(a.id))
})
const updateAssigned = (ids) => {
emit("update:modelValue", ids)
}
const assignByIban = async () => {
const search = normalizeIban(ibanSearch.value)
if (!search) return
const match = accounts.value.find((a) => normalizeIban(a.iban) === search)
if (!match) {
toast.add({ title: "Kein Bankkonto mit dieser IBAN gefunden.", color: "rose" })
return
}
if (assignedIds.value.includes(match.id)) {
toast.add({ title: "Dieses Bankkonto ist bereits zugewiesen.", color: "amber" })
return
}
updateAssigned([...assignedIds.value, match.id])
ibanSearch.value = ""
}
const removeAssigned = (id) => {
updateAssigned(assignedIds.value.filter((i) => i !== id))
}
const createAndAssign = async () => {
if (!createPayload.value.iban || !createPayload.value.bic || !createPayload.value.bankName) {
toast.add({ title: "IBAN, BIC und Bankinstitut sind Pflichtfelder.", color: "rose" })
return
}
const created = await useEntities("entitybankaccounts").create(createPayload.value, true)
await loadAccounts()
updateAssigned([...assignedIds.value, created.id])
createPayload.value = { iban: "", bic: "", bankName: "", description: "" }
showCreate.value = false
}
const resolveCreatePayloadFromIban = async () => {
const normalized = normalizeIban(createPayload.value.iban)
if (!normalized) return
resolvingIban.value = true
try {
const data = await useFunctions().useBankingResolveIban(normalized)
if (!data) return
createPayload.value.iban = data.iban || normalized
if (data.bic) createPayload.value.bic = data.bic
if (data.bankName) createPayload.value.bankName = data.bankName
} catch (e) {
// intentionally ignored: user can still enter fields manually
} finally {
resolvingIban.value = false
}
}
loadAccounts()
</script>
<template>
<div class="flex flex-col gap-2 w-full">
<div class="flex flex-wrap gap-2" v-if="assignedAccounts.length > 0">
<UBadge
v-for="account in assignedAccounts"
:key="account.id"
color="primary"
variant="subtle"
>
{{ account.displayLabel || account.iban }}
<UButton
v-if="!disabled"
variant="ghost"
color="gray"
size="2xs"
icon="i-heroicons-x-mark"
class="ml-1"
@click="removeAssigned(account.id)"
/>
</UBadge>
</div>
<InputGroup class="w-full">
<UInput
v-model="ibanSearch"
class="flex-auto"
placeholder="IBAN eingeben und zuweisen"
:disabled="disabled"
@keydown.enter.prevent="assignByIban"
/>
<UButton :disabled="disabled" @click="assignByIban">
Zuweisen
</UButton>
<UButton :disabled="disabled" color="gray" variant="outline" @click="showCreate = true">
Neu
</UButton>
</InputGroup>
</div>
<UModal v-model="showCreate">
<UCard>
<template #header>Neue Bankverbindung erstellen</template>
<div class="space-y-3">
<UFormGroup label="IBAN">
<InputGroup>
<UInput
v-model="createPayload.iban"
@blur="resolveCreatePayloadFromIban"
@keydown.enter.prevent="resolveCreatePayloadFromIban"
/>
<UButton
color="gray"
variant="outline"
:loading="resolvingIban"
@click="resolveCreatePayloadFromIban"
>
Ermitteln
</UButton>
</InputGroup>
</UFormGroup>
<UFormGroup label="BIC">
<UInput v-model="createPayload.bic" />
</UFormGroup>
<UFormGroup label="Bankinstitut">
<UInput v-model="createPayload.bankName" />
</UFormGroup>
<UFormGroup label="Beschreibung (optional)">
<UInput v-model="createPayload.description" />
</UFormGroup>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="gray" variant="outline" @click="showCreate = false">Abbrechen</UButton>
<UButton @click="createAndAssign">Erstellen und zuweisen</UButton>
</div>
</template>
</UCard>
</UModal>
</template>

View File

@@ -69,20 +69,31 @@ generateOldItemData()
const saveAllowed = computed(() => { const saveAllowed = computed(() => {
if (!item.value) return false if (!item.value) return false
const isFilledValue = (value) => {
if (Array.isArray(value)) return value.length > 0
if (typeof value === "string") return value.trim().length > 0
return value !== null && value !== undefined && value !== false
}
let allowedCount = 0 let allowedCount = 0
// Nur Input-Felder berücksichtigen // Nur Input-Felder berücksichtigen
const relevantColumns = dataType.templateColumns.filter(i => i.inputType) const relevantColumns = dataType.templateColumns.filter(i => {
if (!i.inputType) return false
if (i.showFunction && !i.showFunction(item.value)) return false
if (i.disabledFunction && i.disabledFunction(item.value)) return false
return true
})
relevantColumns.forEach(datapoint => { relevantColumns.forEach(datapoint => {
if(datapoint.required) { if(datapoint.required) {
if(datapoint.key.includes(".")){ if(datapoint.key.includes(".")){
const [parentKey, childKey] = datapoint.key.split('.') const [parentKey, childKey] = datapoint.key.split('.')
// Prüfung: Existiert Parent UND ist Child "truthy" (nicht null/undefined/empty) // Prüfung: Existiert Parent UND ist Child "truthy" (nicht null/undefined/empty)
if(item.value[parentKey] && item.value[parentKey][childKey]) { if(item.value[parentKey] && isFilledValue(item.value[parentKey][childKey])) {
allowedCount += 1 allowedCount += 1
} }
} else { } else {
if(item.value[datapoint.key]) { if(isFilledValue(item.value[datapoint.key])) {
allowedCount += 1 allowedCount += 1
} }
} }
@@ -427,6 +438,11 @@ const updateItem = async () => {
/> />
</template> </template>
</UPopover> </UPopover>
<BankAccountAssignInput
v-else-if="datapoint.inputType === 'bankaccountassign'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<Tiptap <Tiptap
v-else-if="datapoint.inputType === 'editor'" v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)" @updateContent="(i) => contentChanged(i,datapoint)"
@@ -527,6 +543,11 @@ const updateItem = async () => {
/> />
</template> </template>
</UPopover> </UPopover>
<BankAccountAssignInput
v-else-if="datapoint.inputType === 'bankaccountassign'"
v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<Tiptap <Tiptap
v-else-if="datapoint.inputType === 'editor'" v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)" @updateContent="(i) => contentChanged(i,datapoint)"
@@ -652,6 +673,11 @@ const updateItem = async () => {
/> />
</template> </template>
</UPopover> </UPopover>
<BankAccountAssignInput
v-else-if="datapoint.inputType === 'bankaccountassign'"
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<Tiptap <Tiptap
v-else-if="datapoint.inputType === 'editor'" v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)" @updateContent="(i) => contentChanged(i,datapoint)"
@@ -752,6 +778,11 @@ const updateItem = async () => {
/> />
</template> </template>
</UPopover> </UPopover>
<BankAccountAssignInput
v-else-if="datapoint.inputType === 'bankaccountassign'"
v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<Tiptap <Tiptap
v-else-if="datapoint.inputType === 'editor'" v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)" @updateContent="(i) => contentChanged(i,datapoint)"
@@ -788,4 +819,4 @@ td {
padding-bottom: 0.15em; padding-bottom: 0.15em;
padding-top: 0.15em; padding-top: 0.15em;
} }
</style> </style>

View File

@@ -69,6 +69,12 @@ const profileStore = useProfileStore()
const tempStore = useTempStore() const tempStore = useTempStore()
const dataType = dataStore.dataTypes[type] const dataType = dataStore.dataTypes[type]
const canCreate = computed(() => {
if (type === "members") {
return has("members-create") || has("customers-create")
}
return has(`${type}-create`)
})
const selectedColumns = ref(tempStore.columns[type] ? tempStore.columns[type] : dataType.templateColumns.filter(i => !i.disabledInTable)) const selectedColumns = ref(tempStore.columns[type] ? tempStore.columns[type] : dataType.templateColumns.filter(i => !i.disabledInTable))
const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key))) const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key)))
@@ -138,7 +144,7 @@ const filteredRows = computed(() => {
/> />
<UButton <UButton
v-if="platform !== 'mobile' && has(`${type}-create`)/*&& useRole().checkRight(`${type}-create`)*/" v-if="platform !== 'mobile' && canCreate/*&& useRole().checkRight(`${type}-create`)*/"
@click="router.push(`/standardEntity/${type}/create`)" @click="router.push(`/standardEntity/${type}/create`)"
class="ml-3" class="ml-3"
>+ {{dataType.labelSingle}}</UButton> >+ {{dataType.labelSingle}}</UButton>
@@ -200,4 +206,4 @@ const filteredRows = computed(() => {
<style scoped> <style scoped>
</style> </style>

View File

@@ -2,6 +2,7 @@
import EntityShowSubTimes from "~/components/EntityShowSubTimes.vue"; import EntityShowSubTimes from "~/components/EntityShowSubTimes.vue";
import WikiEntityWidget from "~/components/wiki/WikiEntityWidget.vue"; import WikiEntityWidget from "~/components/wiki/WikiEntityWidget.vue";
import LabelPrintModal from "~/components/LabelPrintModal.vue";
const props = defineProps({ const props = defineProps({
type: { type: {
@@ -69,7 +70,7 @@ const getAvailableQueryStringData = (keys) => {
if(props.item.customer) { if(props.item.customer) {
addParam("customer", props.item.customer.id) addParam("customer", props.item.customer.id)
} else if(type === "customers") { } else if(type === "customers" || type === "members") {
addParam("customer", props.item.id) addParam("customer", props.item.id)
} }
@@ -136,6 +137,18 @@ const changePinned = async () => {
} }
const openCustomerInventoryLabelPrint = () => {
modal.open(LabelPrintModal, {
context: {
id: props.item.id,
customerInventoryId: props.item.customerInventoryId,
name: props.item.name,
customerName: props.item.customer?.name,
serialNumber: props.item.serialNumber
}
})
}
</script> </script>
<template> <template>
@@ -193,6 +206,14 @@ const changePinned = async () => {
color="yellow" color="yellow"
@click="changePinned" @click="changePinned"
></UButton> ></UButton>
<UButton
v-if="type === 'customerinventoryitems'"
icon="i-heroicons-printer"
variant="outline"
@click="openCustomerInventoryLabelPrint"
>
Label
</UButton>
<UButton <UButton
@click="router.push(`/standardEntity/${type}/edit/${item.id}`)" @click="router.push(`/standardEntity/${type}/edit/${item.id}`)"
> >
@@ -214,6 +235,14 @@ const changePinned = async () => {
>{{item ? `${dataType.labelSingle}${props.item[dataType.templateColumns.find(i => i.title).key] ? ': ' + props.item[dataType.templateColumns.find(i => i.title).key] : ''}`: '' }}</h1> >{{item ? `${dataType.labelSingle}${props.item[dataType.templateColumns.find(i => i.title).key] ? ': ' + props.item[dataType.templateColumns.find(i => i.title).key] : ''}`: '' }}</h1>
</template> </template>
<template #right> <template #right>
<UButton
v-if="type === 'customerinventoryitems'"
icon="i-heroicons-printer"
variant="outline"
@click="openCustomerInventoryLabelPrint"
>
Label
</UButton>
<UButton <UButton
@click="router.push(`/standardEntity/${type}/edit/${item.id}`)" @click="router.push(`/standardEntity/${type}/edit/${item.id}`)"
> >
@@ -372,4 +401,4 @@ const changePinned = async () => {
<style scoped> <style scoped>
</style> </style>

View File

@@ -31,6 +31,7 @@ const dataStore = useDataStore()
const tempStore = useTempStore() const tempStore = useTempStore()
const router = useRouter() const router = useRouter()
const createRoute = computed(() => type.value === "tasks" ? `/tasks/create?${props.queryStringData}` : `/standardEntity/${type.value}/create?${props.queryStringData}`)
let dataType = null let dataType = null
@@ -80,7 +81,7 @@ setup()
</template> </template>
<Toolbar> <Toolbar>
<UButton <UButton
@click="router.push(`/standardEntity/${type}/create?${props.queryStringData}`)" @click="router.push(createRoute)"
> >
+ {{dataType.labelSingle}} + {{dataType.labelSingle}}
</UButton> </UButton>
@@ -125,4 +126,4 @@ setup()
<style scoped> <style scoped>
</style> </style>

View File

@@ -21,13 +21,20 @@ const props = defineProps({
const dataStore = useDataStore() const dataStore = useDataStore()
const dataType = dataStore.dataTypes[props.topLevelType] const dataType = dataStore.dataTypes[props.topLevelType]
const historyType = computed(() => {
const holder = dataType?.historyItemHolder
if (!holder) return props.topLevelType
const normalized = String(holder).toLowerCase()
return normalized.endsWith("s") ? normalized : `${normalized}s`
})
</script> </script>
<template> <template>
<UCard class="mt-5 scroll" :style="props.platform !== 'mobile' ? 'height: 80vh' : ''"> <UCard class="mt-5 scroll" :style="props.platform !== 'mobile' ? 'height: 80vh' : ''">
<HistoryDisplay <HistoryDisplay
:type="props.topLevelType" :type="historyType"
v-if="props.item.id" v-if="props.item.id"
:element-id="props.item.id" :element-id="props.item.id"
render-headline render-headline
@@ -39,4 +46,4 @@ const dataType = dataStore.dataTypes[props.topLevelType]
<style scoped> <style scoped>
</style> </style>

View File

@@ -1,4 +1,5 @@
<script setup> <script setup>
import dayjs from "dayjs";
const props = defineProps({ const props = defineProps({
queryStringData: { queryStringData: {
@@ -28,6 +29,33 @@ const dataType = dataStore.dataTypes[props.topLevelType]
// const selectedColumns = ref(tempStore.columns[props.topLevelType] ? tempStore.columns[props.topLevelType] : dataType.templateColumns.filter(i => !i.disabledInTable)) // const selectedColumns = ref(tempStore.columns[props.topLevelType] ? tempStore.columns[props.topLevelType] : dataType.templateColumns.filter(i => !i.disabledInTable))
// const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key))) // const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key)))
const getDatapointValue = (datapoint) => {
if (datapoint.key.includes(".")) {
const [parentKey, childKey] = datapoint.key.split(".")
return props.item?.[parentKey]?.[childKey]
}
return props.item?.[datapoint.key]
}
const renderDatapointValue = (datapoint) => {
const value = getDatapointValue(datapoint)
if (value === null || value === undefined || value === "") return "-"
if (datapoint.inputType === "date") {
return dayjs(value).isValid() ? dayjs(value).format("DD.MM.YYYY") : String(value)
}
if (datapoint.inputType === "datetime") {
return dayjs(value).isValid() ? dayjs(value).format("DD.MM.YYYY HH:mm") : String(value)
}
if (datapoint.inputType === "bool" || typeof value === "boolean") {
return value ? "Ja" : "Nein"
}
return `${value}${datapoint.unit ? datapoint.unit : ""}`
}
</script> </script>
<template> <template>
@@ -53,8 +81,7 @@ const dataType = dataStore.dataTypes[props.topLevelType]
<td> <td>
<component v-if="datapoint.component" :is="datapoint.component" :row="props.item" :in-show="true"></component> <component v-if="datapoint.component" :is="datapoint.component" :row="props.item" :in-show="true"></component>
<div v-else> <div v-else>
<span v-if="datapoint.key.includes('.')">{{props.item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]}}{{datapoint.unit}}</span> <span>{{ renderDatapointValue(datapoint) }}</span>
<span v-else>{{props.item[datapoint.key]}} {{datapoint.unit}}</span>
</div> </div>
</td> </td>
</tr> </tr>
@@ -74,4 +101,4 @@ td {
padding-bottom: 0.15em; padding-bottom: 0.15em;
padding-top: 0.15em; padding-top: 0.15em;
} }
</style> </style>

View File

@@ -24,6 +24,7 @@ const emit = defineEmits(["updateNeeded"]);
const router = useRouter() const router = useRouter()
const profileStore = useProfileStore() const profileStore = useProfileStore()
const auth = useAuthStore()
const renderedPhases = computed(() => { const renderedPhases = computed(() => {
if(props.topLevelType === "projects" && props.item.phases) { if(props.topLevelType === "projects" && props.item.phases) {
@@ -57,6 +58,7 @@ const renderedPhases = computed(() => {
}) })
const changeActivePhase = async (key) => { const changeActivePhase = async (key) => {
console.log(props.item)
let item = await useEntities("projects").selectSingle(props.item.id,'*') let item = await useEntities("projects").selectSingle(props.item.id,'*')
let phaseLabel = "" let phaseLabel = ""
@@ -67,13 +69,15 @@ const changeActivePhase = async (key) => {
if(p.key === key) { if(p.key === key) {
p.active = true p.active = true
p.activated_at = dayjs().format() p.activated_at = dayjs().format()
p.activated_by = profileStore.activeProfile.id p.activated_by = auth.user.id
phaseLabel = p.label phaseLabel = p.label
} }
return p return p
}) })
console.log(item)
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})
emit("updateNeeded") emit("updateNeeded")
@@ -140,7 +144,7 @@ const changeActivePhase = async (key) => {
<div> <div>
<p v-if="item.activated_at" class="dark:text-white text-black">Aktiviert am: {{dayjs(item.activated_at).format("DD.MM.YY HH:mm")}} Uhr</p> <p v-if="item.activated_at" class="dark:text-white text-black">Aktiviert am: {{dayjs(item.activated_at).format("DD.MM.YY HH:mm")}} Uhr</p>
<p v-if="item.activated_by" class="dark:text-white text-black">Aktiviert durch: {{profileStore.getProfileById(item.activated_by).fullName}}</p> <p v-if="item.activated_by" class="dark:text-white text-black">Aktiviert durch: {{item.activated_by}}</p>
<p v-if="item.description" class="dark:text-white text-black">Beschreibung: {{item.description}}</p> <p v-if="item.description" class="dark:text-white text-black">Beschreibung: {{item.description}}</p>
</div> </div>
</UCard> </UCard>

View File

@@ -1,4 +1,6 @@
<script setup> <script setup>
const getShowRoute = (entityType, id) => entityType === "tasks" ? `/tasks/show/${id}` : `/standardEntity/${entityType}/show/${id}`
defineShortcuts({ defineShortcuts({
/*'/': () => { /*'/': () => {
//console.log(searchinput) //console.log(searchinput)
@@ -8,7 +10,7 @@
'Enter': { 'Enter': {
usingInput: true, usingInput: true,
handler: () => { handler: () => {
router.push(`/standardEntity/${props.type}/show/${props.rows.value[selectedItem.value].id}`) router.push(getShowRoute(props.type, props.rows[selectedItem.value].id))
} }
}, },
'arrowdown': () => { 'arrowdown': () => {
@@ -75,7 +77,7 @@
:columns="props.columns" :columns="props.columns"
class="w-full" class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }" :ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(i) => router.push(`/standardEntity/${type}/show/${i.id}`) " @select="(i) => router.push(getShowRoute(type, i.id))"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: `Keine ${dataType.label} anzuzeigen` }" :empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: `Keine ${dataType.label} anzuzeigen` }"
> >
<!-- <template <!-- <template
@@ -134,4 +136,4 @@
<style scoped> <style scoped>
</style> </style>

View File

@@ -46,6 +46,7 @@
const dataStore = useDataStore() const dataStore = useDataStore()
const router = useRouter() const router = useRouter()
const getShowRoute = (entityType, id) => entityType === "tasks" ? `/tasks/show/${id}` : `/standardEntity/${entityType}/show/${id}`
const dataType = dataStore.dataTypes[props.type] const dataType = dataStore.dataTypes[props.type]
@@ -59,7 +60,7 @@
<a <a
v-for="item in props.rows" v-for="item in props.rows"
class="my-1" class="my-1"
@click="router.push(`/standardEntity/${type}/show/${item.id}`)" @click="router.push(getShowRoute(type, item.id))"
> >
<p class="truncate text-left text-primary text-xl">{{dataType.templateColumns.find(i => i.title).key ? item[dataType.templateColumns.find(i => i.title).key] : null}}</p> <p class="truncate text-left text-primary text-xl">{{dataType.templateColumns.find(i => i.title).key ? item[dataType.templateColumns.find(i => i.title).key] : null}}</p>
<p class="text-sm"> <p class="text-sm">
@@ -126,4 +127,4 @@
<style scoped> <style scoped>
</style> </style>

View File

@@ -3,11 +3,13 @@ import dayjs from "dayjs"
const props = defineProps({ const props = defineProps({
type: { type: {
type: String, type: String,
required: true required: false,
default: null
}, },
elementId: { elementId: {
type: String, type: String,
required: true required: false,
default: null
}, },
renderHeadline: { renderHeadline: {
type: Boolean, type: Boolean,
@@ -25,13 +27,11 @@ const items = ref([])
const platform = ref("default") const platform = ref("default")
const setup = async () => { const setup = async () => {
if(props.type && props.elementId){ if(props.type && props.elementId){
items.value = await useNuxtApp().$api(`/api/resource/${props.type}/${props.elementId}/history`) items.value = await useNuxtApp().$api(`/api/resource/${props.type}/${props.elementId}/history`)
} /*else { } else {
items.value = await useNuxtApp().$api(`/api/history`)
}*/ }
} }
setup() setup()
@@ -43,6 +43,10 @@ const addHistoryItemData = ref({
}) })
const addHistoryItem = async () => { const addHistoryItem = async () => {
if (!props.type || !props.elementId) {
toast.add({ title: "Im zentralen Logbuch können keine direkten Einträge erstellt werden." })
return
}
const res = await useNuxtApp().$api(`/api/resource/${props.type}/${props.elementId}/history`, { const res = await useNuxtApp().$api(`/api/resource/${props.type}/${props.elementId}/history`, {
method: "POST", method: "POST",
@@ -161,4 +165,4 @@ const renderText = (text) => {
<style scoped> <style scoped>
</style> </style>

View File

@@ -44,7 +44,9 @@ async function loadLabel() {
labelData.value = await $api(`/api/print/label`, { labelData.value = await $api(`/api/print/label`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
context: props.context || null context: props.context || null,
width: 584,
height: 354
}) })
}) })
} catch (err) { } catch (err) {
@@ -78,11 +80,17 @@ onMounted(() => {
}) })
watch(() => labelPrinter.connected, (connected) => {
if (connected && !labelData.value) {
loadLabel()
}
})
</script> </script>
<template> <template>
<UModal> <UModal :ui="{ width: 'sm:max-w-5xl' }">
<UCard> <UCard class="w-[92vw] max-w-5xl">
<template #header> <template #header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -91,11 +99,11 @@ onMounted(() => {
</div> </div>
</template> </template>
<div v-if="!loading && labelPrinter.connected"> <div v-if="!loading && labelPrinter.connected" class="w-full">
<img <img
:src="`data:image/png;base64,${labelData.base64}`" :src="`data:image/png;base64,${labelData.base64}`"
alt="Label Preview" alt="Label Preview"
class="max-w-full max-h-64 object-contain" class="w-full max-h-[70vh] object-contain"
/> />
</div> </div>
<div v-else-if="loading && !labelPrinter.connected"> <div v-else-if="loading && !labelPrinter.connected">

View File

@@ -5,8 +5,251 @@ const { has } = usePermission()
// Lokaler State für den Taschenrechner // Lokaler State für den Taschenrechner
const showCalculator = ref(false) const showCalculator = ref(false)
const tenantExtraModules = computed(() => {
const modules = auth.activeTenantData?.extraModules
return Array.isArray(modules) ? modules : []
})
const showMembersNav = computed(() => {
return tenantExtraModules.value.includes("verein") && (has("members") || has("customers"))
})
const showMemberRelationsNav = computed(() => {
return tenantExtraModules.value.includes("verein") && has("members")
})
const tenantFeatures = computed(() => auth.activeTenantData?.features || {})
const featureEnabled = (key) => tenantFeatures.value?.[key] !== false
const links = computed(() => { const links = computed(() => {
const organisationChildren = [
has("tasks") && featureEnabled("tasks") ? {
label: "Aufgaben",
to: "/tasks",
icon: "i-heroicons-rectangle-stack"
} : null,
featureEnabled("wiki") ? {
label: "Wiki",
to: "/wiki",
icon: "i-heroicons-book-open"
} : null,
].filter(Boolean)
const documentChildren = [
featureEnabled("files") ? {
label: "Dateien",
to: "/files",
icon: "i-heroicons-document"
} : null,
featureEnabled("createdletters") ? {
label: "Anschreiben",
to: "/createdletters",
icon: "i-heroicons-document",
disabled: true
} : null,
featureEnabled("documentboxes") ? {
label: "Boxen",
to: "/standardEntity/documentboxes",
icon: "i-heroicons-archive-box",
disabled: true
} : null,
].filter(Boolean)
const communicationChildren = [
featureEnabled("helpdesk") ? {
label: "Helpdesk",
to: "/helpdesk",
icon: "i-heroicons-chat-bubble-left-right",
disabled: true
} : null,
featureEnabled("email") ? {
label: "E-Mail",
to: "/email/new",
icon: "i-heroicons-envelope",
disabled: true
} : null,
].filter(Boolean)
const contactsChildren = [
showMembersNav.value && featureEnabled("members") ? {
label: "Mitglieder",
to: "/standardEntity/members",
icon: "i-heroicons-user-group"
} : null,
has("customers") && featureEnabled("customers") ? {
label: "Kunden",
to: "/standardEntity/customers",
icon: "i-heroicons-user-group"
} : null,
has("vendors") && featureEnabled("vendors") ? {
label: "Lieferanten",
to: "/standardEntity/vendors",
icon: "i-heroicons-truck"
} : null,
has("contacts") && featureEnabled("contactsList") ? {
label: "Ansprechpartner",
to: "/standardEntity/contacts",
icon: "i-heroicons-user-group"
} : null,
].filter(Boolean)
const staffChildren = [
featureEnabled("staffTime") ? {
label: "Zeiten",
to: "/staff/time",
icon: "i-heroicons-clock",
} : null,
].filter(Boolean)
const accountingChildren = [
featureEnabled("createDocument") ? {
label: "Ausgangsbelege",
to: "/createDocument",
icon: "i-heroicons-document-text"
} : null,
featureEnabled("serialInvoice") ? {
label: "Serienvorlagen",
to: "/createDocument/serialInvoice",
icon: "i-heroicons-document-text"
} : null,
featureEnabled("incomingInvoices") ? {
label: "Eingangsbelege",
to: "/incomingInvoices",
icon: "i-heroicons-document-text",
} : null,
featureEnabled("costcentres") ? {
label: "Kostenstellen",
to: "/standardEntity/costcentres",
icon: "i-heroicons-document-currency-euro"
} : null,
featureEnabled("accounts") ? {
label: "Buchungskonten",
to: "/accounts",
icon: "i-heroicons-document-text",
} : null,
featureEnabled("ownaccounts") ? {
label: "zusätzliche Buchungskonten",
to: "/standardEntity/ownaccounts",
icon: "i-heroicons-document-text"
} : null,
featureEnabled("banking") ? {
label: "Bank",
to: "/banking",
icon: "i-heroicons-document-text",
} : null,
].filter(Boolean)
const inventoryChildren = [
has("spaces") && featureEnabled("spaces") ? {
label: "Lagerplätze",
to: "/standardEntity/spaces",
icon: "i-heroicons-square-3-stack-3d"
} : null,
has("inventoryitems") && featureEnabled("customerspaces") ? {
label: "Kundenlagerplätze",
to: "/standardEntity/customerspaces",
icon: "i-heroicons-squares-plus"
} : null,
has("inventoryitems") && featureEnabled("customerinventoryitems") ? {
label: "Kundeninventar",
to: "/standardEntity/customerinventoryitems",
icon: "i-heroicons-qr-code"
} : null,
has("inventoryitems") && featureEnabled("inventoryitems") ? {
label: "Inventar",
to: "/standardEntity/inventoryitems",
icon: "i-heroicons-puzzle-piece"
} : null,
has("inventoryitems") && featureEnabled("inventoryitemgroups") ? {
label: "Inventargruppen",
to: "/standardEntity/inventoryitemgroups",
icon: "i-heroicons-puzzle-piece"
} : null,
].filter(Boolean)
const masterDataChildren = [
has("products") && featureEnabled("products") ? {
label: "Artikel",
to: "/standardEntity/products",
icon: "i-heroicons-puzzle-piece"
} : null,
has("productcategories") && featureEnabled("productcategories") ? {
label: "Artikelkategorien",
to: "/standardEntity/productcategories",
icon: "i-heroicons-puzzle-piece"
} : null,
has("services") && featureEnabled("services") ? {
label: "Leistungen",
to: "/standardEntity/services",
icon: "i-heroicons-wrench-screwdriver"
} : null,
has("servicecategories") && featureEnabled("servicecategories") ? {
label: "Leistungskategorien",
to: "/standardEntity/servicecategories",
icon: "i-heroicons-wrench-screwdriver"
} : null,
showMemberRelationsNav.value && featureEnabled("memberrelations") ? {
label: "Mitgliedsverhältnisse",
to: "/standardEntity/memberrelations",
icon: "i-heroicons-identification"
} : null,
featureEnabled("staffProfiles") ? {
label: "Mitarbeiter",
to: "/staff/profiles",
icon: "i-heroicons-user-group"
} : null,
featureEnabled("hourrates") ? {
label: "Stundensätze",
to: "/standardEntity/hourrates",
icon: "i-heroicons-user-group"
} : null,
featureEnabled("projecttypes") ? {
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("contracttypes") ? {
label: "Vertragstypen",
to: "/standardEntity/contracttypes",
icon: "i-heroicons-document-duplicate",
} : null,
has("vehicles") && featureEnabled("vehicles") ? {
label: "Fahrzeuge",
to: "/standardEntity/vehicles",
icon: "i-heroicons-truck"
} : null,
].filter(Boolean)
const settingsChildren = [
featureEnabled("settingsNumberRanges") ? {
label: "Nummernkreise",
to: "/settings/numberRanges",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("settingsEmailAccounts") ? {
label: "E-Mail Konten",
to: "/settings/emailaccounts",
icon: "i-heroicons-envelope",
} : null,
featureEnabled("settingsBanking") ? {
label: "Bankkonten",
to: "/settings/banking",
icon: "i-heroicons-currency-euro",
} : null,
featureEnabled("settingsTexttemplates") ? {
label: "Textvorlagen",
to: "/settings/texttemplates",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("settingsTenant") ? {
label: "Firmeneinstellungen",
to: "/settings/tenant",
icon: "i-heroicons-building-office",
} : null,
featureEnabled("export") ? {
label: "Export",
to: "/export",
icon: "i-heroicons-clipboard-document-list"
} : null,
].filter(Boolean)
return [ return [
...(auth.profile?.pinned_on_navigation || []).map(pin => { ...(auth.profile?.pinned_on_navigation || []).map(pin => {
if (pin.type === "external") { if (pin.type === "external") {
@@ -20,272 +263,96 @@ const links = computed(() => {
} else if (pin.type === "standardEntity") { } else if (pin.type === "standardEntity") {
return { return {
label: pin.label, label: pin.label,
to: `/standardEntity/${pin.datatype}/show/${pin.id}`, to: pin.datatype === "tasks" ? `/tasks/show/${pin.id}` : `/standardEntity/${pin.datatype}/show/${pin.id}`,
icon: pin.icon, icon: pin.icon,
pinned: true pinned: true
} }
} }
}), }),
{ featureEnabled("dashboard") ? {
id: 'dashboard', id: 'dashboard',
label: "Dashboard", label: "Dashboard",
to: "/", to: "/",
icon: "i-heroicons-home" icon: "i-heroicons-home"
}, } : null,
{ featureEnabled("historyitems") ? {
id: 'historyitems', id: 'historyitems',
label: "Logbuch", label: "Logbuch",
to: "/historyitems", to: "/historyitems",
icon: "i-heroicons-book-open", icon: "i-heroicons-book-open"
disabled: true } : null,
}, ...(organisationChildren.length > 0 ? [{
{
label: "Organisation", label: "Organisation",
icon: "i-heroicons-rectangle-stack", icon: "i-heroicons-rectangle-stack",
defaultOpen: false, defaultOpen: false,
children: [ children: organisationChildren
...has("tasks") ? [{ }] : []),
label: "Aufgaben", ...(documentChildren.length > 0 ? [{
to: "/standardEntity/tasks",
icon: "i-heroicons-rectangle-stack"
}] : [],
...true ? [{
label: "Wiki",
to: "/wiki",
icon: "i-heroicons-book-open"
}] : [],
]
},
{
label: "Dokumente", label: "Dokumente",
icon: "i-heroicons-rectangle-stack", icon: "i-heroicons-rectangle-stack",
defaultOpen: false, defaultOpen: false,
children: [ children: documentChildren
{ }] : []),
label: "Dateien", ...(communicationChildren.length > 0 ? [{
to: "/files",
icon: "i-heroicons-document"
}, {
label: "Anschreiben",
to: "/createdletters",
icon: "i-heroicons-document",
disabled: true
}, {
label: "Boxen",
to: "/standardEntity/documentboxes",
icon: "i-heroicons-archive-box",
disabled: true
},
]
},
{
label: "Kommunikation", label: "Kommunikation",
icon: "i-heroicons-megaphone", icon: "i-heroicons-megaphone",
defaultOpen: false, defaultOpen: false,
children: [ children: communicationChildren
{ }] : []),
label: "Helpdesk", ...(contactsChildren.length > 0 ? [{
to: "/helpdesk",
icon: "i-heroicons-chat-bubble-left-right",
disabled: true
},
{
label: "E-Mail",
to: "/email/new",
icon: "i-heroicons-envelope",
disabled: true
}
]
},
...(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: contactsChildren
...has("customers") ? [{ }] : []),
label: "Kunden", ...(staffChildren.length > 0 ? [{
to: "/standardEntity/customers",
icon: "i-heroicons-user-group"
}] : [],
...has("vendors") ? [{
label: "Lieferanten",
to: "/standardEntity/vendors",
icon: "i-heroicons-truck"
}] : [],
...has("contacts") ? [{
label: "Ansprechpartner",
to: "/standardEntity/contacts",
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: staffChildren
...true ? [{ }] : []),
label: "Zeiten", ...(accountingChildren.length > 0 ? [{
to: "/staff/time",
icon: "i-heroicons-clock",
}] : [],
]
},
...[{
label: "Buchhaltung", label: "Buchhaltung",
defaultOpen: false, defaultOpen: false,
icon: "i-heroicons-chart-bar-square", icon: "i-heroicons-chart-bar-square",
children: [ children: accountingChildren
{ }] : []),
label: "Ausgangsbelege", ...(inventoryChildren.length > 0 ? [{
to: "/createDocument",
icon: "i-heroicons-document-text"
}, {
label: "Serienvorlagen",
to: "/createDocument/serialInvoice",
icon: "i-heroicons-document-text"
}, {
label: "Eingangsbelege",
to: "/incomingInvoices",
icon: "i-heroicons-document-text",
}, {
label: "Kostenstellen",
to: "/standardEntity/costcentres",
icon: "i-heroicons-document-currency-euro"
}, {
label: "Buchungskonten",
to: "/accounts",
icon: "i-heroicons-document-text",
}, {
label: "zusätzliche Buchungskonten",
to: "/standardEntity/ownaccounts",
icon: "i-heroicons-document-text"
},
{
label: "Bank",
to: "/banking",
icon: "i-heroicons-document-text",
},
]
}],
...has("inventory") ? [{
label: "Lager", label: "Lager",
icon: "i-heroicons-puzzle-piece", icon: "i-heroicons-puzzle-piece",
defaultOpen: false, defaultOpen: false,
children: [ children: inventoryChildren
...has("spaces") ? [{ }] : []),
label: "Lagerplätze", ...(masterDataChildren.length > 0 ? [{
to: "/standardEntity/spaces",
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: masterDataChildren
...has("products") ? [{ }] : []),
label: "Artikel",
to: "/standardEntity/products",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("productcategories") ? [{
label: "Artikelkategorien",
to: "/standardEntity/productcategories",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("services") ? [{
label: "Leistungen",
to: "/standardEntity/services",
icon: "i-heroicons-wrench-screwdriver"
}] : [],
...has("servicecategories") ? [{
label: "Leistungskategorien",
to: "/standardEntity/servicecategories",
icon: "i-heroicons-wrench-screwdriver"
}] : [],
{
label: "Mitarbeiter",
to: "/staff/profiles",
icon: "i-heroicons-user-group"
},
{
label: "Stundensätze",
to: "/standardEntity/hourrates",
icon: "i-heroicons-user-group"
},
...has("vehicles") ? [{
label: "Fahrzeuge",
to: "/standardEntity/vehicles",
icon: "i-heroicons-truck"
}] : [],
...has("inventoryitems") ? [{
label: "Inventar",
to: "/standardEntity/inventoryitems",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("inventoryitems") ? [{
label: "Inventargruppen",
to: "/standardEntity/inventoryitemgroups",
icon: "i-heroicons-puzzle-piece"
}] : [],
]
},
...has("projects") ? [{ ...(has("projects") && featureEnabled("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") && featureEnabled("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") && featureEnabled("plants")) ? [{
label: "Objekte", label: "Objekte",
to: "/standardEntity/plants", to: "/standardEntity/plants",
icon: "i-heroicons-clipboard-document" icon: "i-heroicons-clipboard-document"
}] : [], }] : [],
{ ...(settingsChildren.length > 0 ? [{
label: "Einstellungen", label: "Einstellungen",
defaultOpen: false, defaultOpen: false,
icon: "i-heroicons-cog-8-tooth", icon: "i-heroicons-cog-8-tooth",
children: [ children: settingsChildren
{ }] : []),
label: "Nummernkreise", ].filter(Boolean)
to: "/settings/numberRanges",
icon: "i-heroicons-clipboard-document-list",
}, {
label: "E-Mail Konten",
to: "/settings/emailaccounts",
icon: "i-heroicons-envelope",
}, {
label: "Bankkonten",
to: "/settings/banking",
icon: "i-heroicons-currency-euro",
}, {
label: "Textvorlagen",
to: "/settings/texttemplates",
icon: "i-heroicons-clipboard-document-list",
}, {
label: "Firmeneinstellungen",
to: "/settings/tenant",
icon: "i-heroicons-building-office",
}, {
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list",
}, {
label: "Export",
to: "/export",
icon: "i-heroicons-clipboard-document-list"
}
]
},
]
}) })
const accordionItems = computed(() => const accordionItems = computed(() =>
@@ -365,4 +432,4 @@ const buttonItems = computed(() =>
</UAccordion> </UAccordion>
<Calculator v-if="showCalculator" v-model="showCalculator"/> <Calculator v-if="showCalculator" v-model="showCalculator"/>
</template> </template>

View File

@@ -1,4 +1,6 @@
<script setup> <script setup>
import { computed } from "vue"
const props = defineProps({ const props = defineProps({
row: { row: {
type: Object, type: Object,
@@ -6,13 +8,15 @@ const props = defineProps({
default: {} default: {}
} }
}) })
const addressData = computed(() => props.row?.infoData || props.row?.info_data || {})
</script> </script>
<template> <template>
<span v-if="props.row.infoData.streetNumber">{{props.row.infoData.streetNumber}},</span> <span v-if="addressData.streetNumber">{{ addressData.streetNumber }},</span>
<span v-if="props.row.infoData.street">{{props.row.infoData.street}},</span> <span v-if="addressData.street">{{ addressData.street }},</span>
<span v-if="props.row.infoData.special">{{props.row.infoData.special}},</span> <span v-if="addressData.special">{{ addressData.special }},</span>
<span v-if="props.row.infoData.zip">{{props.row.infoData.zip}},</span> <span v-if="addressData.zip">{{ addressData.zip }},</span>
<span v-if="props.row.infoData.city">{{props.row.infoData.city}}</span> <span v-if="addressData.city">{{ addressData.city }}</span>
</template> </template>

View File

@@ -0,0 +1,65 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
const accounts = ref([])
const showFullIban = ref(false)
const formatIban = (iban) => {
const cleaned = String(iban || "").replace(/\s+/g, "").toUpperCase()
if (!cleaned) return ""
return cleaned.match(/.{1,4}/g)?.join(" ") || cleaned
}
const maskIban = (iban) => {
const cleaned = String(iban || "").replace(/\s+/g, "").toUpperCase()
if (!cleaned) return ""
if (cleaned.length <= 8) return cleaned
return `${cleaned.slice(0, 4)} **** **** ${cleaned.slice(-4)}`
}
const bankAccounts = computed(() => {
const ids = Array.isArray(props.row?.infoData?.bankAccountIds) ? props.row.infoData.bankAccountIds : []
if (!ids.length) return []
return accounts.value
.filter((a) => ids.includes(a.id))
.map((a) => {
const iban = formatIban(a.iban)
const ibanDisplay = showFullIban.value ? iban : maskIban(a.iban)
const parts = [ibanDisplay]
if (a.bankName) parts.push(a.bankName)
if (a.description) parts.push(`(${a.description})`)
return {
id: a.id,
label: parts.filter(Boolean).join(" | ")
}
})
})
const setup = async () => {
accounts.value = await useEntities("entitybankaccounts").select()
}
setup()
</script>
<template>
<div class="flex flex-col gap-1">
<div v-if="bankAccounts.length > 0" class="flex">
<UButton
color="gray"
variant="ghost"
size="2xs"
:icon="showFullIban ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'"
@click="showFullIban = !showFullIban"
/>
</div>
<span v-for="account in bankAccounts" :key="account.id">{{ account.label }}</span>
<span v-if="bankAccounts.length === 0">-</span>
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span>{{ props.row.contracttype ? props.row.contracttype.name : '' }}</span>
</template>

View File

@@ -6,8 +6,20 @@ const props = defineProps({
default: {} default: {}
} }
}) })
const descriptionText = computed(() => {
const description = props.row?.description
if (!description) return ""
if (typeof description === "string") return description
if (typeof description === "object") {
if (typeof description.text === "string" && description.text.trim().length) {
return description.text
}
}
return String(description)
})
</script> </script>
<template> <template>
<div v-if="props.row.description" v-html="props.row.description.html"/> <div v-if="descriptionText">{{ descriptionText }}</div>
</template> </template>

View File

@@ -0,0 +1,52 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
const relations = ref([])
const normalizeId = (value) => {
if (value === null || value === undefined || value === "") return null
if (typeof value === "object") return normalizeId(value.id)
const parsed = Number(value)
return Number.isNaN(parsed) ? String(value) : parsed
}
const relationLabel = computed(() => {
const relation = props.row?.memberrelation
if (relation && typeof relation === "object" && relation.type) return relation.type
const id = normalizeId(relation)
if (!id) return ""
return relations.value.find((i) => normalizeId(i.id) === id)?.type || ""
})
const relationId = computed(() => {
return normalizeId(props.row?.memberrelation)
})
const loadRelations = async () => {
try {
relations.value = await useEntities("memberrelations").select()
} catch (e) {
relations.value = []
}
}
loadRelations()
</script>
<template>
<NuxtLink
v-if="relationId && relationLabel"
:to="`/standardEntity/memberrelations/show/${relationId}`"
class="text-primary"
>
{{ relationLabel }}
</NuxtLink>
<span v-else>{{ relationLabel }}</span>
</template>

View File

@@ -0,0 +1,20 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
},
inShow: {
type: Boolean,
default: false
}
})
</script>
<template>
<div v-if="props.row.product">
<nuxt-link v-if="props.inShow" :to="`/standardEntity/products/show/${props.row.product.id}`">{{ props.row.product ? props.row.product.name : '' }}</nuxt-link>
<span v-else>{{ props.row.product ? props.row.product.name : '' }}</span>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
const productcategories = ref([])
const setup = async () => {
productcategories.value = await useEntities("productcategories").select()
}
setup()
const renderedCategories = computed(() => {
if (!Array.isArray(props.row?.productcategories)) return ""
return props.row.productcategories
.map((id) => productcategories.value.find((x) => x.id === id)?.name)
.filter(Boolean)
.join(", ")
})
</script>
<template>
<span v-if="renderedCategories">{{ renderedCategories }}</span>
</template>

View File

@@ -1,239 +1,279 @@
<script setup> <script setup>
import customParseFormat from "dayjs/plugin/customParseFormat"; import customParseFormat from "dayjs/plugin/customParseFormat";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Line } from "vue-chartjs";
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
const tempStore = useTempStore()
let incomeData = ref({}) const amountMode = ref("net")
let expenseData = ref({}) const granularity = ref("year")
const selectedYear = ref(dayjs().year())
const selectedMonth = ref(dayjs().month() + 1)
const setup = async () => { const incomeDocuments = ref([])
let incomeRawData = (await useEntities("createddocuments").select()).filter(i => i.state === "Gebucht" && ['invoices','advanceInvoices','cancellationInvoices'].includes(i.type)) const expenseInvoices = ref([])
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)) const granularityOptions = [
{ label: "Jahr", value: "year" },
{ label: "Monat", value: "month" }
]
const monthOptions = [
{ label: "Januar", value: 1 },
{ label: "Februar", value: 2 },
{ label: "März", value: 3 },
{ label: "April", value: 4 },
{ label: "Mai", value: 5 },
{ label: "Juni", value: 6 },
{ label: "Juli", value: 7 },
{ label: "August", value: 8 },
{ label: "September", value: 9 },
{ label: "Oktober", value: 10 },
{ label: "November", value: 11 },
{ label: "Dezember", value: 12 }
]
let expenseRawData =(await useEntities("incominginvoices").select()) const normalizeMode = (value) => value === "gross" ? "gross" : "net"
let withoutInvoiceRawData = (await useEntities("statementallocations").select()).filter(i => i.account) const normalizeGranularity = (value) => value === "month" ? "month" : "year"
let withoutInvoiceRawDataExpenses = [] watch(
let withoutInvoiceRawDataIncomes = [] () => tempStore.settings?.dashboardIncomeExpenseView,
(storedView) => {
const legacyMode = tempStore.settings?.dashboardIncomeExpenseMode
withoutInvoiceRawData.forEach(i => { amountMode.value = normalizeMode(storedView?.amountMode || legacyMode)
if(i.amount > 0) { granularity.value = normalizeGranularity(storedView?.granularity)
withoutInvoiceRawDataIncomes.push({
id: i.id, const nextYear = Number(storedView?.year)
date: dayjs(i.created_at).format("DD-MM-YY"), const nextMonth = Number(storedView?.month)
amount: Math.abs(i.amount),
bs_id: i.bs_id selectedYear.value = Number.isFinite(nextYear) ? nextYear : dayjs().year()
}) selectedMonth.value = Number.isFinite(nextMonth) && nextMonth >= 1 && nextMonth <= 12
} else if(i.amount < 0) { ? nextMonth
withoutInvoiceRawDataExpenses.push({ : dayjs().month() + 1
id: i.id, },
date: dayjs(i.created_at).format("DD-MM-YY"), { immediate: true }
amount: Math.abs(i.amount), )
bs_id: i.bs_id
}) watch([amountMode, granularity, selectedYear, selectedMonth], () => {
} tempStore.modifySettings("dashboardIncomeExpenseView", {
amountMode: amountMode.value,
granularity: granularity.value,
year: selectedYear.value,
month: selectedMonth.value
}) })
/*withoutInvoiceRawDataExpenses.forEach(i => { // Backward compatibility for any existing consumers.
expenseData.value[i.date] ? expenseData.value[i.date] = Number((expenseData.value[i.date] + i.amount).toFixed(2)) : expenseData.value[i.date] = i.amount tempStore.modifySettings("dashboardIncomeExpenseMode", amountMode.value)
})
withoutInvoiceRawDataIncomes.forEach(i => {
incomeData.value[i.date] ? incomeData.value[i.date] = Number((incomeData.value[i.date] + i.amount).toFixed(2)) : incomeData.value[i.date] = i.amount
})*/
expenseRawData = expenseRawData.filter(i => i.date).map(i => {
let amount = 0
i.accounts.forEach(a => {
amount += a.amountNet
})
amount = Number(amount.toFixed(2))
return {
id: i.id,
date: dayjs(i.date).format("DD-MM-YY"),
amount
}
})
expenseRawData.forEach(i => {
expenseData.value[i.date] ? expenseData.value[i.date] = Number((expenseData.value[i.date] + i.amount).toFixed(2)) : expenseData.value[i.date] = i.amount
})
let expenseMonths = {
"01": 0,
"02": 0,
"03": 0,
"04": 0,
"05": 0,
"06": 0,
"07": 0,
"08": 0,
"09": 0,
"10": 0,
"11": 0,
"12": 0,
}
Object.keys(expenseMonths).forEach(month => {
let dates = Object.keys(expenseData.value).filter(i => i.split("-")[1] === month && i.split("-")[2] === dayjs().format("YY"))
dates.forEach(date => {
if(expenseMonths[month]){
expenseMonths[month] = Number((expenseMonths[month] + expenseData.value[date]).toFixed(2))
} else {
expenseMonths[month] = expenseData.value[date]
}
})
})
expenseData.value = expenseMonths
incomeRawData = incomeRawData.map(i => {
let amount = 0
i.rows.forEach(r => {
if(r.mode !== "pagebreak" && r.mode !== "title" && r.mode !== "text"){
amount += r.price * r.quantity * (1 - r.discountPercent/100)
}
})
amount = Number(amount.toFixed(2))
return {
id: i.id,
date: dayjs(i.documentDate).format("DD-MM-YY"),
amount
}
})
incomeRawData.forEach(i => {
incomeData.value[i.date] ? incomeData.value[i.date] = Number((incomeData.value[i.date] + i.amount).toFixed(2)) : incomeData.value[i.date] = i.amount
})
let incomeMonths = {
"01": 0,
"02": 0,
"03": 0,
"04": 0,
"05": 0,
"06": 0,
"07": 0,
"08": 0,
"09": 0,
"10": 0,
"11": 0,
"12": 0,
}
Object.keys(incomeMonths).forEach(month => {
let dates = Object.keys(incomeData.value).filter(i => i.split("-")[1] === month && i.split("-")[2] === dayjs().format("YY"))
dates.forEach(date => {
if(incomeMonths[month]){
incomeMonths[month] = Number((incomeMonths[month] + incomeData.value[date]).toFixed(2))
} else {
incomeMonths[month] = incomeData.value[date]
}
})
})
incomeData.value = incomeMonths
}
const days = computed(() => {
let days = []
days = Object.keys(incomeData.value)
let expenseDays = Object.keys(expenseData.value)
expenseDays.forEach(expenseDay => {
if(!days.find(i => i === expenseDay)){
days.push(expenseDay)
}
})
days = days.sort(function(a, b) {
var keyA = dayjs(a, "DD-MM-YY"),
keyB = dayjs(b, "DD-MM-YY");
// Compare the 2 dates
if (keyA.isBefore(keyB,'day')) {
return -1;
} else if(keyB.isBefore(keyA, 'day')) {
return 1
} else {
return 0;
}
});
return days
}) })
/*const chartData = computed(() => { const loadData = async () => {
return { const [docs, incoming] = await Promise.all([
labels: days.value, useEntities("createddocuments").select(),
datasets: [ useEntities("incominginvoices").select()
{ ])
label: 'Einnahmen',
data: [2, 1, 16, 3, 2], incomeDocuments.value = (docs || []).filter((item) => item.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(item.type))
backgroundColor: 'rgba(20, 255, 0, 0.3)', expenseInvoices.value = (incoming || []).filter((item) => item.date)
borderColor: 'red', }
borderWidth: 2,
} const yearsInData = computed(() => {
] const years = new Set([dayjs().year()])
incomeDocuments.value.forEach((item) => {
const parsed = dayjs(item.documentDate)
if (parsed.isValid()) years.add(parsed.year())
})
expenseInvoices.value.forEach((item) => {
const parsed = dayjs(item.date)
if (parsed.isValid()) years.add(parsed.year())
})
return Array.from(years).sort((a, b) => b - a)
})
const yearOptions = computed(() => yearsInData.value.map((year) => ({ label: String(year), value: year })))
watch(yearsInData, (years) => {
if (!years.includes(selectedYear.value) && years.length > 0) {
selectedYear.value = years[0]
} }
})*/ }, { immediate: true })
const computeDocumentAmount = (doc) => {
let amount = 0
;(doc.rows || []).forEach((row) => {
if (["pagebreak", "title", "text"].includes(row.mode)) return
const net = Number(row.price || 0) * Number(row.quantity || 0) * (1 - Number(row.discountPercent || 0) / 100)
const taxPercent = Number(row.taxPercent)
const gross = net * (1 + (Number.isFinite(taxPercent) ? taxPercent : 0) / 100)
amount += amountMode.value === "gross" ? gross : net
})
return Number(amount.toFixed(2))
}
const computeIncomingInvoiceAmount = (invoice) => {
let amount = 0
;(invoice.accounts || []).forEach((account) => {
const net = Number(account.amountNet || 0)
const tax = Number(account.amountTax || 0)
const grossValue = Number(account.amountGross)
const gross = Number.isFinite(grossValue) ? grossValue : (net + tax)
amount += amountMode.value === "gross" ? gross : net
})
return Number(amount.toFixed(2))
}
const buckets = computed(() => {
const income = {}
const expense = {}
if (granularity.value === "year") {
for (let month = 1; month <= 12; month += 1) {
const key = String(month).padStart(2, "0")
income[key] = 0
expense[key] = 0
}
} else {
const daysInMonth = dayjs(`${selectedYear.value}-${String(selectedMonth.value).padStart(2, "0")}-01`).daysInMonth()
for (let day = 1; day <= daysInMonth; day += 1) {
const key = String(day).padStart(2, "0")
income[key] = 0
expense[key] = 0
}
}
incomeDocuments.value.forEach((doc) => {
const docDate = dayjs(doc.documentDate)
if (!docDate.isValid() || docDate.year() !== selectedYear.value) return
if (granularity.value === "month" && docDate.month() + 1 !== selectedMonth.value) return
const key = granularity.value === "year"
? String(docDate.month() + 1).padStart(2, "0")
: String(docDate.date()).padStart(2, "0")
income[key] = Number((income[key] + computeDocumentAmount(doc)).toFixed(2))
})
expenseInvoices.value.forEach((invoice) => {
const invoiceDate = dayjs(invoice.date)
if (!invoiceDate.isValid() || invoiceDate.year() !== selectedYear.value) return
if (granularity.value === "month" && invoiceDate.month() + 1 !== selectedMonth.value) return
const key = granularity.value === "year"
? String(invoiceDate.month() + 1).padStart(2, "0")
: String(invoiceDate.date()).padStart(2, "0")
expense[key] = Number((expense[key] + computeIncomingInvoiceAmount(invoice)).toFixed(2))
})
return { income, expense }
})
const chartLabels = computed(() => {
if (granularity.value === "year") {
return ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
}
return Object.keys(buckets.value.income).map((day) => `${day}.`)
})
import { Line } from 'vue-chartjs'
const chartData = computed(() => { const chartData = computed(() => {
const keys = Object.keys(buckets.value.income).sort()
return { return {
labels: ["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"], labels: chartLabels.value,
datasets: [ datasets: [
{ {
label: 'Ausgaben', label: "Ausgaben",
backgroundColor: '#f87979', backgroundColor: "#f87979",
borderColor: '#f87979', borderColor: "#f87979",
data: Object.keys(expenseData.value).sort().map(i => expenseData.value[i]), data: keys.map((key) => buckets.value.expense[key]),
tension: 0.3, tension: 0.3,
},{ },
label: 'Einnahmen', {
backgroundColor: '#69c350', label: "Einnahmen",
borderColor: '#69c350', backgroundColor: "#69c350",
data: Object.keys(incomeData.value).sort().map(i => incomeData.value[i]), borderColor: "#69c350",
data: keys.map((key) => buckets.value.income[key]),
tension: 0.3 tension: 0.3
}, },
], ],
} }
}) })
const chartOptions = ref({ const chartOptions = ref({
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
}) })
setup() loadData()
</script> </script>
<template> <template>
<Line <div class="h-full flex flex-col gap-2">
:data="chartData" <div class="flex flex-wrap items-center justify-between gap-2">
:options="chartOptions" <div class="flex flex-wrap items-center gap-2">
/> <USelectMenu
v-model="granularity"
:options="granularityOptions"
value-attribute="value"
option-attribute="label"
class="w-28"
/>
<USelectMenu
v-model="selectedYear"
:options="yearOptions"
value-attribute="value"
option-attribute="label"
class="w-24"
/>
<USelectMenu
v-if="granularity === 'month'"
v-model="selectedMonth"
:options="monthOptions"
value-attribute="value"
option-attribute="label"
class="w-36"
/>
</div>
<UButtonGroup size="xs">
<UButton
:variant="amountMode === 'net' ? 'solid' : 'outline'"
@click="amountMode = 'net'"
>
Netto
</UButton>
<UButton
:variant="amountMode === 'gross' ? 'solid' : 'outline'"
@click="amountMode = 'gross'"
>
Brutto
</UButton>
</UButtonGroup>
</div>
<div class="flex-1 min-h-[280px]">
<Line
:data="chartData"
:options="chartOptions"
/>
</div>
</div>
</template> </template>
<style scoped> <style scoped>

View File

@@ -5,8 +5,11 @@ const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const setupPage = async () => { const setupPage = async () => {
//TODO: BACKEND CHANGE Migrate to auth_users for profile openTasks.value = (await useEntities("tasks").select()).filter((task) => {
openTasks.value = (await useEntities("tasks").select()).filter(i => !i.archived && i.user_id === auth.user.id) const assignee = task.userId || task.user_id || task.profile
const currentUser = auth.user?.user_id || auth.user?.id
return !task.archived && assignee === currentUser
})
} }
setupPage() setupPage()
@@ -18,7 +21,7 @@ setupPage()
v-if="openTasks.length > 0" v-if="openTasks.length > 0"
:rows="openTasks" :rows="openTasks"
:columns="[{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}]" :columns="[{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}]"
@select="(i) => router.push(`/standardEntity/tasks/show/${i.id}`)" @select="(i) => router.push(`/tasks/show/${i.id}`)"
/> />
<div v-else> <div v-else>
<p class="text-center font-bold">Keine offenen Aufgaben</p> <p class="text-center font-bold">Keine offenen Aufgaben</p>
@@ -27,4 +30,4 @@ setupPage()
<style scoped> <style scoped>
</style> </style>

View File

@@ -8,7 +8,7 @@ const _useDashboard = () => {
defineShortcuts({ defineShortcuts({
'g-h': () => router.push('/'), 'g-h': () => router.push('/'),
'g-a': () => router.push('/standardEntity/tasks'), 'g-a': () => router.push('/tasks'),
'g-d': () => router.push('/files'), 'g-d': () => router.push('/files'),
'g-k': () => router.push('/standardEntity/customers'), 'g-k': () => router.push('/standardEntity/customers'),
'g-l': () => router.push('/standardEntity/vendors'), 'g-l': () => router.push('/standardEntity/vendors'),
@@ -31,4 +31,4 @@ const _useDashboard = () => {
} }
} }
export const useDashboard = createSharedComposable(_useDashboard) export const useDashboard = createSharedComposable(_useDashboard)

View File

@@ -86,5 +86,11 @@ export const useFunctions = () => {
} }
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useCreatePDF} const useBankingResolveIban = async (iban) => {
const normalized = String(iban || "").replace(/\s+/g, "").toUpperCase()
if (!normalized) return null
return await useNuxtApp().$api(`/api/banking/iban/${encodeURIComponent(normalized)}`)
}
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useCreatePDF}
} }

View File

@@ -31,6 +31,18 @@ export const useRole = () => {
label: "Verträge erstellen", label: "Verträge erstellen",
parent: "contracts" parent: "contracts"
}, },
contracttypes: {
label: "Vertragstypen",
showToAllUsers: false
},
"contracttypes-viewAll": {
label: "Alle Vertragstypen einsehen",
parent: "contracttypes"
},
"contracttypes-create": {
label: "Vertragstypen erstellen",
parent: "contracttypes"
},
plants: { plants: {
label: "Objekte", label: "Objekte",
showToAllUsers: false showToAllUsers: false
@@ -306,4 +318,4 @@ export const useRole = () => {
checkRight checkRight
} }
} }

View File

@@ -99,6 +99,10 @@ const setupData = async () => {
} }
const loaded = ref(false) const loaded = ref(false)
const normalizeEntityId = (value) => {
if (value === null || typeof value === "undefined") return null
return typeof value === "object" ? (value.id ?? null) : value
}
const setupPage = async () => { const setupPage = async () => {
await setupData() await setupData()
@@ -138,14 +142,15 @@ const setupPage = async () => {
if (route.query.loadMode === "deliveryNotes") { if (route.query.loadMode === "deliveryNotes") {
let linkedDocuments = (await useEntities("createddocuments").select()).filter(i => JSON.parse(route.query.linkedDocuments).includes(i.id)) let linkedDocuments = (await useEntities("createddocuments").select()).filter(i => JSON.parse(route.query.linkedDocuments).includes(i.id))
if (linkedDocuments.length === 0) return
//TODO: Implement Checking for Same Customer, Contact and Project //TODO: Implement Checking for Same Customer, Contact and Project
itemInfo.value.customer = linkedDocuments[0].customer ? linkedDocuments[0].customer.id : null itemInfo.value.customer = normalizeEntityId(linkedDocuments[0].customer)
itemInfo.value.project = linkedDocuments[0].project ? linkedDocuments[0].project.id : null itemInfo.value.project = normalizeEntityId(linkedDocuments[0].project)
itemInfo.value.contact = linkedDocuments[0].contact ? linkedDocuments[0].contact.id : null itemInfo.value.contact = normalizeEntityId(linkedDocuments[0].contact)
setCustomerData() await setCustomerData(null, true)
let firstDate = null let firstDate = null
let lastDate = null let lastDate = null
@@ -207,21 +212,23 @@ const setupPage = async () => {
} }
else if (route.query.loadMode === "finalInvoice") { else if (route.query.loadMode === "finalInvoice") {
let linkedDocuments = (await useEntities("createddocuments").select()).filter(i => JSON.parse(route.query.linkedDocuments).includes(i.id)) let linkedDocuments = (await useEntities("createddocuments").select()).filter(i => JSON.parse(route.query.linkedDocuments).includes(i.id))
if (linkedDocuments.length === 0) return
//TODO: Implement Checking for Same Customer, Contact and Project //TODO: Implement Checking for Same Customer, Contact and Project
console.log(linkedDocuments) console.log(linkedDocuments)
itemInfo.value.customer = linkedDocuments[0].customer itemInfo.value.customer = normalizeEntityId(linkedDocuments[0].customer)
itemInfo.value.project = linkedDocuments[0].project itemInfo.value.project = normalizeEntityId(linkedDocuments[0].project)
itemInfo.value.contact = linkedDocuments[0].contact itemInfo.value.contact = normalizeEntityId(linkedDocuments[0].contact)
setCustomerData() await setCustomerData(null, true)
for await (const doc of linkedDocuments.filter(i => i.type === "confirmationOrders")) { for await (const doc of linkedDocuments.filter(i => i.type === "confirmationOrders")) {
let linkedDocument = await useEntities("createddocuments").selectSingle(doc.id) let linkedDocument = await useEntities("createddocuments").selectSingle(doc.id)
itemInfo.value.rows.push({ itemInfo.value.rows.push({
id: uuidv4(),
mode: "title", mode: "title",
text: linkedDocument.title, text: linkedDocument.title,
}) })
@@ -233,6 +240,7 @@ const setupPage = async () => {
let linkedDocument = await useEntities("createddocuments").selectSingle(doc.id) let linkedDocument = await useEntities("createddocuments").selectSingle(doc.id)
itemInfo.value.rows.push({ itemInfo.value.rows.push({
id: uuidv4(),
mode: "title", mode: "title",
text: linkedDocument.title, text: linkedDocument.title,
}) })
@@ -241,6 +249,7 @@ const setupPage = async () => {
} }
itemInfo.value.rows.push({ itemInfo.value.rows.push({
id: uuidv4(),
mode: "title", mode: "title",
text: "Abschlagsrechnungen", text: "Abschlagsrechnungen",
}) })
@@ -2064,6 +2073,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
> >
<UInput <UInput
type="number" type="number"
step="0.01"
v-model="itemInfo.customSurchargePercentage" v-model="itemInfo.customSurchargePercentage"
@change="updateCustomSurcharge" @change="updateCustomSurcharge"
> >

View File

@@ -264,7 +264,18 @@ const clearSearchString = () => {
debouncedSearchString.value = '' debouncedSearchString.value = ''
} }
const selectableFilters = ref(dataType.filters.map(i => i.name)) const openUnpaidInvoicesFilter = {
name: 'Nur offene Rechnungen',
filterFunction: (row) => {
return ['invoices', 'advanceInvoices'].includes(row.type)
&& row.state === 'Gebucht'
&& !useSum().getIsPaid(row, items.value)
&& !items.value.find(i => i.linkedDocument && i.linkedDocument.id === row.id)
}
}
const availableFilters = computed(() => [...dataType.filters, openUnpaidInvoicesFilter])
const selectableFilters = computed(() => availableFilters.value.map(i => i.name))
const selectedFilters = ref(dataType.filters.filter(i => i.default).map(i => i.name) || []) const selectedFilters = ref(dataType.filters.filter(i => i.default).map(i => i.name) || [])
const filteredRows = computed(() => { const filteredRows = computed(() => {
@@ -286,8 +297,10 @@ const filteredRows = computed(() => {
if (selectedFilters.value.length > 0) { if (selectedFilters.value.length > 0) {
selectedFilters.value.forEach(filterName => { selectedFilters.value.forEach(filterName => {
let filter = dataType.filters.find(i => i.name === filterName) const filter = availableFilters.value.find(i => i.name === filterName)
tempItems = tempItems.filter(filter.filterFunction) if (filter?.filterFunction) {
tempItems = tempItems.filter(filter.filterFunction)
}
}) })
} }
@@ -310,4 +323,4 @@ const isPaid = (item) => {
item.statementallocations.forEach(allocation => amountPaid += allocation.amount) item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
return Number(amountPaid.toFixed(2)) === useSum().getCreatedDocumentSum(item, items.value) return Number(amountPaid.toFixed(2)) === useSum().getCreatedDocumentSum(item, items.value)
} }
</script> </script>

View File

@@ -158,13 +158,24 @@
<UDivider label="Vorlagen auswählen" /> <UDivider label="Vorlagen auswählen" />
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<UInput <div class="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
v-model="modalSearch" <UInput
icon="i-heroicons-magnifying-glass" v-model="modalSearch"
placeholder="Kunde oder Vertrag suchen..." icon="i-heroicons-magnifying-glass"
class="w-full sm:w-64" placeholder="Kunde oder Vertrag suchen..."
size="sm" class="w-full sm:w-64"
/> size="sm"
/>
<USelectMenu
v-model="selectedExecutionIntervall"
:options="executionIntervallOptions"
option-attribute="label"
value-attribute="value"
size="sm"
class="w-full sm:w-52"
/>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xs text-gray-500 hidden sm:inline"> <span class="text-xs text-gray-500 hidden sm:inline">
@@ -201,11 +212,14 @@
{{displayCurrency(calculateDocSum(row))}} {{displayCurrency(calculateDocSum(row))}}
</template> </template>
<template #serialConfig.intervall-data="{row}"> <template #serialConfig.intervall-data="{row}">
{{ row.serialConfig?.intervall }} {{ getIntervallLabel(row.serialConfig?.intervall) }}
</template> </template>
<template #contract-data="{row}"> <template #contract-data="{row}">
{{row.contract?.contractNumber}} - {{row.contract?.name}} {{row.contract?.contractNumber}} - {{row.contract?.name}}
</template> </template>
<template #plant-data="{row}">
{{ row.plant?.name || "-" }}
</template>
</UTable> </UTable>
</div> </div>
@@ -287,6 +301,7 @@ const executionDate = ref(dayjs().format('YYYY-MM-DD'))
const selectedExecutionRows = ref([]) const selectedExecutionRows = ref([])
const isExecuting = ref(false) const isExecuting = ref(false)
const modalSearch = ref("") // NEU: Suchstring für das Modal const modalSearch = ref("") // NEU: Suchstring für das Modal
const selectedExecutionIntervall = ref("all")
// --- SerialExecutions State --- // --- SerialExecutions State ---
const showExecutionsSlideover = ref(false) const showExecutionsSlideover = ref(false)
@@ -295,7 +310,7 @@ const executionsLoading = ref(false)
const finishingId = ref(null) const finishingId = ref(null)
const setupPage = async () => { const setupPage = async () => {
items.value = await useEntities("createddocuments").select("*, customer(id,name), contract(id,name, contractNumber)","documentDate",undefined,true) items.value = await useEntities("createddocuments").select("*, customer(id,name), contract(id,name, contractNumber), plant(id,name)","documentDate",undefined,true)
await fetchExecutions() await fetchExecutions()
} }
@@ -390,30 +405,78 @@ const filteredRows = computed(() => {
return useSearch(searchString.value, temp.slice().reverse()) return useSearch(searchString.value, temp.slice().reverse())
}) })
// Basis Liste für das Modal (nur Aktive) // Basis Liste für das Modal (nur aktive und nicht archivierte Vorlagen)
const activeTemplates = computed(() => { const activeTemplates = computed(() => {
return items.value return items.value
.filter(i => i.type === "serialInvoices" && !!i.serialConfig?.active) .filter(i => i.type === "serialInvoices" && !!i.serialConfig?.active && !i.archived)
.map(i => ({...i})) .map(i => ({...i}))
}) })
const intervallLabelMap = {
"wöchentlich": "Wöchentlich",
"2 - wöchentlich": "Alle 2 Wochen",
"monatlich": "Monatlich",
"vierteljährlich": "Quartalsweise",
"halbjährlich": "Halbjährlich",
"jährlich": "Jährlich"
}
const getIntervallLabel = (intervall) => {
if (!intervall) return "-"
return intervallLabelMap[intervall] || intervall
}
const executionIntervallOptions = computed(() => {
const availableIntervals = [...new Set(
activeTemplates.value
.map(row => row.serialConfig?.intervall)
.filter(Boolean)
)]
const sorted = availableIntervals.sort((a, b) =>
getIntervallLabel(a).localeCompare(getIntervallLabel(b), 'de')
)
return [
{label: 'Alle Intervalle', value: 'all'},
...sorted.map(intervall => ({
label: getIntervallLabel(intervall),
value: intervall
}))
]
})
// NEU: Gefilterte Liste für das Modal basierend auf der Suche // NEU: Gefilterte Liste für das Modal basierend auf der Suche
const filteredExecutionList = computed(() => { const filteredExecutionList = computed(() => {
if (!modalSearch.value) return activeTemplates.value let filtered = [...activeTemplates.value]
if (selectedExecutionIntervall.value !== 'all') {
filtered = filtered.filter(
row => row.serialConfig?.intervall === selectedExecutionIntervall.value
)
}
if (!modalSearch.value) return filtered
const term = modalSearch.value.toLowerCase() const term = modalSearch.value.toLowerCase()
return activeTemplates.value.filter(row => { return filtered.filter(row => {
const customerName = row.customer?.name?.toLowerCase() || "" const customerName = row.customer?.name?.toLowerCase() || ""
const contractNum = row.contract?.contractNumber?.toLowerCase() || "" const contractNum = row.contract?.contractNumber?.toLowerCase() || ""
const contractName = row.contract?.name?.toLowerCase() || "" const contractName = row.contract?.name?.toLowerCase() || ""
const plantName = row.plant?.name?.toLowerCase() || ""
return customerName.includes(term) || return customerName.includes(term) ||
contractNum.includes(term) || contractNum.includes(term) ||
contractName.includes(term) contractName.includes(term) ||
plantName.includes(term)
}) })
}) })
watch(selectedExecutionIntervall, () => {
selectedExecutionRows.value = [...filteredExecutionList.value]
})
// NEU: Alle auswählen (nur die aktuell sichtbaren/gefilterten) // NEU: Alle auswählen (nur die aktuell sichtbaren/gefilterten)
const selectAllTemplates = () => { const selectAllTemplates = () => {
// WICHTIG: Überschreibt nicht bestehende Auswahl, sondern fügt hinzu oder ersetzt. // WICHTIG: Überschreibt nicht bestehende Auswahl, sondern fügt hinzu oder ersetzt.
@@ -469,6 +532,7 @@ const templateColumns = [
const executionColumns = [ const executionColumns = [
{key: 'partner', label: "Kunde"}, {key: 'partner', label: "Kunde"},
{key: 'plant', label: "Objekt"},
{key: 'contract', label: "Vertrag"}, {key: 'contract', label: "Vertrag"},
{key: 'serialConfig.intervall', label: "Intervall"}, {key: 'serialConfig.intervall', label: "Intervall"},
{key: "amount", label: "Betrag"}, {key: "amount", label: "Betrag"},
@@ -509,8 +573,9 @@ const calculateDocSum = (row) => {
const openExecutionModal = () => { const openExecutionModal = () => {
executionDate.value = dayjs().format('YYYY-MM-DD') executionDate.value = dayjs().format('YYYY-MM-DD')
selectedExecutionRows.value = []
modalSearch.value = "" // Reset Search modalSearch.value = "" // Reset Search
selectedExecutionIntervall.value = "all"
selectedExecutionRows.value = []
showExecutionModal.value = true showExecutionModal.value = true
} }
@@ -557,4 +622,4 @@ const executeSerialInvoices = async () => {
} }
setupPage() setupPage()
</script> </script>

View File

@@ -350,6 +350,14 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<span class="font-mono text-xs opacity-75 mr-2">{{ option.vendorNumber }}</span> {{ option.name }} <span class="font-mono text-xs opacity-75 mr-2">{{ option.vendorNumber }}</span> {{ option.name }}
</template> </template>
</USelectMenu> </USelectMenu>
<UButton
v-if="mode !== 'show'"
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
:disabled="!itemInfo.vendor"
@click="itemInfo.vendor = null"
/>
<EntityModalButtons <EntityModalButtons
v-if="mode !== 'show'" v-if="mode !== 'show'"
type="vendors" type="vendors"

View File

@@ -18,7 +18,10 @@ defineShortcuts({
'Enter': { 'Enter': {
usingInput: true, usingInput: true,
handler: () => { handler: () => {
router.push(`/incomingInvoices/show/${filteredRows.value[selectedItem.value].id}`) const invoice = filteredRows.value[selectedItem.value]
if (invoice) {
selectIncomingInvoice(invoice)
}
} }
}, },
'arrowdown': () => { 'arrowdown': () => {
@@ -146,13 +149,11 @@ const isPaid = (item) => {
} }
const selectIncomingInvoice = (invoice) => { const selectIncomingInvoice = (invoice) => {
if(invoice.state === "Vorbereitet" ) { if (invoice.state === "Gebucht") {
router.push(`/incomingInvoices/edit/${invoice.id}`)
} else {
router.push(`/incomingInvoices/show/${invoice.id}`) router.push(`/incomingInvoices/show/${invoice.id}`)
} else {
router.push(`/incomingInvoices/edit/${invoice.id}`)
} }
} }
@@ -291,4 +292,4 @@ const selectIncomingInvoice = (invoice) => {
<style scoped> <style scoped>
</style> </style>

View File

@@ -14,7 +14,7 @@
<UDashboardPanelContent> <UDashboardPanelContent>
<div class="mb-5"> <div class="mb-5">
<UDashboardCard <UDashboardCard
title="Einnahmen und Ausgaben(netto)" title="Einnahmen und Ausgaben"
class="mt-3" class="mt-3"
> >
<display-income-and-expenditure/> <display-income-and-expenditure/>
@@ -89,4 +89,4 @@ const { isNotificationsSlideoverOpen } = useDashboard()
<style scoped> <style scoped>
</style> </style>

View File

@@ -14,6 +14,9 @@ const resources = {
spaces: { spaces: {
label: "Lagerplätze" label: "Lagerplätze"
}, },
customerspaces: {
label: "Kundenlagerplätze"
},
invoices: { invoices: {
label: "Rechnungen" label: "Rechnungen"
}, },
@@ -23,6 +26,9 @@ const resources = {
inventoryitems: { inventoryitems: {
label: "Inventarartikel" label: "Inventarartikel"
}, },
customerinventoryitems: {
label: "Kundeninventarartikel"
},
projects: { projects: {
label: "Projekte" label: "Projekte"
}, },
@@ -37,7 +43,17 @@ const resources = {
} }
} }
const numberRanges = ref(auth.activeTenantData.numberRanges) const numberRanges = ref(auth.activeTenantData.numberRanges || {})
Object.keys(resources).forEach((key) => {
if (!numberRanges.value[key]) {
numberRanges.value[key] = {
prefix: "",
suffix: "",
nextNumber: 1000
}
}
})
const updateNumberRanges = async (range) => { const updateNumberRanges = async (range) => {

View File

@@ -1,6 +1,108 @@
<script setup> <script setup>
const auth = useAuthStore() const auth = useAuthStore()
const defaultFeatures = {
objects: true,
calendar: true,
contacts: true,
projects: true,
vehicles: true,
contracts: true,
inventory: true,
accounting: true,
timeTracking: true,
planningBoard: true,
workingTimeTracking: true,
dashboard: true,
historyitems: true,
tasks: true,
wiki: true,
files: true,
createdletters: true,
documentboxes: true,
helpdesk: true,
email: true,
members: true,
customers: true,
vendors: true,
contactsList: true,
staffTime: true,
createDocument: true,
serialInvoice: true,
incomingInvoices: true,
costcentres: true,
accounts: true,
ownaccounts: true,
banking: true,
spaces: true,
customerspaces: true,
customerinventoryitems: true,
inventoryitems: true,
inventoryitemgroups: true,
products: true,
productcategories: true,
services: true,
servicecategories: true,
memberrelations: true,
staffProfiles: true,
hourrates: true,
projecttypes: true,
contracttypes: true,
plants: true,
settingsNumberRanges: true,
settingsEmailAccounts: true,
settingsBanking: true,
settingsTexttemplates: true,
settingsTenant: true,
export: true,
}
const featureOptions = [
{ key: "dashboard", label: "Dashboard" },
{ key: "historyitems", label: "Logbuch" },
{ key: "tasks", label: "Aufgaben" },
{ key: "wiki", label: "Wiki" },
{ key: "files", label: "Dateien" },
{ key: "createdletters", label: "Anschreiben" },
{ key: "documentboxes", label: "Boxen" },
{ key: "helpdesk", label: "Helpdesk" },
{ key: "email", label: "E-Mail" },
{ key: "members", label: "Mitglieder" },
{ key: "customers", label: "Kunden" },
{ key: "vendors", label: "Lieferanten" },
{ key: "contactsList", label: "Ansprechpartner" },
{ key: "staffTime", label: "Mitarbeiter: Zeiten" },
{ key: "createDocument", label: "Buchhaltung: Ausgangsbelege" },
{ key: "serialInvoice", label: "Buchhaltung: Serienvorlagen" },
{ key: "incomingInvoices", label: "Buchhaltung: Eingangsbelege" },
{ key: "costcentres", label: "Buchhaltung: Kostenstellen" },
{ key: "accounts", label: "Buchhaltung: Buchungskonten" },
{ key: "ownaccounts", label: "Buchhaltung: Zusätzliche Buchungskonten" },
{ key: "banking", label: "Buchhaltung: Bank" },
{ key: "spaces", label: "Lagerplätze" },
{ key: "customerspaces", label: "Kundenlagerplätze" },
{ key: "customerinventoryitems", label: "Kundeninventar" },
{ key: "inventoryitems", label: "Inventar" },
{ key: "inventoryitemgroups", label: "Inventargruppen" },
{ key: "products", label: "Stammdaten: Artikel" },
{ key: "productcategories", label: "Stammdaten: Artikelkategorien" },
{ key: "services", label: "Stammdaten: Leistungen" },
{ key: "servicecategories", label: "Stammdaten: Leistungskategorien" },
{ key: "memberrelations", label: "Stammdaten: Mitgliedsverhältnisse" },
{ key: "staffProfiles", label: "Stammdaten: Mitarbeiter" },
{ key: "hourrates", label: "Stammdaten: Stundensätze" },
{ key: "projecttypes", label: "Stammdaten: Projekttypen" },
{ key: "contracttypes", label: "Stammdaten: Vertragstypen" },
{ key: "vehicles", label: "Stammdaten: Fahrzeuge" },
{ key: "projects", label: "Projekte" },
{ key: "contracts", label: "Verträge" },
{ key: "plants", label: "Objekte" },
{ key: "settingsNumberRanges", label: "Einstellungen: Nummernkreise" },
{ key: "settingsEmailAccounts", label: "Einstellungen: E-Mail Konten" },
{ key: "settingsBanking", label: "Einstellungen: Bankkonten" },
{ key: "settingsTexttemplates", label: "Einstellungen: Textvorlagen" },
{ key: "settingsTenant", label: "Einstellungen: Firmeneinstellungen" },
{ key: "export", label: "Einstellungen: Export" },
]
const itemInfo = ref({ const itemInfo = ref({
features: {}, features: {},
@@ -13,8 +115,13 @@ const setupPage = async () => {
console.log(itemInfo.value) console.log(itemInfo.value)
} }
const features = ref(auth.activeTenantData.features) const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
const businessInfo = ref(auth.activeTenantData.businessInfo) const businessInfo = ref(auth.activeTenantData.businessInfo)
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
const accountChartOptions = [
{ label: "SKR 03", value: "skr03" },
{ label: "Verein", value: "verein" }
]
const updateTenant = async (newData) => { const updateTenant = async (newData) => {
@@ -24,6 +131,15 @@ const updateTenant = async (newData) => {
data: newData, data: newData,
} }
}) })
if (res) {
itemInfo.value = res
auth.activeTenantData = res
features.value = { ...defaultFeatures, ...(res?.features || {}) }
}
}
const saveFeatures = async () => {
await updateTenant({features: features.value})
} }
setupPage() setupPage()
@@ -40,6 +156,8 @@ setupPage()
label: 'Dokubox' label: 'Dokubox'
},{ },{
label: 'Rechnung & Kontakt' label: 'Rechnung & Kontakt'
},{
label: 'Funktionen'
} }
]" ]"
> >
@@ -63,8 +181,8 @@ setupPage()
</div> </div>
<div v-if="item.label === 'Rechnung & Kontakt'"> <div v-if="item.label === 'Rechnung & Kontakt'">
<UCard class="mt-5"> <UCard class="mt-5">
<UForm class="w-1/2"> <UForm class="w-1/2">
<UFormGroup <UFormGroup
label="Firmenname:" label="Firmenname:"
> >
@@ -90,6 +208,23 @@ setupPage()
> >
Speichern Speichern
</UButton> </UButton>
<UFormGroup
label="Kontenrahmen:"
class="mt-6"
>
<USelectMenu
v-model="accountChart"
:options="accountChartOptions"
option-attribute="label"
value-attribute="value"
/>
</UFormGroup>
<UButton
class="mt-3"
@click="updateTenant({accountChart: accountChart})"
>
Kontenrahmen speichern
</UButton>
</UForm> </UForm>
</UCard> </UCard>
@@ -104,59 +239,11 @@ setupPage()
class="mb-5" class="mb-5"
/> />
<UCheckbox <UCheckbox
label="Kalendar" v-for="option in featureOptions"
v-model="features.calendar" :key="option.key"
@change="updateTenant({features: features})" :label="option.label"
/> v-model="features[option.key]"
<UCheckbox @change="saveFeatures"
label="Kontakte"
v-model="features.contacts"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Plantafel"
v-model="features.planningBoard"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Zeiterfassung"
v-model="features.timeTracking"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Anwesenheiten"
v-model="features.workingTimeTracking"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Lager"
v-model="features.inventory"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Fahrzeuge"
v-model="features.vehicles"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Buchhaltung"
v-model="features.accounting"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Projekte"
v-model="features.projects"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Verträge"
v-model="features.contracts"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Objekte"
v-model="features.objects"
@change="updateTenant({features: features})"
/> />
</UCard> </UCard>
</div> </div>

View File

@@ -22,6 +22,13 @@ const setupPage = async (sort_column = null, sort_direction = null) => {
loaded.value = false loaded.value = false
setPageLayout(platform) setPageLayout(platform)
if (type === "tasks") {
const query = { ...route.query, mode: route.params.mode }
if (route.params.id) query.id = route.params.id
await navigateTo({ path: "/tasks", query })
return
}
if (route.params.mode) mode.value = route.params.mode if (route.params.mode) mode.value = route.params.mode
@@ -88,4 +95,4 @@ setupPage()
<style scoped> <style scoped>
</style> </style>

View File

@@ -49,6 +49,12 @@ const tempStore = useTempStore()
const type = route.params.type const type = route.params.type
const dataType = dataStore.dataTypes[type] const dataType = dataStore.dataTypes[type]
const canCreate = computed(() => {
if (type === "members") {
return has("members-create") || has("customers-create")
}
return has(`${type}-create`)
})
const selectedColumns = ref(tempStore.columns[type] ? tempStore.columns[type] : dataType.templateColumns.filter(i => !i.disabledInTable)) const selectedColumns = ref(tempStore.columns[type] ? tempStore.columns[type] : dataType.templateColumns.filter(i => !i.disabledInTable))
const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key))) const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key)))
@@ -71,6 +77,7 @@ const sort = ref({
const columnsToFilter = ref({}) const columnsToFilter = ref({})
const showMobileFilter = ref(false) const showMobileFilter = ref(false)
let lastSearchRequestId = 0
//Functions //Functions
@@ -80,6 +87,7 @@ function resetMobileFilters() {
Object.keys(itemsMeta.value.distinctValues).forEach(key => { Object.keys(itemsMeta.value.distinctValues).forEach(key => {
columnsToFilter.value[key] = [...itemsMeta.value.distinctValues[key]] columnsToFilter.value[key] = [...itemsMeta.value.distinctValues[key]]
tempStore.clearFilter(type, key)
}) })
showMobileFilter.value = false showMobileFilter.value = false
@@ -88,7 +96,16 @@ function resetMobileFilters() {
function applyMobileFilters() { function applyMobileFilters() {
Object.keys(columnsToFilter.value).forEach(key => { Object.keys(columnsToFilter.value).forEach(key => {
tempStore.modifyFilter(type, key, columnsToFilter.value[key]) const selected = columnsToFilter.value[key]
const available = itemsMeta.value?.distinctValues?.[key] || []
if (!Array.isArray(selected) || selected.length === 0 || selected.length === available.length) {
tempStore.clearFilter(type, key)
columnsToFilter.value[key] = [...available]
return
}
tempStore.modifyFilter(type, key, selected)
}) })
showMobileFilter.value = false showMobileFilter.value = false
@@ -102,7 +119,7 @@ const clearSearchString = () => {
} }
const performSearch = async () => { const performSearch = async () => {
tempStore.modifySearchString(type,searchString) tempStore.modifySearchString(type, searchString.value)
changePage(1,true) changePage(1,true)
setupPage() setupPage()
} }
@@ -137,20 +154,81 @@ const isFiltered = computed(() => {
}) })
}) })
const initializeDistinctFilters = () => {
const distinctValues = itemsMeta.value?.distinctValues || {}
const storedDomainFilters = tempStore.filters[type] || {}
const normalizedStoredFilters = {}
// Nur gültige, noch vorhandene Filterwerte aus dem TempStore übernehmen.
Object.entries(distinctValues).forEach(([column, availableValues]) => {
const available = Array.isArray(availableValues) ? availableValues : []
const storedSelected = storedDomainFilters[column]
if (!Array.isArray(storedSelected)) {
columnsToFilter.value[column] = [...available]
return
}
const selected = storedSelected.filter((value) => available.includes(value))
const isFullSelection = selected.length === 0 || selected.length === available.length
if (isFullSelection) {
columnsToFilter.value[column] = [...available]
return
}
columnsToFilter.value[column] = selected
normalizedStoredFilters[column] = selected
})
// Persistiert bereinigte Filter nur einmalig bei der Initialisierung.
tempStore.setFilters(type, normalizedStoredFilters)
}
const syncDistinctFiltersFromStore = () => {
const distinctValues = itemsMeta.value?.distinctValues || {}
const storedDomainFilters = tempStore.filters[type] || {}
Object.entries(distinctValues).forEach(([column, availableValues]) => {
const available = Array.isArray(availableValues) ? availableValues : []
const storedSelected = storedDomainFilters[column]
if (!Array.isArray(storedSelected) || storedSelected.length === 0) {
columnsToFilter.value[column] = [...available]
return
}
const selected = storedSelected.filter((value) => available.includes(value))
if (selected.length === 0 || selected.length === available.length) {
columnsToFilter.value[column] = [...available]
tempStore.clearFilter(type, column)
return
}
columnsToFilter.value[column] = selected
})
}
//SETUP //SETUP
const setupPage = async () => { const setupPage = async () => {
const currentRequestId = ++lastSearchRequestId
loading.value = true loading.value = true
setPageLayout(platformIsNative ? "mobile" : "default") setPageLayout(platformIsNative ? "mobile" : "default")
if (type === "tasks") {
await navigateTo("/tasks")
return
}
const filters = { const filters = {
archived:false archived:false
} }
Object.keys(columnsToFilter.value).forEach((column) => { Object.entries(tempStore.filters[type] || {}).forEach(([column, selected]) => {
if(columnsToFilter.value[column].length !== itemsMeta.value.distinctValues[column].length) { if (Array.isArray(selected) && selected.length > 0) {
filters[column] = columnsToFilter.value[column] filters[column] = selected
} }
}) })
@@ -166,16 +244,16 @@ const setupPage = async () => {
distinctColumns: dataType.templateColumns.filter(i => i.distinct).map(i => i.key), distinctColumns: dataType.templateColumns.filter(i => i.distinct).map(i => i.key),
}) })
// Verhindert Race Conditions beim schnellen Tippen:
// Nur das Ergebnis des letzten Requests darf den State setzen.
if (currentRequestId !== lastSearchRequestId) return
items.value = data items.value = data
itemsMeta.value = meta itemsMeta.value = meta
if(!initialSetupDone.value){ if(!initialSetupDone.value){
Object.keys(tempStore.filters[type] || {}).forEach((column) => { initializeDistinctFilters()
columnsToFilter.value[column] = tempStore.filters[type][column] } else {
}) syncDistinctFiltersFromStore()
Object.keys(itemsMeta.value.distinctValues).filter(i => !Object.keys(tempStore.filters[type] || {}).includes(i)).forEach(distinctValue => {
columnsToFilter.value[distinctValue] = itemsMeta.value.distinctValues[distinctValue]
})
} }
@@ -188,9 +266,18 @@ setupPage()
const handleFilterChange = async (action,column) => { const handleFilterChange = async (action,column) => {
if(action === 'reset') { if(action === 'reset') {
columnsToFilter.value[column] = itemsMeta.value.distinctValues[column] columnsToFilter.value[column] = [...(itemsMeta.value.distinctValues?.[column] || [])]
tempStore.clearFilter(type, column)
} else if(action === 'change') { } else if(action === 'change') {
tempStore.modifyFilter(type,column,columnsToFilter.value[column]) const selected = columnsToFilter.value[column]
const available = itemsMeta.value.distinctValues?.[column] || []
if (!Array.isArray(selected) || selected.length === 0 || selected.length === available.length) {
tempStore.clearFilter(type, column)
columnsToFilter.value[column] = [...available]
} else {
tempStore.modifyFilter(type,column,selected)
}
} }
setupPage() setupPage()
} }
@@ -235,7 +322,7 @@ const handleFilterChange = async (action,column) => {
<UTooltip :text="`${dataType.labelSingle} erstellen`"> <UTooltip :text="`${dataType.labelSingle} erstellen`">
<UButton <UButton
v-if="platform !== 'mobile' && has(`${type}-create`)/*&& useRole().checkRight(`${type}-create`)*/" v-if="platform !== 'mobile' && canCreate/*&& useRole().checkRight(`${type}-create`)*/"
@click="router.push(`/standardEntity/${type}/create`)" @click="router.push(`/standardEntity/${type}/create`)"
class="ml-3" class="ml-3"
>+ {{dataType.labelSingle}}</UButton> >+ {{dataType.labelSingle}}</UButton>
@@ -624,4 +711,4 @@ const handleFilterChange = async (action,column) => {
<style scoped> <style scoped>
</style> </style>

View File

@@ -0,0 +1,22 @@
<script setup>
const route = useRoute()
const mode = typeof route.params.mode === "string" ? route.params.mode : ""
const id = typeof route.params.id === "string" ? route.params.id : ""
const query = { ...route.query }
if (["create", "show", "edit"].includes(mode)) {
query.mode = mode
}
if (id) {
query.id = id
}
await navigateTo({ path: "/tasks", query }, { replace: true })
</script>
<template>
<UProgress animation="carousel" class="p-5 mt-10" />
</template>

View File

@@ -0,0 +1,736 @@
<script setup>
import { setPageLayout } from "#app"
const router = useRouter()
const route = useRoute()
const toast = useToast()
const auth = useAuthStore()
const profileStore = useProfileStore()
const { has } = usePermission()
const STATUS_COLUMNS = ["Offen", "In Bearbeitung", "Abgeschlossen"]
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const quickCompleteLoadingId = ref(null)
const tasks = ref([])
const search = ref("")
const viewMode = ref("kanban")
const draggingTaskId = ref(null)
const droppingOn = ref("")
const projects = ref([])
const customers = ref([])
const plants = ref([])
const canViewAll = computed(() => has("tasks-viewAll"))
const canCreate = computed(() => has("tasks-create"))
const currentUserId = computed(() => auth.user?.user_id || auth.user?.id || null)
const showOnlyMine = ref(!canViewAll.value)
const isModalOpen = ref(false)
const modalMode = ref("show")
const taskForm = ref(getEmptyTask())
const assigneeOptions = computed(() =>
(profileStore.profiles || [])
.map((profile) => {
const value = profile.user_id || profile.id
const label = profile.full_name || profile.fullName || profile.email
return value && label ? { value, label } : null
})
.filter(Boolean)
)
const projectOptions = computed(() =>
(projects.value || []).map((project) => ({ value: project.id, label: project.name }))
)
const customerOptions = computed(() =>
(customers.value || []).map((customer) => ({ value: customer.id, label: customer.name }))
)
const plantOptions = computed(() =>
(plants.value || []).map((plant) => ({ value: plant.id, label: plant.name }))
)
const filteredTasks = computed(() => {
const needle = search.value.trim().toLowerCase()
return tasks.value.filter((task) => {
const assigneeId = getTaskAssigneeId(task)
const mineMatch = !showOnlyMine.value || (currentUserId.value && assigneeId === currentUserId.value)
const searchMatch = !needle || [task.name, task.description, task.categorie].some((value) => String(value || "").toLowerCase().includes(needle))
return !task.archived && mineMatch && searchMatch
})
})
const groupedTasks = computed(() => {
return STATUS_COLUMNS.reduce((acc, status) => {
acc[status] = filteredTasks.value.filter((task) => normalizeStatus(task.categorie) === status)
return acc
}, {})
})
const modalTitle = computed(() => {
if (modalMode.value === "create") return "Neue Aufgabe"
if (modalMode.value === "edit") return "Aufgabe bearbeiten"
return "Aufgabe"
})
const isFormReadonly = computed(() => modalMode.value === "show")
const listColumns = [
{ key: "actions", label: "" },
{ key: "name", label: "Titel" },
{ key: "categorie", label: "Status" },
{ key: "assignee", label: "Zuweisung" },
{ key: "project", label: "Projekt" },
{ key: "customer", label: "Kunde" },
{ key: "plant", label: "Objekt" }
]
function getEmptyTask() {
return {
id: null,
name: "",
description: "",
categorie: "Offen",
userId: currentUserId.value || null,
project: null,
customer: null,
plant: null
}
}
function normalizeStatus(status) {
if (!status) return "Offen"
if (status === "Erledigt") return "Abgeschlossen"
return STATUS_COLUMNS.includes(status) ? status : "Offen"
}
function getTaskAssigneeId(task) {
return task.userId || task.user_id || task.profile || null
}
function toNullableNumber(value) {
if (value === null || value === undefined || value === "") return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function mapTaskToForm(task) {
return {
id: task.id,
name: task.name || "",
description: task.description || "",
categorie: normalizeStatus(task.categorie),
userId: getTaskAssigneeId(task),
project: toNullableNumber(task.project?.id ?? task.project),
customer: toNullableNumber(task.customer?.id ?? task.customer),
plant: toNullableNumber(task.plant?.id ?? task.plant)
}
}
function getEntityLabel(options, id) {
if (!id) return null
const hit = options.find((item) => Number(item.value) === Number(id))
return hit?.label || null
}
function getAssigneeLabel(task) {
const assigneeId = getTaskAssigneeId(task)
return assigneeOptions.value.find((option) => option.value === assigneeId)?.label || "-"
}
async function loadTasks() {
loading.value = true
try {
const rows = await useEntities("tasks").select()
tasks.value = rows.map((task) => ({ ...task, categorie: normalizeStatus(task.categorie) }))
} finally {
loading.value = false
}
}
async function loadTaskOptions() {
const [projectRows, customerRows, plantRows] = await Promise.all([
useEntities("projects").select(),
useEntities("customers").select(),
useEntities("plants").select()
])
projects.value = projectRows || []
customers.value = customerRows || []
plants.value = plantRows || []
}
function openCreateTask(initialData = {}) {
if (!canCreate.value) return
modalMode.value = "create"
taskForm.value = {
...getEmptyTask(),
...initialData,
categorie: normalizeStatus(initialData.categorie || "Offen"),
userId: initialData.userId || initialData.user_id || currentUserId.value || null
}
isModalOpen.value = true
}
function openTask(task, mode = "show") {
modalMode.value = mode
taskForm.value = mapTaskToForm(task)
isModalOpen.value = true
}
function closeModal() {
isModalOpen.value = false
modalMode.value = "show"
taskForm.value = getEmptyTask()
const query = { ...route.query }
delete query.mode
delete query.id
router.replace({ path: "/tasks", query })
}
async function saveTask() {
if (!canCreate.value) return
const name = taskForm.value.name?.trim()
if (!name) {
toast.add({ title: "Name fehlt", description: "Bitte einen Aufgabennamen angeben.", color: "orange" })
return
}
const payload = {
name,
description: taskForm.value.description || null,
categorie: normalizeStatus(taskForm.value.categorie),
userId: taskForm.value.userId || null,
project: toNullableNumber(taskForm.value.project),
customer: toNullableNumber(taskForm.value.customer),
plant: toNullableNumber(taskForm.value.plant)
}
saving.value = true
try {
let targetId = taskForm.value.id
if (taskForm.value.id) {
await useEntities("tasks").update(taskForm.value.id, payload, true)
} else {
const created = await useEntities("tasks").create(payload, true)
targetId = created?.id || null
}
await loadTasks()
if (targetId) {
const query = { ...route.query, mode: "show", id: String(targetId) }
router.replace({ path: "/tasks", query })
const target = tasks.value.find((task) => String(task.id) === String(targetId))
if (target) openTask(target, "show")
return
}
closeModal()
} finally {
saving.value = false
}
}
async function archiveTask() {
if (!canCreate.value || !taskForm.value.id) return
deleting.value = true
try {
await useEntities("tasks").update(taskForm.value.id, { archived: true }, true)
await loadTasks()
closeModal()
} finally {
deleting.value = false
}
}
async function completeTaskQuick(task) {
if (!canCreate.value) return
if (!task?.id || normalizeStatus(task.categorie) === "Abgeschlossen") return
const previousStatus = task.categorie
quickCompleteLoadingId.value = task.id
task.categorie = "Abgeschlossen"
try {
await useEntities("tasks").update(task.id, { categorie: "Abgeschlossen" }, true)
toast.add({ title: "Aufgabe abgeschlossen", color: "green" })
} catch (error) {
task.categorie = previousStatus
toast.add({ title: "Aufgabe konnte nicht abgeschlossen werden", color: "red" })
} finally {
quickCompleteLoadingId.value = null
}
}
function onDragStart(task) {
draggingTaskId.value = task.id
}
async function onDrop(status) {
if (!canCreate.value || !draggingTaskId.value) return
const droppedTask = tasks.value.find((task) => String(task.id) === String(draggingTaskId.value))
draggingTaskId.value = null
droppingOn.value = ""
if (!droppedTask || normalizeStatus(droppedTask.categorie) === status) return
const oldStatus = droppedTask.categorie
droppedTask.categorie = status
try {
await useEntities("tasks").update(droppedTask.id, { categorie: status }, true)
} catch (error) {
droppedTask.categorie = oldStatus
toast.add({ title: "Status konnte nicht geändert werden", color: "red" })
}
}
async function handleRouteIntent() {
const mode = typeof route.query.mode === "string" ? route.query.mode : null
const id = typeof route.query.id === "string" ? route.query.id : null
if (!mode) {
if (isModalOpen.value) closeModal()
return
}
if (mode === "create") {
openCreateTask({
project: toNullableNumber(route.query.project),
customer: toNullableNumber(route.query.customer),
plant: toNullableNumber(route.query.plant),
userId: route.query.userId || route.query.user_id || currentUserId.value || null
})
return
}
if (!id) return
let task = tasks.value.find((item) => String(item.id) === id)
if (!task) {
const loadedTask = await useEntities("tasks").selectSingle(id, "*", true)
if (loadedTask) {
task = { ...loadedTask, categorie: normalizeStatus(loadedTask.categorie) }
const idx = tasks.value.findIndex((item) => String(item.id) === id)
if (idx === -1) tasks.value.push(task)
}
}
if (!task) return
openTask(task, mode === "edit" ? "edit" : "show")
}
function openCreateViaRoute() {
router.push({ path: "/tasks", query: { ...route.query, mode: "create" } })
}
function openTaskViaRoute(task) {
router.push({ path: "/tasks", query: { ...route.query, mode: "show", id: String(task.id) } })
}
watch(
() => route.query,
async () => {
await handleRouteIntent()
},
{ deep: true }
)
onMounted(async () => {
setPageLayout("default")
await Promise.all([loadTasks(), loadTaskOptions()])
await handleRouteIntent()
})
</script>
<template>
<UDashboardNavbar title="Aufgaben" :badge="filteredTasks.length">
<template #right>
<div class="flex items-center gap-2">
<UInput
v-model="search"
icon="i-heroicons-magnifying-glass"
placeholder="Aufgaben durchsuchen..."
class="w-72"
/>
<UButton
icon="i-heroicons-arrow-path"
variant="soft"
:loading="loading"
@click="loadTasks"
/>
<UButton
v-if="canCreate"
icon="i-heroicons-plus"
@click="openCreateViaRoute"
>
Aufgabe
</UButton>
</div>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left>
<UCheckbox
v-if="canViewAll"
v-model="showOnlyMine"
label="Nur meine Aufgaben"
/>
<span v-else class="text-sm">Ansicht: Nur eigene Aufgaben</span>
</template>
<template #right>
<div class="view-toggle">
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-view-columns"
:variant="viewMode === 'kanban' ? 'solid' : 'ghost'"
@click="viewMode = 'kanban'"
>
Kanban
</UButton>
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-list-bullet"
:variant="viewMode === 'list' ? 'solid' : 'ghost'"
@click="viewMode = 'list'"
>
Liste
</UButton>
</div>
</template>
</UDashboardToolbar>
<UDashboardPanelContent>
<div v-if="viewMode === 'kanban'" class="kanban-grid">
<section
v-for="status in STATUS_COLUMNS"
:key="status"
class="kanban-column"
@dragover.prevent="droppingOn = status"
@dragleave="droppingOn = ''"
@drop.prevent="onDrop(status)"
>
<header class="kanban-column-header">
<h3>{{ status }}</h3>
<UBadge variant="subtle">{{ groupedTasks[status]?.length || 0 }}</UBadge>
</header>
<div :class="['kanban-dropzone', droppingOn === status ? 'kanban-dropzone-active' : '']">
<article
v-for="task in groupedTasks[status]"
:key="task.id"
class="kanban-card"
draggable="true"
@dragstart="onDragStart(task)"
@click="openTaskViaRoute(task)"
>
<div class="kanban-card-title">{{ task.name }}</div>
<p v-if="task.description" class="kanban-card-description">{{ task.description }}</p>
<div class="kanban-card-meta">
<UBadge v-if="getEntityLabel(projectOptions, task.project?.id || task.project)" variant="soft">
{{ getEntityLabel(projectOptions, task.project?.id || task.project) }}
</UBadge>
<UBadge v-if="getEntityLabel(customerOptions, task.customer?.id || task.customer)" variant="soft">
{{ getEntityLabel(customerOptions, task.customer?.id || task.customer) }}
</UBadge>
<UBadge v-if="getEntityLabel(plantOptions, task.plant?.id || task.plant)" variant="soft">
{{ getEntityLabel(plantOptions, task.plant?.id || task.plant) }}
</UBadge>
</div>
</article>
<div v-if="!groupedTasks[status]?.length" class="kanban-empty">
Keine Aufgaben
</div>
</div>
</section>
</div>
<UTable
v-else-if="filteredTasks.length"
:rows="filteredTasks"
:columns="listColumns"
@select="(task) => openTaskViaRoute(task)"
>
<template #actions-data="{ row }">
<UButton
v-if="normalizeStatus(row.categorie) !== 'Abgeschlossen' && canCreate"
size="xs"
variant="soft"
icon="i-heroicons-check"
:loading="quickCompleteLoadingId === row.id"
@click.stop="completeTaskQuick(row)"
>
Erledigt
</UButton>
</template>
<template #categorie-data="{ row }">
<UBadge variant="soft">{{ normalizeStatus(row.categorie) }}</UBadge>
</template>
<template #assignee-data="{ row }">
{{ getAssigneeLabel(row) }}
</template>
<template #project-data="{ row }">
{{ getEntityLabel(projectOptions, row.project?.id || row.project) || "-" }}
</template>
<template #customer-data="{ row }">
{{ getEntityLabel(customerOptions, row.customer?.id || row.customer) || "-" }}
</template>
<template #plant-data="{ row }">
{{ getEntityLabel(plantOptions, row.plant?.id || row.plant) || "-" }}
</template>
</UTable>
<UAlert
v-else
icon="i-heroicons-circle-stack-20-solid"
title="Keine Aufgaben anzuzeigen"
variant="subtle"
/>
</UDashboardPanelContent>
<UModal v-model="isModalOpen" :prevent-close="saving || deleting">
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">{{ modalTitle }}</h3>
<UBadge variant="subtle">{{ taskForm.id ? `#${taskForm.id}` : "Neu" }}</UBadge>
</div>
</template>
<div class="space-y-3">
<div>
<label class="form-label">Titel</label>
<UInput v-model="taskForm.name" :disabled="isFormReadonly || !canCreate" />
</div>
<div>
<label class="form-label">Beschreibung</label>
<UTextarea v-model="taskForm.description" :disabled="isFormReadonly || !canCreate" />
</div>
<USelectMenu
v-model="taskForm.categorie"
:options="STATUS_COLUMNS.map((status) => ({ label: status, value: status }))"
value-attribute="value"
option-attribute="label"
:disabled="isFormReadonly || !canCreate"
>
<template #label>
{{ taskForm.categorie || "Status auswählen" }}
</template>
</USelectMenu>
<USelectMenu
v-model="taskForm.userId"
:options="assigneeOptions"
value-attribute="value"
option-attribute="label"
:disabled="isFormReadonly || !canCreate"
searchable
>
<template #label>
{{ assigneeOptions.find((option) => option.value === taskForm.userId)?.label || "Zuweisung" }}
</template>
</USelectMenu>
<USelectMenu
v-model="taskForm.project"
:options="projectOptions"
value-attribute="value"
option-attribute="label"
:disabled="isFormReadonly || !canCreate"
searchable
clear-search-on-close
>
<template #label>
{{ getEntityLabel(projectOptions, taskForm.project) || "Projekt" }}
</template>
</USelectMenu>
<USelectMenu
v-model="taskForm.customer"
:options="customerOptions"
value-attribute="value"
option-attribute="label"
:disabled="isFormReadonly || !canCreate"
searchable
clear-search-on-close
>
<template #label>
{{ getEntityLabel(customerOptions, taskForm.customer) || "Kunde" }}
</template>
</USelectMenu>
<USelectMenu
v-model="taskForm.plant"
:options="plantOptions"
value-attribute="value"
option-attribute="label"
:disabled="isFormReadonly || !canCreate"
searchable
clear-search-on-close
>
<template #label>
{{ getEntityLabel(plantOptions, taskForm.plant) || "Objekt" }}
</template>
</USelectMenu>
</div>
<template #footer>
<div class="flex items-center justify-between gap-2">
<div class="flex gap-2">
<UButton
v-if="taskForm.id && canCreate"
variant="soft"
:loading="deleting"
@click="archiveTask"
>
Archivieren
</UButton>
</div>
<div class="flex gap-2">
<UButton variant="ghost" @click="closeModal">Schließen</UButton>
<UButton
v-if="modalMode === 'show' && canCreate"
variant="soft"
@click="modalMode = 'edit'"
>
Bearbeiten
</UButton>
<UButton
v-if="modalMode !== 'show' && canCreate"
:loading="saving"
@click="saveTask"
>
Speichern
</UButton>
</div>
</div>
</template>
</UCard>
</UModal>
</template>
<style scoped>
.kanban-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
align-items: start;
}
@media (min-width: 1024px) {
.kanban-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.kanban-column {
border: 1px solid var(--ui-border);
border-radius: 0.75rem;
background: var(--ui-bg);
min-height: 500px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
box-shadow: 0 1px 2px color-mix(in oklab, var(--ui-text) 10%, transparent);
}
@media (min-width: 1024px) {
.kanban-column:not(:first-child)::before {
content: "";
position: absolute;
left: -0.7rem;
top: 0.75rem;
bottom: 0.75rem;
width: 1px;
background: var(--ui-border);
opacity: 0.9;
pointer-events: none;
}
}
.kanban-column-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-bg-muted);
}
.kanban-dropzone {
padding: 0.9rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.85rem;
transition: background-color 0.2s ease;
}
.kanban-dropzone-active {
background: var(--ui-bg-muted);
}
.kanban-card {
border: 1px solid var(--ui-border);
background: var(--ui-bg-elevated);
border-radius: 0.6rem;
padding: 0.7rem;
cursor: pointer;
}
.kanban-card-title {
font-weight: 600;
line-height: 1.25rem;
}
.kanban-card-description {
margin-top: 0.4rem;
color: var(--ui-text-dimmed);
font-size: 0.875rem;
line-height: 1.15rem;
}
.kanban-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.6rem;
}
.kanban-empty {
border: 1px dashed var(--ui-border);
color: var(--ui-text-dimmed);
border-radius: 0.6rem;
padding: 0.7rem;
font-size: 0.875rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.35rem;
color: var(--ui-text);
}
.view-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
border: 1px solid var(--ui-border);
border-radius: 0.5rem;
padding: 0.25rem;
}
</style>

View File

@@ -4,10 +4,13 @@ import dayjs from "dayjs"
import projecttype from "~/components/columnRenderings/projecttype.vue" import projecttype from "~/components/columnRenderings/projecttype.vue"
import contracttype from "~/components/columnRenderings/contracttype.vue"
import memberrelation from "~/components/columnRenderings/memberrelation.vue"
import customer from "~/components/columnRenderings/customer.vue" import customer from "~/components/columnRenderings/customer.vue"
import contact from "~/components/columnRenderings/contact.vue" import contact from "~/components/columnRenderings/contact.vue"
import plant from "~/components/columnRenderings/plant.vue" import plant from "~/components/columnRenderings/plant.vue"
import vendor from "~/components/columnRenderings/vendor.vue" import vendor from "~/components/columnRenderings/vendor.vue"
import product from "~/components/columnRenderings/product.vue"
import active from "~/components/columnRenderings/active.vue" import active from "~/components/columnRenderings/active.vue"
import sellingPrice from "~/components/columnRenderings/sellingPrice.vue"; import sellingPrice from "~/components/columnRenderings/sellingPrice.vue";
import unit from "~/components/columnRenderings/unit.vue"; import unit from "~/components/columnRenderings/unit.vue";
@@ -31,6 +34,7 @@ import endDate from "~/components/columnRenderings/endDate.vue"
import startDateTime from "~/components/columnRenderings/startDateTime.vue" import startDateTime from "~/components/columnRenderings/startDateTime.vue"
import endDateTime from "~/components/columnRenderings/endDateTime.vue" import endDateTime from "~/components/columnRenderings/endDateTime.vue"
import serviceCategories from "~/components/columnRenderings/serviceCategories.vue" import serviceCategories from "~/components/columnRenderings/serviceCategories.vue"
import productcategoriesWithLoad from "~/components/columnRenderings/productcategoriesWithLoad.vue"
import phase from "~/components/columnRenderings/phase.vue" import phase from "~/components/columnRenderings/phase.vue"
import vehiclesWithLoad from "~/components/columnRenderings/vehiclesWithLoad.vue" import vehiclesWithLoad from "~/components/columnRenderings/vehiclesWithLoad.vue"
import inventoryitemsWithLoad from "~/components/columnRenderings/inventoryitemsWithLoad.vue" import inventoryitemsWithLoad from "~/components/columnRenderings/inventoryitemsWithLoad.vue"
@@ -42,6 +46,7 @@ import quantity from "~/components/helpRenderings/quantity.vue"
import {useFunctions} from "~/composables/useFunctions.js"; import {useFunctions} from "~/composables/useFunctions.js";
import signDate from "~/components/columnRenderings/signDate.vue"; import signDate from "~/components/columnRenderings/signDate.vue";
import sepaDate from "~/components/columnRenderings/sepaDate.vue"; import sepaDate from "~/components/columnRenderings/sepaDate.vue";
import bankAccounts from "~/components/columnRenderings/bankAccounts.vue";
// @ts-ignore // @ts-ignore
export const useDataStore = defineStore('data', () => { export const useDataStore = defineStore('data', () => {
@@ -53,7 +58,7 @@ export const useDataStore = defineStore('data', () => {
isArchivable: true, isArchivable: true,
label: "Aufgaben", label: "Aufgaben",
labelSingle: "Aufgabe", labelSingle: "Aufgabe",
isStandardEntity: true, isStandardEntity: false,
redirect: true, redirect: true,
historyItemHolder: "task", historyItemHolder: "task",
selectWithInformation: "*, plant(*), project(*), customer(*)", selectWithInformation: "*, plant(*), project(*), customer(*)",
@@ -165,7 +170,7 @@ export const useDataStore = defineStore('data', () => {
numberRangeHolder: "customerNumber", numberRangeHolder: "customerNumber",
historyItemHolder: "customer", historyItemHolder: "customer",
sortColumn: "customerNumber", sortColumn: "customerNumber",
selectWithInformation: "*, projects(*), plants(*), contracts(*), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*)", selectWithInformation: "*, projects(*), plants(*), contracts(*), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*), customerinventoryitems(*), customerspaces(*)",
filters: [{ filters: [{
name: "Archivierte ausblenden", name: "Archivierte ausblenden",
default: true, default: true,
@@ -409,6 +414,14 @@ export const useDataStore = defineStore('data', () => {
inputType: "text", inputType: "text",
inputColumn: "Kontaktdaten" inputColumn: "Kontaktdaten"
}, },
{
key: "infoData.bankAccountIds",
label: "Bankkonten",
component: bankAccounts,
inputType: "bankaccountassign",
inputColumn: "Kontaktdaten",
disabledInTable: true
},
{ {
key: "infoData.ustid", key: "infoData.ustid",
label: "USt-Id", label: "USt-Id",
@@ -433,6 +446,220 @@ export const useDataStore = defineStore('data', () => {
inputColumn: "Allgemeines" inputColumn: "Allgemeines"
},*/ },*/
], ],
showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'},{label: 'Kundeninventar', key: 'customerinventoryitems'},{label: 'Kundenlagerplätze', key: 'customerspaces'},{label: 'Wiki'}]
},
members: {
isArchivable: true,
label: "Mitglieder",
labelSingle: "Mitglied",
isStandardEntity: true,
redirect: true,
numberRangeHolder: "customerNumber",
historyItemHolder: "customer",
sortColumn: "customerNumber",
selectWithInformation: "*, projects(*), plants(*), contracts(*), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*)",
filters: [{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
return !row.archived
}
}],
inputColumns: [
"Allgemeines",
"Bank & Kontakt"
],
templateColumns: [
{
key: 'customerNumber',
label: "Mitgliedsnummer",
inputIsNumberRange: true,
inputType: "text",
inputColumn: "Allgemeines",
sortable: true
},
{
key: "name",
label: "Name",
title: true,
sortable: true,
distinct: true
},
{
key: "salutation",
label: "Anrede",
inputType: "text",
inputChangeFunction: function (row) {
row.name = `${row.salutation ? (row.salutation + " ") : ""}${row.title ? (row.title + " ") : ""}${row.firstname ? (row.firstname + " ") : ""}${row.lastname || ""}`.trim();
},
inputColumn: "Allgemeines",
sortable: true,
distinct: true
},
{
key: "title",
label: "Titel",
inputType: "text",
inputChangeFunction: function (row) {
row.name = `${row.salutation ? (row.salutation + " ") : ""}${row.title ? (row.title + " ") : ""}${row.firstname ? (row.firstname + " ") : ""}${row.lastname || ""}`.trim();
},
inputColumn: "Allgemeines"
},
{
key: "firstname",
label: "Vorname",
required: true,
inputType: "text",
inputChangeFunction: function (row) {
row.name = `${row.salutation ? (row.salutation + " ") : ""}${row.title ? (row.title + " ") : ""}${row.firstname ? (row.firstname + " ") : ""}${row.lastname || ""}`.trim();
},
inputColumn: "Allgemeines",
sortable: true
},
{
key: "lastname",
label: "Nachname",
required: true,
inputType: "text",
inputChangeFunction: function (row) {
row.name = `${row.salutation ? (row.salutation + " ") : ""}${row.title ? (row.title + " ") : ""}${row.firstname ? (row.firstname + " ") : ""}${row.lastname || ""}`.trim();
},
inputColumn: "Allgemeines",
sortable: true
},
{
key: "infoData.birthdate",
label: "Geburtsdatum",
inputType: "date",
inputColumn: "Allgemeines",
disabledInTable: true
},
{
key: "memberrelation",
label: "Mitgliedsverhältnis",
component: memberrelation,
inputType: "select",
selectDataType: "memberrelations",
selectOptionAttribute: "type",
selectSearchAttributes: ['type', 'billingInterval'],
inputColumn: "Allgemeines",
sortable: true
},
{
key: "active",
label: "Aktiv",
component: active,
inputType: "bool",
inputColumn: "Allgemeines",
sortable: true,
distinct: true
},
{
key: "customPaymentDays",
label: "Beitragsintervall in Tagen",
inputType: "number",
inputColumn: "Allgemeines",
sortable: true
},
{
key: "infoData.bankAccountIds",
label: "Bankkonten",
component: bankAccounts,
inputType: "bankaccountassign",
required: true,
inputColumn: "Bank & Kontakt",
disabledInTable: true
},
{
key: "infoData.hasSEPA",
label: "SEPA-Mandat vorhanden",
inputType: "bool",
inputColumn: "Bank & Kontakt",
disabledInTable: true
},
{
key: "infoData.sepaSignedAt",
label: "SEPA unterschrieben am",
inputType: "date",
required: true,
showFunction: function (item) {
return Boolean(item.infoData?.hasSEPA)
},
inputColumn: "Bank & Kontakt",
disabledInTable: true
},
{
key: "infoData.street",
label: "Straße + Hausnummer",
inputType: "text",
disabledInTable: true,
inputColumn: "Bank & Kontakt"
},
{
key: "infoData.special",
label: "Adresszusatz",
inputType: "text",
disabledInTable: true,
inputColumn: "Bank & Kontakt"
},
{
key: "infoData.zip",
label: "Postleitzahl",
inputType: "text",
inputChangeFunction: async function (row) {
const zip = String(row.infoData.zip || "").replace(/\D/g, "")
row.infoData.zip = zip
if ([4, 5].includes(zip.length)) {
const zipData = await useFunctions().useZipCheck(zip)
row.infoData.zip = zipData?.zip || row.infoData.zip
row.infoData.city = zipData?.short || row.infoData.city
}
},
disabledInTable: true,
inputColumn: "Bank & Kontakt"
},
{
key: "infoData.city",
label: "Stadt",
inputType: "text",
disabledInTable: true,
inputColumn: "Bank & Kontakt"
},
{
key: "infoData.country",
label: "Land",
inputType: "select",
selectDataType: "countrys",
selectOptionAttribute: "name",
selectValueAttribute: "name",
disabledInTable: true,
inputColumn: "Bank & Kontakt"
},
{
key: "address",
label: "Adresse",
component: address,
inputColumn: "Bank & Kontakt"
},
{
key: "infoData.tel",
label: "Telefon",
inputType: "text",
inputColumn: "Bank & Kontakt"
},
{
key: "infoData.email",
label: "E-Mail",
inputType: "text",
inputColumn: "Bank & Kontakt"
},
{
key: "notes",
label: "Notizen",
inputType: "textarea",
inputColumn: "Allgemeines"
},
],
showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'},{label: 'Wiki'}] showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'},{label: 'Wiki'}]
}, },
contacts: { contacts: {
@@ -582,7 +809,7 @@ export const useDataStore = defineStore('data', () => {
"Allgemeines", "Allgemeines",
"Abrechnung" "Abrechnung"
], ],
selectWithInformation: "*, customer(*), files(*)", selectWithInformation: "*, customer(*), contracttype(*), files(*)",
templateColumns: [ templateColumns: [
{ {
key: 'contractNumber', key: 'contractNumber',
@@ -600,6 +827,23 @@ export const useDataStore = defineStore('data', () => {
inputType: "text", inputType: "text",
inputColumn: "Allgemeines", inputColumn: "Allgemeines",
sortable: true sortable: true
},{
key: "contracttype",
label: "Vertragstyp",
component: contracttype,
inputType: "select",
selectDataType: "contracttypes",
selectOptionAttribute: "name",
selectSearchAttributes: ["name"],
inputChangeFunction: function (item, loadedOptions = {}) {
const selectedContractType = (loadedOptions.contracttypes || []).find(i => i.id === item.contracttype)
if (!selectedContractType) return
item.paymentType = selectedContractType.paymentType || null
item.recurring = Boolean(selectedContractType.recurring)
item.billingInterval = selectedContractType.billingInterval || null
},
inputColumn: "Allgemeines"
},{ },{
key: "active", key: "active",
label: "Aktiv", label: "Aktiv",
@@ -670,6 +914,19 @@ export const useDataStore = defineStore('data', () => {
{label:'Überweisung'} {label:'Überweisung'}
], ],
inputColumn: "Abrechnung" inputColumn: "Abrechnung"
},{
key: "billingInterval",
label: "Abrechnungsintervall",
inputType: "select",
selectValueAttribute: "label",
selectManualOptions: [
{ label: "Monatlich" },
{ label: "Quartalsweise" },
{ label: "Halbjährlich" },
{ label: "Jährlich" }
],
inputColumn: "Abrechnung",
sortable: true
},{ },{
key: 'startDate', key: 'startDate',
label: "Vertragsstart", label: "Vertragsstart",
@@ -730,6 +987,126 @@ export const useDataStore = defineStore('data', () => {
], ],
showTabs: [{label: 'Informationen'},{label: 'Dateien'},{label: 'Wiki'}] showTabs: [{label: 'Informationen'},{label: 'Dateien'},{label: 'Wiki'}]
}, },
contracttypes: {
isArchivable: true,
label: "Vertragstypen",
labelSingle: "Vertragstyp",
isStandardEntity: true,
redirect: true,
sortColumn: "name",
selectWithInformation: "*",
historyItemHolder: "contracttype",
filters: [{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
if(!row.archived) {
return true
} else {
return false
}
}
}],
templateColumns: [
{
key: "name",
label: "Name",
required: true,
title: true,
inputType: "text",
sortable: true
},
{
key: "description",
label: "Beschreibung",
inputType: "textarea",
sortable: true
},
{
key: "paymentType",
label: "Zahlart",
inputType: "select",
selectValueAttribute: "label",
selectManualOptions: [
{ label: "Einzug" },
{ label: "Überweisung" }
],
sortable: true
},
{
key: "billingInterval",
label: "Abrechnungsintervall",
inputType: "select",
selectValueAttribute: "label",
selectManualOptions: [
{ label: "Monatlich" },
{ label: "Quartalsweise" },
{ label: "Halbjährlich" },
{ label: "Jährlich" }
],
sortable: true
},
{
key: "recurring",
label: "Wiederkehrend",
inputType: "bool",
component: recurring,
sortable: true
}
],
showTabs: [{ label: "Informationen" }]
},
memberrelations: {
isArchivable: true,
label: "Mitgliedsverhältnisse",
labelSingle: "Mitgliedsverhältnis",
isStandardEntity: true,
historyItemHolder: "memberrelation",
redirect: true,
sortColumn: "type",
selectWithInformation: "*",
filters: [{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
return !row.archived
}
}],
templateColumns: [
{
key: "type",
label: "Typ",
required: true,
title: true,
inputType: "text",
sortable: true
},
{
key: "billingInterval",
label: "Abrechnungsintervall",
required: true,
inputType: "select",
selectValueAttribute: "label",
selectOptionAttribute: "label",
selectManualOptions: [
{ label: "Monatlich" },
{ label: "Quartalsweise" },
{ label: "Halbjährlich" },
{ label: "Jährlich" }
],
sortable: true
},
{
key: "billingAmount",
label: "Abrechnungshöhe",
required: true,
inputType: "number",
inputTrailing: "€",
sortable: true
}
],
showTabs: [{ label: "Informationen" }]
},
absencerequests: { absencerequests: {
isArchivable: true, isArchivable: true,
label: "Abwesenheiten", label: "Abwesenheiten",
@@ -860,9 +1237,9 @@ export const useDataStore = defineStore('data', () => {
selectOptionAttribute: "name", selectOptionAttribute: "name",
selectSearchAttributes: ['name'], selectSearchAttributes: ['name'],
},{ },{
key: "description", key: "description.text",
label: "Beschreibung", label: "Beschreibung",
inputType:"editor", inputType:"textarea",
component: description component: description
}, },
], ],
@@ -988,7 +1365,8 @@ export const useDataStore = defineStore('data', () => {
selectDataType: "productcategories", selectDataType: "productcategories",
selectOptionAttribute: "name", selectOptionAttribute: "name",
selectSearchAttributes: ['name'], selectSearchAttributes: ['name'],
selectMultiple: true selectMultiple: true,
component: productcategoriesWithLoad
}, },
{ {
key: "description", key: "description",
@@ -1355,6 +1733,13 @@ export const useDataStore = defineStore('data', () => {
label: "Web", label: "Web",
inputType: "text" inputType: "text"
}, },
{
key: "infoData.bankAccountIds",
label: "Bankkonten",
component: bankAccounts,
inputType: "bankaccountassign",
disabledInTable: true
},
{ {
key: "infoData.ustid", key: "infoData.ustid",
label: "USt-Id", label: "USt-Id",
@@ -1461,6 +1846,7 @@ export const useDataStore = defineStore('data', () => {
selectValueAttribute: "label", selectValueAttribute: "label",
selectManualOptions: [ selectManualOptions: [
{label:"Standort"}, {label:"Standort"},
{label:"Raum"},
{label:"Regalplatz"}, {label:"Regalplatz"},
{label:"Kiste"}, {label:"Kiste"},
{label:"Palettenplatz"}, {label:"Palettenplatz"},
@@ -1544,6 +1930,149 @@ export const useDataStore = defineStore('data', () => {
},{label: 'Inventarartikel'},{label: 'Wiki'} },{label: 'Inventarartikel'},{label: 'Wiki'}
] ]
}, },
customerspaces: {
isArchivable: true,
label: "Kundenlagerplätze",
labelSingle: "Kundenlagerplatz",
isStandardEntity: true,
selectWithInformation: "*, customer(id,name), files(*)",
sortColumn: "space_number",
redirect: true,
numberRangeHolder: "space_number",
historyItemHolder: "customerspace",
filters:[{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
return !row.archived
}
}],
inputColumns: [
"Allgemeines",
"Ort"
],
templateColumns: [
{
key: "name",
label: "Name",
inputType: "text",
required: true,
title: true,
inputColumn: "Allgemeines",
sortable: true
},
{
key: "customer",
label: "Kunde",
inputType: "select",
required: true,
selectDataType: "customers",
selectOptionAttribute: "name",
selectSearchAttributes: ["name"],
component: customer,
inputColumn: "Allgemeines",
sortable: true
},
{
key: "space_number",
label: "Kundenlagerplatznr.",
inputType: "text",
inputIsNumberRange: true,
inputColumn: "Allgemeines",
sortable: true
},
{
key: "type",
label: "Typ",
inputType: "select",
required: true,
selectValueAttribute: "label",
selectManualOptions: [
{label:"Standort"},
{label:"Raum"},
{label:"Regalplatz"},
{label:"Kiste"},
{label:"Palettenplatz"},
{label:"Sonstiges"}
],
inputColumn: "Allgemeines",
sortable: true
},
{
key: "parentSpace",
label: "Übergeordneter Kundenlagerplatz",
inputType: "select",
selectDataType: "customerspaces",
selectOptionAttribute: "space_number",
selectValueAttribute: "id",
inputColumn: "Allgemeines"
},
{
key: "info_data.streetNumber",
label: "Straße + Hausnummer",
inputType: "text",
disabledInTable: true,
inputColumn: "Ort"
},
{
key: "info_data.special",
label: "Adresszusatz",
inputType: "text",
disabledInTable: true,
inputColumn: "Ort"
},
{
key: "info_data.zip",
label: "Postleitzahl",
inputType: "text",
disabledInTable: true,
inputColumn: "Ort",
inputChangeFunction: async function (row) {
const zip = String(row.info_data.zip || "").replace(/\D/g, "")
row.info_data.zip = zip
if ([4, 5].includes(zip.length)) {
const zipData = await useFunctions().useZipCheck(zip)
row.info_data.zip = zipData?.zip || row.info_data.zip
row.info_data.city = zipData?.short || row.info_data.city
}
},
},
{
key: "info_data.city",
label: "Stadt",
inputType: "text",
disabledInTable: true,
inputColumn: "Ort"
},
{
key: "info_data.country",
label: "Land",
inputType: "select",
selectDataType: "countrys",
selectOptionAttribute: "name",
selectValueAttribute: "name",
disabledInTable: true,
inputColumn: "Ort"
},
{
key: "address",
label: "Adresse",
component: address
},
{
key: "description",
label: "Beschreibung",
inputType: "textarea",
}
],
showTabs: [
{
label: 'Informationen',
}, {
label: 'Dateien',
},{label: 'Kundeninventar', key: 'customerinventoryitems'},{label: 'Wiki'}
]
},
users: { users: {
label: "Benutzer", label: "Benutzer",
labelSingle: "Benutzer" labelSingle: "Benutzer"
@@ -1656,6 +2185,179 @@ export const useDataStore = defineStore('data', () => {
} }
] ]
}, },
customerinventoryitems: {
isArchivable: true,
label: "Kundeninventar",
labelSingle: "Kundeninventarartikel",
isStandardEntity: true,
selectWithInformation: "*, files(*), customer(id,name), customerspace(id,name,space_number), product(id,name,article_number,description,manufacturer,manufacturer_number,purchase_price,vendorAllocation), vendor(id,name)",
redirect: true,
numberRangeHolder: "customerInventoryId",
historyItemHolder: "customerinventoryitem",
inputColumns: [
"Allgemeines",
"Anschaffung"
],
filters:[{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
return !row.archived
}
}],
templateColumns: [
{
key: "name",
label: "Name",
title: true,
required: true,
inputType: "text",
inputColumn: "Allgemeines",
sortable: true
},
{
key: "customer",
label: "Kunde",
inputType: "select",
required: true,
selectDataType: "customers",
selectOptionAttribute: "name",
selectSearchAttributes: ["name"],
component: customer,
inputColumn: "Allgemeines",
sortable: true
},
{
key: "customerInventoryId",
label: "Kundeninventar-ID",
inputType: "text",
inputIsNumberRange: true,
inputColumn: "Allgemeines",
sortable: true
},
{
key: "product",
label: "Ableitung von Artikel",
inputType: "select",
selectDataType: "products",
selectOptionAttribute: "name",
selectSearchAttributes: ["name", "article_number"],
inputColumn: "Allgemeines",
component: product,
inputChangeFunction: function (row, loadedOptions) {
const products = loadedOptions?.products || []
const selected = products.find((p) => p.id === row.product)
if (!selected) return
row.name = selected.name || null
row.description = selected.description || null
row.manufacturer = selected.manufacturer || null
row.manufacturerNumber = selected.manufacturer_number || null
row.purchasePrice = typeof selected.purchase_price === "number" ? selected.purchase_price : row.purchasePrice
row.currentValue = row.currentValue ?? row.purchasePrice
const allocations = Array.isArray(selected.vendor_allocation)
? selected.vendor_allocation
: (Array.isArray(selected.vendorAllocation) ? selected.vendorAllocation : [])
const firstAllocation = allocations[0]
if (typeof firstAllocation === "number") {
row.vendor = firstAllocation
} else if (firstAllocation && typeof firstAllocation === "object") {
row.vendor = firstAllocation.vendor || firstAllocation.vendor_id || firstAllocation.id || row.vendor
}
}
},
{
key: "description",
label: "Beschreibung",
inputType: "textarea",
inputColumn: "Allgemeines",
sortable: true
},
{
key: "customerspace",
label: "Aktueller Kundenlagerplatz",
inputType: "select",
selectDataType: "customerspaces",
selectOptionAttribute: "name",
selectSearchAttributes: ["name", "space_number"],
inputColumn: "Allgemeines",
component: space
},
{
key: "serialNumber",
label: "Seriennummer",
inputType: "text",
inputColumn: "Allgemeines"
},
{
key: "quantity",
label: "Menge",
inputType: "number",
inputColumn: "Allgemeines",
disabledFunction: function (item) {
return item.serialNumber
},
helpComponent: quantity,
sortable: true
},
{
key: "purchaseDate",
label: "Kaufdatum",
inputType: "date",
inputColumn: "Anschaffung",
sortable: true
},
{
key: "vendor",
label: "Lieferant",
inputType: "select",
selectDataType: "vendors",
selectOptionAttribute: "name",
selectSearchAttributes: ["name"],
inputColumn: "Anschaffung",
component: vendor
},
{
key: "purchasePrice",
label: "Kaufpreis",
inputType: "number",
inputStepSize: "0.01",
inputColumn: "Anschaffung",
component: purchasePrice,
sortable: true
},
{
key: "manufacturer",
label: "Hersteller",
inputType: "text",
inputColumn: "Anschaffung"
},
{
key: "manufacturerNumber",
label: "Herstellernummer",
inputType: "text",
inputColumn: "Anschaffung"
},
{
key: "currentValue",
label: "Aktueller Wert",
inputType: "number",
inputStepSize: "0.01",
inputColumn: "Anschaffung",
sortable: true
},
],
showTabs: [
{
label: 'Informationen',
}, {
label: 'Dateien',
}, {
label: 'Wiki',
}
]
},
inventoryitems: { inventoryitems: {
isArchivable: true, isArchivable: true,
label: "Inventarartikel", label: "Inventarartikel",
@@ -2600,6 +3302,55 @@ export const useDataStore = defineStore('data', () => {
bankaccounts: { bankaccounts: {
label: "Bankkonten", label: "Bankkonten",
labelSingle: "Bankkonto", labelSingle: "Bankkonto",
},
entitybankaccounts: {
isArchivable: true,
label: "Bankverbindungen",
labelSingle: "Bankverbindung",
isStandardEntity: false,
redirect: false,
sortColumn: "created_at",
filters: [{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
return !row.archived
}
}],
templateColumns: [
{
key: "displayLabel",
label: "Bankverbindung",
title: true,
sortable: true
},
{
key: "iban",
label: "IBAN",
required: true,
inputType: "text",
sortable: true
},
{
key: "bic",
label: "BIC",
required: true,
inputType: "text",
sortable: true
},
{
key: "bankName",
label: "Bankinstitut",
required: true,
inputType: "text",
sortable: true
},
{
key: "description",
label: "Beschreibung",
inputType: "text",
},
],
} }
} }

View File

@@ -49,6 +49,23 @@ export const useTempStore = defineStore('temp', () => {
storeTempConfig() storeTempConfig()
} }
function setFilters(domain, nextFilters) {
filters.value[domain] = nextFilters
if (!filters.value[domain] || Object.keys(filters.value[domain]).length === 0) {
delete filters.value[domain]
}
storeTempConfig()
}
function clearFilter(domain, type) {
if (!filters.value[domain]) return
delete filters.value[domain][type]
if (Object.keys(filters.value[domain]).length === 0) {
delete filters.value[domain]
}
storeTempConfig()
}
function modifyColumns(type, input) { function modifyColumns(type, input) {
columns.value[type] = input columns.value[type] = input
storeTempConfig() storeTempConfig()
@@ -81,6 +98,8 @@ export const useTempStore = defineStore('temp', () => {
clearSearchString, clearSearchString,
filters, filters,
modifyFilter, modifyFilter,
setFilters,
clearFilter,
columns, columns,
modifyColumns, modifyColumns,
modifyPages, modifyPages,
@@ -89,4 +108,4 @@ export const useTempStore = defineStore('temp', () => {
modifyBankingPeriod, // Neue Funktion exportiert modifyBankingPeriod, // Neue Funktion exportiert
settings settings
} }
}) })

1
mobile/.env Normal file
View File

@@ -0,0 +1 @@
EXPO_PUBLIC_API_BASE=http://192.168.1.157:3100

1
mobile/.env.example Normal file
View File

@@ -0,0 +1 @@
EXPO_PUBLIC_API_BASE=http://localhost:3100

43
mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

1
mobile/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
mobile/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

50
mobile/README.md Normal file
View File

@@ -0,0 +1,50 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

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