diff --git a/src/main/bundles/dev.bundle b/src/main/bundles/dev.bundle index 1a86cbf..d2959f9 100644 Binary files a/src/main/bundles/dev.bundle and b/src/main/bundles/dev.bundle differ diff --git a/src/main/frontend/invoice-generator/profile-invoice-generator.js b/src/main/frontend/invoice-generator/profile-invoice-generator.js index b4611e8..66585bc 100644 --- a/src/main/frontend/invoice-generator/profile-invoice-generator.js +++ b/src/main/frontend/invoice-generator/profile-invoice-generator.js @@ -73,7 +73,6 @@ window.initProfileInvoiceGenerator = function() { var isDragging = false; var dragStart = { x: 0, y: 0 }; var elementStart = { x: 0, y: 0 }; - var gridSize = 5; // Image cache to prevent flickering during resize var imageCache = {}; @@ -102,8 +101,43 @@ window.initProfileInvoiceGenerator = function() { pageY = (h - pageHeight) / 2; } + // Kein Raster mehr: Positionen werden pixelgenau übernommen function snapToGrid(value) { - return Math.round(value / gridSize) * gridSize; + return value; + } + + // Freie Textelemente bleiben beim Verändern der Größe zentriert: Der Text + // wird mittig im Rahmen dargestellt (Canvas und PDF nutzen dieselbe + // textAlign-Eigenschaft, die Darstellung bleibt also identisch). + function keepTextCentered(el) { + if (el.type !== 'line' && el.type !== 'image' && !el.isStatic) { + el.textAlign = 'center'; + } + } + + // Mindestgröße eines Textelements (Basis-px): der dargestellte Text darf + // beim Verkleinern nicht abgeschnitten werden. Für andere Elementtypen + // gibt es keine inhaltsabhängige Untergrenze (null). + function minTextSize(el) { + if (el.type === 'line' || el.type === 'image' || el.variable === 'services.list') { + return null; + } + var fontSize = el.fontSize || 14; + ctx.save(); + ctx.font = ((el.fontStyle || '') + ' ' + fontSize + 'px ' + (el.fontFamily || 'Arial')).trim(); + var lines = (el.text || '').split('\n'); + var maxLineWidth = 0; + lines.forEach(function(line) { + maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width); + }); + ctx.restore(); + // Gleiche Polsterung wie der Auswahlrahmen (Breite +10, Höhe +6): so kann + // das Element nie kleiner werden als der gezeichnete Rahmen und der Text + // bleibt darin zentriert. + return { + width: Math.max(20, Math.ceil(maxLineWidth + 10)), + height: Math.max(20, Math.ceil(lines.length * fontSize * 1.2 + 6)) + }; } // Notify Java about element selection @@ -120,7 +154,8 @@ window.initProfileInvoiceGenerator = function() { el.width || 100, el.height || 30, el.isStatic || false, - el.variable || null + el.variable || null, + el.fontFamily || 'Arial' ); } } @@ -130,6 +165,39 @@ window.initProfileInvoiceGenerator = function() { window.invoiceGeneratorViewProfile.$server.resetPropertiesPanel(); } } + + // Klick auf die leere Zeichenfläche: Canvas-Einstellungen in der Sidebar anzeigen + function notifyCanvasSelected() { + if (window.invoiceGeneratorViewProfile && window.invoiceGeneratorViewProfile.$server) { + window.invoiceGeneratorViewProfile.$server.showCanvasProperties( + !!window.profileInvoiceState.backgroundImage); + } + } + + // Entprellter Autosave: sichert den Canvas-Stand serverseitig, damit nach + // einem Seiten-Reload (z. B. durch Session-Ablauf) nichts verloren geht. + // Erst aktiv, nachdem der initiale Stand geladen wurde, damit ein leeres + // Canvas keinen vorhandenen Autosave überschreibt. + var autosaveTimer = null; + var lastAutosaved = null; + var autosaveEnabled = false; + + function scheduleAutosave() { + if (!autosaveEnabled) return; + if (autosaveTimer) clearTimeout(autosaveTimer); + autosaveTimer = setTimeout(function() { + try { + var snapshot = JSON.stringify(window.getProfileCanvasData()); + if (snapshot === lastAutosaved) return; + lastAutosaved = snapshot; + if (window.invoiceGeneratorViewProfile && window.invoiceGeneratorViewProfile.$server) { + window.invoiceGeneratorViewProfile.$server.autosaveCanvas(snapshot); + } + } catch (err) { + console.error('Autosave fehlgeschlagen:', err); + } + }, 2000); + } // Draw function function draw() { @@ -155,24 +223,22 @@ window.initProfileInvoiceGenerator = function() { ctx.strokeStyle = '#cccccc'; ctx.lineWidth = 1; ctx.strokeRect(pageX, pageY, pageWidth, pageHeight); - - // Grid (scaled by zoom factor) - ctx.strokeStyle = 'rgba(200, 200, 200, 0.3)'; - ctx.lineWidth = 0.5; - var scaledGridSize = gridSize * zoomFactor; - for (var x = pageX; x <= pageX + pageWidth; x += scaledGridSize) { - ctx.beginPath(); - ctx.moveTo(x, pageY); - ctx.lineTo(x, pageY + pageHeight); - ctx.stroke(); + + // Hintergrundbild: auf Seitengröße skaliert, hinter allen Elementen + var backgroundImage = window.profileInvoiceState.backgroundImage; + if (backgroundImage) { + if (imageCache[backgroundImage]) { + ctx.drawImage(imageCache[backgroundImage], pageX, pageY, pageWidth, pageHeight); + } else { + var bgImg = new Image(); + bgImg.onload = function() { + imageCache[backgroundImage] = bgImg; + draw(); + }; + bgImg.src = backgroundImage; + } } - for (var y = pageY; y <= pageY + pageHeight; y += scaledGridSize) { - ctx.beginPath(); - ctx.moveTo(pageX, y); - ctx.lineTo(pageX + pageWidth, y); - ctx.stroke(); - } - + // Draw elements console.log('Drawing', elements.length, 'elements'); elements.forEach(function(el) { @@ -184,6 +250,8 @@ window.initProfileInvoiceGenerator = function() { if (selectedElement) { drawSelection(selectedElement); } + + scheduleAutosave(); } function drawElement(el) { @@ -282,23 +350,36 @@ window.initProfileInvoiceGenerator = function() { // Vertically center the text in the element var ty = y + (h - totalTextHeight) / 2; - // Draw background highlight for static elements + // Masterdata elements are never bold; other elements respect their fontStyle + var fontWeight = (el.isStatic && !el.isCustomer) ? '' : (el.fontStyle || ''); + // Set the font BEFORE measuring text, so the highlight width matches + ctx.font = (fontWeight ? fontWeight + ' ' : '') + fontSize + 'px ' + (el.fontFamily || 'Arial'); + + // Draw background highlight for static elements, fitted to the widest + // text line (not the element width) and following the text alignment if (el.isStatic) { - var textWidth = ctx.measureText(el.text || '').width + (10 * zoomFactor); + var maxLineWidth = 0; + lines.forEach(function(line) { + maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width); + }); + var bgWidth = maxLineWidth + (10 * zoomFactor); + var bgX = x - (3 * zoomFactor); + if (textAlign === 'center') { + bgX = x + (w - bgWidth) / 2; + } else if (textAlign === 'right') { + bgX = x + w - bgWidth + (3 * zoomFactor); + } // Different background colors: green for customer, blue for masterdata if (el.isCustomer) { ctx.fillStyle = 'rgba(46, 204, 113, 0.15)'; // Light green for customer } else { ctx.fillStyle = 'rgba(25, 118, 210, 0.1)'; // Light blue for masterdata } - ctx.fillRect(x - (3 * zoomFactor), y - (2 * zoomFactor), Math.max(w, textWidth), h); + ctx.fillRect(bgX, y - (2 * zoomFactor), bgWidth, h); } - - // Always use black text for static elements, otherwise use element color - ctx.fillStyle = el.isStatic ? '#000000' : (el.color || '#333333'); - // Masterdata elements are never bold; other elements respect their fontStyle - var fontWeight = (el.isStatic && !el.isCustomer) ? '' : (el.fontStyle || ''); - ctx.font = (fontWeight ? fontWeight + ' ' : '') + fontSize + 'px Arial'; + + // Die gewählte Elementfarbe verwenden — wie im gerenderten PDF + ctx.fillStyle = el.color || '#333333'; ctx.textBaseline = 'top'; ctx.textAlign = textAlign; @@ -318,140 +399,94 @@ window.initProfileInvoiceGenerator = function() { ctx.restore(); } - // Draw services list as a table with columns: Name, Steuersatz, Nettobetrag - // Plus summary section below: Nettosumme, USt, Gesamtsumme + // Positionsliste im Briefbogen-Layout: Spalten Menge, Bezeichnung, + // Einzelpreis und Gesamt, durchgezogene Spaltenlinien bis zum Summenblock + // und die Summen (Nettobetrag, MwSt., Endbetrag) rechts unten. function drawServicesTable(el, x, y, w, h, fontSize) { + var fontFamily = el.fontFamily || 'Arial'; var lineHeight = fontSize * 1.4; var padding = 4 * zoomFactor; var rowHeight = lineHeight + padding * 2; var summaryRowHeight = fontSize * 1.6; var summaryGap = fontSize * 0.5; - - // Column widths (percentages of total width) - var colNameWidth = w * 0.55; // 55% for Name (left-aligned) - var colVatWidth = w * 0.20; // 20% for Steuersatz (right-aligned) - var colNetWidth = w * 0.25; // 25% for Nettobetrag (right-aligned) - - var colNameX = x; - var colVatX = x + colNameWidth; - var colNetX = colVatX + colVatWidth; - + + // Spaltengrenzen wie im PDF-Renderer (9 / 61 / 15 / 15 Prozent) + var xName = x + w * 0.09; + var xUnit = x + w * 0.70; + var xTotal = x + w * 0.85; + var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19; var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%'; - // Rows come from window.profileInvoiceServiceData when provided (system - // invoice template: the three admin price table positions); otherwise - // fall back to sample data (per-user profile invoice generator) var serviceData = window.profileInvoiceServiceData; var rows = (serviceData && serviceData.rows && serviceData.rows.length) ? serviceData.rows : [ - { name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 €' }, - { name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 €' }, - { name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 €' } + { quantity: '1', name: 'Umzugsleistung inkl. Verpackung', unitPrice: '450,00 \u20ac', net: '450,00 \u20ac' }, + { quantity: '1', name: 'Entsorgung M\u00f6bel', unitPrice: '85,00 \u20ac', net: '85,00 \u20ac' }, + { quantity: '1', name: 'Montage/De-Montage', unitPrice: '120,00 \u20ac', net: '120,00 \u20ac' } ]; - // Calculate actual content height based on table + summary - var tableOnlyHeight = rowHeight * (rows.length + 1); // Header + data rows - var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; - var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight; - // Ensure background covers at least the element height or the calculated content - var bgHeight = Math.max(h, calculatedContentHeight * zoomFactor); - - // Draw orange background highlight for entire service variable element + // Mindesthoehe: Kopf + Zeilen + Summenblock; ist das Element hoeher, + // laufen die Spaltenlinien entsprechend weiter nach unten + var summaryHeight = 3 * summaryRowHeight + summaryGap; + var minHeight = rowHeight * (rows.length + 1) + summaryHeight; + var totalHeight = Math.max(h, minHeight); + var bodyBottom = y + totalHeight - summaryHeight; + + // Orangefarbene Markierung des gesamten Bausteins (nur im Designer) var bgPadding = 3 * zoomFactor; ctx.fillStyle = 'rgba(255, 152, 0, 0.15)'; - ctx.fillRect(x - bgPadding, y - (2 * zoomFactor), w + (2 * bgPadding), bgHeight + (4 * zoomFactor)); - - // Draw table header (overlays the background) - ctx.fillStyle = '#f5f5f5'; - ctx.fillRect(x, y, w, rowHeight); - - // Header border - ctx.strokeStyle = '#cccccc'; - ctx.lineWidth = Math.max(0.5, zoomFactor); - ctx.beginPath(); - ctx.moveTo(x, y + rowHeight); - ctx.lineTo(x + w, y + rowHeight); - ctx.stroke(); - - // Header text - ctx.fillStyle = '#333333'; - ctx.font = 'bold ' + fontSize + 'px Arial'; - ctx.textBaseline = 'middle'; - - // Name column header (left-aligned) - ctx.textAlign = 'left'; - ctx.fillText('Name', colNameX + padding, y + rowHeight / 2); - - // Steuersatz column header (right-aligned) - ctx.textAlign = 'right'; - ctx.fillText('Steuersatz', colVatX + colVatWidth - padding, y + rowHeight / 2); - - // Nettobetrag column header (right-aligned) - ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2); - - var currentY = y + rowHeight; + ctx.fillRect(x - bgPadding, y - (2 * zoomFactor), w + (2 * bgPadding), totalHeight + (4 * zoomFactor)); - // Draw data rows - ctx.font = fontSize + 'px Arial'; - rows.forEach(function(row, index) { - // Draw row background (alternating) - if (index % 2 === 1) { - ctx.fillStyle = 'rgba(0,0,0,0.02)'; - ctx.fillRect(x, currentY, w, rowHeight); - } - - // Row bottom border - ctx.strokeStyle = '#eeeeee'; - ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5); - ctx.beginPath(); - ctx.moveTo(x, currentY + rowHeight); - ctx.lineTo(x + w, currentY + rowHeight); - ctx.stroke(); - - // Draw cell text + // Kopfzeile: graue Flaeche mit kleinen Beschriftungen + ctx.fillStyle = '#eeeeee'; + ctx.fillRect(x, y, w, rowHeight); + + ctx.fillStyle = '#333333'; + ctx.font = (fontSize * 0.75) + 'px ' + fontFamily; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; + ctx.fillText('Menge', x + padding, y + rowHeight / 2); + ctx.fillText('Bezeichnung', xName + padding, y + rowHeight / 2); + ctx.fillText('Einzelpreis', xUnit + padding, y + rowHeight / 2); + ctx.fillText('Gesamt', xTotal + padding, y + rowHeight / 2); + + // Datenzeilen + var currentY = y + rowHeight; + ctx.font = fontSize + 'px ' + fontFamily; + rows.forEach(function(row) { ctx.fillStyle = '#333333'; - - // Name (left-aligned) + ctx.textAlign = 'center'; + ctx.fillText(row.quantity || '', x + (xName - x) / 2, currentY + rowHeight / 2); ctx.textAlign = 'left'; - ctx.fillText(row.name, colNameX + padding, currentY + rowHeight / 2); - - // Steuersatz (right-aligned) + ctx.fillText(row.name || '', xName + padding, currentY + rowHeight / 2); ctx.textAlign = 'right'; - ctx.fillText(row.vat, colVatX + colVatWidth - padding, currentY + rowHeight / 2); - - // Nettobetrag (right-aligned) - ctx.fillText(row.net, colNetX + colNetWidth - padding, currentY + rowHeight / 2); - + ctx.fillText(row.unitPrice || '', xTotal - padding, currentY + rowHeight / 2); + ctx.fillText(row.net || '', x + w - padding, currentY + rowHeight / 2); currentY += rowHeight; }); - - // Draw column separator lines - ctx.strokeStyle = '#e0e0e0'; - ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5); + + // Spaltenlinien vom Kopf bis zum Summenblock (inkl. Abstand davor, + // damit keine Luecke zur ersten Summenzeile entsteht). X-Koordinaten + // aufs Pixelraster einrasten (n + 0,5), sonst verwischt der Browser + // die 1px-Linien je nach Subpixel-Lage zu breiten grauen Baendern. + var linesBottom = bodyBottom + summaryGap; + var lineXName = Math.round(xName) + 0.5; + var lineXUnit = Math.round(xUnit) + 0.5; + var lineXTotal = Math.round(xTotal) + 0.5; + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; ctx.beginPath(); - // Line between Name and Steuersatz - ctx.moveTo(colVatX, y); - ctx.lineTo(colVatX, currentY); - // Line between Steuersatz and Nettobetrag - ctx.moveTo(colNetX, y); - ctx.lineTo(colNetX, currentY); + ctx.moveTo(lineXName, y); + ctx.lineTo(lineXName, linesBottom); + ctx.moveTo(lineXUnit, y); + ctx.lineTo(lineXUnit, linesBottom); + ctx.moveTo(lineXTotal, y); + ctx.lineTo(lineXTotal, linesBottom); ctx.stroke(); - - // Draw outer border around table - ctx.strokeStyle = '#cccccc'; - ctx.lineWidth = Math.max(0.5, zoomFactor); - ctx.strokeRect(x, y, w, currentY - y); - - // Draw summary section below the table - var summaryY = currentY + summaryGap; - - // Column positions for summary section - var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned) - var valueX = x + w - padding; // Value column (right-aligned) - - // Totals: provided with the service data or calculated from sample data + + // Summen: Nettobetrag, MwSt., Endbetrag rechts unten var netTotalLabel, vatTotalLabel, grossTotalLabel; if (serviceData && serviceData.netTotal) { netTotalLabel = serviceData.netTotal; @@ -460,43 +495,43 @@ window.initProfileInvoiceGenerator = function() { } else { var netTotal = 655.00; // 450 + 85 + 120 var vatTotal = netTotal * vatRate; - netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' €'; - vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' €'; - grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' €'; + netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' \u20ac'; + vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' \u20ac'; + grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' \u20ac'; } - // Draw summary lines + var summaryRows = [ + { label: 'Nettobetrag', value: netTotalLabel, shaded: true }, + { label: '+ ' + vatPctLabel + ' MwSt.', value: vatTotalLabel, shaded: false }, + { label: 'Endbetrag', value: grossTotalLabel, shaded: true } + ]; + + var summaryY = bodyBottom + summaryGap; ctx.textBaseline = 'middle'; - - // Nettosumme - label left, value right - ctx.fillStyle = '#333333'; - ctx.font = fontSize + 'px Arial'; - ctx.textAlign = 'left'; - ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2); - ctx.font = 'bold ' + fontSize + 'px Arial'; - ctx.textAlign = 'right'; - ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2); - summaryY += summaryRowHeight; + summaryRows.forEach(function(row) { + if (row.shaded) { + ctx.fillStyle = '#eeeeee'; + ctx.fillRect(xUnit, summaryY, x + w - xUnit, summaryRowHeight); + } + // Fortsetzung der Spaltenlinie links neben den Summen-Labels + // (gleiche eingerastete X-Koordinate wie die Spaltenlinie darueber) + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(Math.round(xUnit) + 0.5, summaryY); + ctx.lineTo(Math.round(xUnit) + 0.5, summaryY + summaryRowHeight); + ctx.stroke(); - // Umsatzsteuer - label left, value right - ctx.font = fontSize + 'px Arial'; - ctx.textAlign = 'left'; - ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2); - ctx.font = 'bold ' + fontSize + 'px Arial'; - ctx.textAlign = 'right'; - ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2); - summaryY += summaryRowHeight; - - // Gesamtsumme - label left, value right - summaryY += summaryGap; // Extra gap before total - ctx.fillStyle = '#000000'; - ctx.font = 'bold ' + (fontSize * 1.1) + 'px Arial'; - ctx.textAlign = 'left'; - ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2); - ctx.textAlign = 'right'; - ctx.fillText(grossTotalLabel, valueX, summaryY + summaryRowHeight / 2); + ctx.fillStyle = '#333333'; + ctx.font = fontSize + 'px ' + fontFamily; + ctx.textAlign = 'left'; + ctx.fillText(row.label, xUnit + padding, summaryY + summaryRowHeight / 2); + ctx.textAlign = 'right'; + ctx.fillText(row.value, x + w - padding, summaryY + summaryRowHeight / 2); + summaryY += summaryRowHeight; + }); } - + function drawSelection(el) { var x = pageX + (el.x * zoomFactor); var y = pageY + (el.y * zoomFactor); @@ -506,7 +541,7 @@ window.initProfileInvoiceGenerator = function() { // For text elements, calculate actual text dimensions if (el.type !== 'line' && el.type !== 'image') { var fontSize = (el.fontSize || 14) * zoomFactor; - ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px Arial'; + ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px ' + (el.fontFamily || 'Arial'); // For services.list, calculate table height including summary section if (el.variable === 'services.list') { @@ -516,7 +551,7 @@ window.initProfileInvoiceGenerator = function() { var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows var summaryRowHeight = fontSize * 1.6; var summaryGap = fontSize * 0.5; - var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; + var summaryHeight = 3 * summaryRowHeight + summaryGap; var totalHeight = tableHeight + summaryHeight; h = Math.max(h, totalHeight); } else { @@ -571,44 +606,60 @@ window.initProfileInvoiceGenerator = function() { }); } + // Der anklickbare Bereich entspricht dem dargestellten Inhalt, nicht dem + // (ggf. größeren) Elementrahmen. function hitTest(x, y, el) { var ex = pageX + (el.x * zoomFactor); var ey = pageY + (el.y * zoomFactor); var ew = (el.width || 100) * zoomFactor; var eh = (el.height || 30) * zoomFactor; - - // For text elements, calculate actual text dimensions for hit testing - if (el.type !== 'line' && el.type !== 'image') { - var fontSize = (el.fontSize || 14) * zoomFactor; - ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px Arial'; - - // For services.list, calculate table height - if (el.variable === 'services.list') { - var lineHeight = fontSize * 1.4; - var padding = 4 * zoomFactor; - var rowHeight = lineHeight + padding * 2; - var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows - var summaryRowHeight = fontSize * 1.6; - var summaryGap = fontSize * 0.5; - var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; - eh = Math.max(eh, tableHeight + summaryHeight); - } else { - var lines = (el.text || '').split('\n'); - var maxLineWidth = 0; - lines.forEach(function(line) { - var lineWidth = ctx.measureText(line).width; - maxLineWidth = Math.max(maxLineWidth, lineWidth); - }); - - ew = Math.max(ew, maxLineWidth + (10 * zoomFactor)); - - var lineHeight = fontSize * 1.2; - var textHeight = lines.length * lineHeight; - eh = Math.max(eh, textHeight + (6 * zoomFactor)); - } + + if (el.type === 'line') { + // Nur die gezeichnete Linie, mit kleiner vertikaler Toleranz + var tolerance = Math.max(3, 4 * zoomFactor); + return x >= ex && x <= ex + ew && y >= ey - tolerance && y <= ey + tolerance; } - - return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh; + if (el.type === 'image') { + return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh; + } + + var fontSize = (el.fontSize || 14) * zoomFactor; + ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px ' + (el.fontFamily || 'Arial'); + + // services.list: die Tabelle füllt die Elementbreite, Höhe wie gezeichnet + if (el.variable === 'services.list') { + var lineHeight = fontSize * 1.4; + var padding = 4 * zoomFactor; + var rowHeight = lineHeight + padding * 2; + var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows + var summaryRowHeight = fontSize * 1.6; + var summaryGap = fontSize * 0.5; + var summaryHeight = 3 * summaryRowHeight + summaryGap; + eh = Math.max(eh, tableHeight + summaryHeight); + return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh; + } + + // Textelemente: Maße des gezeichneten Texts (breiteste Zeile, Zeilenhöhe), + // horizontal gemäß Ausrichtung, vertikal zentriert wie beim Zeichnen + var lines = (el.text || '').split('\n'); + var maxLineWidth = 0; + lines.forEach(function(line) { + maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width); + }); + var textHeight = lines.length * fontSize * 1.2; + + var textAlign = el.textAlign || 'left'; + var contentX = ex; + if (textAlign === 'center') { + contentX = ex + (ew - maxLineWidth) / 2; + } else if (textAlign === 'right') { + contentX = ex + ew - maxLineWidth; + } + var contentY = ey + (eh - textHeight) / 2; + + var pad = 3 * zoomFactor; + return x >= contentX - pad && x <= contentX + maxLineWidth + pad + && y >= contentY - pad && y <= contentY + textHeight + pad; } // Resizing state @@ -636,7 +687,7 @@ window.initProfileInvoiceGenerator = function() { var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows var summaryRowHeight = fontSize * 1.6; var summaryGap = fontSize * 0.5; - var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; + var summaryHeight = 3 * summaryRowHeight + summaryGap; eh = Math.max(eh, tableHeight + summaryHeight); } @@ -708,8 +759,9 @@ window.initProfileInvoiceGenerator = function() { canvas.style.cursor = 'move'; notifyElementSelected(selectedElement); } else { + // Klick auf die leere Zeichenfläche: Canvas-Einstellungen anzeigen selectedElement = null; - notifyElementDeselected(); + notifyCanvasSelected(); } draw(); @@ -724,47 +776,53 @@ window.initProfileInvoiceGenerator = function() { // Calculate delta in base coordinates var dx = (x - resizeStart.x) / zoomFactor; var dy = (y - resizeStart.y) / zoomFactor; - + var newWidth = resizeStart.width; var newHeight = resizeStart.height; var newX = resizeStart.elemX; var newY = resizeStart.elemY; - + + // Textelemente lassen sich nur bis zur Größe des dargestellten Texts + // verkleinern; alle anderen Typen behalten das bisherige Minimum. + var minSize = minTextSize(selectedElement); + var minW = minSize ? minSize.width : 20; + var minH = minSize ? minSize.height : 20; + // Handle indices: 0=TL, 1=TC, 2=TR, 3=ML, 4=MR, 5=BL, 6=BC, 7=BR switch(resizeHandle) { case 0: // Top-left - newWidth = Math.max(20, resizeStart.width - dx); - newHeight = Math.max(20, resizeStart.height - dy); + newWidth = Math.max(minW, resizeStart.width - dx); + newHeight = Math.max(minH, resizeStart.height - dy); newX = resizeStart.elemX + (resizeStart.width - newWidth); newY = resizeStart.elemY + (resizeStart.height - newHeight); break; case 1: // Top-center - newHeight = Math.max(20, resizeStart.height - dy); + newHeight = Math.max(minH, resizeStart.height - dy); newY = resizeStart.elemY + (resizeStart.height - newHeight); break; case 2: // Top-right - newWidth = Math.max(20, resizeStart.width + dx); - newHeight = Math.max(20, resizeStart.height - dy); + newWidth = Math.max(minW, resizeStart.width + dx); + newHeight = Math.max(minH, resizeStart.height - dy); newY = resizeStart.elemY + (resizeStart.height - newHeight); break; case 3: // Middle-left - newWidth = Math.max(20, resizeStart.width - dx); + newWidth = Math.max(minW, resizeStart.width - dx); newX = resizeStart.elemX + (resizeStart.width - newWidth); break; case 4: // Middle-right - newWidth = Math.max(20, resizeStart.width + dx); + newWidth = Math.max(minW, resizeStart.width + dx); break; case 5: // Bottom-left - newWidth = Math.max(20, resizeStart.width - dx); - newHeight = Math.max(20, resizeStart.height + dy); + newWidth = Math.max(minW, resizeStart.width - dx); + newHeight = Math.max(minH, resizeStart.height + dy); newX = resizeStart.elemX + (resizeStart.width - newWidth); break; case 6: // Bottom-center - newHeight = Math.max(20, resizeStart.height + dy); + newHeight = Math.max(minH, resizeStart.height + dy); break; case 7: // Bottom-right - newWidth = Math.max(20, resizeStart.width + dx); - newHeight = Math.max(20, resizeStart.height + dy); + newWidth = Math.max(minW, resizeStart.width + dx); + newHeight = Math.max(minH, resizeStart.height + dy); break; } @@ -772,7 +830,8 @@ window.initProfileInvoiceGenerator = function() { selectedElement.height = snapToGrid(newHeight); selectedElement.x = snapToGrid(newX); selectedElement.y = snapToGrid(newY); - + keepTextCentered(selectedElement); + draw(); notifyElementSelected(selectedElement); } else if (isDragging && selectedElement) { @@ -856,40 +915,29 @@ window.initProfileInvoiceGenerator = function() { var moved = false; + // Pfeiltasten bewegen um 0,1 mm, mit Shift um 0,5 mm (A4: 210 x 297 mm) + var stepMm = e.shiftKey ? 0.5 : 0.1; + var stepX = stepMm / 210 * basePageWidth; + var stepY = stepMm / 297 * basePageHeight; + switch(e.key) { case 'ArrowUp': - if (e.shiftKey) { - selectedElement.y = Math.max(0, selectedElement.y - 1); - } else { - selectedElement.y = Math.max(0, Math.floor((selectedElement.y - 1) / gridSize) * gridSize); - } + selectedElement.y = Math.max(0, selectedElement.y - stepY); moved = true; e.preventDefault(); break; case 'ArrowDown': - if (e.shiftKey) { - selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), selectedElement.y + 1); - } else { - selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), Math.ceil((selectedElement.y + 1) / gridSize) * gridSize); - } + selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), selectedElement.y + stepY); moved = true; e.preventDefault(); break; case 'ArrowLeft': - if (e.shiftKey) { - selectedElement.x = Math.max(0, selectedElement.x - 1); - } else { - selectedElement.x = Math.max(0, Math.floor((selectedElement.x - 1) / gridSize) * gridSize); - } + selectedElement.x = Math.max(0, selectedElement.x - stepX); moved = true; e.preventDefault(); break; case 'ArrowRight': - if (e.shiftKey) { - selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), selectedElement.x + 1); - } else { - selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), Math.ceil((selectedElement.x + 1) / gridSize) * gridSize); - } + selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), selectedElement.x + stepX); moved = true; e.preventDefault(); break; @@ -933,6 +981,7 @@ window.initProfileInvoiceGenerator = function() { width: 150, height: 30, fontSize: 14, + fontFamily: 'Arial', color: '#333333', isStatic: isStatic || false, variable: variable || null, @@ -959,7 +1008,7 @@ window.initProfileInvoiceGenerator = function() { var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 sample rows var summaryRowHeight = el.fontSize * 1.6; var summaryGap = el.fontSize * 0.5; - var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; + var summaryHeight = 3 * summaryRowHeight + summaryGap; var totalHeight = tableHeight + summaryHeight; el.height = Math.round(totalHeight); } else { @@ -1019,6 +1068,7 @@ window.initProfileInvoiceGenerator = function() { elements.push(el); selectedElement = el; + autosaveEnabled = true; draw(); notifyElementSelected(el); @@ -1032,6 +1082,46 @@ window.initProfileInvoiceGenerator = function() { if (el) { pushUndoState(); el.text = text; + // Höhe an die Zeilenzahl anpassen, damit mehrzeiliger Text ins Element + // passt; Breite mindestens auf die breiteste Zeile aufweiten + if (el.type !== 'line' && el.type !== 'image') { + var lines = (text || '').split('\n'); + var lineHeight = (el.fontSize || 14) * 1.2; + el.height = Math.round(lines.length * lineHeight + 6); + var minSize = minTextSize(el); + if (minSize) { + el.width = Math.max(el.width || 0, minSize.width); + } + } + draw(); + } + }; + + window.updateProfileElementSize = function(id, width, height) { + var el = elements.find(function(e) { return e.id === id; }); + if (el) { + pushUndoState(); + var minSize = minTextSize(el); + var minW = minSize ? minSize.width : 1; + var minH = minSize ? minSize.height : 1; + if (width !== null) el.width = Math.max(minW, width); + if (height !== null) el.height = Math.max(minH, height); + keepTextCentered(el); + draw(); + } + }; + + window.updateProfileElementFontFamily = function(id, family) { + var el = elements.find(function(e) { return e.id === id; }); + if (el) { + pushUndoState(); + el.fontFamily = family; + // Breite mindestens auf die breiteste Zeile in der neuen Schrift aufweiten + var minSize = minTextSize(el); + if (minSize) { + el.width = Math.max(el.width || 0, minSize.width); + el.height = Math.max(el.height || 0, minSize.height); + } draw(); } }; @@ -1051,12 +1141,17 @@ window.initProfileInvoiceGenerator = function() { if (el) { pushUndoState(); el.fontSize = size; - // Update height based on text content and new font size + // Update height based on text content and new font size; + // Breite mindestens auf die breiteste Zeile aufweiten if (el.type !== 'line' && el.type !== 'image') { var lines = (el.text || '').split('\n'); var lineHeight = size * 1.2; var textHeight = lines.length * lineHeight; el.height = Math.max(textHeight + 6, 20); // Minimum height of 20 + var minSize = minTextSize(el); + if (minSize) { + el.width = Math.max(el.width || 0, minSize.width); + } } draw(); } @@ -1192,6 +1287,7 @@ window.initProfileInvoiceGenerator = function() { heightPercent: toPercentY(el.height), fontSize: el.fontSize, fontStyle: el.fontStyle, + fontFamily: el.fontFamily, textAlign: el.textAlign, color: el.color, isStatic: el.isStatic, @@ -1200,10 +1296,18 @@ window.initProfileInvoiceGenerator = function() { imageData: el.imageData }; }); + // Das Hintergrundbild wird bewusst NICHT mitgeschickt: Es wird serverseitig + // gehalten und dort ins Template-JSON übernommen — die große Bild-Payload + // würde sonst das Limit für Client-zu-Server-Aufrufe sprengen. return { elements: elementsWithPercent }; }; + + window.updateProfileCanvasBackground = function(dataUrl) { + window.profileInvoiceState.backgroundImage = dataUrl || null; + draw(); + }; window.updateProfileVatRate = function(rate) { if (rate == null || isNaN(rate)) return; @@ -1275,6 +1379,7 @@ window.initProfileInvoiceGenerator = function() { try { console.log('loadProfileTemplate called with data:', JSON.stringify(templateData).substring(0, 200)); var data = (typeof templateData === 'string') ? JSON.parse(templateData) : templateData; + window.profileInvoiceState.backgroundImage = data.backgroundImage || null; if (data.elements && Array.isArray(data.elements)) { console.log('Loading ' + data.elements.length + ' elements'); console.log('Current elements array before clear:', elements.length); @@ -1386,7 +1491,11 @@ window.initProfileInvoiceGenerator = function() { // Save to global state saveState(); - + + // Autosave erst ab jetzt: der geladene Stand ist die neue Referenz + lastAutosaved = JSON.stringify(window.getProfileCanvasData()); + autosaveEnabled = true; + console.log('Calling draw(), elements count:', elements.length); console.log('Canvas dimensions:', canvas.width, 'x', canvas.height); draw(); diff --git a/src/main/java/de/assecutor/pdftool/template/InvoiceTemplateService.java b/src/main/java/de/assecutor/pdftool/template/InvoiceTemplateService.java index 1b3a655..5ca4b58 100644 --- a/src/main/java/de/assecutor/pdftool/template/InvoiceTemplateService.java +++ b/src/main/java/de/assecutor/pdftool/template/InvoiceTemplateService.java @@ -31,6 +31,12 @@ public class InvoiceTemplateService { /** Name des Standard-Templates (Ziel der einmaligen Datei-Migration). */ public static final String DEFAULT_TEMPLATE_NAME = "standard"; + /** + * Reservierter Name für den automatisch gesicherten Arbeitsstand des + * Rechnungsgenerators; taucht in der Template-Auswahl nicht auf. + */ + public static final String AUTOSAVE_NAME = "__autosave__"; + private static final String LEGACY_TEMPLATE_FILE_NAME = "rechnungstemplate.json"; private final InvoiceTemplateRepository repository; @@ -64,10 +70,28 @@ public class InvoiceTemplateService { migrateLegacyTemplateIfMissing(); return repository.findAll().stream() .map(template -> template.getName()) + .filter(name -> !AUTOSAVE_NAME.equals(name)) .sorted(String.CASE_INSENSITIVE_ORDER) .toList(); } + /** Sichert den aktuellen Arbeitsstand des Rechnungsgenerators. */ + public void saveAutosave(String templateData) { + saveTemplate(AUTOSAVE_NAME, templateData); + } + + /** @return der zuletzt gesicherte Arbeitsstand oder leer, wenn keiner existiert. */ + public Optional loadAutosave() { + return repository.findByName(AUTOSAVE_NAME) + .map(template -> template.getTemplateData()) + .filter(data -> !data.isBlank()); + } + + /** Verwirft den gesicherten Arbeitsstand, z. B. nach dem Speichern eines Templates. */ + public void clearAutosave() { + repository.findByName(AUTOSAVE_NAME).ifPresent(repository::delete); + } + /** @return das gespeicherte Template mit dem Namen oder leer, wenn keines existiert. */ public Optional loadTemplate(String name) { if (DEFAULT_TEMPLATE_NAME.equals(name)) { diff --git a/src/main/java/de/assecutor/pdftool/template/TemplatePdfService.java b/src/main/java/de/assecutor/pdftool/template/TemplatePdfService.java index b6cdde1..907f5c8 100644 --- a/src/main/java/de/assecutor/pdftool/template/TemplatePdfService.java +++ b/src/main/java/de/assecutor/pdftool/template/TemplatePdfService.java @@ -3,13 +3,17 @@ package de.assecutor.pdftool.template; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.itextpdf.html2pdf.ConverterProperties; import com.itextpdf.html2pdf.HtmlConverter; +import com.itextpdf.html2pdf.resolver.font.DefaultFontProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.io.ByteArrayOutputStream; import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; import java.math.RoundingMode; import java.util.ArrayList; import java.util.List; @@ -48,6 +52,20 @@ public class TemplatePdfService { JsonNode rootNode = mapper.readTree(jsonTemplateData); JsonNode elements = rootNode.get("elements"); + // Hintergrundbild: als body-Hintergrund auf Seitengröße skaliert; alle + // Elemente liegen darüber. (Ein wird von html2pdf an dieser + // Stelle nicht unterstützt, background-image dagegen schon.) + String backgroundCss = ""; + JsonNode background = rootNode.get("backgroundImage"); + if (background != null && !background.isNull() && !background.asText().isEmpty()) { + String imageData = background.asText(); + if (!imageData.startsWith("data:")) { + imageData = "data:image/png;base64," + imageData; + } + backgroundCss = " background-image: url('" + imageData.replace("'", "%27") + "');" + + " background-size: 210mm 297mm; background-repeat: no-repeat;"; + } + StringBuilder htmlBuilder = new StringBuilder(); htmlBuilder.append(""); htmlBuilder.append(""); @@ -55,7 +73,8 @@ public class TemplatePdfService { htmlBuilder.append("