+
Hinweis
- Kein Wachstum oder verunreinigte Proben werden spaeter automatisch vom
+ Kein Wachstum oder verunreinigte Proben werden später automatisch vom
Antibiogramm ausgeschlossen.
diff --git a/frontend/src/pages/InvoiceTemplatePage.tsx b/frontend/src/pages/InvoiceTemplatePage.tsx
deleted file mode 100644
index f96b6e0..0000000
--- a/frontend/src/pages/InvoiceTemplatePage.tsx
+++ /dev/null
@@ -1,2635 +0,0 @@
-import {
- useEffect,
- useState,
- useRef,
- type ChangeEvent as ReactChangeEvent,
- type DragEvent,
- type KeyboardEvent as ReactKeyboardEvent,
- type MouseEvent as ReactMouseEvent,
-} from "react";
-import { ApiError, apiGet, apiPut } from "../lib/api";
-import { useSession } from "../lib/session";
-import type { UserOption } from "../lib/types";
-
-const CANVAS_WIDTH = 794;
-const CANVAS_HEIGHT = 1123;
-const CANVAS_ASPECT_RATIO = CANVAS_WIDTH / CANVAS_HEIGHT;
-const GRID_SIZE = 5;
-const DRAG_DATA_TYPE = "application/x-muh-invoice-template";
-const PDF_PAGE_WIDTH = 595.28;
-const PDF_PAGE_HEIGHT = 841.89;
-const PDF_SCALE = PDF_PAGE_WIDTH / CANVAS_WIDTH;
-const PDF_TEXT_PADDING_X = 12;
-const PDF_TEXT_PADDING_Y = 10;
-const PDF_LINE_HEIGHT = 1.35;
-const CANVAS_BOTTOM_GAP = 10;
-const MIN_ELEMENT_WIDTH = 100;
-const MIN_MEDIA_WIDTH = 40;
-const MIN_MEDIA_HEIGHT = 40;
-const MIN_LINE_THICKNESS = 1;
-const MIN_LINE_LENGTH = 40;
-const MIN_FONT_SIZE = 12;
-const MAX_FONT_SIZE = 44;
-const INVOICE_LOCKED_TEXT_PALETTE_IDS = new Set([
- "invoice-title",
- "company-name",
- "contact-person",
- "street",
- "house-number",
- "postal-code",
- "city",
- "email",
- "phone",
- "invoice-number",
- "invoice-date",
- "due-date",
- "customer-number",
- "line-item",
- "amount",
- "iban",
- "bic",
- "footer",
-]);
-const REPORT_LOCKED_TEXT_PALETTE_IDS = new Set([
- "report-title",
- "report-farmer",
- "report-cow",
- "report-clinical-findings",
- "report-treatment",
- "report-examination-start",
- "report-examination-end",
- "report-sample-number",
- "report-date",
- "report-quarter-vl-label",
- "report-quarter-vr-label",
- "report-quarter-hl-label",
- "report-quarter-hr-label",
- "report-quarter-vl-result",
- "report-quarter-vr-result",
- "report-quarter-hl-result",
- "report-quarter-hr-result",
- "report-antibiogram-heading",
- "report-antibiogram-summary",
- "report-antibiogram-details",
- "report-therapy-heading",
- "report-therapy-text",
- "report-misc-heading",
- "report-misc-note",
- "report-lab-note",
-]);
-
-type ElementKind = "text" | "line" | "image";
-type LineOrientation = "horizontal" | "vertical";
-type TextAlign = "left" | "center" | "right";
-type FontWeight = 400 | 500 | 600 | 700;
-type PaletteCategory = string;
-
-interface PaletteItem {
- id: string;
- category: PaletteCategory;
- label: string;
- description: string;
- kind?: ElementKind;
- width: number;
- height?: number;
- fontSize: number;
- fontWeight: FontWeight;
- textAlign: TextAlign;
- lineOrientation?: LineOrientation;
- defaultContent: (user: UserOption | null) => string;
-}
-
-interface TemplateElement {
- id: string;
- paletteId: string;
- kind: ElementKind;
- label: string;
- content: string;
- x: number;
- y: number;
- width: number;
- height?: number;
- fontSize: number;
- fontWeight: FontWeight;
- textAlign: TextAlign;
- lineOrientation?: LineOrientation;
- imageSrc?: string | null;
- imageNaturalWidth?: number | null;
- imageNaturalHeight?: number | null;
-}
-
-interface CanvasViewport {
- height: number;
- scale: number;
- syncPanelHeight: boolean;
- width: number;
-}
-
-interface PdfImageResource {
- body: string;
- elementId: string;
- name: string;
- pixelHeight: number;
- pixelWidth: number;
-}
-
-interface UploadedImageAsset {
- dataUrl: string;
- height: number;
- width: number;
-}
-
-interface InvoiceTemplateResponse {
- elements: unknown;
- stored: boolean;
- updatedAt: string | null;
-}
-
-interface InvoiceTemplatePageProps {
- buildStarterLayout?: (user: UserOption | null, paletteItems: PaletteItem[]) => TemplateElement[];
- lockedTextPaletteIds?: ReadonlySet
;
- paletteGroups?: Array<{ category: PaletteCategory; title: string }>;
- paletteItems?: PaletteItem[];
- pdfDownloadName?: string;
- pdfPreviewTitle?: string;
- templateApiPath?: string;
- templateTitle?: string;
-}
-
-type DragPayload =
- | {
- kind: "palette";
- paletteId: string;
- }
- | {
- kind: "element";
- elementId: string;
- };
-
-const CP1252_MAP: Record = {
- 338: 140,
- 339: 156,
- 352: 138,
- 353: 154,
- 376: 159,
- 381: 142,
- 382: 158,
- 402: 131,
- 710: 136,
- 732: 152,
- 8211: 150,
- 8212: 151,
- 8216: 145,
- 8217: 146,
- 8218: 130,
- 8220: 147,
- 8221: 148,
- 8222: 132,
- 8224: 134,
- 8225: 135,
- 8226: 149,
- 8230: 133,
- 8240: 137,
- 8249: 139,
- 8250: 155,
- 8364: 128,
- 8482: 153,
-};
-
-const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
- {
- id: "invoice-title",
- category: "master-data",
- label: "Rechnungstitel",
- description: "Markante Ueberschrift fuer die Rechnung",
- width: 240,
- fontSize: 32,
- fontWeight: 700,
- textAlign: "left",
- defaultContent: () => "Rechnung",
- },
- {
- id: "company-name",
- category: "master-data",
- label: "Firmenname",
- description: "Name Ihres Unternehmens",
- width: 280,
- fontSize: 24,
- fontWeight: 700,
- textAlign: "left",
- defaultContent: (user) => user?.companyName || "Firmenname",
- },
- {
- id: "contact-person",
- category: "master-data",
- label: "Ansprechpartner",
- description: "Name des Ansprechpartners",
- width: 240,
- fontSize: 16,
- fontWeight: 500,
- textAlign: "left",
- defaultContent: (user) => user?.displayName || "Ansprechpartner",
- },
- {
- id: "street",
- category: "master-data",
- label: "Strasse",
- description: "Strassenname fuer die Anschrift",
- width: 220,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: (user) => user?.street || "Musterstrasse",
- },
- {
- id: "house-number",
- category: "master-data",
- label: "Hausnummer",
- description: "Hausnummer der Anschrift",
- width: 100,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: (user) => user?.houseNumber || "12",
- },
- {
- id: "postal-code",
- category: "master-data",
- label: "PLZ",
- description: "Postleitzahl",
- width: 100,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: (user) => user?.postalCode || "12345",
- },
- {
- id: "city",
- category: "master-data",
- label: "Ort",
- description: "Stadt oder Ort",
- width: 180,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: (user) => user?.city || "Musterstadt",
- },
- {
- id: "email",
- category: "master-data",
- label: "E-Mail",
- description: "Kontakt-E-Mail",
- width: 260,
- fontSize: 15,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: (user) => user?.email || "mail@example.de",
- },
- {
- id: "phone",
- category: "master-data",
- label: "Telefon",
- description: "Telefonnummer fuer Rueckfragen",
- width: 220,
- fontSize: 15,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: (user) => user?.phoneNumber || "+49 123 456789",
- },
- {
- id: "invoice-number",
- category: "master-data",
- label: "Rechnungsnummer",
- description: "Eindeutige Kennung",
- width: 200,
- fontSize: 16,
- fontWeight: 600,
- textAlign: "right",
- defaultContent: () => "RE-2026-001",
- },
- {
- id: "invoice-date",
- category: "master-data",
- label: "Rechnungsdatum",
- description: "Ausstellungsdatum der Rechnung",
- width: 200,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "right",
- defaultContent: () =>
- new Intl.DateTimeFormat("de-DE", {
- dateStyle: "medium",
- }).format(new Date()),
- },
- {
- id: "due-date",
- category: "master-data",
- label: "Zahlungsziel",
- description: "Faelligkeitsdatum",
- width: 220,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "right",
- defaultContent: () => {
- const dueDate = new Date();
- dueDate.setDate(dueDate.getDate() + 14);
- return `Faellig bis ${new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(dueDate)}`;
- },
- },
- {
- id: "customer-number",
- category: "master-data",
- label: "Kundennummer",
- description: "Referenz zum Kunden",
- width: 180,
- fontSize: 16,
- fontWeight: 500,
- textAlign: "left",
- defaultContent: () => "KD-1001",
- },
- {
- id: "line-item",
- category: "master-data",
- label: "Leistungsposition",
- description: "Beschreibung einer Position",
- width: 340,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "Beratung und Diagnostik",
- },
- {
- id: "amount",
- category: "master-data",
- label: "Betrag",
- description: "Gesamt- oder Positionsbetrag",
- width: 180,
- fontSize: 18,
- fontWeight: 700,
- textAlign: "right",
- defaultContent: () => "0,00 EUR",
- },
- {
- id: "iban",
- category: "master-data",
- label: "IBAN",
- description: "Bankverbindung fuer die Zahlung",
- width: 320,
- fontSize: 15,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "DE12 3456 7890 1234 5678 90",
- },
- {
- id: "bic",
- category: "master-data",
- label: "BIC",
- description: "BIC der Bank",
- width: 180,
- fontSize: 15,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "GENODEF1XXX",
- },
- {
- id: "footer",
- category: "master-data",
- label: "Fusszeile",
- description: "Hinweis oder Dankeszeile",
- width: 360,
- fontSize: 14,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "Vielen Dank fuer Ihren Auftrag.",
- },
- {
- id: "recipient-company",
- category: "customer-data",
- label: "Empfaengerfirma",
- description: "Firma des Rechnungsempfaengers",
- width: 280,
- fontSize: 16,
- fontWeight: 600,
- textAlign: "left",
- defaultContent: () => "Firma Rechnungsempfaenger",
- },
- {
- id: "recipient-name",
- category: "customer-data",
- label: "Empfaengername",
- description: "Name des Rechnungsempfaengers",
- width: 240,
- fontSize: 16,
- fontWeight: 500,
- textAlign: "left",
- defaultContent: () => "Name Rechnungsempfaenger",
- },
- {
- id: "recipient-street",
- category: "customer-data",
- label: "Empfaengerstrasse",
- description: "Strasse des Rechnungsempfaengers",
- width: 220,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "Strasse Rechnungsempfaenger",
- },
- {
- id: "recipient-house-number",
- category: "customer-data",
- label: "Empfaenger Hausnummer",
- description: "Hausnummer des Rechnungsempfaengers",
- width: 120,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "Hausnummer",
- },
- {
- id: "recipient-postal-code",
- category: "customer-data",
- label: "Empfaenger PLZ",
- description: "Postleitzahl des Rechnungsempfaengers",
- width: 100,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "PLZ",
- },
- {
- id: "recipient-city",
- category: "customer-data",
- label: "Empfaenger Ort",
- description: "Ort des Rechnungsempfaengers",
- width: 180,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "Ort Rechnungsempfaenger",
- },
- {
- id: "line",
- category: "free-elements",
- label: "Linie",
- description: "Linie mit umschaltbarer Ausrichtung",
- kind: "line",
- width: 260,
- height: 3,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- lineOrientation: "horizontal",
- defaultContent: () => "",
- },
- {
- id: "image",
- category: "free-elements",
- label: "Bild",
- description: "Logo oder Produktbild mit Upload",
- kind: "image",
- width: 180,
- height: 120,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "Bild",
- },
-];
-
-const INVOICE_PALETTE_GROUPS: Array<{ category: PaletteCategory; title: string }> = [
- { category: "master-data", title: "Stammdaten" },
- { category: "customer-data", title: "Kundendaten" },
- { category: "free-elements", title: "Freie Elemente" },
-];
-
-const REPORT_PALETTE_ITEMS: PaletteItem[] = [
- {
- id: "report-title",
- category: "report-header",
- label: "Berichtstitel",
- description: "Titel der Probenauswertung",
- width: 330,
- fontSize: 28,
- fontWeight: 700,
- textAlign: "left",
- defaultContent: () => "Milchprobenauswertung",
- },
- {
- id: "report-sample-number",
- category: "report-header",
- label: "Probe",
- description: "Probenummer",
- width: 120,
- fontSize: 16,
- fontWeight: 600,
- textAlign: "left",
- defaultContent: () => "Probe: 10307",
- },
- {
- id: "report-date",
- category: "report-header",
- label: "Datum",
- description: "Berichtsdatum",
- width: 120,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => `Datum:\n${new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(new Date())}`,
- },
- {
- id: "report-farmer",
- category: "report-data",
- label: "Landwirt",
- description: "Name des Landwirts",
- width: 270,
- fontSize: 16,
- fontWeight: 600,
- textAlign: "left",
- defaultContent: () => "Landwirt: VoS, Dirk Schwissel",
- },
- {
- id: "report-cow",
- category: "report-data",
- label: "Kuh",
- description: "Kuh- oder Tiernummer",
- width: 220,
- fontSize: 16,
- fontWeight: 600,
- textAlign: "left",
- defaultContent: () => "Kuh: 36",
- },
- {
- id: "report-clinical-findings",
- category: "report-data",
- label: "Klinische Untersuchung",
- description: "Klinischer Befund",
- width: 320,
- fontSize: 16,
- fontWeight: 600,
- textAlign: "left",
- defaultContent: () => "Klinische Untersuchung:",
- },
- {
- id: "report-treatment",
- category: "report-data",
- label: "Vorbehandelt",
- description: "Vorbehandlung oder Medikation",
- width: 340,
- fontSize: 16,
- fontWeight: 600,
- textAlign: "left",
- defaultContent: () => "Vorbehandelt mit: Ubrolexin; Betamox",
- },
- {
- id: "report-examination-start",
- category: "report-data",
- label: "Untersuchungsbeginn",
- description: "Startdatum der Untersuchung",
- width: 280,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "Untersuchungsbeginn: 11.03.2026",
- },
- {
- id: "report-examination-end",
- category: "report-data",
- label: "Untersuchungsende",
- description: "Enddatum der Untersuchung",
- width: 260,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "Untersuchungsende: 13.03.2026",
- },
- {
- id: "report-quarter-vl-label",
- category: "report-findings",
- label: "VL",
- description: "Linkes Vorderviertel",
- width: 32,
- fontSize: 16,
- fontWeight: 700,
- textAlign: "center",
- defaultContent: () => "VL",
- },
- {
- id: "report-quarter-vr-label",
- category: "report-findings",
- label: "VR",
- description: "Rechtes Vorderviertel",
- width: 32,
- fontSize: 16,
- fontWeight: 700,
- textAlign: "center",
- defaultContent: () => "VR",
- },
- {
- id: "report-quarter-hl-label",
- category: "report-findings",
- label: "HL",
- description: "Linkes Hinterviertel",
- width: 32,
- fontSize: 16,
- fontWeight: 700,
- textAlign: "center",
- defaultContent: () => "HL",
- },
- {
- id: "report-quarter-hr-label",
- category: "report-findings",
- label: "HR",
- description: "Rechtes Hinterviertel",
- width: 32,
- fontSize: 16,
- fontWeight: 700,
- textAlign: "center",
- defaultContent: () => "HR",
- },
- {
- id: "report-quarter-vl-result",
- category: "report-findings",
- label: "Befund VL",
- description: "Befundtext fuer VL",
- width: 120,
- fontSize: 15,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "kein bakt.\nWachstum",
- },
- {
- id: "report-quarter-vr-result",
- category: "report-findings",
- label: "Befund VR",
- description: "Befundtext fuer VR",
- width: 120,
- fontSize: 15,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "KNS",
- },
- {
- id: "report-quarter-hl-result",
- category: "report-findings",
- label: "Befund HL",
- description: "Befundtext fuer HL",
- width: 120,
- fontSize: 15,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "kein bakt.\nWachstum",
- },
- {
- id: "report-quarter-hr-result",
- category: "report-findings",
- label: "Befund HR",
- description: "Befundtext fuer HR",
- width: 120,
- fontSize: 15,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "kein bakt.\nWachstum",
- },
- {
- id: "report-antibiogram-heading",
- category: "report-sections",
- label: "Antibiogramm Titel",
- description: "Ueberschrift des Antibiogramms",
- width: 170,
- fontSize: 18,
- fontWeight: 700,
- textAlign: "left",
- defaultContent: () => "Antibiogramm 4/4",
- },
- {
- id: "report-antibiogram-summary",
- category: "report-sections",
- label: "Antibiogramm Ergebnis",
- description: "Kurzfassung zum Viertel",
- width: 520,
- fontSize: 15,
- fontWeight: 600,
- textAlign: "left",
- defaultContent: () => "VR (sensibel): Penicillin, Cefalexin ...",
- },
- {
- id: "report-antibiogram-details",
- category: "report-sections",
- label: "Antibiogramm Details",
- description: "Ausfuehrliche Antibiogramm-Liste",
- width: 610,
- fontSize: 15,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () =>
- "Cefquinom, Enrofloxacin, Sulfadoxin Trimethoprim, Amoxicillin Clavulansaeure, Oxacillin, Ampicillin, Sulfadoxin Trimethoprim",
- },
- {
- id: "report-therapy-heading",
- category: "report-sections",
- label: "Empfehlung/Therapie",
- description: "Ueberschrift der Therapieempfehlung",
- width: 230,
- fontSize: 17,
- fontWeight: 700,
- textAlign: "left",
- defaultContent: () => "Empfehlung/Therapie:",
- },
- {
- id: "report-therapy-text",
- category: "report-sections",
- label: "Therapietext",
- description: "Empfehlungstext",
- width: 400,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "weiter wie begonnen",
- },
- {
- id: "report-misc-heading",
- category: "report-sections",
- label: "Sonstiges",
- description: "Ueberschrift fuer Hinweise",
- width: 140,
- fontSize: 18,
- fontWeight: 700,
- textAlign: "left",
- defaultContent: () => "Sonstiges",
- },
- {
- id: "report-misc-note",
- category: "report-sections",
- label: "Hinweis",
- description: "Allgemeiner Hinweistext",
- width: 620,
- fontSize: 15,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () =>
- "· Bei Rueckfragen setzen Sie sich bitte mit Ihrem behandelnden Tierarzt in Verbindung.",
- },
- {
- id: "report-lab-note",
- category: "report-sections",
- label: "Laborvermerk",
- description: "Interner Vermerk",
- width: 220,
- fontSize: 15,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "Interner Vermerk: Labor",
- },
- {
- id: "free-text",
- category: "free-elements",
- label: "Freitext",
- description: "Beliebig editierbarer Text",
- width: 220,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "Freitext",
- },
- {
- id: "line",
- category: "free-elements",
- label: "Linie",
- description: "Linie mit umschaltbarer Ausrichtung",
- kind: "line",
- width: 260,
- height: 3,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- lineOrientation: "horizontal",
- defaultContent: () => "",
- },
- {
- id: "image",
- category: "free-elements",
- label: "Bild",
- description: "Logo, Kopfgrafik oder Gestaltungselement",
- kind: "image",
- width: 180,
- height: 120,
- fontSize: 16,
- fontWeight: 400,
- textAlign: "left",
- defaultContent: () => "Bild",
- },
-];
-
-const REPORT_PALETTE_GROUPS: Array<{ category: PaletteCategory; title: string }> = [
- { category: "report-header", title: "Kopfbereich" },
- { category: "report-data", title: "Probendaten" },
- { category: "report-findings", title: "Befunde" },
- { category: "report-sections", title: "Abschnitte" },
- { category: "free-elements", title: "Freie Elemente" },
-];
-
-function clamp(value: number, minimum: number, maximum: number) {
- return Math.min(Math.max(value, minimum), maximum);
-}
-
-function snapToGrid(value: number) {
- return Math.round(value / GRID_SIZE) * GRID_SIZE;
-}
-
-function formatPdfNumber(value: number) {
- const normalized = Number(value.toFixed(2));
- if (Number.isInteger(normalized)) {
- return String(normalized);
- }
- return normalized.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
-}
-
-function measureTextWidth(text: string, fontSize: number, fontWeight: FontWeight) {
- if (typeof document === "undefined") {
- return text.length * fontSize * 0.56;
- }
-
- const canvas = document.createElement("canvas");
- const context = canvas.getContext("2d");
- if (!context) {
- return text.length * fontSize * 0.56;
- }
-
- context.font = `${fontWeight >= 600 ? 700 : 400} ${fontSize}px Arial`;
- return context.measureText(text).width;
-}
-
-function splitLongToken(token: string, maxWidth: number, fontSize: number, fontWeight: FontWeight) {
- const segments: string[] = [];
- let currentSegment = "";
-
- for (const character of token) {
- const candidate = `${currentSegment}${character}`;
- if (currentSegment && measureTextWidth(candidate, fontSize, fontWeight) > maxWidth) {
- segments.push(currentSegment);
- currentSegment = character;
- continue;
- }
- currentSegment = candidate;
- }
-
- if (currentSegment) {
- segments.push(currentSegment);
- }
-
- return segments;
-}
-
-function wrapTextLines(text: string, maxWidth: number, fontSize: number, fontWeight: FontWeight) {
- const paragraphs = text.split(/\r?\n/);
- const wrappedLines: string[] = [];
-
- for (const paragraph of paragraphs) {
- const words = paragraph.trim().split(/\s+/).filter(Boolean);
- if (!words.length) {
- wrappedLines.push("");
- continue;
- }
-
- let currentLine = "";
- for (const word of words) {
- const fragments =
- measureTextWidth(word, fontSize, fontWeight) > maxWidth
- ? splitLongToken(word, maxWidth, fontSize, fontWeight)
- : [word];
-
- for (const fragment of fragments) {
- const candidate = currentLine ? `${currentLine} ${fragment}` : fragment;
- if (!currentLine || measureTextWidth(candidate, fontSize, fontWeight) <= maxWidth) {
- currentLine = candidate;
- continue;
- }
-
- wrappedLines.push(currentLine);
- currentLine = fragment;
- }
- }
-
- if (currentLine) {
- wrappedLines.push(currentLine);
- }
- }
-
- return wrappedLines.length ? wrappedLines : [""];
-}
-
-function encodePdfText(text: string) {
- const bytes: number[] = [];
-
- for (const character of text) {
- const codePoint = character.codePointAt(0) ?? 63;
- if (CP1252_MAP[codePoint]) {
- bytes.push(CP1252_MAP[codePoint]);
- continue;
- }
- if (codePoint >= 0 && codePoint <= 255) {
- bytes.push(codePoint);
- continue;
- }
- bytes.push(63);
- }
-
- return bytes.map((value) => value.toString(16).padStart(2, "0")).join("").toUpperCase();
-}
-
-function getDefaultElementHeight(
- kind: ElementKind,
- fontSize: number,
- lineOrientation?: LineOrientation,
-) {
- if (kind === "line") {
- return lineOrientation === "vertical" ? 180 : 3;
- }
- if (kind === "image") {
- return 120;
- }
- return Math.max(fontSize + 16, 28);
-}
-
-function getMinimumWidthForElement(kind: ElementKind, lineOrientation?: LineOrientation) {
- if (kind === "line") {
- return lineOrientation === "vertical" ? MIN_LINE_THICKNESS : MIN_LINE_LENGTH;
- }
- if (kind === "image") {
- return MIN_MEDIA_WIDTH;
- }
- return MIN_ELEMENT_WIDTH;
-}
-
-function getMinimumHeightForElement(
- kind: ElementKind,
- fontSize: number,
- lineOrientation?: LineOrientation,
-) {
- if (kind === "line") {
- return lineOrientation === "vertical" ? MIN_LINE_LENGTH : MIN_LINE_THICKNESS;
- }
- if (kind === "image") {
- return MIN_MEDIA_HEIGHT;
- }
- return getDefaultElementHeight(kind, fontSize, lineOrientation);
-}
-
-function getElementHeight(
- element: Pick,
-) {
- return Math.max(
- 1,
- Math.round(
- element.height ?? getDefaultElementHeight(element.kind, element.fontSize, element.lineOrientation),
- ),
- );
-}
-
-function fitContainedSize(
- containerWidth: number,
- containerHeight: number,
- assetWidth: number,
- assetHeight: number,
-) {
- if (assetWidth <= 0 || assetHeight <= 0) {
- return {
- height: containerHeight,
- offsetX: 0,
- offsetY: 0,
- width: containerWidth,
- };
- }
-
- const scale = Math.min(containerWidth / assetWidth, containerHeight / assetHeight);
- const width = assetWidth * scale;
- const height = assetHeight * scale;
-
- return {
- height,
- offsetX: (containerWidth - width) / 2,
- offsetY: (containerHeight - height) / 2,
- width,
- };
-}
-
-function decodeBase64DataUrl(dataUrl: string) {
- const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
- if (!match) {
- return null;
- }
-
- const [, mimeType, base64Payload] = match;
- const binary = atob(base64Payload);
- const bytes = new Uint8Array(binary.length);
-
- for (let index = 0; index < binary.length; index += 1) {
- bytes[index] = binary.charCodeAt(index);
- }
-
- return {
- bytes,
- mimeType,
- };
-}
-
-function bytesToHex(bytes: Uint8Array) {
- let hex = "";
- for (const value of bytes) {
- hex += value.toString(16).padStart(2, "0").toUpperCase();
- }
- return hex;
-}
-
-function createPdfImageResources(elements: TemplateElement[]) {
- const imageElements = elements.filter(
- (element) => element.kind === "image" && typeof element.imageSrc === "string" && element.imageSrc,
- );
-
- return imageElements.reduce((resources, element, index) => {
- const decoded = decodeBase64DataUrl(element.imageSrc ?? "");
- if (!decoded || !/image\/jpe?g/i.test(decoded.mimeType)) {
- return resources;
- }
-
- const hexPayload = `${bytesToHex(decoded.bytes)}>`;
- resources.push({
- body:
- `<< /Type /XObject /Subtype /Image /Width ${Math.max(1, Math.round(element.imageNaturalWidth ?? element.width))}` +
- ` /Height ${Math.max(1, Math.round(element.imageNaturalHeight ?? getElementHeight(element)))}` +
- " /ColorSpace /DeviceRGB /BitsPerComponent 8" +
- " /Filter [/ASCIIHexDecode /DCTDecode]" +
- ` /Length ${hexPayload.length} >>\nstream\n${hexPayload}\nendstream`,
- elementId: element.id,
- name: `/Im${index + 1}`,
- pixelHeight: Math.max(1, Math.round(element.imageNaturalHeight ?? getElementHeight(element))),
- pixelWidth: Math.max(1, Math.round(element.imageNaturalWidth ?? element.width)),
- });
-
- return resources;
- }, []);
-}
-
-function createPdfContentStream(
- elements: TemplateElement[],
- imageResources: Map,
-) {
- const commands = ["0 g"];
-
- for (const element of elements) {
- if (element.kind === "line") {
- const elementHeight = getElementHeight(element);
- const pdfX = element.x * PDF_SCALE;
- const pdfY = PDF_PAGE_HEIGHT - (element.y + elementHeight) * PDF_SCALE;
- const pdfWidth = element.width * PDF_SCALE;
- const pdfHeight = elementHeight * PDF_SCALE;
-
- commands.push(
- `${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} ${formatPdfNumber(pdfWidth)} ${formatPdfNumber(pdfHeight)} re f`,
- );
- continue;
- }
-
- if (element.kind === "image") {
- const imageResource = imageResources.get(element.id);
- if (!imageResource) {
- continue;
- }
-
- const boxHeight = getElementHeight(element);
- const fitted = fitContainedSize(
- element.width,
- boxHeight,
- imageResource.pixelWidth,
- imageResource.pixelHeight,
- );
- const pdfX = (element.x + fitted.offsetX) * PDF_SCALE;
- const pdfY =
- PDF_PAGE_HEIGHT - (element.y + fitted.offsetY + fitted.height) * PDF_SCALE;
- const pdfWidth = fitted.width * PDF_SCALE;
- const pdfHeight = fitted.height * PDF_SCALE;
-
- commands.push("q");
- commands.push(
- `${formatPdfNumber(pdfWidth)} 0 0 ${formatPdfNumber(pdfHeight)} ${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} cm`,
- );
- commands.push(`${imageResource.name} Do`);
- commands.push("Q");
- continue;
- }
-
- const fontName = element.fontWeight >= 600 ? "/F2" : "/F1";
- const fontSize = element.fontSize * PDF_SCALE;
- const lineHeight = element.fontSize * PDF_LINE_HEIGHT;
- const textStartX = element.x + PDF_TEXT_PADDING_X;
- const textWidth = Math.max(24, element.width - PDF_TEXT_PADDING_X * 2);
- const wrappedLines = wrapTextLines(
- element.content || "",
- textWidth,
- element.fontSize,
- element.fontWeight,
- );
-
- wrappedLines.forEach((line, index) => {
- if (!line) {
- return;
- }
-
- const renderedLineWidth = measureTextWidth(line, element.fontSize, element.fontWeight);
- let textX = textStartX;
- if (element.textAlign === "center") {
- textX += Math.max(0, (textWidth - renderedLineWidth) / 2);
- } else if (element.textAlign === "right") {
- textX += Math.max(0, textWidth - renderedLineWidth);
- }
-
- const baselineY = element.y + PDF_TEXT_PADDING_Y + element.fontSize + index * lineHeight;
- const pdfX = textX * PDF_SCALE;
- const pdfY = PDF_PAGE_HEIGHT - baselineY * PDF_SCALE;
-
- commands.push("BT");
- commands.push(`${fontName} ${formatPdfNumber(fontSize)} Tf`);
- commands.push(`1 0 0 1 ${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} Tm`);
- commands.push(`<${encodePdfText(line)}> Tj`);
- commands.push("ET");
- });
- }
-
- return commands.join("\n");
-}
-
-function createPdfBlob(elements: TemplateElement[]) {
- const imageResources = createPdfImageResources(elements);
- const imageResourceMap = new Map(imageResources.map((resource) => [resource.elementId, resource]));
- const contentStream = createPdfContentStream(elements, imageResourceMap);
- const imageObjectNumberStart = 6;
- const contentObjectNumber = imageObjectNumberStart + imageResources.length;
- const xObjectResources = imageResources.length
- ? ` /XObject << ${imageResources
- .map((resource, index) => `${resource.name} ${imageObjectNumberStart + index} 0 R`)
- .join(" ")} >>`
- : "";
- const objects = [
- "<< /Type /Catalog /Pages 2 0 R >>",
- "<< /Type /Pages /Count 1 /Kids [3 0 R] >>",
- `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${formatPdfNumber(PDF_PAGE_WIDTH)} ${formatPdfNumber(PDF_PAGE_HEIGHT)}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >>${xObjectResources} >> /Contents ${contentObjectNumber} 0 R >>`,
- "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
- "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>",
- ...imageResources.map((resource) => resource.body),
- `<< /Length ${contentStream.length} >>\nstream\n${contentStream}\nendstream`,
- ];
-
- let pdf = "%PDF-1.4\n";
- const offsets = [0];
-
- objects.forEach((body, index) => {
- offsets.push(pdf.length);
- pdf += `${index + 1} 0 obj\n${body}\nendobj\n`;
- });
-
- const xrefOffset = pdf.length;
- pdf += `xref\n0 ${objects.length + 1}\n`;
- pdf += "0000000000 65535 f \n";
-
- for (let index = 1; index <= objects.length; index += 1) {
- pdf += `${String(offsets[index]).padStart(10, "0")} 00000 n \n`;
- }
-
- pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
-
- return new Blob([pdf], { type: "application/pdf" });
-}
-
-function generateId() {
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
- return crypto.randomUUID();
- }
- return `invoice-template-${Date.now()}-${Math.random().toString(16).slice(2)}`;
-}
-
-function normalizeFontWeight(value: unknown): FontWeight {
- return value === 500 || value === 600 || value === 700 ? value : 400;
-}
-
-function normalizeTextAlign(value: unknown): TextAlign {
- return value === "center" || value === "right" ? value : "left";
-}
-
-function normalizeElementKind(value: unknown): ElementKind {
- return value === "line" || value === "image" ? value : "text";
-}
-
-function normalizeLineOrientation(value: unknown): LineOrientation {
- return value === "vertical" ? "vertical" : "horizontal";
-}
-
-function normalizeElement(element: TemplateElement): TemplateElement {
- const kind = normalizeElementKind(element.kind);
- const lineOrientation = kind === "line" ? normalizeLineOrientation(element.lineOrientation) : undefined;
- const fontSize = clamp(Math.round(element.fontSize), MIN_FONT_SIZE, MAX_FONT_SIZE);
- let width = Math.round(element.width);
- let height = getElementHeight({
- fontSize,
- height: element.height,
- kind,
- lineOrientation,
- });
-
- if (kind === "line") {
- width = clamp(width, getMinimumWidthForElement(kind, lineOrientation), CANVAS_WIDTH);
- height = clamp(height, getMinimumHeightForElement(kind, fontSize, lineOrientation), CANVAS_HEIGHT);
- } else if (kind === "image") {
- width = clamp(width, getMinimumWidthForElement(kind), CANVAS_WIDTH - 32);
- height = clamp(height, getMinimumHeightForElement(kind, fontSize), CANVAS_HEIGHT - 32);
- } else {
- width = clamp(width, getMinimumWidthForElement(kind), CANVAS_WIDTH - 32);
- height = getDefaultElementHeight(kind, fontSize, lineOrientation);
- }
-
- const x = clamp(snapToGrid(element.x), 0, CANVAS_WIDTH - width);
- const y = clamp(
- snapToGrid(element.y),
- 0,
- kind === "text" ? CANVAS_HEIGHT - 40 : CANVAS_HEIGHT - height,
- );
- const imageNaturalWidth =
- kind === "image" && typeof element.imageNaturalWidth === "number" && element.imageNaturalWidth > 0
- ? Math.round(element.imageNaturalWidth)
- : null;
- const imageNaturalHeight =
- kind === "image" && typeof element.imageNaturalHeight === "number" && element.imageNaturalHeight > 0
- ? Math.round(element.imageNaturalHeight)
- : null;
-
- return {
- ...element,
- kind,
- width,
- height: kind === "text" ? undefined : height,
- fontSize,
- x,
- y,
- fontWeight: normalizeFontWeight(element.fontWeight),
- textAlign: normalizeTextAlign(element.textAlign),
- lineOrientation,
- imageSrc: kind === "image" ? element.imageSrc ?? null : undefined,
- imageNaturalWidth,
- imageNaturalHeight,
- };
-}
-
-function findPaletteItem(paletteItems: PaletteItem[], paletteId: string) {
- return paletteItems.find((entry) => entry.id === paletteId) ?? null;
-}
-
-function requirePaletteItem(paletteItems: PaletteItem[], paletteId: string) {
- const paletteItem = findPaletteItem(paletteItems, paletteId);
- if (!paletteItem) {
- throw new Error(`Palette item ${paletteId} is not configured.`);
- }
- return paletteItem;
-}
-
-function getPalettePreviewText(paletteItem: PaletteItem, user: UserOption | null) {
- if (paletteItem.kind === "image") {
- return "Bild auswaehlen";
- }
- if (paletteItem.kind === "line") {
- return "Linie";
- }
- return paletteItem.defaultContent(user);
-}
-
-function isElementContentEditable(
- element: Pick,
- lockedTextPaletteIds: ReadonlySet,
-) {
- return element.kind === "text" && !lockedTextPaletteIds.has(element.paletteId);
-}
-
-function createElementFromPalette(
- paletteItem: PaletteItem,
- user: UserOption | null,
- position: { x: number; y: number },
-): TemplateElement {
- return normalizeElement({
- id: generateId(),
- paletteId: paletteItem.id,
- kind: paletteItem.kind ?? "text",
- label: paletteItem.label,
- content: paletteItem.defaultContent(user),
- x: position.x,
- y: position.y,
- width: paletteItem.width,
- height: paletteItem.height,
- fontSize: paletteItem.fontSize,
- fontWeight: paletteItem.fontWeight,
- textAlign: paletteItem.textAlign,
- lineOrientation: paletteItem.lineOrientation,
- imageSrc: null,
- imageNaturalWidth: null,
- imageNaturalHeight: null,
- });
-}
-
-function createInvoiceStarterLayout(user: UserOption | null, paletteItems: PaletteItem[]) {
- return [
- createElementFromPalette(requirePaletteItem(paletteItems, "company-name"), user, { x: 56, y: 56 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "street"), user, { x: 56, y: 104 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "house-number"), user, { x: 288, y: 104 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "postal-code"), user, { x: 56, y: 132 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "city"), user, { x: 164, y: 132 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "invoice-title"), user, { x: 56, y: 238 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "invoice-date"), user, { x: 538, y: 64 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "invoice-number"), user, { x: 538, y: 96 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "amount"), user, { x: 558, y: 432 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "footer"), user, { x: 56, y: 1008 }),
- ];
-}
-
-function createReportStarterLayout(user: UserOption | null, paletteItems: PaletteItem[]) {
- const horizontalLine = (x: number, y: number, width: number) =>
- normalizeElement({
- ...createElementFromPalette(requirePaletteItem(paletteItems, "line"), user, { x, y }),
- width,
- });
- const verticalLine = (x: number, y: number, height: number) =>
- normalizeElement({
- ...createElementFromPalette(requirePaletteItem(paletteItems, "line"), user, { x, y }),
- height,
- lineOrientation: "vertical",
- width: 3,
- });
-
- return [
- createElementFromPalette(requirePaletteItem(paletteItems, "report-title"), user, { x: 56, y: 108 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-farmer"), user, { x: 56, y: 154 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-cow"), user, { x: 56, y: 186 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-clinical-findings"), user, { x: 56, y: 218 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-treatment"), user, { x: 56, y: 250 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-examination-start"), user, { x: 56, y: 282 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-examination-end"), user, { x: 56, y: 314 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-sample-number"), user, { x: 430, y: 108 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-date"), user, { x: 572, y: 108 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-vl-label"), user, { x: 538, y: 206 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-vr-label"), user, { x: 628, y: 206 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-hl-label"), user, { x: 538, y: 236 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-hr-label"), user, { x: 628, y: 236 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-vl-result"), user, { x: 430, y: 168 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-vr-result"), user, { x: 618, y: 184 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-hl-result"), user, { x: 430, y: 272 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-hr-result"), user, { x: 618, y: 272 }),
- horizontalLine(520, 254, 170),
- verticalLine(606, 196, 106),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-antibiogram-heading"), user, { x: 56, y: 426 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-antibiogram-summary"), user, { x: 56, y: 458 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-antibiogram-details"), user, { x: 56, y: 490 }),
- horizontalLine(56, 396, 646),
- horizontalLine(56, 548, 646),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-therapy-heading"), user, { x: 56, y: 574 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-therapy-text"), user, { x: 292, y: 574 }),
- horizontalLine(56, 628, 646),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-misc-heading"), user, { x: 56, y: 656 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-misc-note"), user, { x: 56, y: 702 }),
- createElementFromPalette(requirePaletteItem(paletteItems, "report-lab-note"), user, { x: 56, y: 754 }),
- ];
-}
-
-function readFileAsDataUrl(file: File) {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onerror = () => reject(new Error("Bild konnte nicht geladen werden."));
- reader.onload = () => {
- if (typeof reader.result === "string") {
- resolve(reader.result);
- return;
- }
- reject(new Error("Bild konnte nicht geladen werden."));
- };
- reader.readAsDataURL(file);
- });
-}
-
-function loadImageAsset(dataUrl: string) {
- return new Promise((resolve, reject) => {
- const image = new Image();
- image.onerror = () => reject(new Error("Bild konnte nicht gelesen werden."));
- image.onload = () =>
- resolve({
- dataUrl,
- height: image.naturalHeight,
- width: image.naturalWidth,
- });
- image.src = dataUrl;
- });
-}
-
-async function convertImageFileToJpeg(file: File) {
- const source = await readFileAsDataUrl(file);
- const asset = await loadImageAsset(source);
- const canvas = document.createElement("canvas");
- canvas.width = asset.width;
- canvas.height = asset.height;
-
- const context = canvas.getContext("2d");
- if (!context) {
- throw new Error("Bild konnte nicht verarbeitet werden.");
- }
-
- const image = await loadImageAsset(source);
- context.fillStyle = "#ffffff";
- context.fillRect(0, 0, canvas.width, canvas.height);
- const renderImage = new Image();
-
- await new Promise((resolve, reject) => {
- renderImage.onerror = () => reject(new Error("Bild konnte nicht verarbeitet werden."));
- renderImage.onload = () => {
- context.drawImage(renderImage, 0, 0, canvas.width, canvas.height);
- resolve();
- };
- renderImage.src = image.dataUrl;
- });
-
- return {
- dataUrl: canvas.toDataURL("image/jpeg", 0.92),
- height: asset.height,
- width: asset.width,
- };
-}
-
-function normalizeTemplateElements(raw: unknown) {
- if (!Array.isArray(raw)) {
- return null;
- }
-
- const elements = raw
- .map((entry) => {
- if (!entry || typeof entry !== "object") {
- return null;
- }
-
- const candidate = entry as Partial;
- if (
- typeof candidate.id !== "string" ||
- typeof candidate.paletteId !== "string" ||
- typeof candidate.label !== "string" ||
- typeof candidate.content !== "string" ||
- typeof candidate.x !== "number" ||
- typeof candidate.y !== "number" ||
- typeof candidate.width !== "number" ||
- typeof candidate.fontSize !== "number"
- ) {
- return null;
- }
-
- return normalizeElement({
- id: candidate.id,
- paletteId: candidate.paletteId,
- kind: normalizeElementKind(candidate.kind),
- label: candidate.label,
- content: candidate.content,
- x: candidate.x,
- y: candidate.y,
- width: candidate.width,
- height: typeof candidate.height === "number" ? candidate.height : undefined,
- fontSize: candidate.fontSize,
- fontWeight: normalizeFontWeight(candidate.fontWeight),
- textAlign: normalizeTextAlign(candidate.textAlign),
- lineOrientation: normalizeLineOrientation(candidate.lineOrientation),
- imageSrc: typeof candidate.imageSrc === "string" ? candidate.imageSrc : null,
- imageNaturalWidth:
- typeof candidate.imageNaturalWidth === "number" ? candidate.imageNaturalWidth : null,
- imageNaturalHeight:
- typeof candidate.imageNaturalHeight === "number" ? candidate.imageNaturalHeight : null,
- });
- })
- .filter((entry): entry is TemplateElement => entry !== null);
-
- return elements;
-}
-
-function formatTemplateTimestamp(value: string | null) {
- if (!value) {
- return null;
- }
-
- const parsed = new Date(value);
- if (Number.isNaN(parsed.getTime())) {
- return null;
- }
-
- return new Intl.DateTimeFormat("de-DE", {
- dateStyle: "medium",
- timeStyle: "short",
- }).format(parsed);
-}
-
-function readDragPayload(event: DragEvent): DragPayload | null {
- const raw = event.dataTransfer.getData(DRAG_DATA_TYPE);
- if (!raw) {
- return null;
- }
-
- try {
- const parsed = JSON.parse(raw) as DragPayload;
- if (parsed.kind === "palette" && typeof parsed.paletteId === "string") {
- return parsed;
- }
- if (parsed.kind === "element" && typeof parsed.elementId === "string") {
- return parsed;
- }
- return null;
- } catch {
- return null;
- }
-}
-
-export default function InvoiceTemplatePage({
- buildStarterLayout = createInvoiceStarterLayout,
- lockedTextPaletteIds = INVOICE_LOCKED_TEXT_PALETTE_IDS,
- paletteGroups = INVOICE_PALETTE_GROUPS,
- paletteItems = INVOICE_PALETTE_ITEMS,
- pdfDownloadName = "rechnung-template.pdf",
- pdfPreviewTitle = "PDF-Vorschau Rechnungsvorlage",
- templateApiPath = "/session/invoice-template",
- templateTitle = "Rechnungsvorlage",
-}: InvoiceTemplatePageProps) {
- const { user } = useSession();
- const pageRef = useRef(null);
- const cardRef = useRef(null);
- const canvasRef = useRef(null);
- const canvasShellRef = useRef(null);
- const dragDepthRef = useRef(0);
- const mouseDragCleanupRef = useRef<(() => void) | null>(null);
- const moveOffsetRef = useRef<{ x: number; y: number } | null>(null);
-
- const [elements, setElements] = useState(() =>
- buildStarterLayout(user, paletteItems),
- );
- const [selectedElementId, setSelectedElementId] = useState(null);
- const [isCanvasActive, setIsCanvasActive] = useState(false);
- const [draggingElementId, setDraggingElementId] = useState(null);
- const [resizingElementId, setResizingElementId] = useState(null);
- const [pdfError, setPdfError] = useState(null);
- const [templateError, setTemplateError] = useState(null);
- const [templateUpdatedAt, setTemplateUpdatedAt] = useState(null);
- const [isTemplateLoading, setIsTemplateLoading] = useState(false);
- const [isTemplateSaving, setIsTemplateSaving] = useState(false);
- const [isTemplateApiAvailable, setIsTemplateApiAvailable] = useState(true);
- const [pdfPreviewUrl, setPdfPreviewUrl] = useState(null);
- const [pageViewportHeight, setPageViewportHeight] = useState(null);
- const [canvasViewport, setCanvasViewport] = useState({
- height: CANVAS_HEIGHT,
- scale: 1,
- syncPanelHeight: false,
- width: CANVAS_WIDTH,
- });
-
- const selectedElement = elements.find((entry) => entry.id === selectedElementId) ?? null;
- const selectedElementHeight = selectedElement ? getElementHeight(selectedElement) : null;
- const selectedElementContentEditable = selectedElement
- ? isElementContentEditable(selectedElement, lockedTextPaletteIds)
- : false;
- const selectedElementMinimumWidth = selectedElement
- ? getMinimumWidthForElement(selectedElement.kind, selectedElement.lineOrientation)
- : MIN_ELEMENT_WIDTH;
- const selectedElementMinimumHeight = selectedElement
- ? getMinimumHeightForElement(
- selectedElement.kind,
- selectedElement.fontSize,
- selectedElement.lineOrientation,
- )
- : MIN_MEDIA_HEIGHT;
- const templateTimestampLabel = formatTemplateTimestamp(templateUpdatedAt);
- const emptyCanvasMessage = `Ziehen Sie links ein Element auf diese Fläche, um Ihre ${templateTitle} zu starten.`;
-
- useEffect(() => {
- if (!user) {
- const starterLayout = buildStarterLayout(null, paletteItems);
- setElements(starterLayout);
- setSelectedElementId(starterLayout[0]?.id ?? null);
- setResizingElementId(null);
- setIsTemplateApiAvailable(true);
- setIsTemplateLoading(false);
- setTemplateUpdatedAt(null);
- setTemplateError(null);
- return;
- }
-
- let cancelled = false;
- setTemplateError(null);
- setIsTemplateLoading(true);
-
- void apiGet(templateApiPath)
- .then((response) => {
- if (cancelled) {
- return;
- }
-
- setIsTemplateApiAvailable(true);
- setTemplateUpdatedAt(response.updatedAt);
- if (!response.stored) {
- const starterLayout = buildStarterLayout(user, paletteItems);
- setElements(starterLayout);
- setSelectedElementId(starterLayout[0]?.id ?? null);
- setResizingElementId(null);
- return;
- }
-
- const loadedElements = normalizeTemplateElements(response.elements);
- if (!loadedElements) {
- throw new Error("Gespeichertes Template konnte nicht geladen werden.");
- }
-
- setElements(loadedElements);
- setSelectedElementId(loadedElements[0]?.id ?? null);
- setResizingElementId(null);
- })
- .catch((error) => {
- if (!cancelled) {
- if (error instanceof ApiError && error.status === 404) {
- const starterLayout = buildStarterLayout(user, paletteItems);
- setElements(starterLayout);
- setSelectedElementId(starterLayout[0]?.id ?? null);
- setResizingElementId(null);
- setTemplateUpdatedAt(null);
- setTemplateError(null);
- setIsTemplateApiAvailable(false);
- return;
- }
- setTemplateError((error as Error).message);
- }
- })
- .finally(() => {
- if (!cancelled) {
- setIsTemplateLoading(false);
- }
- });
-
- return () => {
- cancelled = true;
- };
- }, [buildStarterLayout, paletteItems, templateApiPath, user]);
-
- useEffect(() => {
- if (selectedElementId && elements.some((entry) => entry.id === selectedElementId)) {
- return;
- }
- setSelectedElementId(elements[0]?.id ?? null);
- }, [elements, selectedElementId]);
-
- useEffect(() => {
- return () => {
- mouseDragCleanupRef.current?.();
- };
- }, []);
-
- useEffect(() => {
- return () => {
- if (pdfPreviewUrl) {
- URL.revokeObjectURL(pdfPreviewUrl);
- }
- };
- }, [pdfPreviewUrl]);
-
- useEffect(() => {
- if (!pdfPreviewUrl) {
- return;
- }
-
- function handleEscape(event: KeyboardEvent) {
- if (event.key === "Escape") {
- setPdfPreviewUrl(null);
- }
- }
-
- window.addEventListener("keydown", handleEscape);
- return () => {
- window.removeEventListener("keydown", handleEscape);
- };
- }, [pdfPreviewUrl]);
-
- useEffect(() => {
- let frameId = 0;
-
- function updateCanvasViewport() {
- cancelAnimationFrame(frameId);
- frameId = window.requestAnimationFrame(() => {
- const pageElement = pageRef.current;
- const cardElement = cardRef.current;
- const canvasShellElement = canvasShellRef.current;
- if (!pageElement || !cardElement || !canvasShellElement) {
- return;
- }
-
- const pageRect = pageElement.getBoundingClientRect();
- const nextPageHeight = Math.max(window.innerHeight - pageRect.top - 36, 420);
- setPageViewportHeight((current) => (current === nextPageHeight ? current : nextPageHeight));
-
- const cardStyles = window.getComputedStyle(cardElement);
- const shellStyles = window.getComputedStyle(canvasShellElement);
- const syncPanelHeight = window.innerWidth > 1200;
- const cardPaddingBottom = Number.parseFloat(cardStyles.paddingBottom) || 0;
- const horizontalPadding =
- (Number.parseFloat(shellStyles.paddingLeft) || 0) +
- (Number.parseFloat(shellStyles.paddingRight) || 0);
- const verticalPadding =
- (Number.parseFloat(shellStyles.paddingTop) || 0) +
- (Number.parseFloat(shellStyles.paddingBottom) || 0);
- const cardRect = cardElement.getBoundingClientRect();
- const shellRect = canvasShellElement.getBoundingClientRect();
- const availableWidth = Math.max(
- Math.floor(canvasShellElement.clientWidth - horizontalPadding),
- 320,
- );
- const availableHeight = Math.max(
- Math.floor(cardRect.bottom - shellRect.top - cardPaddingBottom - verticalPadding - CANVAS_BOTTOM_GAP),
- 240,
- );
- const nextWidth = Math.min(
- CANVAS_WIDTH,
- availableWidth,
- Math.floor(availableHeight * CANVAS_ASPECT_RATIO),
- );
- const nextHeight = Math.floor(nextWidth / CANVAS_ASPECT_RATIO);
- const nextScale = nextWidth / CANVAS_WIDTH;
-
- setCanvasViewport((current) => {
- if (
- current.height === nextHeight &&
- current.scale === nextScale &&
- current.syncPanelHeight === syncPanelHeight &&
- current.width === nextWidth
- ) {
- return current;
- }
-
- return {
- height: nextHeight,
- scale: nextScale,
- syncPanelHeight,
- width: nextWidth,
- };
- });
- });
- }
-
- const resizeObserver =
- typeof ResizeObserver === "undefined"
- ? null
- : new ResizeObserver(() => {
- updateCanvasViewport();
- });
-
- if (resizeObserver) {
- if (pageRef.current) {
- resizeObserver.observe(pageRef.current);
- }
- if (cardRef.current) {
- resizeObserver.observe(cardRef.current);
- }
- if (canvasShellRef.current) {
- resizeObserver.observe(canvasShellRef.current);
- }
- }
-
- updateCanvasViewport();
- window.addEventListener("resize", updateCanvasViewport);
- window.addEventListener("scroll", updateCanvasViewport, { passive: true });
-
- return () => {
- cancelAnimationFrame(frameId);
- resizeObserver?.disconnect();
- window.removeEventListener("resize", updateCanvasViewport);
- window.removeEventListener("scroll", updateCanvasViewport);
- };
- }, [pdfPreviewUrl]);
-
- function resetCanvasDragState() {
- dragDepthRef.current = 0;
- setIsCanvasActive(false);
- }
-
- function updateElement(elementId: string, patch: Partial) {
- setElements((current) =>
- current.map((entry) =>
- entry.id === elementId
- ? normalizeElement({
- ...entry,
- ...(!isElementContentEditable(entry, lockedTextPaletteIds) &&
- Object.prototype.hasOwnProperty.call(patch, "content")
- ? { ...patch, content: entry.content }
- : patch),
- })
- : entry,
- ),
- );
- }
-
- function removeElement(elementId: string) {
- mouseDragCleanupRef.current?.();
- setElements((current) => current.filter((entry) => entry.id !== elementId));
- setSelectedElementId((current) => (current === elementId ? null : current));
- setResizingElementId((current) => (current === elementId ? null : current));
- }
-
- function placePaletteElement(paletteId: string, clientX: number, clientY: number) {
- const paletteItem = findPaletteItem(paletteItems, paletteId);
- const canvasElement = canvasRef.current;
- if (!paletteItem || !canvasElement) {
- return;
- }
-
- const rect = canvasElement.getBoundingClientRect();
- const previewHeight = getDefaultElementHeight(
- paletteItem.kind ?? "text",
- paletteItem.fontSize,
- paletteItem.lineOrientation,
- );
- const x = (clientX - rect.left) / canvasViewport.scale - paletteItem.width / 2;
- const y = (clientY - rect.top) / canvasViewport.scale - previewHeight / 2;
- const nextElement = createElementFromPalette(paletteItem, user, { x, y });
-
- setElements((current) => [...current, nextElement]);
- setSelectedElementId(nextElement.id);
- }
-
- function moveCanvasElement(elementId: string, clientX: number, clientY: number) {
- const canvasElement = canvasRef.current;
- if (!canvasElement) {
- return;
- }
-
- const rect = canvasElement.getBoundingClientRect();
- const moveOffset = moveOffsetRef.current;
- const x = (clientX - rect.left) / canvasViewport.scale - (moveOffset?.x ?? 0);
- const y = (clientY - rect.top) / canvasViewport.scale - (moveOffset?.y ?? 0);
-
- updateElement(elementId, { x, y });
- }
-
- function handleCanvasDragEnter(event: DragEvent) {
- event.preventDefault();
- dragDepthRef.current += 1;
- setIsCanvasActive(true);
- }
-
- function handleCanvasDragOver(event: DragEvent) {
- event.preventDefault();
- const payload = readDragPayload(event);
- event.dataTransfer.dropEffect = payload?.kind === "element" ? "move" : "copy";
- setIsCanvasActive(true);
- }
-
- function handleCanvasDragLeave(event: DragEvent) {
- event.preventDefault();
- dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
- if (dragDepthRef.current === 0) {
- setIsCanvasActive(false);
- }
- }
-
- function handleCanvasDrop(event: DragEvent) {
- event.preventDefault();
- const payload = readDragPayload(event);
- resetCanvasDragState();
- if (!payload) {
- return;
- }
-
- if (payload.kind === "palette") {
- placePaletteElement(payload.paletteId, event.clientX, event.clientY);
- return;
- }
-
- moveCanvasElement(payload.elementId, event.clientX, event.clientY);
- }
-
- function handleResetToStarter() {
- mouseDragCleanupRef.current?.();
- const starterLayout = buildStarterLayout(user, paletteItems);
- setElements(starterLayout);
- setSelectedElementId(starterLayout[0]?.id ?? null);
- setResizingElementId(null);
- }
-
- function handleClearCanvas() {
- if (!window.confirm("Moechten Sie wirklich alle Elemente vom Canvas entfernen?")) {
- return;
- }
-
- mouseDragCleanupRef.current?.();
- setElements([]);
- setSelectedElementId(null);
- setResizingElementId(null);
- }
-
- function handleCreatePdfPreview() {
- try {
- const pdfBlob = createPdfBlob(elements);
- const nextPreviewUrl = URL.createObjectURL(pdfBlob);
- setPdfPreviewUrl(nextPreviewUrl);
- setPdfError(null);
- } catch {
- setPdfError("PDF konnte nicht erstellt werden.");
- }
- }
-
- async function handleSaveTemplate() {
- if (!user) {
- setTemplateError("Vorlagen koennen nur fuer angemeldete Benutzer gespeichert werden.");
- return;
- }
- if (!isTemplateApiAvailable) {
- setTemplateError("Template-Speicherung ist auf diesem Server nicht verfuegbar.");
- return;
- }
-
- setTemplateError(null);
- setIsTemplateSaving(true);
-
- try {
- const response = await apiPut(templateApiPath, {
- elements,
- });
- const savedElements = normalizeTemplateElements(response.elements);
- if (!savedElements) {
- throw new Error("Gespeichertes Template konnte nicht gelesen werden.");
- }
-
- setElements(savedElements);
- setSelectedElementId((current) =>
- savedElements.some((entry) => entry.id === current) ? current : savedElements[0]?.id ?? null,
- );
- setResizingElementId(null);
- setTemplateUpdatedAt(response.updatedAt);
- setIsTemplateApiAvailable(true);
- } catch (error) {
- if (error instanceof ApiError && error.status === 404) {
- setIsTemplateApiAvailable(false);
- setTemplateError("Template-Speicherung ist auf diesem Server nicht verfuegbar.");
- } else {
- setTemplateError((error as Error).message);
- }
- } finally {
- setIsTemplateSaving(false);
- }
- }
-
- async function handleImageUpload(
- event: ReactChangeEvent,
- elementId: string,
- ) {
- const file = event.target.files?.[0];
- event.target.value = "";
- if (!file) {
- return;
- }
-
- const currentElement = elements.find((entry) => entry.id === elementId);
- if (!currentElement || currentElement.kind !== "image") {
- return;
- }
-
- try {
- const image = await convertImageFileToJpeg(file);
- const nextHeight = Math.max(
- MIN_MEDIA_HEIGHT,
- Math.round((currentElement.width * image.height) / image.width),
- );
-
- updateElement(elementId, {
- height: nextHeight,
- imageNaturalHeight: image.height,
- imageNaturalWidth: image.width,
- imageSrc: image.dataUrl,
- });
- setPdfError(null);
- } catch (error) {
- setPdfError((error as Error).message);
- }
- }
-
- function handleElementMouseDown(
- event: ReactMouseEvent,
- elementId: string,
- ) {
- const canvasElement = canvasRef.current;
- if (!canvasElement || event.button !== 0) {
- return;
- }
-
- mouseDragCleanupRef.current?.();
-
- const rect = event.currentTarget.getBoundingClientRect();
- const startOffset = {
- x: (event.clientX - rect.left) / canvasViewport.scale,
- y: (event.clientY - rect.top) / canvasViewport.scale,
- };
-
- moveOffsetRef.current = startOffset;
- setSelectedElementId(elementId);
- setDraggingElementId(elementId);
- setResizingElementId(null);
- event.currentTarget.focus();
- event.preventDefault();
-
- function handleMouseMove(moveEvent: MouseEvent) {
- moveCanvasElement(elementId, moveEvent.clientX, moveEvent.clientY);
- }
-
- function stopDragging() {
- moveOffsetRef.current = null;
- setDraggingElementId(null);
- window.removeEventListener("mousemove", handleMouseMove);
- window.removeEventListener("mouseup", handleMouseUp);
- mouseDragCleanupRef.current = null;
- }
-
- function handleMouseUp() {
- stopDragging();
- }
-
- window.addEventListener("mousemove", handleMouseMove);
- window.addEventListener("mouseup", handleMouseUp);
- mouseDragCleanupRef.current = stopDragging;
- }
-
- function handleResizeMouseDown(
- event: ReactMouseEvent,
- elementId: string,
- ) {
- const currentElement = elements.find((entry) => entry.id === elementId);
- if (!currentElement || event.button !== 0) {
- return;
- }
-
- mouseDragCleanupRef.current?.();
- setSelectedElementId(elementId);
- setDraggingElementId(null);
- setResizingElementId(elementId);
- event.currentTarget.parentElement?.focus();
- event.preventDefault();
- event.stopPropagation();
-
- const startWidth = currentElement.width;
- const startHeight = getElementHeight(currentElement);
- const elementKind = currentElement.kind;
- const elementFontSize = currentElement.fontSize;
- const elementLineOrientation = currentElement.lineOrientation;
- const imageNaturalWidth = currentElement.imageNaturalWidth;
- const imageNaturalHeight = currentElement.imageNaturalHeight;
- const startClientX = event.clientX;
- const startClientY = event.clientY;
-
- function handleMouseMove(moveEvent: MouseEvent) {
- const deltaX = (moveEvent.clientX - startClientX) / canvasViewport.scale;
- const deltaY = (moveEvent.clientY - startClientY) / canvasViewport.scale;
- const nextWidth = Math.max(
- getMinimumWidthForElement(elementKind, elementLineOrientation),
- snapToGrid(startWidth + deltaX),
- );
- const nextHeight = Math.max(
- getMinimumHeightForElement(
- elementKind,
- elementFontSize,
- elementLineOrientation,
- ),
- snapToGrid(startHeight + deltaY),
- );
-
- if (elementKind === "image") {
- const assetWidth = imageNaturalWidth ?? startWidth;
- const assetHeight = imageNaturalHeight ?? startHeight;
- const widthScale = nextWidth / assetWidth;
- const heightScale = nextHeight / assetHeight;
-
- if (widthScale <= heightScale) {
- updateElement(elementId, {
- height: Math.max(
- getMinimumHeightForElement(elementKind, elementFontSize),
- Math.floor(((nextWidth * assetHeight) / assetWidth) / GRID_SIZE) * GRID_SIZE,
- ),
- width: nextWidth,
- });
- return;
- }
-
- updateElement(elementId, {
- height: nextHeight,
- width: Math.max(
- getMinimumWidthForElement(elementKind),
- Math.floor(((nextHeight * assetWidth) / assetHeight) / GRID_SIZE) * GRID_SIZE,
- ),
- });
- return;
- }
-
- updateElement(elementId, {
- width: nextWidth,
- ...(elementKind === "text" ? {} : { height: nextHeight }),
- });
- }
-
- function stopResizing() {
- setResizingElementId(null);
- window.removeEventListener("mousemove", handleMouseMove);
- window.removeEventListener("mouseup", handleMouseUp);
- mouseDragCleanupRef.current = null;
- }
-
- function handleMouseUp() {
- stopResizing();
- }
-
- window.addEventListener("mousemove", handleMouseMove);
- window.addEventListener("mouseup", handleMouseUp);
- mouseDragCleanupRef.current = stopResizing;
- }
-
- function handleElementKeyDown(
- event: ReactKeyboardEvent,
- elementId: string,
- ) {
- const currentElement = elements.find((entry) => entry.id === elementId);
- if (!currentElement) {
- return;
- }
-
- if (event.key === "Delete") {
- event.preventDefault();
- removeElement(elementId);
- return;
- }
-
- let patch: Partial | null = null;
-
- if (event.key === "ArrowLeft") {
- patch = { x: currentElement.x - GRID_SIZE };
- } else if (event.key === "ArrowRight") {
- patch = { x: currentElement.x + GRID_SIZE };
- } else if (event.key === "ArrowUp") {
- patch = { y: currentElement.y - GRID_SIZE };
- } else if (event.key === "ArrowDown") {
- patch = { y: currentElement.y + GRID_SIZE };
- }
-
- if (!patch) {
- return;
- }
-
- event.preventDefault();
- setSelectedElementId(elementId);
- updateElement(elementId, patch);
- }
-
- const panelStyle = canvasViewport.syncPanelHeight
- ? { height: `${canvasViewport.height}px` }
- : undefined;
- const pageStyle =
- pageViewportHeight && canvasViewport.syncPanelHeight
- ? { height: `${pageViewportHeight}px` }
- : undefined;
-
- return (
-
-
-
-
-
Vorlage bearbeiten
-
-
-
-
-
-
-
-
-
-
- {pdfError ? {pdfError}
: null}
- {templateError ? {templateError}
: null}
-
-
-
-
-
-
-
-
{templateTitle}
-
- {isTemplateLoading
- ? "Gespeicherte Vorlage wird geladen..."
- : !isTemplateApiAvailable
- ? "Template-API auf diesem Server nicht verfuegbar"
- : templateTimestampLabel
- ? `In Datenbank gespeichert: ${templateTimestampLabel}`
- : "Noch keine gespeicherte Vorlage vorhanden"}
-
-
-
-
-
-
-
- {elements.length ? (
- elements.map((element) => (
-
- ))
- ) : (
-
{emptyCanvasMessage}
- )}
-
-
-
-
-
-
-
-
-
- {pdfPreviewUrl ? (
-
setPdfPreviewUrl(null)}>
-
event.stopPropagation()}>
-
-
-
-
-
-
-
- ) : null}
-
- );
-}
-
-export {
- REPORT_LOCKED_TEXT_PALETTE_IDS,
- REPORT_PALETTE_GROUPS,
- REPORT_PALETTE_ITEMS,
- createReportStarterLayout,
-};
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index bcf4545..a987a03 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -100,7 +100,7 @@ export default function LoginPage() {
MUH-App
Moderne Steuerung fuer Milchproben und Therapien.
- Fokus auf klare Arbeitsablaeufe, schnelle Probenbearbeitung und ein Portal
+ Fokus auf klare Arbeitsabläufe, schnelle Probenbearbeitung und ein Portal
fuer Verwaltung, Berichtsdruck und Versandstatus.
diff --git a/frontend/src/pages/ReportTemplatePage.tsx b/frontend/src/pages/ReportTemplatePage.tsx
index 03f07bb..41454a2 100644
--- a/frontend/src/pages/ReportTemplatePage.tsx
+++ b/frontend/src/pages/ReportTemplatePage.tsx
@@ -1,21 +1,2266 @@
-import InvoiceTemplatePage, {
- REPORT_LOCKED_TEXT_PALETTE_IDS,
- REPORT_PALETTE_GROUPS,
- REPORT_PALETTE_ITEMS,
- createReportStarterLayout,
-} from "./InvoiceTemplatePage";
+import {
+ useEffect,
+ useState,
+ useRef,
+ type ChangeEvent as ReactChangeEvent,
+ type DragEvent,
+ type KeyboardEvent as ReactKeyboardEvent,
+ type MouseEvent as ReactMouseEvent,
+} from "react";
+import { ApiError, apiGet, apiPut } from "../lib/api";
+import { useSession } from "../lib/session";
+import type { UserOption } from "../lib/types";
-export default function ReportTemplatePage() {
- return (
-