Compare commits

...

30 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
f596b46364 Missing Files
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 1m6s
2026-02-15 13:25:23 +01:00
117da523d2 Fix #51 2026-02-15 13:25:14 +01:00
145 changed files with 30424 additions and 789 deletions

View File

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

@@ -36,6 +36,104 @@
"when": 1765716877146,
"tag": "0004_stormy_onslaught",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1771096926109,
"tag": "0005_green_shinobi_shaw",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1772000000000,
"tag": "0006_nifty_price_lock",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1772000100000,
"tag": "0007_bright_default_tax_type",
"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(),
label: text("label").notNull(),
accountChart: text("accountChart").notNull().default("skr03"),
description: text("description"),
})

View File

@@ -11,6 +11,7 @@ import {
import { tenants } from "./tenants"
import { customers } from "./customers"
import { contacts } from "./contacts"
import { contracttypes } from "./contracttypes"
import { authUsers } from "./auth_users"
export const contracts = pgTable(
@@ -48,6 +49,9 @@ export const contracts = pgTable(
contact: bigint("contact", { mode: "number" }).references(
() => contacts.id
),
contracttype: bigint("contracttype", { mode: "number" }).references(
() => contracttypes.id
),
bankingIban: text("bankingIban"),
bankingBIC: text("bankingBIC"),
@@ -57,6 +61,7 @@ export const contracts = pgTable(
sepaDate: timestamp("sepaDate", { withTimezone: true }),
paymentType: text("paymentType"),
billingInterval: text("billingInterval"),
invoiceDispatch: text("invoiceDispatch"),
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,
boolean,
smallint,
doublePrecision,
uuid,
} from "drizzle-orm/pg-core"
@@ -96,7 +97,7 @@ export const createddocuments = pgTable("createddocuments", {
taxType: text("taxType"),
customSurchargePercentage: smallint("customSurchargePercentage")
customSurchargePercentage: doublePrecision("customSurchargePercentage")
.notNull()
.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"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { memberrelations } from "./memberrelations"
export const customers = pgTable(
"customers",
@@ -62,6 +63,8 @@ export const customers = pgTable(
updatedBy: uuid("updated_by").references(() => authUsers.id),
customPaymentType: text("custom_payment_type"), // ENUM payment_types separat?
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 { bankstatements } from "./bankstatements"
import { spaces } from "./spaces"
import { customerspaces } from "./customerspaces"
import { customerinventoryitems } from "./customerinventoryitems"
import { costcentres } from "./costcentres"
import { ownaccounts } from "./ownaccounts"
import { createddocuments } from "./createddocuments"
@@ -32,6 +34,7 @@ import { events } from "./events"
import { inventoryitemgroups } from "./inventoryitemgroups"
import { authUsers } from "./auth_users"
import {files} from "./files";
import { memberrelations } from "./memberrelations";
export const historyitems = pgTable("historyitems", {
id: bigint("id", { mode: "number" })
@@ -99,6 +102,12 @@ export const historyitems = pgTable("historyitems", {
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"),
projecttype: bigint("projecttype", { mode: "number" }).references(

View File

@@ -13,15 +13,19 @@ export * from "./checks"
export * from "./citys"
export * from "./contacts"
export * from "./contracts"
export * from "./contracttypes"
export * from "./costcentres"
export * from "./countrys"
export * from "./createddocuments"
export * from "./createdletters"
export * from "./customers"
export * from "./customerspaces"
export * from "./customerinventoryitems"
export * from "./devices"
export * from "./documentboxes"
export * from "./enums"
export * from "./events"
export * from "./entitybankaccounts"
export * from "./files"
export * from "./filetags"
export * from "./folders"
@@ -42,7 +46,9 @@ export * from "./incominginvoices"
export * from "./inventoryitemgroups"
export * from "./inventoryitems"
export * from "./letterheads"
export * from "./memberrelations"
export * from "./movements"
export * from "./m2m_api_keys"
export * from "./notifications_event_types"
export * from "./notifications_items"
export * from "./notifications_preferences"
@@ -72,4 +78,4 @@ export * from "./staff_time_events"
export * from "./serialtypes"
export * from "./serialexecutions"
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,
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,
}),
ownFields: jsonb("ownFields"),
@@ -88,10 +130,13 @@ export const tenants = pgTable(
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 },
}),
accountChart: text("accountChart").notNull().default("skr03"),
standardEmailForInvoices: text("standardEmailForInvoices"),

View File

@@ -6,6 +6,6 @@ export default defineConfig({
schema: "./db/schema",
out: "./db/migrations",
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",
"build": "tsc",
"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": {
"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 tenantRoutesInternal from "./routes/internal/tenant";
import staffTimeRoutesInternal from "./routes/internal/time";
import authM2mInternalRoutes from "./routes/internal/auth.m2m";
//Devices
import devicesRFIDRoutes from "./routes/devices/rfid";
@@ -107,6 +108,7 @@ async function main() {
await app.register(async (m2mApp) => {
await m2mApp.register(authM2m)
await m2mApp.register(authM2mInternalRoutes)
await m2mApp.register(helpdeskInboundEmailRoutes)
await m2mApp.register(deviceRoutes)
await m2mApp.register(tenantRoutesInternal)
@@ -167,4 +169,4 @@ async function main() {
}
}
main();
main();

View File

@@ -0,0 +1,249 @@
import { and, eq } from "drizzle-orm";
import * as schema from "../../db/schema";
import { FastifyInstance } from "fastify";
type CompositionRow = {
product?: number | string | null;
service?: number | string | null;
hourrate?: string | null;
quantity?: number | string | null;
price?: number | string | null;
purchasePrice?: number | string | null;
[key: string]: any;
};
function toNumber(value: any): number {
const num = Number(value ?? 0);
return Number.isFinite(num) ? num : 0;
}
function round2(value: number): number {
return Number(value.toFixed(2));
}
function getJsonNumber(source: unknown, key: string): number {
if (!source || typeof source !== "object") return 0;
return toNumber((source as Record<string, unknown>)[key]);
}
function normalizeId(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function normalizeUuid(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
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) {
const [services, products, hourrates] = await Promise.all([
server.db.select().from(schema.services).where(eq(schema.services.tenant, tenantId)),
server.db.select().from(schema.products).where(eq(schema.products.tenant, tenantId)),
server.db.select().from(schema.hourrates).where(eq(schema.hourrates.tenant, tenantId)),
]);
const serviceMap = new Map(services.map((item) => [item.id, item]));
const productMap = new Map(products.map((item) => [item.id, item]));
const hourrateMap = new Map(hourrates.map((item) => [item.id, item]));
const memo = new Map<number, {
sellingTotal: number;
purchaseTotal: number;
materialTotal: number;
materialPurchaseTotal: number;
workerTotal: number;
workerPurchaseTotal: number;
materialComposition: CompositionRow[];
personalComposition: CompositionRow[];
}>();
const stack = new Set<number>();
const calculateService = (serviceId: number) => {
if (memo.has(serviceId)) return memo.get(serviceId)!;
const service = serviceMap.get(serviceId);
const emptyResult = {
sellingTotal: 0,
purchaseTotal: 0,
materialTotal: 0,
materialPurchaseTotal: 0,
workerTotal: 0,
workerPurchaseTotal: 0,
materialComposition: [],
personalComposition: [],
};
if (!service) return emptyResult;
if (stack.has(serviceId)) return emptyResult;
// Gesperrte Leistungen bleiben bei automatischen Preis-Updates unverändert.
if (service.priceUpdateLocked) {
const lockedResult = {
sellingTotal: getJsonNumber(service.sellingPriceComposed, "total") || toNumber(service.sellingPrice),
purchaseTotal: getJsonNumber(service.purchasePriceComposed, "total"),
materialTotal: getJsonNumber(service.sellingPriceComposed, "material"),
materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
materialComposition: sanitizeCompositionRows(service.materialComposition),
personalComposition: sanitizeCompositionRows(service.personalComposition),
};
memo.set(serviceId, lockedResult);
return lockedResult;
}
stack.add(serviceId);
try {
const materialComposition = sanitizeCompositionRows(service.materialComposition);
const personalComposition = sanitizeCompositionRows(service.personalComposition);
const hasMaterialComposition = materialComposition.length > 0;
const hasPersonalComposition = personalComposition.length > 0;
// Ohne Zusammensetzung keine automatische Überschreibung:
// manuell gepflegte Preise sollen erhalten bleiben.
if (!hasMaterialComposition && !hasPersonalComposition) {
const manualResult = {
sellingTotal: getJsonNumber(service.sellingPriceComposed, "total") || toNumber(service.sellingPrice),
purchaseTotal: getJsonNumber(service.purchasePriceComposed, "total"),
materialTotal: getJsonNumber(service.sellingPriceComposed, "material"),
materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
materialComposition,
personalComposition,
};
memo.set(serviceId, manualResult);
return manualResult;
}
let materialTotal = 0;
let materialPurchaseTotal = 0;
const normalizedMaterialComposition = materialComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const productId = normalizeId(entry.product);
const childServiceId = normalizeId(entry.service);
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;
materialPurchaseTotal += quantity * purchasePrice;
return {
...entry,
price: round2(sellingPrice),
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,
};
memo.set(serviceId, result);
return result;
} finally {
stack.delete(serviceId);
}
};
for (const service of services) {
calculateService(service.id);
}
const updates = services
.filter((service) => !service.priceUpdateLocked)
.map(async (service) => {
const calc = memo.get(service.id);
if (!calc) return;
const sellingPriceComposed = {
worker: calc.workerTotal,
material: calc.materialTotal,
total: calc.sellingTotal,
};
const purchasePriceComposed = {
worker: calc.workerPurchaseTotal,
material: calc.materialPurchaseTotal,
total: calc.purchaseTotal,
};
const unchanged =
JSON.stringify(service.materialComposition ?? []) === JSON.stringify(calc.materialComposition) &&
JSON.stringify(service.personalComposition ?? []) === JSON.stringify(calc.personalComposition) &&
JSON.stringify(service.sellingPriceComposed ?? {}) === JSON.stringify(sellingPriceComposed) &&
JSON.stringify(service.purchasePriceComposed ?? {}) === JSON.stringify(purchasePriceComposed) &&
round2(toNumber(service.sellingPrice)) === calc.sellingTotal;
if (unchanged) return;
await server.db
.update(schema.services)
.set({
materialComposition: calc.materialComposition,
personalComposition: calc.personalComposition,
sellingPriceComposed,
purchasePriceComposed,
sellingPrice: calc.sellingTotal,
updatedAt: new Date(),
updatedBy: updatedBy ?? null,
})
.where(and(eq(schema.services.id, service.id), eq(schema.services.tenant, tenantId)));
});
await Promise.all(updates);
}

View File

@@ -1,6 +1,9 @@
import { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
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.
@@ -12,26 +15,99 @@ import { secrets } from "../utils/secrets";
* server.register(m2mAuthPlugin, { allowedPrefix: '/internal' })
*/
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) => {
try {
// Nur prüfen, wenn Route unterhalb des Prefix liegt
//if (!req.url.startsWith(allowedPrefix)) return;
const apiKeyHeader = req.headers["x-api-key"];
const apiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader;
const apiKey = req.headers["x-api-key"];
if (!apiKey || apiKey !== secrets.M2M_API_KEY) {
if (!apiKey) {
server.log.warn(`[M2M Auth] Ungültiger oder fehlender API-Key bei ${req.url}`);
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 = {
verified: true,
type: "internal",
key: apiKey,
};
req.role = "m2m"
req.permissions = []
req.hasPermission = () => false
} catch (err) {
// @ts-ignore
server.log.error("[M2M Auth] Fehler beim Prüfen des API-Keys:", err);

View File

@@ -1,31 +1,25 @@
import fp from "fastify-plugin"
import {drizzle, NodePgDatabase} from "drizzle-orm/node-postgres"
import * as schema from "../../db/schema"
import {secrets} from "../utils/secrets";
import { Pool } from "pg"
// src/plugins/db.ts
import fp from "fastify-plugin";
import { NodePgDatabase } from "drizzle-orm/node-postgres";
import * as schema from "../../db/schema";
import { db, pool } from "../../db"; // <--- Importiert jetzt die globale Instanz
export default fp(async (server, opts) => {
const pool = new Pool({
connectionString: secrets.DATABASE_URL,
max: 10, // je nach Last
})
// Wir nutzen die db, die wir in src/db/index.ts erstellt haben
server.decorate("db", db);
const db = drizzle(pool , {schema})
// Dekorieren -> überall server.db
server.decorate("db", db)
// Graceful Shutdown
// Graceful Shutdown: Wenn Fastify ausgeht, schließen wir den Pool
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" {
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>
console.log(query)
// Pagination deaktivieren?
const disablePagination =
query.noPagination === 'true' ||

View File

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

View File

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

View File

@@ -4,10 +4,19 @@ import dayjs from "dayjs"
import { secrets } from "../utils/secrets"
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 {
bankrequisitions,
bankstatements,
createddocuments,
customers,
entitybankaccounts,
incominginvoices,
statementallocations,
vendors,
} from "../../db/schema"
import {
@@ -17,6 +26,322 @@ import {
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
@@ -171,9 +496,35 @@ export default async function bankingRoutes(server: FastifyInstance) {
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, {
entity: "bankstatements",
entityId: createdRecord.id,
entityId: Number(createdRecord.bankstatement),
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
@@ -216,7 +567,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: id,
entityId: Number(old.bankstatement),
action: "deleted",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify";
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 { ready as zplReady } from 'zpl-renderer-js'
//import { renderZPL } from "zpl-image";
@@ -15,7 +15,6 @@ import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys} from "../../db/schema";
import {eq} from "drizzle-orm";
import {useNextNumberRangeNumber} from "../utils/functions";
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
dayjs.extend(customParseFormat)
dayjs.extend(isoWeek)
@@ -177,44 +176,20 @@ export default async function functionRoutes(server: FastifyInstance) {
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) => {
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 {
const base64 = await generateLabel(context,width,heigth)
const base64 = await generateLabel(context,width,height)
return {
encoded: await encodeBase64ToNiimbot(base64, 'top'),
base64: base64
}
} catch (err) {
console.error('[ZPL Preview Error]', err)
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
console.error('[Label Render Error]', err)
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> = {
customers: historyitems.customer,
members: historyitems.customer,
vendors: historyitems.vendor,
projects: historyitems.project,
plants: historyitems.plant,
@@ -22,10 +23,14 @@ const columnMap: Record<string, any> = {
documentboxes: historyitems.documentbox,
hourrates: historyitems.hourrate,
services: historyitems.service,
customerspaces: historyitems.customerspace,
customerinventoryitems: historyitems.customerinventoryitem,
memberrelations: historyitems.memberrelation,
};
const insertFieldMap: Record<string, string> = {
customers: "customer",
members: "customer",
vendors: "vendor",
projects: "project",
plants: "plant",
@@ -43,6 +48,9 @@ const insertFieldMap: Record<string, string> = {
documentboxes: "documentbox",
hourrates: "hourrate",
services: "service",
customerspaces: "customerspace",
customerinventoryitems: "customerinventoryitem",
memberrelations: "memberrelation",
}
const parseId = (value: string) => {
@@ -51,6 +59,44 @@ const parseId = (value: string) => {
}
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<{
Params: { resource: string; id: string }
}>("/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,
count,
inArray,
or
or,
sql,
} from "drizzle-orm"
import { resourceConfig } from "../../utils/resource.config";
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 { decrypt, encrypt } from "../../utils/crypt";
// -------------------------------------------------------------
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
@@ -20,15 +24,202 @@ import { recalculateServicePricesForTenant } from "../../modules/service-price-r
function buildSearchCondition(columns: any[], search: string) {
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)
.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) {
@@ -53,6 +244,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const table = config.table
let whereCond: any = eq(table.tenant, tenantId)
whereCond = applyResourceWhereFilters(resource, table, whereCond)
let q = server.db.select().from(table).$dynamic()
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
@@ -122,7 +314,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
if(config.mtmListLoad) {
for await (const relation of config.mtmListLoad) {
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)))
data = data.map(row => ({
...row,
@@ -131,6 +323,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
}
if (resource === "entitybankaccounts") {
return data.map((row) => decryptEntityBankAccount(row))
}
return data
} catch (err) {
@@ -156,7 +352,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
const { search, distinctColumns } = req.query as { search?: string; distinctColumns?: string; };
let whereCond: any = eq(table.tenant, tenantId);
whereCond = applyResourceWhereFilters(resource, table, whereCond)
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 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));
if (relConfig.searchColumns) {
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 (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());
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)) {
const col = (table as any)[key];
if (!col) continue;
parsedFilters.push({ key, value: val })
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())) {
const col = (table as any)[colName];
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();
}
}
@@ -251,7 +499,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (config.mtmListLoad) {
for await (const relation of config.mtmListLoad) {
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)));
data = data.map(row => ({
...row,
@@ -260,6 +508,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
}
if (resource === "entitybankaccounts") {
data = data.map((row) => decryptEntityBankAccount(row))
}
return {
data,
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 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
.select()
.from(table)
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
.where(whereCond)
.limit(1)
if (!projRows.length)
@@ -309,12 +564,16 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (resourceConfig[resource].mtmLoad) {
for await (const relation of resourceConfig[resource].mtmLoad) {
const relTable = resourceConfig[relation].table
const parentKey = resource.substring(0, resource.length - 1)
const parentKey = resourceConfig[resource].relationKey || resource.substring(0, resource.length - 1)
data[relation] = await server.db.select().from(relTable).where(eq(relTable[parentKey], id))
}
}
}
if (resource === "entitybankaccounts") {
return decryptEntityBankAccount(data)
}
return data
} catch (err) {
console.error("ERROR /resource/:resource/:id", err)
@@ -327,14 +586,32 @@ export default async function resourceRoutes(server: FastifyInstance) {
try {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
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 config = resourceConfig[resource];
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]) {
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
}
@@ -349,6 +626,28 @@ export default async function resourceRoutes(server: FastifyInstance) {
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;
} catch (error) {
console.error(error);
@@ -360,6 +659,9 @@ export default async function resourceRoutes(server: FastifyInstance) {
server.put("/resource/:resource/:id", async (req, reply) => {
try {
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 tenantId = req.user?.tenant_id
const userId = req.user?.user_id
@@ -369,22 +671,93 @@ export default async function resourceRoutes(server: FastifyInstance) {
const table = resourceConfig[resource].table
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
let data = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
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
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) => {
if ((key.includes("_at") || key.includes("At") || key.toLowerCase().includes("date")) && key !== "deliveryDateType") {
data[key] = normalizeDate(data[key])
const value = 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)) {
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
} catch (err) {
console.error(err)

View File

@@ -1,9 +1,9 @@
import { FastifyInstance } from "fastify"
import { asc, desc } from "drizzle-orm"
import { asc, desc, eq } from "drizzle-orm"
import { sortData } from "../utils/sort"
// Schema imports
import { accounts, units,countrys } from "../../db/schema"
import { accounts, units, countrys, tenants } from "../../db/schema"
const TABLE_MAP: Record<string, any> = {
accounts,
@@ -40,6 +40,44 @@ export default async function resourceRoutesSpecial(server: FastifyInstance) {
// 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)
// ---------------------------------------

View File

@@ -1,18 +1,26 @@
import { FastifyInstance } from "fastify"
import jwt from "jsonwebtoken"
import { secrets } from "../utils/secrets"
import { createHash, randomBytes } from "node:crypto"
import {
authTenantUsers,
authUsers,
authProfiles,
tenants
tenants,
m2mApiKeys
} 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) {
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,
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 3,
maxAge: 60 * 60 * 6,
})
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,
products,
inventoryitems,
customerinventoryitems,
customerspaces,
// NEU HINZUGEFÜGT (Basierend auf deinem DataStore)
tasks,
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' },
'products': { table: products, labelField: products.name, rootLabel: 'Artikel', 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 ---
'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 }
})
}
}

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

View File

@@ -6,6 +6,149 @@ type ValueResolver = (
ctx?: Record<string, 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<
string,
{ label: string; resolve?: ValueResolver }
@@ -44,7 +187,7 @@ export const diffTranslations: Record<
}),
},
resources: {
label: "Resourcen",
label: "Ressourcen",
resolve: (o, n) => ({
oldVal: Array.isArray(o) ? o.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" },
manufacturer: { label: "Hersteller" },
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" },
serialNumber: { label: "Seriennummer" },
customerInventoryId: { label: "Kundeninventar-ID" },
customerinventoryitems: { label: "Kundeninventar" },
usePlanning: { label: "In Plantafel verwenden" },
currentSpace: { label: "Lagerplatz" },
customerspace: { label: "Kundenlagerplatz" },
customer: {
label: "Kunde",
@@ -108,6 +259,7 @@ export const diffTranslations: Record<
description: { label: "Beschreibung" },
categorie: { label: "Kategorie" },
category: { label: "Kategorie" },
profile: {
label: "Mitarbeiter",
@@ -147,6 +299,8 @@ export const diffTranslations: Record<
},
projecttype: { label: "Projekttyp" },
contracttype: { label: "Vertragstyp" },
billingInterval: { label: "Abrechnungsintervall" },
fixed: {
label: "Festgeschrieben",

View File

@@ -1,11 +1,8 @@
import {FastifyInstance} from "fastify";
// import { PNG } from 'pngjs'
// import { ready as zplReady } from 'zpl-renderer-js'
// import { Utils } from '@mmote/niimbluelib'
// import { createCanvas } from 'canvas'
// import bwipjs from 'bwip-js'
// import Sharp from 'sharp'
// import fs from 'fs'
import { FastifyInstance } from "fastify"
import { PNG } from "pngjs"
import { Utils } from "@mmote/niimbluelib"
import bwipjs from "bwip-js"
import Sharp from "sharp"
import { tenants } from "../../db/schema"
import { eq } from "drizzle-orm"
@@ -15,7 +12,6 @@ export const useNextNumberRangeNumber = async (
tenantId: number,
numberRange: string
) => {
// 1⃣ Tenant laden
const [tenant] = await server.db
.select()
.from(tenants)
@@ -33,23 +29,20 @@ export const useNextNumberRangeNumber = async (
const current = numberRanges[numberRange]
// 2⃣ Used Number generieren
const usedNumber =
(current.prefix || "") +
current.nextNumber +
(current.suffix || "")
// 3⃣ nextNumber erhöhen
const updatedRanges = {
// @ts-ignore
...numberRanges,
[numberRange]: {
...current,
nextNumber: current.nextNumber + 1
}
nextNumber: current.nextNumber + 1,
},
}
// 4⃣ Tenant aktualisieren
await server.db
.update(tenants)
.set({ numberRanges: updatedRanges })
@@ -58,24 +51,17 @@ export const useNextNumberRangeNumber = async (
return { usedNumber }
}
/*
export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') {
// 1⃣ PNG dekodieren
const buffer = Buffer.from(base64Png, 'base64')
const png = PNG.sync.read(buffer) // liefert {width, height, data: Uint8Array(RGBA)}
export async function encodeBase64ToNiimbot(base64Png: string, printDirection: "top" | "left" = "top") {
const buffer = Buffer.from(base64Png, "base64")
const png = PNG.sync.read(buffer)
const { width, height, data } = png
console.log(width, height, data)
const cols = printDirection === 'left' ? height : width
const rows = printDirection === 'left' ? width : height
const rowsData = []
const cols = printDirection === "left" ? height : width
const rows = printDirection === "left" ? width : height
const rowsData: any[] = []
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++) {
let isVoid = true
let blackPixelsCount = 0
@@ -84,8 +70,8 @@ export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') {
for (let colOct = 0; colOct < cols / 8; colOct++) {
let pixelsOctet = 0
for (let colBit = 0; colBit < 8; colBit++) {
const x = printDirection === 'left' ? row : colOct * 8 + colBit
const y = printDirection === 'left' ? height - 1 - (colOct * 8 + colBit) : row
const x = printDirection === "left" ? row : colOct * 8 + colBit
const y = printDirection === "left" ? height - 1 - (colOct * 8 + colBit) : row
const idx = (y * width + x) * 4
const lum = 0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2]
const isBlack = lum < 128
@@ -99,7 +85,7 @@ export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') {
}
const newPart = {
dataType: isVoid ? 'void' : 'pixels',
dataType: isVoid ? "void" : "pixels",
rowNumber: row,
repeat: 1,
rowData: isVoid ? undefined : rowData,
@@ -111,14 +97,15 @@ export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') {
} else {
const last = rowsData[rowsData.length - 1]
let same = newPart.dataType === last.dataType
if (same && newPart.dataType === 'pixels') {
if (same && newPart.dataType === "pixels") {
same = Utils.u8ArraysEqual(newPart.rowData, last.rowData)
}
if (same) last.repeat++
else rowsData.push(newPart)
if (row % 200 === 199) {
rowsData.push({
dataType: 'check',
dataType: "check",
rowNumber: row,
repeat: 0,
rowData: undefined,
@@ -131,44 +118,69 @@ export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') {
return { cols, rows, rowsData }
}
export async function generateLabel(context,width,height) {
// Canvas für Hintergrund & Text
const canvas = createCanvas(width, height)
const ctx = canvas.getContext('2d')
function escapeXml(value: string) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&apos;")
}
// Hintergrund weiß
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(0, 0, width, height)
export async function generateLabel(context: any = {}, width = 584, height = 354) {
const normalizedWidth = Math.ceil(Number(width) / 8) * 8
const normalizedHeight = Math.max(1, Number(height) || 203)
// Überschrift
ctx.fillStyle = '#000000'
ctx.font = '32px Arial'
ctx.fillText(context.text, 20, 40)
const idFont = Math.max(24, Math.round(normalizedHeight * 0.125))
const nameFont = Math.max(17, Math.round(normalizedHeight * 0.078))
const customerFont = Math.max(14, Math.round(normalizedHeight * 0.06))
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({
bcid: 'datamatrix',
text: context.datamatrix,
scale: 6,
bcid: "datamatrix",
text: String(labelId),
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 base = await Sharp(canvas.toBuffer())
.png()
.toBuffer()
const textSvg = `
<svg width="${normalizedWidth}" height="${normalizedHeight}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="white"/>
<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(base)
const final = await Sharp({
create: {
width: normalizedWidth,
height: normalizedHeight,
channels: 3,
background: { r: 255, g: 255, b: 255 },
},
})
.composite([
{ input: dataMatrixPng, top: 60, left: 20 },
{ input: Buffer.from(textSvg), top: 0, left: 0 },
{ input: dataMatrixPng, top: dmTop, left: dmLeft },
])
.png()
.toBuffer()
fs.writeFileSync('label.png', final)
// Optional: Base64 zurückgeben (z.B. für API)
const base64 = final.toString('base64')
return base64
}*/
return final.toString("base64")
}

View File

@@ -11,7 +11,7 @@ import { s3 } from "./s3";
import { secrets } from "./secrets";
// Drizzle schema
import { vendors, accounts } from "../../db/schema";
import { vendors, accounts, tenants } from "../../db/schema";
import {eq} from "drizzle-orm";
let openai: OpenAI | null = null;
@@ -163,13 +163,22 @@ export const getInvoiceDataFromGPT = async function (
.from(vendors)
.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
.select({
id: accounts.id,
label: accounts.label,
number: accounts.number,
})
.from(accounts);
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart));
// ---------------------------------------------------------
// 4) GPT ANALYSIS

View File

@@ -1,6 +1,43 @@
import { FastifyInstance } from "fastify"
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(
server: FastifyInstance,
params: {
@@ -14,15 +51,18 @@ export async function insertHistoryItem(
text?: string
}
) {
const entityLabel = getHistoryEntityLabel(params.entity)
const textMap = {
created: `Neuer Eintrag in ${params.entity} erstellt`,
updated: `Eintrag in ${params.entity} geändert`,
archived: `Eintrag in ${params.entity} archiviert`,
deleted: `Eintrag in ${params.entity} gelöscht`
created: `Neuer Eintrag in ${entityLabel} erstellt`,
updated: `Eintrag in ${entityLabel} geändert`,
unchanged: `Eintrag in ${entityLabel} unverändert`,
archived: `Eintrag in ${entityLabel} archiviert`,
deleted: `Eintrag in ${entityLabel} gelöscht`
}
const columnMap: Record<string, string> = {
customers: "customer",
members: "customer",
vendors: "vendor",
projects: "project",
plants: "plant",
@@ -42,10 +82,15 @@ export async function insertHistoryItem(
roles: "role",
checks: "check",
spaces: "space",
customerspaces: "customerspace",
customerinventoryitems: "customerinventoryitem",
trackingtrips: "trackingtrip",
createddocuments: "createddocument",
inventoryitemgroups: "inventoryitemgroup",
bankstatements: "bankstatement"
bankstatements: "bankstatement",
incominginvoices: "incomingInvoice",
files: "file",
memberrelations: "memberrelation",
}
const fkColumn = columnMap[params.entity]
@@ -54,14 +99,19 @@ export async function insertHistoryItem(
return
}
const stringifyHistoryValue = (value: any) => {
if (value === undefined || value === null) return null
return typeof value === "string" ? value : JSON.stringify(value)
}
const entry = {
tenant: params.tenant_id,
created_by: params.created_by,
createdBy: params.created_by,
text: params.text || textMap[params.action],
action: params.action,
[fkColumn]: params.entityId,
oldVal: params.oldVal ? JSON.stringify(params.oldVal) : null,
newVal: params.newVal ? JSON.stringify(params.newVal) : null
oldVal: stringifyHistoryValue(params.oldVal),
newVal: stringifyHistoryValue(params.newVal)
}
await server.db.insert(historyitems).values(entry as any)

View File

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

View File

@@ -1,37 +1,70 @@
version: "3"
services:
web:
image: reg.federspiel.software/fedeo/software:beta
frontend:
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
restart: always
environment:
- INFISICAL_CLIENT_ID=abc
- INFISICAL_CLIENT_SECRET=abc
- NUXT_PUBLIC_API_BASE=https://app.fedeo.de/backend
- 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:
image: reg.federspiel.software/fedeo/backend:main
image: git.federspiel.tech/flfeders/fedeo/backend:dev
restart: always
environment:
- NUXT_PUBLIC_API_BASE=
- NUXT_PUBLIC_PDF_LICENSE=
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"
- INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf
- INFISICAL_CLIENT_SECRET=4e3441acc0adbffd324aa50e668a95a556a3f55ec6bb85954e176e35a3392003
- NODE_ENV=production
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=3100"
# Middlewares
- "traefik.http.middlewares.fedeo-backend-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend"
# Web Entrypoint
- "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:
image: traefik:v2.2
image: traefik:v2.11
restart: unless-stopped
container_name: traefik
command:
- "--api.insecure=false"
- "--api.dashboard=true"
- "--api.dashboard=false"
- "--api.debug=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
@@ -43,19 +76,18 @@ services:
- "--accesslog.bufferingsize=5000"
- "--accesslog.fields.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.email=info@sandelcom.de" # <== Setting email for certs
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json" # <== Defining acme file to store cert information
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" #
- "--certificatesresolvers.mytlschallenge.acme.email=moin@fedeo.de"
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
ports:
- 80:80
- 8080:8080
- 443:443
volumes:
- "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS)
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./traefik/logs:/logs"
labels:
#### Labels define the behavior and rules of the traefik proxy for this container ####
- "traefik.enable=true" # <== Enable traefik on itself to view dashboard and assign subdomain to view it
- "traefik.http.routers.api.rule=Host(`srv1.drinkingteam.de`)" # <== Setting the domain for the dashboard
- "traefik.http.routers.api.service=api@internal" # <== Enabling the api to be a service to access
networks:
- traefik
networks:
traefik:
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(() => {
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
// 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 => {
if(datapoint.required) {
if(datapoint.key.includes(".")){
const [parentKey, childKey] = datapoint.key.split('.')
// Prüfung: Existiert Parent UND ist Child "truthy" (nicht null/undefined/empty)
if(item.value[parentKey] && item.value[parentKey][childKey]) {
if(item.value[parentKey] && isFilledValue(item.value[parentKey][childKey])) {
allowedCount += 1
}
} else {
if(item.value[datapoint.key]) {
if(isFilledValue(item.value[datapoint.key])) {
allowedCount += 1
}
}
@@ -427,6 +438,11 @@ const updateItem = async () => {
/>
</template>
</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
v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)"
@@ -527,6 +543,11 @@ const updateItem = async () => {
/>
</template>
</UPopover>
<BankAccountAssignInput
v-else-if="datapoint.inputType === 'bankaccountassign'"
v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<Tiptap
v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)"
@@ -652,6 +673,11 @@ const updateItem = async () => {
/>
</template>
</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
v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)"
@@ -752,6 +778,11 @@ const updateItem = async () => {
/>
</template>
</UPopover>
<BankAccountAssignInput
v-else-if="datapoint.inputType === 'bankaccountassign'"
v-model="item[datapoint.key]"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
/>
<Tiptap
v-else-if="datapoint.inputType === 'editor'"
@updateContent="(i) => contentChanged(i,datapoint)"
@@ -788,4 +819,4 @@ td {
padding-bottom: 0.15em;
padding-top: 0.15em;
}
</style>
</style>

View File

@@ -69,6 +69,12 @@ const profileStore = useProfileStore()
const tempStore = useTempStore()
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 columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key)))
@@ -138,7 +144,7 @@ const filteredRows = computed(() => {
/>
<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`)"
class="ml-3"
>+ {{dataType.labelSingle}}</UButton>
@@ -200,4 +206,4 @@ const filteredRows = computed(() => {
<style scoped>
</style>
</style>

View File

@@ -2,6 +2,7 @@
import EntityShowSubTimes from "~/components/EntityShowSubTimes.vue";
import WikiEntityWidget from "~/components/wiki/WikiEntityWidget.vue";
import LabelPrintModal from "~/components/LabelPrintModal.vue";
const props = defineProps({
type: {
@@ -69,7 +70,7 @@ const getAvailableQueryStringData = (keys) => {
if(props.item.customer) {
addParam("customer", props.item.customer.id)
} else if(type === "customers") {
} else if(type === "customers" || type === "members") {
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>
<template>
@@ -193,6 +206,14 @@ const changePinned = async () => {
color="yellow"
@click="changePinned"
></UButton>
<UButton
v-if="type === 'customerinventoryitems'"
icon="i-heroicons-printer"
variant="outline"
@click="openCustomerInventoryLabelPrint"
>
Label
</UButton>
<UButton
@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>
</template>
<template #right>
<UButton
v-if="type === 'customerinventoryitems'"
icon="i-heroicons-printer"
variant="outline"
@click="openCustomerInventoryLabelPrint"
>
Label
</UButton>
<UButton
@click="router.push(`/standardEntity/${type}/edit/${item.id}`)"
>
@@ -372,4 +401,4 @@ const changePinned = async () => {
<style scoped>
</style>
</style>

View File

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

View File

@@ -21,13 +21,20 @@ const props = defineProps({
const dataStore = useDataStore()
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>
<template>
<UCard class="mt-5 scroll" :style="props.platform !== 'mobile' ? 'height: 80vh' : ''">
<HistoryDisplay
:type="props.topLevelType"
:type="historyType"
v-if="props.item.id"
:element-id="props.item.id"
render-headline
@@ -39,4 +46,4 @@ const dataType = dataStore.dataTypes[props.topLevelType]
<style scoped>
</style>
</style>

View File

@@ -1,4 +1,5 @@
<script setup>
import dayjs from "dayjs";
const props = defineProps({
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 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>
<template>
@@ -53,8 +81,7 @@ const dataType = dataStore.dataTypes[props.topLevelType]
<td>
<component v-if="datapoint.component" :is="datapoint.component" :row="props.item" :in-show="true"></component>
<div v-else>
<span v-if="datapoint.key.includes('.')">{{props.item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]}}{{datapoint.unit}}</span>
<span v-else>{{props.item[datapoint.key]}} {{datapoint.unit}}</span>
<span>{{ renderDatapointValue(datapoint) }}</span>
</div>
</td>
</tr>
@@ -74,4 +101,4 @@ td {
padding-bottom: 0.15em;
padding-top: 0.15em;
}
</style>
</style>

View File

@@ -24,6 +24,7 @@ const emit = defineEmits(["updateNeeded"]);
const router = useRouter()
const profileStore = useProfileStore()
const auth = useAuthStore()
const renderedPhases = computed(() => {
if(props.topLevelType === "projects" && props.item.phases) {
@@ -57,6 +58,7 @@ const renderedPhases = computed(() => {
})
const changeActivePhase = async (key) => {
console.log(props.item)
let item = await useEntities("projects").selectSingle(props.item.id,'*')
let phaseLabel = ""
@@ -67,13 +69,15 @@ const changeActivePhase = async (key) => {
if(p.key === key) {
p.active = true
p.activated_at = dayjs().format()
p.activated_by = profileStore.activeProfile.id
p.activated_by = auth.user.id
phaseLabel = p.label
}
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})
emit("updateNeeded")
@@ -140,7 +144,7 @@ const changeActivePhase = async (key) => {
<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_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>
</div>
</UCard>

View File

@@ -1,4 +1,6 @@
<script setup>
const getShowRoute = (entityType, id) => entityType === "tasks" ? `/tasks/show/${id}` : `/standardEntity/${entityType}/show/${id}`
defineShortcuts({
/*'/': () => {
//console.log(searchinput)
@@ -8,7 +10,7 @@
'Enter': {
usingInput: true,
handler: () => {
router.push(`/standardEntity/${props.type}/show/${props.rows.value[selectedItem.value].id}`)
router.push(getShowRoute(props.type, props.rows[selectedItem.value].id))
}
},
'arrowdown': () => {
@@ -75,7 +77,7 @@
:columns="props.columns"
class="w-full"
: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` }"
>
<!-- <template
@@ -134,4 +136,4 @@
<style scoped>
</style>
</style>

View File

@@ -46,6 +46,7 @@
const dataStore = useDataStore()
const router = useRouter()
const getShowRoute = (entityType, id) => entityType === "tasks" ? `/tasks/show/${id}` : `/standardEntity/${entityType}/show/${id}`
const dataType = dataStore.dataTypes[props.type]
@@ -59,7 +60,7 @@
<a
v-for="item in props.rows"
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="text-sm">
@@ -126,4 +127,4 @@
<style scoped>
</style>
</style>

View File

@@ -3,11 +3,13 @@ import dayjs from "dayjs"
const props = defineProps({
type: {
type: String,
required: true
required: false,
default: null
},
elementId: {
type: String,
required: true
required: false,
default: null
},
renderHeadline: {
type: Boolean,
@@ -25,13 +27,11 @@ const items = ref([])
const platform = ref("default")
const setup = async () => {
if(props.type && props.elementId){
items.value = await useNuxtApp().$api(`/api/resource/${props.type}/${props.elementId}/history`)
} /*else {
}*/
} else {
items.value = await useNuxtApp().$api(`/api/history`)
}
}
setup()
@@ -43,6 +43,10 @@ const addHistoryItemData = ref({
})
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`, {
method: "POST",
@@ -161,4 +165,4 @@ const renderText = (text) => {
<style scoped>
</style>
</style>

View File

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

View File

@@ -5,8 +5,251 @@ const { has } = usePermission()
// Lokaler State für den Taschenrechner
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 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 [
...(auth.profile?.pinned_on_navigation || []).map(pin => {
if (pin.type === "external") {
@@ -20,272 +263,96 @@ const links = computed(() => {
} else if (pin.type === "standardEntity") {
return {
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,
pinned: true
}
}
}),
{
featureEnabled("dashboard") ? {
id: 'dashboard',
label: "Dashboard",
to: "/",
icon: "i-heroicons-home"
},
{
} : null,
featureEnabled("historyitems") ? {
id: 'historyitems',
label: "Logbuch",
to: "/historyitems",
icon: "i-heroicons-book-open",
disabled: true
},
{
icon: "i-heroicons-book-open"
} : null,
...(organisationChildren.length > 0 ? [{
label: "Organisation",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: [
...has("tasks") ? [{
label: "Aufgaben",
to: "/standardEntity/tasks",
icon: "i-heroicons-rectangle-stack"
}] : [],
...true ? [{
label: "Wiki",
to: "/wiki",
icon: "i-heroicons-book-open"
}] : [],
]
},
{
children: organisationChildren
}] : []),
...(documentChildren.length > 0 ? [{
label: "Dokumente",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: [
{
label: "Dateien",
to: "/files",
icon: "i-heroicons-document"
}, {
label: "Anschreiben",
to: "/createdletters",
icon: "i-heroicons-document",
disabled: true
}, {
label: "Boxen",
to: "/standardEntity/documentboxes",
icon: "i-heroicons-archive-box",
disabled: true
},
]
},
{
children: documentChildren
}] : []),
...(communicationChildren.length > 0 ? [{
label: "Kommunikation",
icon: "i-heroicons-megaphone",
defaultOpen: false,
children: [
{
label: "Helpdesk",
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")) ? [{
children: communicationChildren
}] : []),
...(contactsChildren.length > 0 ? [{
label: "Kontakte",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: [
...has("customers") ? [{
label: "Kunden",
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"
}] : [],
]
}] : [],
{
children: contactsChildren
}] : []),
...(staffChildren.length > 0 ? [{
label: "Mitarbeiter",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: [
...true ? [{
label: "Zeiten",
to: "/staff/time",
icon: "i-heroicons-clock",
}] : [],
]
},
...[{
children: staffChildren
}] : []),
...(accountingChildren.length > 0 ? [{
label: "Buchhaltung",
defaultOpen: false,
icon: "i-heroicons-chart-bar-square",
children: [
{
label: "Ausgangsbelege",
to: "/createDocument",
icon: "i-heroicons-document-text"
}, {
label: "Serienvorlagen",
to: "/createDocument/serialInvoice",
icon: "i-heroicons-document-text"
}, {
label: "Eingangsbelege",
to: "/incomingInvoices",
icon: "i-heroicons-document-text",
}, {
label: "Kostenstellen",
to: "/standardEntity/costcentres",
icon: "i-heroicons-document-currency-euro"
}, {
label: "Buchungskonten",
to: "/accounts",
icon: "i-heroicons-document-text",
}, {
label: "zusätzliche Buchungskonten",
to: "/standardEntity/ownaccounts",
icon: "i-heroicons-document-text"
},
{
label: "Bank",
to: "/banking",
icon: "i-heroicons-document-text",
},
]
}],
...has("inventory") ? [{
children: accountingChildren
}] : []),
...(inventoryChildren.length > 0 ? [{
label: "Lager",
icon: "i-heroicons-puzzle-piece",
defaultOpen: false,
children: [
...has("spaces") ? [{
label: "Lagerplätze",
to: "/standardEntity/spaces",
icon: "i-heroicons-square-3-stack-3d"
}] : [],
]
}] : [],
{
children: inventoryChildren
}] : []),
...(masterDataChildren.length > 0 ? [{
label: "Stammdaten",
defaultOpen: false,
icon: "i-heroicons-clipboard-document",
children: [
...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"
}] : [],
]
},
children: masterDataChildren
}] : []),
...has("projects") ? [{
...(has("projects") && featureEnabled("projects")) ? [{
label: "Projekte",
to: "/standardEntity/projects",
icon: "i-heroicons-clipboard-document-check"
}] : [],
...has("contracts") ? [{
...(has("contracts") && featureEnabled("contracts")) ? [{
label: "Verträge",
to: "/standardEntity/contracts",
icon: "i-heroicons-clipboard-document"
}] : [],
...has("plants") ? [{
...(has("plants") && featureEnabled("plants")) ? [{
label: "Objekte",
to: "/standardEntity/plants",
icon: "i-heroicons-clipboard-document"
}] : [],
{
...(settingsChildren.length > 0 ? [{
label: "Einstellungen",
defaultOpen: false,
icon: "i-heroicons-cog-8-tooth",
children: [
{
label: "Nummernkreise",
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"
}
]
},
]
children: settingsChildren
}] : []),
].filter(Boolean)
})
const accordionItems = computed(() =>
@@ -365,4 +432,4 @@ const buttonItems = computed(() =>
</UAccordion>
<Calculator v-if="showCalculator" v-model="showCalculator"/>
</template>
</template>

View File

@@ -1,4 +1,6 @@
<script setup>
import { computed } from "vue"
const props = defineProps({
row: {
type: Object,
@@ -6,13 +8,15 @@ const props = defineProps({
default: {}
}
})
const addressData = computed(() => props.row?.infoData || props.row?.info_data || {})
</script>
<template>
<span v-if="props.row.infoData.streetNumber">{{props.row.infoData.streetNumber}},</span>
<span v-if="props.row.infoData.street">{{props.row.infoData.street}},</span>
<span v-if="props.row.infoData.special">{{props.row.infoData.special}},</span>
<span v-if="props.row.infoData.zip">{{props.row.infoData.zip}},</span>
<span v-if="props.row.infoData.city">{{props.row.infoData.city}}</span>
<span v-if="addressData.streetNumber">{{ addressData.streetNumber }},</span>
<span v-if="addressData.street">{{ addressData.street }},</span>
<span v-if="addressData.special">{{ addressData.special }},</span>
<span v-if="addressData.zip">{{ addressData.zip }},</span>
<span v-if="addressData.city">{{ addressData.city }}</span>
</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: {}
}
})
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>
<template>
<div v-if="props.row.description" v-html="props.row.description.html"/>
<div v-if="descriptionText">{{ descriptionText }}</div>
</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>
import customParseFormat from "dayjs/plugin/customParseFormat";
import dayjs from "dayjs";
import { Line } from "vue-chartjs";
dayjs.extend(customParseFormat)
const tempStore = useTempStore()
let incomeData = ref({})
let expenseData = ref({})
const amountMode = ref("net")
const granularity = ref("year")
const selectedYear = ref(dayjs().year())
const selectedMonth = ref(dayjs().month() + 1)
const setup = async () => {
let incomeRawData = (await useEntities("createddocuments").select()).filter(i => i.state === "Gebucht" && ['invoices','advanceInvoices','cancellationInvoices'].includes(i.type))
const incomeDocuments = ref([])
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())
let withoutInvoiceRawData = (await useEntities("statementallocations").select()).filter(i => i.account)
const normalizeMode = (value) => value === "gross" ? "gross" : "net"
const normalizeGranularity = (value) => value === "month" ? "month" : "year"
let withoutInvoiceRawDataExpenses = []
let withoutInvoiceRawDataIncomes = []
watch(
() => tempStore.settings?.dashboardIncomeExpenseView,
(storedView) => {
const legacyMode = tempStore.settings?.dashboardIncomeExpenseMode
withoutInvoiceRawData.forEach(i => {
if(i.amount > 0) {
withoutInvoiceRawDataIncomes.push({
id: i.id,
date: dayjs(i.created_at).format("DD-MM-YY"),
amount: Math.abs(i.amount),
bs_id: i.bs_id
})
} else if(i.amount < 0) {
withoutInvoiceRawDataExpenses.push({
id: i.id,
date: dayjs(i.created_at).format("DD-MM-YY"),
amount: Math.abs(i.amount),
bs_id: i.bs_id
})
}
amountMode.value = normalizeMode(storedView?.amountMode || legacyMode)
granularity.value = normalizeGranularity(storedView?.granularity)
const nextYear = Number(storedView?.year)
const nextMonth = Number(storedView?.month)
selectedYear.value = Number.isFinite(nextYear) ? nextYear : dayjs().year()
selectedMonth.value = Number.isFinite(nextMonth) && nextMonth >= 1 && nextMonth <= 12
? nextMonth
: dayjs().month() + 1
},
{ immediate: true }
)
watch([amountMode, granularity, selectedYear, selectedMonth], () => {
tempStore.modifySettings("dashboardIncomeExpenseView", {
amountMode: amountMode.value,
granularity: granularity.value,
year: selectedYear.value,
month: selectedMonth.value
})
/*withoutInvoiceRawDataExpenses.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
})
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
// Backward compatibility for any existing consumers.
tempStore.modifySettings("dashboardIncomeExpenseMode", amountMode.value)
})
/*const chartData = computed(() => {
return {
labels: days.value,
datasets: [
{
label: 'Einnahmen',
data: [2, 1, 16, 3, 2],
backgroundColor: 'rgba(20, 255, 0, 0.3)',
borderColor: 'red',
borderWidth: 2,
}
]
const loadData = async () => {
const [docs, incoming] = await Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select()
])
incomeDocuments.value = (docs || []).filter((item) => item.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(item.type))
expenseInvoices.value = (incoming || []).filter((item) => item.date)
}
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 keys = Object.keys(buckets.value.income).sort()
return {
labels: ["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"],
labels: chartLabels.value,
datasets: [
{
label: 'Ausgaben',
backgroundColor: '#f87979',
borderColor: '#f87979',
data: Object.keys(expenseData.value).sort().map(i => expenseData.value[i]),
label: "Ausgaben",
backgroundColor: "#f87979",
borderColor: "#f87979",
data: keys.map((key) => buckets.value.expense[key]),
tension: 0.3,
},{
label: 'Einnahmen',
backgroundColor: '#69c350',
borderColor: '#69c350',
data: Object.keys(incomeData.value).sort().map(i => incomeData.value[i]),
},
{
label: "Einnahmen",
backgroundColor: "#69c350",
borderColor: "#69c350",
data: keys.map((key) => buckets.value.income[key]),
tension: 0.3
},
],
}
})
const chartOptions = ref({
responsive: true,
maintainAspectRatio: false,
})
setup()
loadData()
</script>
<template>
<Line
:data="chartData"
:options="chartOptions"
/>
<div class="h-full flex flex-col gap-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<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>
<style scoped>

View File

@@ -5,8 +5,11 @@ const router = useRouter()
const auth = useAuthStore()
const setupPage = async () => {
//TODO: BACKEND CHANGE Migrate to auth_users for profile
openTasks.value = (await useEntities("tasks").select()).filter(i => !i.archived && i.user_id === auth.user.id)
openTasks.value = (await useEntities("tasks").select()).filter((task) => {
const assignee = task.userId || task.user_id || task.profile
const currentUser = auth.user?.user_id || auth.user?.id
return !task.archived && assignee === currentUser
})
}
setupPage()
@@ -18,7 +21,7 @@ setupPage()
v-if="openTasks.length > 0"
:rows="openTasks"
: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>
<p class="text-center font-bold">Keine offenen Aufgaben</p>
@@ -27,4 +30,4 @@ setupPage()
<style scoped>
</style>
</style>

View File

@@ -8,7 +8,7 @@ const _useDashboard = () => {
defineShortcuts({
'g-h': () => router.push('/'),
'g-a': () => router.push('/standardEntity/tasks'),
'g-a': () => router.push('/tasks'),
'g-d': () => router.push('/files'),
'g-k': () => router.push('/standardEntity/customers'),
'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",
parent: "contracts"
},
contracttypes: {
label: "Vertragstypen",
showToAllUsers: false
},
"contracttypes-viewAll": {
label: "Alle Vertragstypen einsehen",
parent: "contracttypes"
},
"contracttypes-create": {
label: "Vertragstypen erstellen",
parent: "contracttypes"
},
plants: {
label: "Objekte",
showToAllUsers: false
@@ -306,4 +318,4 @@ export const useRole = () => {
checkRight
}
}
}

View File

@@ -99,6 +99,10 @@ const setupData = async () => {
}
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 () => {
await setupData()
@@ -138,14 +142,15 @@ const setupPage = async () => {
if (route.query.loadMode === "deliveryNotes") {
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
itemInfo.value.customer = linkedDocuments[0].customer ? linkedDocuments[0].customer.id : null
itemInfo.value.project = linkedDocuments[0].project ? linkedDocuments[0].project.id : null
itemInfo.value.contact = linkedDocuments[0].contact ? linkedDocuments[0].contact.id : null
itemInfo.value.customer = normalizeEntityId(linkedDocuments[0].customer)
itemInfo.value.project = normalizeEntityId(linkedDocuments[0].project)
itemInfo.value.contact = normalizeEntityId(linkedDocuments[0].contact)
setCustomerData()
await setCustomerData(null, true)
let firstDate = null
let lastDate = null
@@ -207,21 +212,23 @@ const setupPage = async () => {
}
else if (route.query.loadMode === "finalInvoice") {
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
console.log(linkedDocuments)
itemInfo.value.customer = linkedDocuments[0].customer
itemInfo.value.project = linkedDocuments[0].project
itemInfo.value.contact = linkedDocuments[0].contact
itemInfo.value.customer = normalizeEntityId(linkedDocuments[0].customer)
itemInfo.value.project = normalizeEntityId(linkedDocuments[0].project)
itemInfo.value.contact = normalizeEntityId(linkedDocuments[0].contact)
setCustomerData()
await setCustomerData(null, true)
for await (const doc of linkedDocuments.filter(i => i.type === "confirmationOrders")) {
let linkedDocument = await useEntities("createddocuments").selectSingle(doc.id)
itemInfo.value.rows.push({
id: uuidv4(),
mode: "title",
text: linkedDocument.title,
})
@@ -233,6 +240,7 @@ const setupPage = async () => {
let linkedDocument = await useEntities("createddocuments").selectSingle(doc.id)
itemInfo.value.rows.push({
id: uuidv4(),
mode: "title",
text: linkedDocument.title,
})
@@ -241,6 +249,7 @@ const setupPage = async () => {
}
itemInfo.value.rows.push({
id: uuidv4(),
mode: "title",
text: "Abschlagsrechnungen",
})
@@ -475,6 +484,10 @@ const setCustomerData = async (customerId, loadOnlyAdress = false) => {
if (!loadOnlyAdress && customer.customPaymentDays) itemInfo.value.paymentDays = customer.customPaymentDays
if (!loadOnlyAdress && customer.custom_payment_type) itemInfo.value.payment_type = customer.custom_payment_type
if (!loadOnlyAdress) {
itemInfo.value.taxType = customer.customTaxType || "Standard"
setTaxType()
}
if (!loadOnlyAdress && customer.customSurchargePercentage) {
itemInfo.value.customSurchargePercentage = customer.customSurchargePercentage
@@ -2060,6 +2073,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
>
<UInput
type="number"
step="0.01"
v-model="itemInfo.customSurchargePercentage"
@change="updateCustomSurcharge"
>

View File

@@ -264,7 +264,18 @@ const clearSearchString = () => {
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 filteredRows = computed(() => {
@@ -286,8 +297,10 @@ const filteredRows = computed(() => {
if (selectedFilters.value.length > 0) {
selectedFilters.value.forEach(filterName => {
let filter = dataType.filters.find(i => i.name === filterName)
tempItems = tempItems.filter(filter.filterFunction)
const filter = availableFilters.value.find(i => i.name === filterName)
if (filter?.filterFunction) {
tempItems = tempItems.filter(filter.filterFunction)
}
})
}
@@ -310,4 +323,4 @@ const isPaid = (item) => {
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
return Number(amountPaid.toFixed(2)) === useSum().getCreatedDocumentSum(item, items.value)
}
</script>
</script>

View File

@@ -158,13 +158,24 @@
<UDivider label="Vorlagen auswählen" />
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<UInput
v-model="modalSearch"
icon="i-heroicons-magnifying-glass"
placeholder="Kunde oder Vertrag suchen..."
class="w-full sm:w-64"
size="sm"
/>
<div class="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<UInput
v-model="modalSearch"
icon="i-heroicons-magnifying-glass"
placeholder="Kunde oder Vertrag suchen..."
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">
<span class="text-xs text-gray-500 hidden sm:inline">
@@ -201,11 +212,14 @@
{{displayCurrency(calculateDocSum(row))}}
</template>
<template #serialConfig.intervall-data="{row}">
{{ row.serialConfig?.intervall }}
{{ getIntervallLabel(row.serialConfig?.intervall) }}
</template>
<template #contract-data="{row}">
{{row.contract?.contractNumber}} - {{row.contract?.name}}
</template>
<template #plant-data="{row}">
{{ row.plant?.name || "-" }}
</template>
</UTable>
</div>
@@ -287,6 +301,7 @@ const executionDate = ref(dayjs().format('YYYY-MM-DD'))
const selectedExecutionRows = ref([])
const isExecuting = ref(false)
const modalSearch = ref("") // NEU: Suchstring für das Modal
const selectedExecutionIntervall = ref("all")
// --- SerialExecutions State ---
const showExecutionsSlideover = ref(false)
@@ -295,7 +310,7 @@ const executionsLoading = ref(false)
const finishingId = ref(null)
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()
}
@@ -390,30 +405,78 @@ const filteredRows = computed(() => {
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(() => {
return items.value
.filter(i => i.type === "serialInvoices" && !!i.serialConfig?.active)
.filter(i => i.type === "serialInvoices" && !!i.serialConfig?.active && !i.archived)
.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
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()
return activeTemplates.value.filter(row => {
return filtered.filter(row => {
const customerName = row.customer?.name?.toLowerCase() || ""
const contractNum = row.contract?.contractNumber?.toLowerCase() || ""
const contractName = row.contract?.name?.toLowerCase() || ""
const plantName = row.plant?.name?.toLowerCase() || ""
return customerName.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)
const selectAllTemplates = () => {
// WICHTIG: Überschreibt nicht bestehende Auswahl, sondern fügt hinzu oder ersetzt.
@@ -469,6 +532,7 @@ const templateColumns = [
const executionColumns = [
{key: 'partner', label: "Kunde"},
{key: 'plant', label: "Objekt"},
{key: 'contract', label: "Vertrag"},
{key: 'serialConfig.intervall', label: "Intervall"},
{key: "amount", label: "Betrag"},
@@ -509,8 +573,9 @@ const calculateDocSum = (row) => {
const openExecutionModal = () => {
executionDate.value = dayjs().format('YYYY-MM-DD')
selectedExecutionRows.value = []
modalSearch.value = "" // Reset Search
selectedExecutionIntervall.value = "all"
selectedExecutionRows.value = []
showExecutionModal.value = true
}
@@ -557,4 +622,4 @@ const executeSerialInvoices = async () => {
}
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 }}
</template>
</USelectMenu>
<UButton
v-if="mode !== 'show'"
icon="i-heroicons-x-mark"
variant="outline"
color="rose"
:disabled="!itemInfo.vendor"
@click="itemInfo.vendor = null"
/>
<EntityModalButtons
v-if="mode !== 'show'"
type="vendors"

View File

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

View File

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

View File

@@ -14,6 +14,9 @@ const resources = {
spaces: {
label: "Lagerplätze"
},
customerspaces: {
label: "Kundenlagerplätze"
},
invoices: {
label: "Rechnungen"
},
@@ -23,6 +26,9 @@ const resources = {
inventoryitems: {
label: "Inventarartikel"
},
customerinventoryitems: {
label: "Kundeninventarartikel"
},
projects: {
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) => {

View File

@@ -1,6 +1,108 @@
<script setup>
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({
features: {},
@@ -13,8 +115,13 @@ const setupPage = async () => {
console.log(itemInfo.value)
}
const features = ref(auth.activeTenantData.features)
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
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) => {
@@ -24,6 +131,15 @@ const updateTenant = async (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()
@@ -40,6 +156,8 @@ setupPage()
label: 'Dokubox'
},{
label: 'Rechnung & Kontakt'
},{
label: 'Funktionen'
}
]"
>
@@ -63,8 +181,8 @@ setupPage()
</div>
<div v-if="item.label === 'Rechnung & Kontakt'">
<UCard class="mt-5">
<UForm class="w-1/2">
<UCard class="mt-5">
<UForm class="w-1/2">
<UFormGroup
label="Firmenname:"
>
@@ -90,6 +208,23 @@ setupPage()
>
Speichern
</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>
</UCard>
@@ -104,59 +239,11 @@ setupPage()
class="mb-5"
/>
<UCheckbox
label="Kalendar"
v-model="features.calendar"
@change="updateTenant({features: features})"
/>
<UCheckbox
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})"
v-for="option in featureOptions"
:key="option.key"
:label="option.label"
v-model="features[option.key]"
@change="saveFeatures"
/>
</UCard>
</div>

View File

@@ -22,6 +22,13 @@ const setupPage = async (sort_column = null, sort_direction = null) => {
loaded.value = false
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
@@ -88,4 +95,4 @@ setupPage()
<style scoped>
</style>
</style>

View File

@@ -49,6 +49,12 @@ const tempStore = useTempStore()
const type = route.params.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 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 showMobileFilter = ref(false)
let lastSearchRequestId = 0
//Functions
@@ -80,6 +87,7 @@ function resetMobileFilters() {
Object.keys(itemsMeta.value.distinctValues).forEach(key => {
columnsToFilter.value[key] = [...itemsMeta.value.distinctValues[key]]
tempStore.clearFilter(type, key)
})
showMobileFilter.value = false
@@ -88,7 +96,16 @@ function resetMobileFilters() {
function applyMobileFilters() {
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
@@ -102,7 +119,7 @@ const clearSearchString = () => {
}
const performSearch = async () => {
tempStore.modifySearchString(type,searchString)
tempStore.modifySearchString(type, searchString.value)
changePage(1,true)
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
const setupPage = async () => {
const currentRequestId = ++lastSearchRequestId
loading.value = true
setPageLayout(platformIsNative ? "mobile" : "default")
if (type === "tasks") {
await navigateTo("/tasks")
return
}
const filters = {
archived:false
}
Object.keys(columnsToFilter.value).forEach((column) => {
if(columnsToFilter.value[column].length !== itemsMeta.value.distinctValues[column].length) {
filters[column] = columnsToFilter.value[column]
Object.entries(tempStore.filters[type] || {}).forEach(([column, selected]) => {
if (Array.isArray(selected) && selected.length > 0) {
filters[column] = selected
}
})
@@ -166,16 +244,16 @@ const setupPage = async () => {
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
itemsMeta.value = meta
if(!initialSetupDone.value){
Object.keys(tempStore.filters[type] || {}).forEach((column) => {
columnsToFilter.value[column] = tempStore.filters[type][column]
})
Object.keys(itemsMeta.value.distinctValues).filter(i => !Object.keys(tempStore.filters[type] || {}).includes(i)).forEach(distinctValue => {
columnsToFilter.value[distinctValue] = itemsMeta.value.distinctValues[distinctValue]
})
initializeDistinctFilters()
} else {
syncDistinctFiltersFromStore()
}
@@ -188,9 +266,18 @@ setupPage()
const handleFilterChange = async (action,column) => {
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') {
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()
}
@@ -235,7 +322,7 @@ const handleFilterChange = async (action,column) => {
<UTooltip :text="`${dataType.labelSingle} erstellen`">
<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`)"
class="ml-3"
>+ {{dataType.labelSingle}}</UButton>
@@ -624,4 +711,4 @@ const handleFilterChange = async (action,column) => {
<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 contracttype from "~/components/columnRenderings/contracttype.vue"
import memberrelation from "~/components/columnRenderings/memberrelation.vue"
import customer from "~/components/columnRenderings/customer.vue"
import contact from "~/components/columnRenderings/contact.vue"
import plant from "~/components/columnRenderings/plant.vue"
import vendor from "~/components/columnRenderings/vendor.vue"
import product from "~/components/columnRenderings/product.vue"
import active from "~/components/columnRenderings/active.vue"
import sellingPrice from "~/components/columnRenderings/sellingPrice.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 endDateTime from "~/components/columnRenderings/endDateTime.vue"
import serviceCategories from "~/components/columnRenderings/serviceCategories.vue"
import productcategoriesWithLoad from "~/components/columnRenderings/productcategoriesWithLoad.vue"
import phase from "~/components/columnRenderings/phase.vue"
import vehiclesWithLoad from "~/components/columnRenderings/vehiclesWithLoad.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 signDate from "~/components/columnRenderings/signDate.vue";
import sepaDate from "~/components/columnRenderings/sepaDate.vue";
import bankAccounts from "~/components/columnRenderings/bankAccounts.vue";
// @ts-ignore
export const useDataStore = defineStore('data', () => {
@@ -53,7 +58,7 @@ export const useDataStore = defineStore('data', () => {
isArchivable: true,
label: "Aufgaben",
labelSingle: "Aufgabe",
isStandardEntity: true,
isStandardEntity: false,
redirect: true,
historyItemHolder: "task",
selectWithInformation: "*, plant(*), project(*), customer(*)",
@@ -165,7 +170,7 @@ export const useDataStore = defineStore('data', () => {
numberRangeHolder: "customerNumber",
historyItemHolder: "customer",
sortColumn: "customerNumber",
selectWithInformation: "*, projects(*), plants(*), contracts(*), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*)",
selectWithInformation: "*, projects(*), plants(*), contracts(*), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*), customerinventoryitems(*), customerspaces(*)",
filters: [{
name: "Archivierte ausblenden",
default: true,
@@ -310,6 +315,19 @@ export const useDataStore = defineStore('data', () => {
],
inputColumn: "Allgemeines",
sortable: true
},{
key: "customTaxType",
label: "Standard Steuertyp",
inputType: "select",
selectValueAttribute:"key",
selectOptionAttribute: "label",
selectManualOptions: [
{label:'Standard', key: 'Standard'},
{label:'13b UStG', key: '13b UStG'},
{label:'19 UStG Kleinunternehmer', key: '19 UStG'},
],
inputColumn: "Allgemeines",
sortable: true
}, {
key: "customSurchargePercentage",
label: "Individueller Aufschlag",
@@ -396,6 +414,14 @@ export const useDataStore = defineStore('data', () => {
inputType: "text",
inputColumn: "Kontaktdaten"
},
{
key: "infoData.bankAccountIds",
label: "Bankkonten",
component: bankAccounts,
inputType: "bankaccountassign",
inputColumn: "Kontaktdaten",
disabledInTable: true
},
{
key: "infoData.ustid",
label: "USt-Id",
@@ -420,6 +446,220 @@ export const useDataStore = defineStore('data', () => {
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'}]
},
contacts: {
@@ -569,7 +809,7 @@ export const useDataStore = defineStore('data', () => {
"Allgemeines",
"Abrechnung"
],
selectWithInformation: "*, customer(*), files(*)",
selectWithInformation: "*, customer(*), contracttype(*), files(*)",
templateColumns: [
{
key: 'contractNumber',
@@ -587,6 +827,23 @@ export const useDataStore = defineStore('data', () => {
inputType: "text",
inputColumn: "Allgemeines",
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",
label: "Aktiv",
@@ -657,6 +914,19 @@ export const useDataStore = defineStore('data', () => {
{label:'Überweisung'}
],
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',
label: "Vertragsstart",
@@ -717,6 +987,126 @@ export const useDataStore = defineStore('data', () => {
],
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: {
isArchivable: true,
label: "Abwesenheiten",
@@ -847,9 +1237,9 @@ export const useDataStore = defineStore('data', () => {
selectOptionAttribute: "name",
selectSearchAttributes: ['name'],
},{
key: "description",
key: "description.text",
label: "Beschreibung",
inputType:"editor",
inputType:"textarea",
component: description
},
],
@@ -975,7 +1365,8 @@ export const useDataStore = defineStore('data', () => {
selectDataType: "productcategories",
selectOptionAttribute: "name",
selectSearchAttributes: ['name'],
selectMultiple: true
selectMultiple: true,
component: productcategoriesWithLoad
},
{
key: "description",
@@ -1342,6 +1733,13 @@ export const useDataStore = defineStore('data', () => {
label: "Web",
inputType: "text"
},
{
key: "infoData.bankAccountIds",
label: "Bankkonten",
component: bankAccounts,
inputType: "bankaccountassign",
disabledInTable: true
},
{
key: "infoData.ustid",
label: "USt-Id",
@@ -1448,6 +1846,7 @@ export const useDataStore = defineStore('data', () => {
selectValueAttribute: "label",
selectManualOptions: [
{label:"Standort"},
{label:"Raum"},
{label:"Regalplatz"},
{label:"Kiste"},
{label:"Palettenplatz"},
@@ -1531,6 +1930,149 @@ export const useDataStore = defineStore('data', () => {
},{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: {
label: "Benutzer",
labelSingle: "Benutzer"
@@ -1643,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: {
isArchivable: true,
label: "Inventarartikel",
@@ -2587,6 +3302,55 @@ export const useDataStore = defineStore('data', () => {
bankaccounts: {
label: "Bankkonten",
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()
}
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) {
columns.value[type] = input
storeTempConfig()
@@ -81,6 +98,8 @@ export const useTempStore = defineStore('temp', () => {
clearSearchString,
filters,
modifyFilter,
setFilters,
clearFilter,
columns,
modifyColumns,
modifyPages,
@@ -89,4 +108,4 @@ export const useTempStore = defineStore('temp', () => {
modifyBankingPeriod, // Neue Funktion exportiert
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