diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index d0177c8..abdb90d 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -12,6 +12,8 @@ import { import { resourceConfig } from "../../utils/resource.config"; import { useNextNumberRangeNumber } from "../../utils/functions"; +import { insertHistoryItem } from "../../utils/history"; +import { diffObjects } from "../../utils/diff"; import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"; // ------------------------------------------------------------- @@ -31,6 +33,42 @@ function buildSearchCondition(columns: any[], search: string) { return or(...conditions) } +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, updated: Record) { + return diffObjects(oldRecord, updated).filter((c) => !TECHNICAL_HISTORY_KEYS.has(c.key)) +} + +function buildFieldUpdateHistoryText(resource: string, label: string, oldValue: any, newValue: any) { + return `${resource}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"` +} + export default async function resourceRoutes(server: FastifyInstance) { // ------------------------------------------------------------- @@ -349,6 +387,23 @@ export default async function resourceRoutes(server: FastifyInstance) { await recalculateServicePricesForTenant(server, req.user.tenant_id, req.user?.user_id || null); } + if (created) { + try { + 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 ${resource} erstellt`, + }) + } catch (historyError) { + server.log.warn({ err: historyError, resource }, "Failed to write create history entry") + } + } + return created; } catch (error) { console.error(error); @@ -369,6 +424,12 @@ 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; } + const [oldRecord] = await server.db + .select() + .from(table) + .where(and(eq(table.id, id), eq(table.tenant, tenantId))) + .limit(1) + let data = { ...body, updated_at: new Date().toISOString(), updated_by: userId } //@ts-ignore delete data.updatedBy; delete data.updatedAt; @@ -385,6 +446,39 @@ export default async function resourceRoutes(server: FastifyInstance) { await recalculateServicePricesForTenant(server, tenantId, userId); } + if (updated) { + try { + 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 ${resource} 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") + } + } + return updated } catch (err) { console.error(err) diff --git a/backend/src/utils/diff.ts b/backend/src/utils/diff.ts index 947372c..f2f1ea4 100644 --- a/backend/src/utils/diff.ts +++ b/backend/src/utils/diff.ts @@ -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; -} \ No newline at end of file +} diff --git a/backend/src/utils/diffTranslations.ts b/backend/src/utils/diffTranslations.ts index 290ef38..26e496c 100644 --- a/backend/src/utils/diffTranslations.ts +++ b/backend/src/utils/diffTranslations.ts @@ -6,6 +6,149 @@ type ValueResolver = ( ctx?: Record ) => { oldVal: any; newVal: any }; +const TOKEN_TRANSLATIONS: Record = { + 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,6 +229,11 @@ 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" }, usePlanning: { label: "In Plantafel verwenden" }, @@ -108,6 +256,7 @@ export const diffTranslations: Record< description: { label: "Beschreibung" }, categorie: { label: "Kategorie" }, + category: { label: "Kategorie" }, profile: { label: "Mitarbeiter", diff --git a/backend/src/utils/history.ts b/backend/src/utils/history.ts index afef68e..411ab04 100644 --- a/backend/src/utils/history.ts +++ b/backend/src/utils/history.ts @@ -17,6 +17,7 @@ export async function insertHistoryItem( const textMap = { created: `Neuer Eintrag in ${params.entity} erstellt`, updated: `Eintrag in ${params.entity} geändert`, + unchanged: `Eintrag in ${params.entity} unverändert`, archived: `Eintrag in ${params.entity} archiviert`, deleted: `Eintrag in ${params.entity} gelöscht` } @@ -45,7 +46,9 @@ export async function insertHistoryItem( trackingtrips: "trackingtrip", createddocuments: "createddocument", inventoryitemgroups: "inventoryitemgroup", - bankstatements: "bankstatement" + bankstatements: "bankstatement", + incominginvoices: "incomingInvoice", + files: "file", } const fkColumn = columnMap[params.entity] @@ -54,14 +57,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)