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
This commit is contained in:
2026-02-17 12:38:39 +01:00
parent f26d6bd4f3
commit 6fded3993a
39 changed files with 4837 additions and 158 deletions

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

@@ -85,6 +85,34 @@
"when": 1773000300000, "when": 1773000300000,
"tag": "0011_mighty_member_bankaccounts", "tag": "0011_mighty_member_bankaccounts",
"breakpoints": true "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
} }
] ]
} }

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

@@ -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

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

View File

@@ -19,6 +19,8 @@ export * from "./countrys"
export * from "./createddocuments" export * from "./createddocuments"
export * from "./createdletters" export * from "./createdletters"
export * from "./customers" export * from "./customers"
export * from "./customerspaces"
export * from "./customerinventoryitems"
export * from "./devices" export * from "./devices"
export * from "./documentboxes" export * from "./documentboxes"
export * from "./enums" export * from "./enums"
@@ -44,6 +46,7 @@ export * from "./incominginvoices"
export * from "./inventoryitemgroups" export * from "./inventoryitemgroups"
export * from "./inventoryitems" export * from "./inventoryitems"
export * from "./letterheads" export * from "./letterheads"
export * from "./memberrelations"
export * from "./movements" export * from "./movements"
export * from "./m2m_api_keys" export * from "./m2m_api_keys"
export * from "./notifications_event_types" export * from "./notifications_event_types"

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

@@ -88,7 +88,9 @@ export const tenants = pgTable(
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 }, confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 }, invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 },
spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 }, spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 },
customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 },
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 }, inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },
customerinventoryitems: { prefix: "KIA-", suffix: "", nextNumber: 1000 },
projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 }, projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 },
costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 }, costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 },
}), }),

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

@@ -6,6 +6,7 @@ import { secrets } from "../utils/secrets"
import { insertHistoryItem } from "../utils/history" import { insertHistoryItem } from "../utils/history"
import { decrypt, encrypt } from "../utils/crypt" import { decrypt, encrypt } from "../utils/crypt"
import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes" import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes"
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
import { import {
bankrequisitions, bankrequisitions,
@@ -116,16 +117,20 @@ export default async function bankingRoutes(server: FastifyInstance) {
return remainder === 1 return remainder === 1
} }
const resolveBankInstituteFromIbanLocal = (iban: string) => { const resolveGermanBankDataFromIbanLocal = (iban: string) => {
const normalized = normalizeIban(iban) const normalized = normalizeIban(iban)
if (!isValidIbanLocal(normalized)) return null if (!isValidIbanLocal(normalized)) return null
// Für DE-IBANs kann die BLZ aus Position 5-12 lokal gelesen werden. // Für DE-IBANs kann die BLZ aus Position 5-12 lokal gelesen werden.
if (normalized.startsWith("DE") && normalized.length === 22) { if (normalized.startsWith("DE") && normalized.length === 22) {
const bankCode = normalized.slice(4, 12) const bankCode = normalized.slice(4, 12)
const bankName = DE_BANK_CODE_TO_NAME[bankCode] const bankName = DE_BANK_CODE_TO_NAME[bankCode] || `Unbekannt (BLZ ${bankCode})`
if (bankName) return bankName const bic = DE_BANK_CODE_TO_BIC[bankCode] || null
return `Unbekannt (BLZ ${bankCode})` return {
bankName,
bic,
bankCode,
}
} }
return null return null
@@ -139,13 +144,14 @@ export default async function bankingRoutes(server: FastifyInstance) {
const normalizedIban = normalizeIban(iban) const normalizedIban = normalizeIban(iban)
if (!normalizedIban) return null if (!normalizedIban) return null
const bankInstitute = resolveBankInstituteFromIbanLocal(normalizedIban) const bankData = resolveGermanBankDataFromIbanLocal(normalizedIban)
const allAccounts = await server.db const allAccounts = await server.db
.select({ .select({
id: entitybankaccounts.id, id: entitybankaccounts.id,
ibanEncrypted: entitybankaccounts.ibanEncrypted, ibanEncrypted: entitybankaccounts.ibanEncrypted,
bankNameEncrypted: entitybankaccounts.bankNameEncrypted, bankNameEncrypted: entitybankaccounts.bankNameEncrypted,
bicEncrypted: entitybankaccounts.bicEncrypted,
}) })
.from(entitybankaccounts) .from(entitybankaccounts)
.where(eq(entitybankaccounts.tenant, tenantId)) .where(eq(entitybankaccounts.tenant, tenantId))
@@ -161,19 +167,28 @@ export default async function bankingRoutes(server: FastifyInstance) {
}) })
if (existing?.id) { if (existing?.id) {
if (bankInstitute) { if (bankData) {
let currentBankName = "" let currentBankName = ""
let currentBic = ""
try { try {
currentBankName = String(decrypt(existing.bankNameEncrypted as any) || "").trim() currentBankName = String(decrypt(existing.bankNameEncrypted as any) || "").trim()
} catch { } catch {
currentBankName = "" currentBankName = ""
} }
try {
currentBic = String(decrypt((existing as any).bicEncrypted as any) || "").trim()
} catch {
currentBic = ""
}
if (currentBankName !== bankInstitute) { const nextBankName = bankData?.bankName || "Unbekannt"
const nextBic = bankData?.bic || "UNBEKANNT"
if (currentBankName !== nextBankName || currentBic !== nextBic) {
await server.db await server.db
.update(entitybankaccounts) .update(entitybankaccounts)
.set({ .set({
bankNameEncrypted: encrypt(bankInstitute), bankNameEncrypted: encrypt(nextBankName),
bicEncrypted: encrypt(nextBic),
updatedAt: new Date(), updatedAt: new Date(),
updatedBy: userId, updatedBy: userId,
}) })
@@ -189,8 +204,8 @@ export default async function bankingRoutes(server: FastifyInstance) {
.values({ .values({
tenant: tenantId, tenant: tenantId,
ibanEncrypted: encrypt(normalizedIban), ibanEncrypted: encrypt(normalizedIban),
bicEncrypted: encrypt("UNBEKANNT"), bicEncrypted: encrypt(bankData?.bic || "UNBEKANNT"),
bankNameEncrypted: encrypt(bankInstitute || "Unbekannt"), bankNameEncrypted: encrypt(bankData?.bankName || "Unbekannt"),
description: "Automatisch aus Bankbuchung übernommen", description: "Automatisch aus Bankbuchung übernommen",
updatedAt: new Date(), updatedAt: new Date(),
updatedBy: userId, updatedBy: userId,
@@ -200,6 +215,30 @@ export default async function bankingRoutes(server: FastifyInstance) {
return created?.id ? Number(created.id) : null 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) => { const assignIbanFromStatementToCustomer = async (tenantId: number, userId: string, statementId: number, createdDocumentId?: number) => {
if (!createdDocumentId) return if (!createdDocumentId) return

View File

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

View File

@@ -5,6 +5,7 @@ import { authProfiles, historyitems } from "../../db/schema";
const columnMap: Record<string, any> = { const columnMap: Record<string, any> = {
customers: historyitems.customer, customers: historyitems.customer,
members: historyitems.customer,
vendors: historyitems.vendor, vendors: historyitems.vendor,
projects: historyitems.project, projects: historyitems.project,
plants: historyitems.plant, plants: historyitems.plant,
@@ -22,10 +23,14 @@ const columnMap: Record<string, any> = {
documentboxes: historyitems.documentbox, documentboxes: historyitems.documentbox,
hourrates: historyitems.hourrate, hourrates: historyitems.hourrate,
services: historyitems.service, services: historyitems.service,
customerspaces: historyitems.customerspace,
customerinventoryitems: historyitems.customerinventoryitem,
memberrelations: historyitems.memberrelation,
}; };
const insertFieldMap: Record<string, string> = { const insertFieldMap: Record<string, string> = {
customers: "customer", customers: "customer",
members: "customer",
vendors: "vendor", vendors: "vendor",
projects: "project", projects: "project",
plants: "plant", plants: "plant",
@@ -43,6 +48,9 @@ const insertFieldMap: Record<string, string> = {
documentboxes: "documentbox", documentboxes: "documentbox",
hourrates: "hourrate", hourrates: "hourrate",
services: "service", services: "service",
customerspaces: "customerspace",
customerinventoryitems: "customerinventoryitem",
memberrelations: "memberrelation",
} }
const parseId = (value: string) => { const parseId = (value: string) => {

View File

@@ -12,7 +12,7 @@ import {
import { resourceConfig } from "../../utils/resource.config"; import { resourceConfig } from "../../utils/resource.config";
import { useNextNumberRangeNumber } from "../../utils/functions"; import { useNextNumberRangeNumber } from "../../utils/functions";
import { insertHistoryItem } from "../../utils/history"; import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
import { diffObjects } from "../../utils/diff"; import { diffObjects } from "../../utils/diff";
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"; import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
import { decrypt, encrypt } from "../../utils/crypt"; import { decrypt, encrypt } from "../../utils/crypt";
@@ -67,7 +67,8 @@ function getUserVisibleChanges(oldRecord: Record<string, any>, updated: Record<s
} }
function buildFieldUpdateHistoryText(resource: string, label: string, oldValue: any, newValue: any) { function buildFieldUpdateHistoryText(resource: string, label: string, oldValue: any, newValue: any) {
return `${resource}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"` const resourceLabel = getHistoryEntityLabel(resource)
return `${resourceLabel}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"`
} }
function applyResourceWhereFilters(resource: string, table: any, whereCond: any) { function applyResourceWhereFilters(resource: string, table: any, whereCond: any) {
@@ -525,6 +526,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (created) { if (created) {
try { try {
const resourceLabel = getHistoryEntityLabel(resource)
await insertHistoryItem(server, { await insertHistoryItem(server, {
tenant_id: req.user.tenant_id, tenant_id: req.user.tenant_id,
created_by: req.user?.user_id || null, created_by: req.user?.user_id || null,
@@ -533,7 +535,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
action: "created", action: "created",
oldVal: null, oldVal: null,
newVal: created, newVal: created,
text: `Neuer Eintrag in ${resource} erstellt`, text: `Neuer Eintrag in ${resourceLabel} erstellt`,
}) })
} catch (historyError) { } catch (historyError) {
server.log.warn({ err: historyError, resource }, "Failed to write create history entry") server.log.warn({ err: historyError, resource }, "Failed to write create history entry")
@@ -608,6 +610,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (updated) { if (updated) {
try { try {
const resourceLabel = getHistoryEntityLabel(resource)
const changes = oldRecord ? getUserVisibleChanges(oldRecord, updated) : [] const changes = oldRecord ? getUserVisibleChanges(oldRecord, updated) : []
if (!changes.length) { if (!changes.length) {
await insertHistoryItem(server, { await insertHistoryItem(server, {
@@ -618,7 +621,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
action: "updated", action: "updated",
oldVal: oldRecord || null, oldVal: oldRecord || null,
newVal: updated, newVal: updated,
text: `Eintrag in ${resource} geändert`, text: `Eintrag in ${resourceLabel} geändert`,
}) })
} else { } else {
for (const change of changes) { for (const change of changes) {

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -698,7 +698,7 @@ export const DE_BANK_CODE_TO_NAME: Record<string, string> = {
"28022822": "Oldenburgische Landesbank AG", "28022822": "Oldenburgische Landesbank AG",
"28023224": "Oldenburgische Landesbank AG", "28023224": "Oldenburgische Landesbank AG",
"28023325": "Oldenburgische Landesbank AG", "28023325": "Oldenburgische Landesbank AG",
"28030300": "Oldenburgische Landesbank AG (vormals W. Fortmann & Söhne", "28030300": "Oldenburgische Landesbank AG (vormals W. Fortmann & Söhne)",
"28040046": "Commerzbank", "28040046": "Commerzbank",
"28042865": "Commerzbank", "28042865": "Commerzbank",
"28050100": "Landessparkasse zu Oldenburg", "28050100": "Landessparkasse zu Oldenburg",
@@ -1186,7 +1186,7 @@ export const DE_BANK_CODE_TO_NAME: Record<string, string> = {
"39570061": "Deutsche Bank", "39570061": "Deutsche Bank",
"39580041": "Commerzbank vormals Dresdner Bank", "39580041": "Commerzbank vormals Dresdner Bank",
"40022000": "NRW.BANK", "40022000": "NRW.BANK",
"40030000": "Münsterländische Bank Thie,Zndl. d.VR-Bank Westmünst.-a", "40030000": "Münsterländische Bank Thie,Zndl. d.VR-Bank Westmünst.-alt-",
"40040028": "Commerzbank", "40040028": "Commerzbank",
"40050000": "Landesbank Hessen-Thüringen Girozentrale NL. Düsseldorf", "40050000": "Landesbank Hessen-Thüringen Girozentrale NL. Düsseldorf",
"40050150": "Sparkasse Münsterland Ost", "40050150": "Sparkasse Münsterland Ost",
@@ -3214,7 +3214,7 @@ export const DE_BANK_CODE_TO_NAME: Record<string, string> = {
"76260451": "Raiffeisen-Volksbank Fürth -alt-", "76260451": "Raiffeisen-Volksbank Fürth -alt-",
"76320072": "UniCredit Bank - HypoVereinsbank", "76320072": "UniCredit Bank - HypoVereinsbank",
"76340061": "Commerzbank Erlangen", "76340061": "Commerzbank Erlangen",
"76350000": "Stadt- u. Kreissparkasse Erlangen Höchstadt Herzogenaurac", "76350000": "Stadt- u. Kreissparkasse Erlangen Höchstadt Herzogenaurach",
"76351040": "Sparkasse Forchheim", "76351040": "Sparkasse Forchheim",
"76351560": "Kreissparkasse Höchstadt", "76351560": "Kreissparkasse Höchstadt",
"76360033": "VR-Bank Erlangen-Höchstadt-Herzogenaurach -alt-", "76360033": "VR-Bank Erlangen-Höchstadt-Herzogenaurach -alt-",

View File

@@ -236,8 +236,11 @@ export const diffTranslations: Record<
sellingPriceComposed: { label: "Verkaufspreis Zusammensetzung" }, sellingPriceComposed: { label: "Verkaufspreis Zusammensetzung" },
purchaseDate: { label: "Kaufdatum" }, purchaseDate: { label: "Kaufdatum" },
serialNumber: { label: "Seriennummer" }, serialNumber: { label: "Seriennummer" },
customerInventoryId: { label: "Kundeninventar-ID" },
customerinventoryitems: { label: "Kundeninventar" },
usePlanning: { label: "In Plantafel verwenden" }, usePlanning: { label: "In Plantafel verwenden" },
currentSpace: { label: "Lagerplatz" }, currentSpace: { label: "Lagerplatz" },
customerspace: { label: "Kundenlagerplatz" },
customer: { customer: {
label: "Kunde", label: "Kunde",

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ import {
contracttypes, contracttypes,
costcentres, costcentres,
createddocuments, createddocuments,
customerinventoryitems,
customerspaces,
customers, customers,
files, files,
filetags, filetags,
@@ -18,6 +20,7 @@ import {
inventoryitemgroups, inventoryitemgroups,
inventoryitems, inventoryitems,
letterheads, letterheads,
memberrelations,
ownaccounts, ownaccounts,
plants, plants,
productcategories, productcategories,
@@ -46,7 +49,7 @@ export const resourceConfig = {
}, },
customers: { customers: {
searchColumns: ["name", "customerNumber", "firstname", "lastname", "notes"], searchColumns: ["name", "customerNumber", "firstname", "lastname", "notes"],
mtmLoad: ["contacts","projects","plants","createddocuments","contracts"], mtmLoad: ["contacts","projects","plants","createddocuments","contracts","customerinventoryitems","customerspaces"],
table: customers, table: customers,
numberRangeHolder: "customerNumber", numberRangeHolder: "customerNumber",
}, },
@@ -57,6 +60,10 @@ export const resourceConfig = {
numberRangeHolder: "customerNumber", numberRangeHolder: "customerNumber",
relationKey: "customer", relationKey: "customer",
}, },
memberrelations: {
table: memberrelations,
searchColumns: ["type", "billingInterval"],
},
contacts: { contacts: {
searchColumns: ["firstName", "lastName", "email", "phone", "notes"], searchColumns: ["firstName", "lastName", "email", "phone", "notes"],
table: contacts, table: contacts,
@@ -99,6 +106,12 @@ export const resourceConfig = {
table: inventoryitems, table: inventoryitems,
numberRangeHolder: "articleNumber", numberRangeHolder: "articleNumber",
}, },
customerinventoryitems: {
table: customerinventoryitems,
numberRangeHolder: "customerInventoryId",
mtoLoad: ["customer", "customerspace", "product", "vendor"],
searchColumns: ["name", "customerInventoryId", "serialNumber", "description", "manufacturer", "manufacturerNumber"],
},
inventoryitemgroups: { inventoryitemgroups: {
table: inventoryitemgroups table: inventoryitemgroups
}, },
@@ -133,6 +146,13 @@ export const resourceConfig = {
searchColumns: ["name","space_number","type","info_data"], searchColumns: ["name","space_number","type","info_data"],
numberRangeHolder: "spaceNumber", numberRangeHolder: "spaceNumber",
}, },
customerspaces: {
table: customerspaces,
searchColumns: ["name","space_number","type","info_data","description"],
numberRangeHolder: "space_number",
mtoLoad: ["customer"],
mtmLoad: ["customerinventoryitems"],
},
ownaccounts: { ownaccounts: {
table: ownaccounts, table: ownaccounts,
searchColumns: ["name","description","number"], searchColumns: ["name","description","number"],

View File

@@ -16,6 +16,7 @@ const toast = useToast()
const accounts = ref([]) const accounts = ref([])
const ibanSearch = ref("") const ibanSearch = ref("")
const showCreate = ref(false) const showCreate = ref(false)
const resolvingIban = ref(false)
const createPayload = ref({ const createPayload = ref({
iban: "", iban: "",
@@ -78,6 +79,25 @@ const createAndAssign = async () => {
showCreate.value = false 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() loadAccounts()
</script> </script>
@@ -125,7 +145,21 @@ loadAccounts()
<template #header>Neue Bankverbindung erstellen</template> <template #header>Neue Bankverbindung erstellen</template>
<div class="space-y-3"> <div class="space-y-3">
<UFormGroup label="IBAN"> <UFormGroup label="IBAN">
<UInput v-model="createPayload.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>
<UFormGroup label="BIC"> <UFormGroup label="BIC">
<UInput v-model="createPayload.bic" /> <UInput v-model="createPayload.bic" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,9 @@ const tenantExtraModules = computed(() => {
const showMembersNav = computed(() => { const showMembersNav = computed(() => {
return tenantExtraModules.value.includes("verein") && (has("members") || has("customers")) return tenantExtraModules.value.includes("verein") && (has("members") || has("customers"))
}) })
const showMemberRelationsNav = computed(() => {
return tenantExtraModules.value.includes("verein") && has("members")
})
const links = computed(() => { const links = computed(() => {
return [ return [
@@ -191,6 +194,26 @@ const links = computed(() => {
to: "/standardEntity/spaces", to: "/standardEntity/spaces",
icon: "i-heroicons-square-3-stack-3d" icon: "i-heroicons-square-3-stack-3d"
}] : [], }] : [],
...has("inventoryitems") ? [{
label: "Kundenlagerplätze",
to: "/standardEntity/customerspaces",
icon: "i-heroicons-squares-plus"
}] : [],
...has("inventoryitems") ? [{
label: "Kundeninventar",
to: "/standardEntity/customerinventoryitems",
icon: "i-heroicons-qr-code"
}] : [],
...has("inventoryitems") ? [{
label: "Inventar",
to: "/standardEntity/inventoryitems",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("inventoryitems") ? [{
label: "Inventargruppen",
to: "/standardEntity/inventoryitemgroups",
icon: "i-heroicons-puzzle-piece"
}] : [],
] ]
}] : [], }] : [],
{ {
@@ -218,6 +241,11 @@ const links = computed(() => {
to: "/standardEntity/servicecategories", to: "/standardEntity/servicecategories",
icon: "i-heroicons-wrench-screwdriver" icon: "i-heroicons-wrench-screwdriver"
}] : [], }] : [],
...showMemberRelationsNav.value ? [{
label: "Mitgliedsverhältnisse",
to: "/standardEntity/memberrelations",
icon: "i-heroicons-identification"
}] : [],
{ {
label: "Mitarbeiter", label: "Mitarbeiter",
to: "/staff/profiles", to: "/staff/profiles",
@@ -228,21 +256,21 @@ const links = computed(() => {
to: "/standardEntity/hourrates", to: "/standardEntity/hourrates",
icon: "i-heroicons-user-group" icon: "i-heroicons-user-group"
}, },
{
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list",
},
{
label: "Vertragstypen",
to: "/standardEntity/contracttypes",
icon: "i-heroicons-document-duplicate",
},
...has("vehicles") ? [{ ...has("vehicles") ? [{
label: "Fahrzeuge", label: "Fahrzeuge",
to: "/standardEntity/vehicles", to: "/standardEntity/vehicles",
icon: "i-heroicons-truck" 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"
}] : [],
] ]
}, },
@@ -286,14 +314,6 @@ const links = computed(() => {
label: "Firmeneinstellungen", label: "Firmeneinstellungen",
to: "/settings/tenant", to: "/settings/tenant",
icon: "i-heroicons-building-office", icon: "i-heroicons-building-office",
}, {
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list",
}, {
label: "Vertragstypen",
to: "/standardEntity/contracttypes",
icon: "i-heroicons-document-duplicate",
}, { }, {
label: "Export", label: "Export",
to: "/export", to: "/export",

View File

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

View File

@@ -0,0 +1,48 @@
<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
const parsed = Number(value)
return Number.isNaN(parsed) ? String(value) : parsed
}
const relationLabel = computed(() => {
const id = normalizeId(props.row?.infoData?.memberrelation)
if (!id) return ""
return relations.value.find((i) => normalizeId(i.id) === id)?.type || ""
})
const relationId = computed(() => {
return normalizeId(props.row?.infoData?.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

@@ -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

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

View File

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

View File

@@ -5,10 +5,12 @@ import dayjs from "dayjs"
import projecttype from "~/components/columnRenderings/projecttype.vue" import projecttype from "~/components/columnRenderings/projecttype.vue"
import contracttype from "~/components/columnRenderings/contracttype.vue" import contracttype from "~/components/columnRenderings/contracttype.vue"
import memberrelation from "~/components/columnRenderings/memberrelation.vue"
import customer from "~/components/columnRenderings/customer.vue" import customer from "~/components/columnRenderings/customer.vue"
import contact from "~/components/columnRenderings/contact.vue" import contact from "~/components/columnRenderings/contact.vue"
import plant from "~/components/columnRenderings/plant.vue" import plant from "~/components/columnRenderings/plant.vue"
import vendor from "~/components/columnRenderings/vendor.vue" import vendor from "~/components/columnRenderings/vendor.vue"
import product from "~/components/columnRenderings/product.vue"
import active from "~/components/columnRenderings/active.vue" import active from "~/components/columnRenderings/active.vue"
import sellingPrice from "~/components/columnRenderings/sellingPrice.vue"; import sellingPrice from "~/components/columnRenderings/sellingPrice.vue";
import unit from "~/components/columnRenderings/unit.vue"; import unit from "~/components/columnRenderings/unit.vue";
@@ -32,6 +34,7 @@ import endDate from "~/components/columnRenderings/endDate.vue"
import startDateTime from "~/components/columnRenderings/startDateTime.vue" import startDateTime from "~/components/columnRenderings/startDateTime.vue"
import endDateTime from "~/components/columnRenderings/endDateTime.vue" import endDateTime from "~/components/columnRenderings/endDateTime.vue"
import serviceCategories from "~/components/columnRenderings/serviceCategories.vue" import serviceCategories from "~/components/columnRenderings/serviceCategories.vue"
import productcategoriesWithLoad from "~/components/columnRenderings/productcategoriesWithLoad.vue"
import phase from "~/components/columnRenderings/phase.vue" import phase from "~/components/columnRenderings/phase.vue"
import vehiclesWithLoad from "~/components/columnRenderings/vehiclesWithLoad.vue" import vehiclesWithLoad from "~/components/columnRenderings/vehiclesWithLoad.vue"
import inventoryitemsWithLoad from "~/components/columnRenderings/inventoryitemsWithLoad.vue" import inventoryitemsWithLoad from "~/components/columnRenderings/inventoryitemsWithLoad.vue"
@@ -167,7 +170,7 @@ export const useDataStore = defineStore('data', () => {
numberRangeHolder: "customerNumber", numberRangeHolder: "customerNumber",
historyItemHolder: "customer", historyItemHolder: "customer",
sortColumn: "customerNumber", sortColumn: "customerNumber",
selectWithInformation: "*, projects(*), plants(*), contracts(*), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*)", selectWithInformation: "*, projects(*), plants(*), contracts(*), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*), customerinventoryitems(*), customerspaces(*)",
filters: [{ filters: [{
name: "Archivierte ausblenden", name: "Archivierte ausblenden",
default: true, default: true,
@@ -443,7 +446,7 @@ export const useDataStore = defineStore('data', () => {
inputColumn: "Allgemeines" inputColumn: "Allgemeines"
},*/ },*/
], ],
showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'},{label: 'Wiki'}] 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: { members: {
isArchivable: true, isArchivable: true,
@@ -524,6 +527,24 @@ export const useDataStore = defineStore('data', () => {
inputColumn: "Allgemeines", inputColumn: "Allgemeines",
sortable: true sortable: true
}, },
{
key: "infoData.birthdate",
label: "Geburtsdatum",
inputType: "date",
inputColumn: "Allgemeines",
disabledInTable: true
},
{
key: "infoData.memberrelation",
label: "Mitgliedsverhältnis",
component: memberrelation,
inputType: "select",
selectDataType: "memberrelations",
selectOptionAttribute: "type",
selectSearchAttributes: ['type', 'billingInterval'],
inputColumn: "Allgemeines",
sortable: true
},
{ {
key: "active", key: "active",
label: "Aktiv", label: "Aktiv",
@@ -1035,6 +1056,57 @@ export const useDataStore = defineStore('data', () => {
], ],
showTabs: [{ label: "Informationen" }] showTabs: [{ label: "Informationen" }]
}, },
memberrelations: {
isArchivable: true,
label: "Mitgliedsverhältnisse",
labelSingle: "Mitgliedsverhältnis",
isStandardEntity: true,
historyItemHolder: "memberrelation",
redirect: true,
sortColumn: "type",
selectWithInformation: "*",
filters: [{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
return !row.archived
}
}],
templateColumns: [
{
key: "type",
label: "Typ",
required: true,
title: true,
inputType: "text",
sortable: true
},
{
key: "billingInterval",
label: "Abrechnungsintervall",
required: true,
inputType: "select",
selectValueAttribute: "label",
selectOptionAttribute: "label",
selectManualOptions: [
{ label: "Monatlich" },
{ label: "Quartalsweise" },
{ label: "Halbjährlich" },
{ label: "Jährlich" }
],
sortable: true
},
{
key: "billingAmount",
label: "Abrechnungshöhe",
required: true,
inputType: "number",
inputTrailing: "€",
sortable: true
}
],
showTabs: [{ label: "Informationen" }]
},
absencerequests: { absencerequests: {
isArchivable: true, isArchivable: true,
label: "Abwesenheiten", label: "Abwesenheiten",
@@ -1293,7 +1365,8 @@ export const useDataStore = defineStore('data', () => {
selectDataType: "productcategories", selectDataType: "productcategories",
selectOptionAttribute: "name", selectOptionAttribute: "name",
selectSearchAttributes: ['name'], selectSearchAttributes: ['name'],
selectMultiple: true selectMultiple: true,
component: productcategoriesWithLoad
}, },
{ {
key: "description", key: "description",
@@ -1773,6 +1846,7 @@ export const useDataStore = defineStore('data', () => {
selectValueAttribute: "label", selectValueAttribute: "label",
selectManualOptions: [ selectManualOptions: [
{label:"Standort"}, {label:"Standort"},
{label:"Raum"},
{label:"Regalplatz"}, {label:"Regalplatz"},
{label:"Kiste"}, {label:"Kiste"},
{label:"Palettenplatz"}, {label:"Palettenplatz"},
@@ -1856,6 +1930,149 @@ export const useDataStore = defineStore('data', () => {
},{label: 'Inventarartikel'},{label: 'Wiki'} },{label: 'Inventarartikel'},{label: 'Wiki'}
] ]
}, },
customerspaces: {
isArchivable: true,
label: "Kundenlagerplätze",
labelSingle: "Kundenlagerplatz",
isStandardEntity: true,
selectWithInformation: "*, customer(id,name), files(*)",
sortColumn: "space_number",
redirect: true,
numberRangeHolder: "space_number",
historyItemHolder: "customerspace",
filters:[{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
return !row.archived
}
}],
inputColumns: [
"Allgemeines",
"Ort"
],
templateColumns: [
{
key: "name",
label: "Name",
inputType: "text",
required: true,
title: true,
inputColumn: "Allgemeines",
sortable: true
},
{
key: "customer",
label: "Kunde",
inputType: "select",
required: true,
selectDataType: "customers",
selectOptionAttribute: "name",
selectSearchAttributes: ["name"],
component: customer,
inputColumn: "Allgemeines",
sortable: true
},
{
key: "space_number",
label: "Kundenlagerplatznr.",
inputType: "text",
inputIsNumberRange: true,
inputColumn: "Allgemeines",
sortable: true
},
{
key: "type",
label: "Typ",
inputType: "select",
required: true,
selectValueAttribute: "label",
selectManualOptions: [
{label:"Standort"},
{label:"Raum"},
{label:"Regalplatz"},
{label:"Kiste"},
{label:"Palettenplatz"},
{label:"Sonstiges"}
],
inputColumn: "Allgemeines",
sortable: true
},
{
key: "parentSpace",
label: "Übergeordneter Kundenlagerplatz",
inputType: "select",
selectDataType: "customerspaces",
selectOptionAttribute: "space_number",
selectValueAttribute: "id",
inputColumn: "Allgemeines"
},
{
key: "info_data.streetNumber",
label: "Straße + Hausnummer",
inputType: "text",
disabledInTable: true,
inputColumn: "Ort"
},
{
key: "info_data.special",
label: "Adresszusatz",
inputType: "text",
disabledInTable: true,
inputColumn: "Ort"
},
{
key: "info_data.zip",
label: "Postleitzahl",
inputType: "text",
disabledInTable: true,
inputColumn: "Ort",
inputChangeFunction: async function (row) {
const zip = String(row.info_data.zip || "").replace(/\D/g, "")
row.info_data.zip = zip
if ([4, 5].includes(zip.length)) {
const zipData = await useFunctions().useZipCheck(zip)
row.info_data.zip = zipData?.zip || row.info_data.zip
row.info_data.city = zipData?.short || row.info_data.city
}
},
},
{
key: "info_data.city",
label: "Stadt",
inputType: "text",
disabledInTable: true,
inputColumn: "Ort"
},
{
key: "info_data.country",
label: "Land",
inputType: "select",
selectDataType: "countrys",
selectOptionAttribute: "name",
selectValueAttribute: "name",
disabledInTable: true,
inputColumn: "Ort"
},
{
key: "address",
label: "Adresse",
component: address
},
{
key: "description",
label: "Beschreibung",
inputType: "textarea",
}
],
showTabs: [
{
label: 'Informationen',
}, {
label: 'Dateien',
},{label: 'Kundeninventar', key: 'customerinventoryitems'},{label: 'Wiki'}
]
},
users: { users: {
label: "Benutzer", label: "Benutzer",
labelSingle: "Benutzer" labelSingle: "Benutzer"
@@ -1968,6 +2185,179 @@ export const useDataStore = defineStore('data', () => {
} }
] ]
}, },
customerinventoryitems: {
isArchivable: true,
label: "Kundeninventar",
labelSingle: "Kundeninventarartikel",
isStandardEntity: true,
selectWithInformation: "*, files(*), customer(id,name), customerspace(id,name,space_number), product(id,name,article_number,description,manufacturer,manufacturer_number,purchase_price,vendorAllocation), vendor(id,name)",
redirect: true,
numberRangeHolder: "customerInventoryId",
historyItemHolder: "customerinventoryitem",
inputColumns: [
"Allgemeines",
"Anschaffung"
],
filters:[{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
return !row.archived
}
}],
templateColumns: [
{
key: "name",
label: "Name",
title: true,
required: true,
inputType: "text",
inputColumn: "Allgemeines",
sortable: true
},
{
key: "customer",
label: "Kunde",
inputType: "select",
required: true,
selectDataType: "customers",
selectOptionAttribute: "name",
selectSearchAttributes: ["name"],
component: customer,
inputColumn: "Allgemeines",
sortable: true
},
{
key: "customerInventoryId",
label: "Kundeninventar-ID",
inputType: "text",
inputIsNumberRange: true,
inputColumn: "Allgemeines",
sortable: true
},
{
key: "product",
label: "Ableitung von Artikel",
inputType: "select",
selectDataType: "products",
selectOptionAttribute: "name",
selectSearchAttributes: ["name", "article_number"],
inputColumn: "Allgemeines",
component: product,
inputChangeFunction: function (row, loadedOptions) {
const products = loadedOptions?.products || []
const selected = products.find((p) => p.id === row.product)
if (!selected) return
row.name = selected.name || null
row.description = selected.description || null
row.manufacturer = selected.manufacturer || null
row.manufacturerNumber = selected.manufacturer_number || null
row.purchasePrice = typeof selected.purchase_price === "number" ? selected.purchase_price : row.purchasePrice
row.currentValue = row.currentValue ?? row.purchasePrice
const allocations = Array.isArray(selected.vendor_allocation)
? selected.vendor_allocation
: (Array.isArray(selected.vendorAllocation) ? selected.vendorAllocation : [])
const firstAllocation = allocations[0]
if (typeof firstAllocation === "number") {
row.vendor = firstAllocation
} else if (firstAllocation && typeof firstAllocation === "object") {
row.vendor = firstAllocation.vendor || firstAllocation.vendor_id || firstAllocation.id || row.vendor
}
}
},
{
key: "description",
label: "Beschreibung",
inputType: "textarea",
inputColumn: "Allgemeines",
sortable: true
},
{
key: "customerspace",
label: "Aktueller Kundenlagerplatz",
inputType: "select",
selectDataType: "customerspaces",
selectOptionAttribute: "name",
selectSearchAttributes: ["name", "space_number"],
inputColumn: "Allgemeines",
component: space
},
{
key: "serialNumber",
label: "Seriennummer",
inputType: "text",
inputColumn: "Allgemeines"
},
{
key: "quantity",
label: "Menge",
inputType: "number",
inputColumn: "Allgemeines",
disabledFunction: function (item) {
return item.serialNumber
},
helpComponent: quantity,
sortable: true
},
{
key: "purchaseDate",
label: "Kaufdatum",
inputType: "date",
inputColumn: "Anschaffung",
sortable: true
},
{
key: "vendor",
label: "Lieferant",
inputType: "select",
selectDataType: "vendors",
selectOptionAttribute: "name",
selectSearchAttributes: ["name"],
inputColumn: "Anschaffung",
component: vendor
},
{
key: "purchasePrice",
label: "Kaufpreis",
inputType: "number",
inputStepSize: "0.01",
inputColumn: "Anschaffung",
component: purchasePrice,
sortable: true
},
{
key: "manufacturer",
label: "Hersteller",
inputType: "text",
inputColumn: "Anschaffung"
},
{
key: "manufacturerNumber",
label: "Herstellernummer",
inputType: "text",
inputColumn: "Anschaffung"
},
{
key: "currentValue",
label: "Aktueller Wert",
inputType: "number",
inputStepSize: "0.01",
inputColumn: "Anschaffung",
sortable: true
},
],
showTabs: [
{
label: 'Informationen',
}, {
label: 'Dateien',
}, {
label: 'Wiki',
}
]
},
inventoryitems: { inventoryitems: {
isArchivable: true, isArchivable: true,
label: "Inventarartikel", label: "Inventarartikel",

View File

@@ -0,0 +1,14 @@
# Expected Endpoints
- OpenAPI UI: `/docs`
- OpenAPI JSON: `/openapi.json`
- API key list: `GET /api/tenant/api-keys`
- API key create: `POST /api/tenant/api-keys`
- API key update: `PATCH /api/tenant/api-keys/:id`
- API key delete: `DELETE /api/tenant/api-keys/:id`
- M2M exchange: `POST /internal/auth/m2m/token`
# Header Conventions
- API key auth: `x-api-key`
- JWT auth: `Authorization: Bearer <token>`