diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a4fac72..d00869a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import SearchPage from "./pages/SearchPage"; import SearchFarmerPage from "./pages/SearchFarmerPage"; import SearchCalendarPage from "./pages/SearchCalendarPage"; import UserManagementPage from "./pages/UserManagementPage"; +import InvoiceTemplatePage from "./pages/InvoiceTemplatePage"; function ProtectedRoutes() { const { user, ready } = useSession(); @@ -35,6 +36,7 @@ function ProtectedRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/layout/AppShell.tsx b/frontend/src/layout/AppShell.tsx index b2fcd85..2deef3d 100644 --- a/frontend/src/layout/AppShell.tsx +++ b/frontend/src/layout/AppShell.tsx @@ -5,6 +5,7 @@ const PAGE_TITLES: Record = { "/home": "Startseite", "/samples/new": "Neuanlage einer Probe", "/portal": "MUH-Portal", + "/invoice-template": "Rechnungstemplate", }; function resolvePageTitle(pathname: string) { @@ -79,6 +80,9 @@ export default function AppShell() { Verwaltung + `nav-sublink ${isActive ? "is-active" : ""}`}> + Rechnungstemplate + `nav-sublink ${isActive ? "is-active" : ""}`}> Landwirte diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 1b0036b..3557239 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,2 +1,3 @@ export const USER_STORAGE_KEY = "muh.current-user"; export const AUTH_TOKEN_STORAGE_KEY = "muh.auth-token"; +export const INVOICE_TEMPLATE_STORAGE_KEY = "muh.invoice-template"; diff --git a/frontend/src/pages/InvoiceTemplatePage.tsx b/frontend/src/pages/InvoiceTemplatePage.tsx new file mode 100644 index 0000000..dfcdb5b --- /dev/null +++ b/frontend/src/pages/InvoiceTemplatePage.tsx @@ -0,0 +1,1252 @@ +import { + useEffect, + useRef, + useState, + type DragEvent, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useSession } from "../lib/session"; +import { INVOICE_TEMPLATE_STORAGE_KEY } from "../lib/storage"; +import type { UserOption } from "../lib/types"; + +const CANVAS_WIDTH = 794; +const CANVAS_HEIGHT = 1123; +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 = 8; + +type TextAlign = "left" | "center" | "right"; +type FontWeight = 400 | 500 | 600 | 700; + +interface PaletteItem { + id: string; + label: string; + description: string; + width: number; + fontSize: number; + fontWeight: FontWeight; + textAlign: TextAlign; + defaultContent: (user: UserOption | null) => string; +} + +interface TemplateElement { + id: string; + paletteId: string; + label: string; + content: string; + x: number; + y: number; + width: number; + fontSize: number; + fontWeight: FontWeight; + textAlign: TextAlign; +} + +interface CanvasViewport { + height: number; + scale: number; + syncPanelHeight: boolean; + width: number; +} + +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 PALETTE_ITEMS: PaletteItem[] = [ + { + id: "invoice-title", + label: "Rechnungstitel", + description: "Markante Ueberschrift fuer die Rechnung", + width: 240, + fontSize: 32, + fontWeight: 700, + textAlign: "left", + defaultContent: () => "Rechnung", + }, + { + id: "company-name", + label: "Firmenname", + description: "Name Ihres Unternehmens", + width: 280, + fontSize: 24, + fontWeight: 700, + textAlign: "left", + defaultContent: (user) => user?.companyName || "Firmenname", + }, + { + id: "contact-person", + label: "Ansprechpartner", + description: "Name des Ansprechpartners", + width: 240, + fontSize: 16, + fontWeight: 500, + textAlign: "left", + defaultContent: (user) => user?.displayName || "Ansprechpartner", + }, + { + id: "street", + label: "Strasse", + description: "Strassenname fuer die Anschrift", + width: 220, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => user?.street || "Musterstrasse", + }, + { + id: "house-number", + label: "Hausnummer", + description: "Hausnummer der Anschrift", + width: 100, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => user?.houseNumber || "12", + }, + { + id: "postal-code", + label: "PLZ", + description: "Postleitzahl", + width: 100, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => user?.postalCode || "12345", + }, + { + id: "city", + label: "Ort", + description: "Stadt oder Ort", + width: 180, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => user?.city || "Musterstadt", + }, + { + id: "email", + label: "E-Mail", + description: "Kontakt-E-Mail", + width: 260, + fontSize: 15, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => user?.email || "mail@example.de", + }, + { + id: "phone", + label: "Telefon", + description: "Telefonnummer fuer Rueckfragen", + width: 220, + fontSize: 15, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => user?.phoneNumber || "+49 123 456789", + }, + { + id: "invoice-number", + label: "Rechnungsnummer", + description: "Eindeutige Kennung", + width: 200, + fontSize: 16, + fontWeight: 600, + textAlign: "right", + defaultContent: () => "RE-2026-001", + }, + { + id: "invoice-date", + 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", + 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", + label: "Kundennummer", + description: "Referenz zum Kunden", + width: 180, + fontSize: 16, + fontWeight: 500, + textAlign: "left", + defaultContent: () => "KD-1001", + }, + { + id: "line-item", + label: "Leistungsposition", + description: "Beschreibung einer Position", + width: 340, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "Beratung und Diagnostik", + }, + { + id: "amount", + label: "Betrag", + description: "Gesamt- oder Positionsbetrag", + width: 180, + fontSize: 18, + fontWeight: 700, + textAlign: "right", + defaultContent: () => "0,00 EUR", + }, + { + id: "iban", + label: "IBAN", + description: "Bankverbindung fuer die Zahlung", + width: 320, + fontSize: 15, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "DE12 3456 7890 1234 5678 90", + }, + { + id: "bic", + label: "BIC", + description: "BIC der Bank", + width: 180, + fontSize: 15, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "GENODEF1XXX", + }, + { + id: "footer", + label: "Fusszeile", + description: "Hinweis oder Dankeszeile", + width: 360, + fontSize: 14, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "Vielen Dank fuer Ihren Auftrag.", + }, +]; + +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 createPdfContentStream(elements: TemplateElement[]) { + const commands = ["0 g"]; + + for (const element of elements) { + 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 contentStream = createPdfContentStream(elements); + 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 >> >> /Contents 6 0 R >>`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>", + `<< /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 normalizeElement(element: TemplateElement): TemplateElement { + const width = clamp(Math.round(element.width), 100, CANVAS_WIDTH - 32); + const fontSize = clamp(Math.round(element.fontSize), 12, 44); + const x = clamp(snapToGrid(element.x), 0, CANVAS_WIDTH - width); + const y = clamp(snapToGrid(element.y), 0, CANVAS_HEIGHT - 40); + + return { + ...element, + width, + fontSize, + x, + y, + fontWeight: normalizeFontWeight(element.fontWeight), + textAlign: normalizeTextAlign(element.textAlign), + }; +} + +function findPaletteItem(paletteId: string) { + return PALETTE_ITEMS.find((entry) => entry.id === paletteId) ?? null; +} + +function requirePaletteItem(paletteId: string) { + const paletteItem = findPaletteItem(paletteId); + if (!paletteItem) { + throw new Error(`Palette item ${paletteId} is not configured.`); + } + return paletteItem; +} + +function createElementFromPalette( + paletteItem: PaletteItem, + user: UserOption | null, + position: { x: number; y: number }, +): TemplateElement { + return normalizeElement({ + id: generateId(), + paletteId: paletteItem.id, + label: paletteItem.label, + content: paletteItem.defaultContent(user), + x: position.x, + y: position.y, + width: paletteItem.width, + fontSize: paletteItem.fontSize, + fontWeight: paletteItem.fontWeight, + textAlign: paletteItem.textAlign, + }); +} + +function createStarterLayout(user: UserOption | null) { + return [ + createElementFromPalette(requirePaletteItem("company-name"), user, { x: 56, y: 56 }), + createElementFromPalette(requirePaletteItem("street"), user, { x: 56, y: 104 }), + createElementFromPalette(requirePaletteItem("house-number"), user, { x: 288, y: 104 }), + createElementFromPalette(requirePaletteItem("postal-code"), user, { x: 56, y: 132 }), + createElementFromPalette(requirePaletteItem("city"), user, { x: 164, y: 132 }), + createElementFromPalette(requirePaletteItem("invoice-title"), user, { x: 56, y: 238 }), + createElementFromPalette(requirePaletteItem("invoice-date"), user, { x: 538, y: 64 }), + createElementFromPalette(requirePaletteItem("invoice-number"), user, { x: 538, y: 96 }), + createElementFromPalette(requirePaletteItem("amount"), user, { x: 558, y: 432 }), + createElementFromPalette(requirePaletteItem("footer"), user, { x: 56, y: 1008 }), + ]; +} + +function loadStoredElements(storageKey: string) { + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return null; + } + + const elements = parsed + .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, + label: candidate.label, + content: candidate.content, + x: candidate.x, + y: candidate.y, + width: candidate.width, + fontSize: candidate.fontSize, + fontWeight: normalizeFontWeight(candidate.fontWeight), + textAlign: normalizeTextAlign(candidate.textAlign), + }); + }) + .filter((entry): entry is TemplateElement => entry !== null); + + return elements; + } catch { + return null; + } +} + +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() { + const { user } = useSession(); + const storageKey = `${INVOICE_TEMPLATE_STORAGE_KEY}.${user?.id ?? "default"}`; + const initialElementsRef = useRef(null); + + if (initialElementsRef.current === null) { + initialElementsRef.current = loadStoredElements(storageKey) ?? createStarterLayout(user); + } + + const initialElements = initialElementsRef.current ?? []; + 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(initialElements); + const [selectedElementId, setSelectedElementId] = useState( + initialElements[0]?.id ?? null, + ); + const [isCanvasActive, setIsCanvasActive] = useState(false); + const [draggingElementId, setDraggingElementId] = useState(null); + const [pdfError, setPdfError] = useState(null); + 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; + + useEffect(() => { + window.localStorage.setItem(storageKey, JSON.stringify(elements)); + }, [elements, storageKey]); + + 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 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.floor(CANVAS_HEIGHT * nextScale); + + 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, + }; + }); + }); + } + + updateCanvasViewport(); + window.addEventListener("resize", updateCanvasViewport); + window.addEventListener("scroll", updateCanvasViewport, { passive: true }); + + return () => { + cancelAnimationFrame(frameId); + 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, ...patch }) : entry, + ), + ); + } + + function removeElement(elementId: string) { + setElements((current) => current.filter((entry) => entry.id !== elementId)); + setSelectedElementId((current) => (current === elementId ? null : current)); + } + + function placePaletteElement(paletteId: string, clientX: number, clientY: number) { + const paletteItem = findPaletteItem(paletteId); + const canvasElement = canvasRef.current; + if (!paletteItem || !canvasElement) { + return; + } + + const rect = canvasElement.getBoundingClientRect(); + const x = (clientX - rect.left) / canvasViewport.scale - paletteItem.width / 2; + const y = (clientY - rect.top) / canvasViewport.scale - paletteItem.fontSize; + 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() { + const starterLayout = createStarterLayout(user); + setElements(starterLayout); + setSelectedElementId(starterLayout[0]?.id ?? null); + } + + function handleClearCanvas() { + setElements([]); + setSelectedElementId(null); + } + + function handleCreatePdfPreview() { + try { + const pdfBlob = createPdfBlob(elements); + const nextPreviewUrl = URL.createObjectURL(pdfBlob); + setPdfPreviewUrl(nextPreviewUrl); + setPdfError(null); + } catch { + setPdfError("PDF konnte nicht erstellt werden."); + } + } + + function handleElementMouseDown( + event: ReactMouseEvent, + elementId: string, + ) { + const canvasElement = canvasRef.current; + if (!canvasElement || event.button !== 0) { + return; + } + + 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); + 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 handleElementKeyDown( + event: ReactKeyboardEvent, + elementId: string, + ) { + const currentElement = elements.find((entry) => entry.id === elementId); + if (!currentElement) { + 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 + + + + + PDF erstellen + + + Startlayout laden + + + Canvas leeren + + + + + {pdfError ? {pdfError} : null} + + + + + + + + Rechnungsvorlage + + + + + + + {elements.length ? ( + elements.map((element) => ( + handleElementMouseDown(event, element.id)} + onKeyDown={(event) => handleElementKeyDown(event, element.id)} + onClick={() => setSelectedElementId(element.id)} + onFocus={() => setSelectedElementId(element.id)} + > + + {element.content || "Text eingeben"} + + + )) + ) : ( + + Ziehen Sie links ein Element auf diese Fläche, um Ihr Rechnungstemplate zu + starten. + + )} + + + + + + + + + + {pdfPreviewUrl ? ( + setPdfPreviewUrl(null)}> + event.stopPropagation()}> + + + PDF-Vorschau + + + + + PDF herunterladen + + setPdfPreviewUrl(null)} + > + Schließen + + + + + + + + + + ) : null} + + ); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index ce424ef..0d835f0 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -741,7 +741,314 @@ a { justify-content: flex-end; } +.invoice-template-page { + min-height: 0; + overflow: hidden; +} + +.invoice-template-page__card { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; + padding: 20px; + overflow: hidden; +} + +.invoice-template-page__card .section-card__header { + margin-bottom: 12px; +} + +.invoice-template-page__card .page-actions { + margin-top: 0; +} + +.invoice-template { + display: grid; + grid-template-columns: minmax(230px, 280px) minmax(0, 1fr) minmax(260px, 320px); + gap: 24px; + align-items: start; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.invoice-template__panel, +.invoice-template__canvas-column { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 18px; + min-height: 0; +} + +.invoice-template__panel { + position: sticky; + top: 18px; + overflow: hidden; +} + +.invoice-template__panel-header, +.invoice-template__canvas-header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; +} + +.invoice-template__panel-header h4, +.invoice-template__canvas-header h4 { + margin: 0; +} + +.invoice-template__panel-note { + color: var(--muted); + font-size: 0.82rem; + text-align: right; +} + +.invoice-template__palette { + display: grid; + gap: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + min-height: 0; + overflow-y: auto; + padding-right: 6px; + align-content: start; + scrollbar-gutter: stable; +} + +.invoice-template__tile { + display: grid; + gap: 6px; + padding: 12px 14px; + border: 1px solid rgba(37, 49, 58, 0.1); + border-radius: 18px; + background: rgba(255, 255, 255, 0.72); + text-align: left; + cursor: grab; + transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease; +} + +.invoice-template__tile:hover { + transform: translateY(-1px); + box-shadow: 0 18px 36px rgba(54, 44, 27, 0.1); + border-color: rgba(17, 109, 99, 0.22); +} + +.invoice-template__tile strong, +.invoice-template__tile span, +.invoice-template__tile small { + display: block; +} + +.invoice-template__tile span { + color: var(--text); + font-size: 0.84rem; + line-height: 1.3; + word-break: break-word; +} + +.invoice-template__tile small { + color: var(--muted); + font-size: 0.8rem; + line-height: 1.5; +} + +.invoice-template__canvas-shell { + display: flex; + justify-content: center; + align-items: flex-start; + height: 100%; + min-height: 0; + overflow: hidden; + padding: 0 0 8px; + box-sizing: border-box; +} + +.invoice-template__canvas-stage { + position: relative; + flex: 0 0 auto; + overflow: visible; +} + +.invoice-template__canvas { + position: relative; + width: 794px; + min-width: 794px; + height: 1123px; + border: 1px dashed rgba(17, 109, 99, 0.22); + border-radius: 32px; + background: + linear-gradient(180deg, rgba(17, 109, 99, 0.03), transparent 18%), + linear-gradient(transparent 31px, rgba(37, 49, 58, 0.05) 32px), + linear-gradient(90deg, transparent 31px, rgba(37, 49, 58, 0.05) 32px), + #fffdf9; + background-size: auto, 32px 32px, 32px 32px, auto; + box-shadow: 0 32px 60px rgba(54, 44, 27, 0.12); + overflow: hidden; + transform-origin: top left; + will-change: transform; +} + +.invoice-template__canvas.is-active { + border-color: rgba(17, 109, 99, 0.48); + box-shadow: 0 32px 60px rgba(54, 44, 27, 0.12), 0 0 0 6px rgba(17, 109, 99, 0.08); +} + +.invoice-template__canvas::before { + content: ""; + position: absolute; + inset: 28px; + border: 1px solid rgba(37, 49, 58, 0.05); + border-radius: 20px; + pointer-events: none; +} + +.invoice-template__canvas-empty { + display: grid; + place-items: center; + height: 100%; + padding: 48px; + color: var(--muted); + text-align: center; + line-height: 1.7; +} + +.invoice-template__canvas-element { + position: absolute; + display: grid; + gap: 0; + padding: 4px 5px; + border: 1px solid transparent; + border-radius: 16px; + background: transparent; + color: var(--text); + cursor: move; + box-shadow: none; + user-select: none; +} + +.invoice-template__canvas-element:hover, +.invoice-template__canvas-element.is-selected { + border-color: rgba(17, 109, 99, 0.28); + background: rgba(17, 109, 99, 0.08); +} + +.invoice-template__canvas-element.is-dragging { + cursor: grabbing; + z-index: 2; +} + +.invoice-template__canvas-element-text { + display: block; + line-height: 1.35; + white-space: pre-wrap; + word-break: break-word; +} + +.invoice-template__inspector { + display: grid; + gap: 12px; + min-height: 0; + overflow-y: auto; + padding-right: 6px; + align-content: start; + scrollbar-gutter: stable; +} + +.invoice-template__inspector-actions { + display: flex; + justify-content: flex-end; +} + +.invoice-template-page .field-grid { + gap: 12px; +} + +.invoice-template-page .field input, +.invoice-template-page .field select, +.invoice-template-page .field textarea { + padding: 10px 12px; +} + +.invoice-template-page .field textarea { + min-height: 88px; +} + +.dialog-backdrop { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 28px; + background: rgba(29, 36, 40, 0.42); + backdrop-filter: blur(8px); + z-index: 50; +} + +.dialog { + display: grid; + gap: 18px; + width: min(1120px, 100%); + max-height: calc(100vh - 56px); + padding: 24px; + border: 1px solid rgba(255, 255, 255, 0.42); + border-radius: var(--radius-xl); + background: rgba(255, 248, 240, 0.96); + box-shadow: var(--shadow); +} + +.dialog--wide { + width: min(1180px, 100%); +} + +.dialog__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +.dialog__header h4 { + margin: 0; +} + +.dialog__actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.dialog__actions a { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; +} + +.dialog__body { + min-height: 0; +} + +.dialog__body--pdf { + height: min(80vh, 900px); +} + +.dialog__frame { + width: 100%; + height: 100%; + border: none; + border-radius: 20px; + background: white; +} + @media (max-width: 1200px) { + .invoice-template-page, + .invoice-template-page__card { + height: auto; + overflow: visible; + } + .app-shell { grid-template-columns: 240px minmax(0, 1fr); } @@ -753,10 +1060,25 @@ a { .portal-grid, .form-grid, .field-grid, - .metrics-grid { + .metrics-grid, + .invoice-template { grid-template-columns: 1fr; } + .invoice-template__panel { + position: static; + } + + .invoice-template__palette, + .invoice-template__inspector { + overflow: visible; + padding-right: 0; + } + + .invoice-template__palette { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + } + .quarter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -786,4 +1108,27 @@ a { .quarter-grid { grid-template-columns: 1fr; } + + .invoice-template__canvas-shell { + margin-inline: 0; + padding-inline: 0; + overflow: hidden; + } + + .dialog-backdrop { + padding: 16px; + } + + .dialog { + padding: 18px; + } + + .dialog__header { + align-items: flex-start; + flex-direction: column; + } + + .dialog__body--pdf { + height: 72vh; + } }