diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 78278d1..45e4d0e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,6 +2,16 @@ import { AUTH_TOKEN_STORAGE_KEY } from "./storage"; const API_ROOT = import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? "http://localhost:8090/api" : "/api"); +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + type ApiErrorPayload = { message?: string; error?: string; @@ -44,7 +54,7 @@ function authHeaders(): Record { async function handleResponse(response: Response): Promise { if (!response.ok) { - throw new Error(await readErrorMessage(response)); + throw new ApiError(await readErrorMessage(response), response.status); } if (response.status === 204) { return undefined as T; diff --git a/frontend/src/pages/InvoiceTemplatePage.tsx b/frontend/src/pages/InvoiceTemplatePage.tsx index d515896..b332715 100644 --- a/frontend/src/pages/InvoiceTemplatePage.tsx +++ b/frontend/src/pages/InvoiceTemplatePage.tsx @@ -7,12 +7,13 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; -import { apiGet, apiPut } from "../lib/api"; +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; @@ -25,7 +26,7 @@ const CANVAS_BOTTOM_GAP = 10; const MIN_ELEMENT_WIDTH = 100; const MIN_MEDIA_WIDTH = 40; const MIN_MEDIA_HEIGHT = 40; -const MIN_LINE_THICKNESS = 2; +const MIN_LINE_THICKNESS = 1; const MIN_LINE_LENGTH = 40; const MIN_FONT_SIZE = 12; const MAX_FONT_SIZE = 44; @@ -54,9 +55,11 @@ type ElementKind = "text" | "line" | "image"; type LineOrientation = "horizontal" | "vertical"; type TextAlign = "left" | "center" | "right"; type FontWeight = 400 | 500 | 600 | 700; +type PaletteCategory = "master-data" | "customer-data" | "free-elements"; interface PaletteItem { id: string; + category: PaletteCategory; label: string; description: string; kind?: ElementKind; @@ -158,6 +161,7 @@ const CP1252_MAP: Record = { const PALETTE_ITEMS: PaletteItem[] = [ { id: "invoice-title", + category: "master-data", label: "Rechnungstitel", description: "Markante Ueberschrift fuer die Rechnung", width: 240, @@ -168,6 +172,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "company-name", + category: "master-data", label: "Firmenname", description: "Name Ihres Unternehmens", width: 280, @@ -178,6 +183,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "contact-person", + category: "master-data", label: "Ansprechpartner", description: "Name des Ansprechpartners", width: 240, @@ -188,6 +194,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "street", + category: "master-data", label: "Strasse", description: "Strassenname fuer die Anschrift", width: 220, @@ -198,6 +205,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "house-number", + category: "master-data", label: "Hausnummer", description: "Hausnummer der Anschrift", width: 100, @@ -208,6 +216,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "postal-code", + category: "master-data", label: "PLZ", description: "Postleitzahl", width: 100, @@ -218,6 +227,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "city", + category: "master-data", label: "Ort", description: "Stadt oder Ort", width: 180, @@ -228,6 +238,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "email", + category: "master-data", label: "E-Mail", description: "Kontakt-E-Mail", width: 260, @@ -238,6 +249,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "phone", + category: "master-data", label: "Telefon", description: "Telefonnummer fuer Rueckfragen", width: 220, @@ -248,6 +260,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "invoice-number", + category: "master-data", label: "Rechnungsnummer", description: "Eindeutige Kennung", width: 200, @@ -258,6 +271,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "invoice-date", + category: "master-data", label: "Rechnungsdatum", description: "Ausstellungsdatum der Rechnung", width: 200, @@ -271,6 +285,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "due-date", + category: "master-data", label: "Zahlungsziel", description: "Faelligkeitsdatum", width: 220, @@ -285,6 +300,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "customer-number", + category: "master-data", label: "Kundennummer", description: "Referenz zum Kunden", width: 180, @@ -295,6 +311,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "line-item", + category: "master-data", label: "Leistungsposition", description: "Beschreibung einer Position", width: 340, @@ -305,6 +322,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "amount", + category: "master-data", label: "Betrag", description: "Gesamt- oder Positionsbetrag", width: 180, @@ -315,6 +333,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "iban", + category: "master-data", label: "IBAN", description: "Bankverbindung fuer die Zahlung", width: 320, @@ -325,6 +344,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "bic", + category: "master-data", label: "BIC", description: "BIC der Bank", width: 180, @@ -335,6 +355,7 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { id: "footer", + category: "master-data", label: "Fusszeile", description: "Hinweis oder Dankeszeile", width: 360, @@ -344,9 +365,76 @@ const PALETTE_ITEMS: PaletteItem[] = [ defaultContent: () => "Vielen Dank fuer Ihren Auftrag.", }, { - id: "horizontal-line", - label: "Horizontale Linie", - description: "Trennlinie ueber die Seitenbreite", + 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, @@ -356,21 +444,9 @@ const PALETTE_ITEMS: PaletteItem[] = [ lineOrientation: "horizontal", defaultContent: () => "", }, - { - id: "vertical-line", - label: "Vertikale Linie", - description: "Senkrechte Trennlinie fuer Bereiche", - kind: "line", - width: 3, - height: 180, - fontSize: 16, - fontWeight: 400, - textAlign: "left", - lineOrientation: "vertical", - defaultContent: () => "", - }, { id: "image", + category: "free-elements", label: "Bild", description: "Logo oder Produktbild mit Upload", kind: "image", @@ -383,6 +459,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, ]; +const PALETTE_GROUPS: Array<{ category: PaletteCategory; title: string }> = [ + { category: "master-data", title: "Stammdaten" }, + { category: "customer-data", title: "Kundendaten" }, + { category: "free-elements", title: "Freie Elemente" }, +]; + function clamp(value: number, minimum: number, maximum: number) { return Math.min(Math.max(value, minimum), maximum); } @@ -850,7 +932,7 @@ function getPalettePreviewText(paletteItem: PaletteItem, user: UserOption | null return "Bild auswaehlen"; } if (paletteItem.kind === "line") { - return paletteItem.lineOrientation === "vertical" ? "Vertikale Linie" : "Horizontale Linie"; + return "Linie"; } return paletteItem.defaultContent(user); } @@ -1068,6 +1150,7 @@ export default function InvoiceTemplatePage() { 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({ @@ -1100,6 +1183,7 @@ export default function InvoiceTemplatePage() { setElements(starterLayout); setSelectedElementId(starterLayout[0]?.id ?? null); setResizingElementId(null); + setIsTemplateApiAvailable(true); setIsTemplateLoading(false); setTemplateUpdatedAt(null); setTemplateError(null); @@ -1116,6 +1200,7 @@ export default function InvoiceTemplatePage() { return; } + setIsTemplateApiAvailable(true); setTemplateUpdatedAt(response.updatedAt); if (!response.stored) { const starterLayout = createStarterLayout(user); @@ -1136,6 +1221,16 @@ export default function InvoiceTemplatePage() { }) .catch((error) => { if (!cancelled) { + if (error instanceof ApiError && error.status === 404) { + const starterLayout = createStarterLayout(user); + setElements(starterLayout); + setSelectedElementId(starterLayout[0]?.id ?? null); + setResizingElementId(null); + setTemplateUpdatedAt(null); + setTemplateError(null); + setIsTemplateApiAvailable(false); + return; + } setTemplateError((error as Error).message); } }) @@ -1225,10 +1320,13 @@ export default function InvoiceTemplatePage() { Math.floor(cardRect.bottom - shellRect.top - cardPaddingBottom - verticalPadding - CANVAS_BOTTOM_GAP), 240, ); - const rawScale = Math.min(1, availableWidth / CANVAS_WIDTH, availableHeight / CANVAS_HEIGHT); - const nextScale = Math.floor(rawScale * 10000) / 10000; - const nextWidth = Math.floor(CANVAS_WIDTH * nextScale); - const nextHeight = Math.ceil(CANVAS_HEIGHT * nextScale); + 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 ( @@ -1250,12 +1348,32 @@ export default function InvoiceTemplatePage() { }); } + 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); }; @@ -1370,6 +1488,10 @@ export default function InvoiceTemplatePage() { } function handleClearCanvas() { + if (!window.confirm("Moechten Sie wirklich alle Elemente vom Canvas entfernen?")) { + return; + } + mouseDragCleanupRef.current?.(); setElements([]); setSelectedElementId(null); @@ -1392,6 +1514,10 @@ export default function InvoiceTemplatePage() { 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); @@ -1411,8 +1537,14 @@ export default function InvoiceTemplatePage() { ); setResizingElementId(null); setTemplateUpdatedAt(response.updatedAt); + setIsTemplateApiAvailable(true); } catch (error) { - setTemplateError((error as Error).message); + 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); } @@ -1517,16 +1649,59 @@ export default function InvoiceTemplatePage() { 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: startWidth + deltaX, - ...(elementKind === "text" ? {} : { height: startHeight + deltaY }), + width: nextWidth, + ...(elementKind === "text" ? {} : { height: nextHeight }), }); } @@ -1608,7 +1783,7 @@ export default function InvoiceTemplatePage() { onClick={() => { void handleSaveTemplate(); }} - disabled={isTemplateLoading || isTemplateSaving} + disabled={isTemplateLoading || isTemplateSaving || !isTemplateApiAvailable} > {isTemplateSaving ? "Speichert..." : "Template speichern"} @@ -1633,23 +1808,35 @@ export default function InvoiceTemplatePage() {
- {PALETTE_ITEMS.map((item) => ( - + {PALETTE_GROUPS.map((group) => ( +
+
+
{group.title}
+
+ +
+ {PALETTE_ITEMS.filter((item) => item.category === group.category).map((item) => ( + + ))} +
+
))}
@@ -1661,6 +1848,8 @@ export default function InvoiceTemplatePage() { {isTemplateLoading ? "Gespeicherte Vorlage wird geladen..." + : !isTemplateApiAvailable + ? "Template-API auf diesem Server nicht verfuegbar" : templateTimestampLabel ? `In Datenbank gespeichert: ${templateTimestampLabel}` : "Noch keine gespeicherte Vorlage vorhanden"} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 16ddb37..b4bcdc5 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -810,8 +810,7 @@ a { .invoice-template__palette { display: grid; - gap: 10px; - grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; min-height: 0; overflow-y: auto; padding-right: 6px; @@ -819,6 +818,25 @@ a { scrollbar-gutter: stable; } +.invoice-template__palette-group { + display: grid; + gap: 10px; +} + +.invoice-template__palette-group-header h5 { + margin: 0; + color: var(--muted); + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.invoice-template__palette-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .invoice-template__tile { display: grid; gap: 6px; @@ -880,7 +898,7 @@ a { min-width: 794px; height: 1123px; border: 1px dashed rgba(17, 109, 99, 0.22); - border-radius: 32px; + border-radius: 0; background: linear-gradient(180deg, rgba(17, 109, 99, 0.03), transparent 18%), linear-gradient(transparent 31px, rgba(37, 49, 58, 0.05) 32px), @@ -902,7 +920,7 @@ a { position: absolute; inset: 28px; border: 1px solid rgba(37, 49, 58, 0.05); - border-radius: 20px; + border-radius: 0; pointer-events: none; } @@ -942,6 +960,10 @@ a { background: rgba(17, 109, 99, 0.08); } +.invoice-template__canvas-element.is-selected { + border-radius: 0; +} + .invoice-template__canvas-element.is-dragging { cursor: grabbing; z-index: 2; @@ -1141,7 +1163,7 @@ a { padding-right: 0; } - .invoice-template__palette { + .invoice-template__palette-grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }