Compare commits

...
4 Commits
Author SHA1 Message Date
SvenandClaude Fable 5 5bcaf97eba Dockerfile: Microsoft Core Fonts und DejaVu für den PDF-Renderer installieren
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:08:24 +02:00
SvenandClaude Fable 5 4d16455d17 Rechnungsdesigner: Positionstabelle im Briefbogen-Layout, Hintergrundbild, mm-Angaben, Autosave
Positionstabelle mit Spalten Menge/Bezeichnung/Einzelpreis/Gesamt,
durchgezogenen schwarzen Spaltenlinien bis zum Summenblock (im Canvas aufs
Pixelraster eingerastet) und Summen rechts unten — identisch in Vorschau und
PDF. Dazu: Hintergrundbild hinter allen Elementen, Positionsangaben in mm,
Canvas-Eigenschaften in der Sidebar, kein Positionsraster mehr, zentrierter
Text bei Größenänderung sowie entprellter serverseitiger Autosave des
Arbeitsstands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:08:16 +02:00
SvenandClaude Fable 5 9891a58fad Schutz vor stillen Seiten-Reloads: 12h-Session, eigener Session-Cookie, Meldung statt Sofort-Reload
Session-Timeout auf 12 h erhöht, Cookie-Name PDFTOOL_SESSION gegen
JSESSIONID-Kollisionen anderer lokaler Java-Anwendungen, Tomcat-POST-Limit auf
30 MB für Canvas-Daten mit eingebetteten Bildern, Vaadin-Systemmeldungen mit
Bestätigung statt sofortigem Neuladen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:08:06 +02:00
SvenandClaude Fable 5 48a5831600 Bankverbindung (Bank, IBAN, BIC) im Adressbuch, IBAN/BIC über den Rechnungssteller statt Freitext
IBAN-Prüfung (ISO 13616) in eigenen IbanValidator ausgelagert; Käuferreferenz
und dev-Testdaten aus der Rechnungsmaske entfernt, Entwurf-Speichern nur bei
gefüllten Pflichtfeldern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:07:56 +02:00
13 changed files with 1035 additions and 442 deletions
+8
View File
@@ -7,6 +7,14 @@ ENV TZ=Europe/Berlin
ENV LC_TIME=de_DE.UTF-8 ENV LC_TIME=de_DE.UTF-8
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Schriften für den PDF-Renderer: Microsoft Core Fonts (Arial, Times New Roman,
# Courier New, Verdana) sowie DejaVu als Fallback. Die EULA der MS-Fonts wird
# vorab bestätigt; der Installer lädt die Fonts beim Image-Build herunter.
RUN echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" | debconf-set-selections \
&& apt-get update \
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu-core ttf-mscorefonts-installer \
&& rm -rf /var/lib/apt/lists/*
COPY ${JAR_FILE} app.jar COPY ${JAR_FILE} app.jar
EXPOSE 8083 EXPOSE 8083
ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=production"] ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=production"]
Binary file not shown.
@@ -73,7 +73,6 @@ window.initProfileInvoiceGenerator = function() {
var isDragging = false; var isDragging = false;
var dragStart = { x: 0, y: 0 }; var dragStart = { x: 0, y: 0 };
var elementStart = { x: 0, y: 0 }; var elementStart = { x: 0, y: 0 };
var gridSize = 5;
// Image cache to prevent flickering during resize // Image cache to prevent flickering during resize
var imageCache = {}; var imageCache = {};
@@ -102,8 +101,43 @@ window.initProfileInvoiceGenerator = function() {
pageY = (h - pageHeight) / 2; pageY = (h - pageHeight) / 2;
} }
// Kein Raster mehr: Positionen werden pixelgenau übernommen
function snapToGrid(value) { 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 // Notify Java about element selection
@@ -120,7 +154,8 @@ window.initProfileInvoiceGenerator = function() {
el.width || 100, el.width || 100,
el.height || 30, el.height || 30,
el.isStatic || false, el.isStatic || false,
el.variable || null el.variable || null,
el.fontFamily || 'Arial'
); );
} }
} }
@@ -131,6 +166,39 @@ window.initProfileInvoiceGenerator = function() {
} }
} }
// 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 // Draw function
function draw() { function draw() {
console.log('draw() called, elements:', elements.length); console.log('draw() called, elements:', elements.length);
@@ -156,21 +224,19 @@ window.initProfileInvoiceGenerator = function() {
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeRect(pageX, pageY, pageWidth, pageHeight); ctx.strokeRect(pageX, pageY, pageWidth, pageHeight);
// Grid (scaled by zoom factor) // Hintergrundbild: auf Seitengröße skaliert, hinter allen Elementen
ctx.strokeStyle = 'rgba(200, 200, 200, 0.3)'; var backgroundImage = window.profileInvoiceState.backgroundImage;
ctx.lineWidth = 0.5; if (backgroundImage) {
var scaledGridSize = gridSize * zoomFactor; if (imageCache[backgroundImage]) {
for (var x = pageX; x <= pageX + pageWidth; x += scaledGridSize) { ctx.drawImage(imageCache[backgroundImage], pageX, pageY, pageWidth, pageHeight);
ctx.beginPath(); } else {
ctx.moveTo(x, pageY); var bgImg = new Image();
ctx.lineTo(x, pageY + pageHeight); bgImg.onload = function() {
ctx.stroke(); imageCache[backgroundImage] = bgImg;
} draw();
for (var y = pageY; y <= pageY + pageHeight; y += scaledGridSize) { };
ctx.beginPath(); bgImg.src = backgroundImage;
ctx.moveTo(pageX, y); }
ctx.lineTo(pageX + pageWidth, y);
ctx.stroke();
} }
// Draw elements // Draw elements
@@ -184,6 +250,8 @@ window.initProfileInvoiceGenerator = function() {
if (selectedElement) { if (selectedElement) {
drawSelection(selectedElement); drawSelection(selectedElement);
} }
scheduleAutosave();
} }
function drawElement(el) { function drawElement(el) {
@@ -282,23 +350,36 @@ window.initProfileInvoiceGenerator = function() {
// Vertically center the text in the element // Vertically center the text in the element
var ty = y + (h - totalTextHeight) / 2; 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) { 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 // Different background colors: green for customer, blue for masterdata
if (el.isCustomer) { if (el.isCustomer) {
ctx.fillStyle = 'rgba(46, 204, 113, 0.15)'; // Light green for customer ctx.fillStyle = 'rgba(46, 204, 113, 0.15)'; // Light green for customer
} else { } else {
ctx.fillStyle = 'rgba(25, 118, 210, 0.1)'; // Light blue for masterdata 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 // Die gewählte Elementfarbe verwenden — wie im gerenderten PDF
ctx.fillStyle = el.isStatic ? '#000000' : (el.color || '#333333'); ctx.fillStyle = 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';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
ctx.textAlign = textAlign; ctx.textAlign = textAlign;
@@ -318,140 +399,94 @@ window.initProfileInvoiceGenerator = function() {
ctx.restore(); ctx.restore();
} }
// Draw services list as a table with columns: Name, Steuersatz, Nettobetrag // Positionsliste im Briefbogen-Layout: Spalten Menge, Bezeichnung,
// Plus summary section below: Nettosumme, USt, Gesamtsumme // Einzelpreis und Gesamt, durchgezogene Spaltenlinien bis zum Summenblock
// und die Summen (Nettobetrag, MwSt., Endbetrag) rechts unten.
function drawServicesTable(el, x, y, w, h, fontSize) { function drawServicesTable(el, x, y, w, h, fontSize) {
var fontFamily = el.fontFamily || 'Arial';
var lineHeight = fontSize * 1.4; var lineHeight = fontSize * 1.4;
var padding = 4 * zoomFactor; var padding = 4 * zoomFactor;
var rowHeight = lineHeight + padding * 2; var rowHeight = lineHeight + padding * 2;
var summaryRowHeight = fontSize * 1.6; var summaryRowHeight = fontSize * 1.6;
var summaryGap = fontSize * 0.5; var summaryGap = fontSize * 0.5;
// Column widths (percentages of total width) // Spaltengrenzen wie im PDF-Renderer (9 / 61 / 15 / 15 Prozent)
var colNameWidth = w * 0.55; // 55% for Name (left-aligned) var xName = x + w * 0.09;
var colVatWidth = w * 0.20; // 20% for Steuersatz (right-aligned) var xUnit = x + w * 0.70;
var colNetWidth = w * 0.25; // 25% for Nettobetrag (right-aligned) var xTotal = x + w * 0.85;
var colNameX = x;
var colVatX = x + colNameWidth;
var colNetX = colVatX + colVatWidth;
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19; var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%'; 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 serviceData = window.profileInvoiceServiceData;
var rows = (serviceData && serviceData.rows && serviceData.rows.length) var rows = (serviceData && serviceData.rows && serviceData.rows.length)
? serviceData.rows ? serviceData.rows
: [ : [
{ name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 ' }, { quantity: '1', name: 'Umzugsleistung inkl. Verpackung', unitPrice: '450,00 \u20ac', net: '450,00 \u20ac' },
{ name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 ' }, { quantity: '1', name: 'Entsorgung M\u00f6bel', unitPrice: '85,00 \u20ac', net: '85,00 \u20ac' },
{ name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 ' } { quantity: '1', name: 'Montage/De-Montage', unitPrice: '120,00 \u20ac', net: '120,00 \u20ac' }
]; ];
// Calculate actual content height based on table + summary // Mindesthoehe: Kopf + Zeilen + Summenblock; ist das Element hoeher,
var tableOnlyHeight = rowHeight * (rows.length + 1); // Header + data rows // laufen die Spaltenlinien entsprechend weiter nach unten
var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; var summaryHeight = 3 * summaryRowHeight + summaryGap;
var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight; var minHeight = rowHeight * (rows.length + 1) + summaryHeight;
// Ensure background covers at least the element height or the calculated content var totalHeight = Math.max(h, minHeight);
var bgHeight = Math.max(h, calculatedContentHeight * zoomFactor); var bodyBottom = y + totalHeight - summaryHeight;
// Draw orange background highlight for entire service variable element // Orangefarbene Markierung des gesamten Bausteins (nur im Designer)
var bgPadding = 3 * zoomFactor; var bgPadding = 3 * zoomFactor;
ctx.fillStyle = 'rgba(255, 152, 0, 0.15)'; ctx.fillStyle = 'rgba(255, 152, 0, 0.15)';
ctx.fillRect(x - bgPadding, y - (2 * zoomFactor), w + (2 * bgPadding), bgHeight + (4 * zoomFactor)); ctx.fillRect(x - bgPadding, y - (2 * zoomFactor), w + (2 * bgPadding), totalHeight + (4 * zoomFactor));
// Draw table header (overlays the background) // Kopfzeile: graue Flaeche mit kleinen Beschriftungen
ctx.fillStyle = '#f5f5f5'; ctx.fillStyle = '#eeeeee';
ctx.fillRect(x, y, w, rowHeight); 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.fillStyle = '#333333';
ctx.font = 'bold ' + fontSize + 'px Arial'; ctx.font = (fontSize * 0.75) + 'px ' + fontFamily;
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
// Name column header (left-aligned)
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText('Name', colNameX + padding, y + rowHeight / 2); ctx.fillText('Menge', x + padding, y + rowHeight / 2);
ctx.fillText('Bezeichnung', xName + padding, y + rowHeight / 2);
// Steuersatz column header (right-aligned) ctx.fillText('Einzelpreis', xUnit + padding, y + rowHeight / 2);
ctx.textAlign = 'right'; ctx.fillText('Gesamt', xTotal + padding, y + rowHeight / 2);
ctx.fillText('Steuersatz', colVatX + colVatWidth - padding, y + rowHeight / 2);
// Nettobetrag column header (right-aligned)
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
// Datenzeilen
var currentY = y + rowHeight; var currentY = y + rowHeight;
ctx.font = fontSize + 'px ' + fontFamily;
// Draw data rows rows.forEach(function(row) {
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
ctx.fillStyle = '#333333'; ctx.fillStyle = '#333333';
ctx.textAlign = 'center';
// Name (left-aligned) ctx.fillText(row.quantity || '', x + (xName - x) / 2, currentY + rowHeight / 2);
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText(row.name, colNameX + padding, currentY + rowHeight / 2); ctx.fillText(row.name || '', xName + padding, currentY + rowHeight / 2);
// Steuersatz (right-aligned)
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.fillText(row.vat, colVatX + colVatWidth - padding, currentY + rowHeight / 2); ctx.fillText(row.unitPrice || '', xTotal - padding, currentY + rowHeight / 2);
ctx.fillText(row.net || '', x + w - padding, currentY + rowHeight / 2);
// Nettobetrag (right-aligned)
ctx.fillText(row.net, colNetX + colNetWidth - padding, currentY + rowHeight / 2);
currentY += rowHeight; currentY += rowHeight;
}); });
// Draw column separator lines // Spaltenlinien vom Kopf bis zum Summenblock (inkl. Abstand davor,
ctx.strokeStyle = '#e0e0e0'; // damit keine Luecke zur ersten Summenzeile entsteht). X-Koordinaten
ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5); // 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(); ctx.beginPath();
// Line between Name and Steuersatz ctx.moveTo(lineXName, y);
ctx.moveTo(colVatX, y); ctx.lineTo(lineXName, linesBottom);
ctx.lineTo(colVatX, currentY); ctx.moveTo(lineXUnit, y);
// Line between Steuersatz and Nettobetrag ctx.lineTo(lineXUnit, linesBottom);
ctx.moveTo(colNetX, y); ctx.moveTo(lineXTotal, y);
ctx.lineTo(colNetX, currentY); ctx.lineTo(lineXTotal, linesBottom);
ctx.stroke(); ctx.stroke();
// Draw outer border around table // Summen: Nettobetrag, MwSt., Endbetrag rechts unten
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
var netTotalLabel, vatTotalLabel, grossTotalLabel; var netTotalLabel, vatTotalLabel, grossTotalLabel;
if (serviceData && serviceData.netTotal) { if (serviceData && serviceData.netTotal) {
netTotalLabel = serviceData.netTotal; netTotalLabel = serviceData.netTotal;
@@ -460,41 +495,41 @@ window.initProfileInvoiceGenerator = function() {
} else { } else {
var netTotal = 655.00; // 450 + 85 + 120 var netTotal = 655.00; // 450 + 85 + 120
var vatTotal = netTotal * vatRate; var vatTotal = netTotal * vatRate;
netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' '; netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' \u20ac';
vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' '; vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' \u20ac';
grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' '; 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'; ctx.textBaseline = 'middle';
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();
// Nettosumme - label left, value right ctx.fillStyle = '#333333';
ctx.fillStyle = '#333333'; ctx.font = fontSize + 'px ' + fontFamily;
ctx.font = fontSize + 'px Arial'; ctx.textAlign = 'left';
ctx.textAlign = 'left'; ctx.fillText(row.label, xUnit + padding, summaryY + summaryRowHeight / 2);
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2); ctx.textAlign = 'right';
ctx.font = 'bold ' + fontSize + 'px Arial'; ctx.fillText(row.value, x + w - padding, summaryY + summaryRowHeight / 2);
ctx.textAlign = 'right'; summaryY += summaryRowHeight;
ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2); });
summaryY += summaryRowHeight;
// 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);
} }
function drawSelection(el) { function drawSelection(el) {
@@ -506,7 +541,7 @@ window.initProfileInvoiceGenerator = function() {
// For text elements, calculate actual text dimensions // For text elements, calculate actual text dimensions
if (el.type !== 'line' && el.type !== 'image') { if (el.type !== 'line' && el.type !== 'image') {
var fontSize = (el.fontSize || 14) * zoomFactor; 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 // For services.list, calculate table height including summary section
if (el.variable === 'services.list') { if (el.variable === 'services.list') {
@@ -516,7 +551,7 @@ window.initProfileInvoiceGenerator = function() {
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
var summaryRowHeight = fontSize * 1.6; var summaryRowHeight = fontSize * 1.6;
var summaryGap = fontSize * 0.5; var summaryGap = fontSize * 0.5;
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; var summaryHeight = 3 * summaryRowHeight + summaryGap;
var totalHeight = tableHeight + summaryHeight; var totalHeight = tableHeight + summaryHeight;
h = Math.max(h, totalHeight); h = Math.max(h, totalHeight);
} else { } 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) { function hitTest(x, y, el) {
var ex = pageX + (el.x * zoomFactor); var ex = pageX + (el.x * zoomFactor);
var ey = pageY + (el.y * zoomFactor); var ey = pageY + (el.y * zoomFactor);
var ew = (el.width || 100) * zoomFactor; var ew = (el.width || 100) * zoomFactor;
var eh = (el.height || 30) * zoomFactor; var eh = (el.height || 30) * zoomFactor;
// For text elements, calculate actual text dimensions for hit testing if (el.type === 'line') {
if (el.type !== 'line' && el.type !== 'image') { // Nur die gezeichnete Linie, mit kleiner vertikaler Toleranz
var fontSize = (el.fontSize || 14) * zoomFactor; var tolerance = Math.max(3, 4 * zoomFactor);
ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px Arial'; return x >= ex && x <= ex + ew && y >= ey - tolerance && y <= ey + tolerance;
}
// For services.list, calculate table height if (el.type === 'image') {
if (el.variable === 'services.list') { return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh;
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));
}
} }
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 // Resizing state
@@ -636,7 +687,7 @@ window.initProfileInvoiceGenerator = function() {
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
var summaryRowHeight = fontSize * 1.6; var summaryRowHeight = fontSize * 1.6;
var summaryGap = fontSize * 0.5; var summaryGap = fontSize * 0.5;
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; var summaryHeight = 3 * summaryRowHeight + summaryGap;
eh = Math.max(eh, tableHeight + summaryHeight); eh = Math.max(eh, tableHeight + summaryHeight);
} }
@@ -708,8 +759,9 @@ window.initProfileInvoiceGenerator = function() {
canvas.style.cursor = 'move'; canvas.style.cursor = 'move';
notifyElementSelected(selectedElement); notifyElementSelected(selectedElement);
} else { } else {
// Klick auf die leere Zeichenfläche: Canvas-Einstellungen anzeigen
selectedElement = null; selectedElement = null;
notifyElementDeselected(); notifyCanvasSelected();
} }
draw(); draw();
@@ -730,41 +782,47 @@ window.initProfileInvoiceGenerator = function() {
var newX = resizeStart.elemX; var newX = resizeStart.elemX;
var newY = resizeStart.elemY; 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 // Handle indices: 0=TL, 1=TC, 2=TR, 3=ML, 4=MR, 5=BL, 6=BC, 7=BR
switch(resizeHandle) { switch(resizeHandle) {
case 0: // Top-left case 0: // Top-left
newWidth = Math.max(20, resizeStart.width - dx); newWidth = Math.max(minW, resizeStart.width - dx);
newHeight = Math.max(20, resizeStart.height - dy); newHeight = Math.max(minH, resizeStart.height - dy);
newX = resizeStart.elemX + (resizeStart.width - newWidth); newX = resizeStart.elemX + (resizeStart.width - newWidth);
newY = resizeStart.elemY + (resizeStart.height - newHeight); newY = resizeStart.elemY + (resizeStart.height - newHeight);
break; break;
case 1: // Top-center case 1: // Top-center
newHeight = Math.max(20, resizeStart.height - dy); newHeight = Math.max(minH, resizeStart.height - dy);
newY = resizeStart.elemY + (resizeStart.height - newHeight); newY = resizeStart.elemY + (resizeStart.height - newHeight);
break; break;
case 2: // Top-right case 2: // Top-right
newWidth = Math.max(20, resizeStart.width + dx); newWidth = Math.max(minW, resizeStart.width + dx);
newHeight = Math.max(20, resizeStart.height - dy); newHeight = Math.max(minH, resizeStart.height - dy);
newY = resizeStart.elemY + (resizeStart.height - newHeight); newY = resizeStart.elemY + (resizeStart.height - newHeight);
break; break;
case 3: // Middle-left case 3: // Middle-left
newWidth = Math.max(20, resizeStart.width - dx); newWidth = Math.max(minW, resizeStart.width - dx);
newX = resizeStart.elemX + (resizeStart.width - newWidth); newX = resizeStart.elemX + (resizeStart.width - newWidth);
break; break;
case 4: // Middle-right case 4: // Middle-right
newWidth = Math.max(20, resizeStart.width + dx); newWidth = Math.max(minW, resizeStart.width + dx);
break; break;
case 5: // Bottom-left case 5: // Bottom-left
newWidth = Math.max(20, resizeStart.width - dx); newWidth = Math.max(minW, resizeStart.width - dx);
newHeight = Math.max(20, resizeStart.height + dy); newHeight = Math.max(minH, resizeStart.height + dy);
newX = resizeStart.elemX + (resizeStart.width - newWidth); newX = resizeStart.elemX + (resizeStart.width - newWidth);
break; break;
case 6: // Bottom-center case 6: // Bottom-center
newHeight = Math.max(20, resizeStart.height + dy); newHeight = Math.max(minH, resizeStart.height + dy);
break; break;
case 7: // Bottom-right case 7: // Bottom-right
newWidth = Math.max(20, resizeStart.width + dx); newWidth = Math.max(minW, resizeStart.width + dx);
newHeight = Math.max(20, resizeStart.height + dy); newHeight = Math.max(minH, resizeStart.height + dy);
break; break;
} }
@@ -772,6 +830,7 @@ window.initProfileInvoiceGenerator = function() {
selectedElement.height = snapToGrid(newHeight); selectedElement.height = snapToGrid(newHeight);
selectedElement.x = snapToGrid(newX); selectedElement.x = snapToGrid(newX);
selectedElement.y = snapToGrid(newY); selectedElement.y = snapToGrid(newY);
keepTextCentered(selectedElement);
draw(); draw();
notifyElementSelected(selectedElement); notifyElementSelected(selectedElement);
@@ -856,40 +915,29 @@ window.initProfileInvoiceGenerator = function() {
var moved = false; 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) { switch(e.key) {
case 'ArrowUp': case 'ArrowUp':
if (e.shiftKey) { selectedElement.y = Math.max(0, selectedElement.y - stepY);
selectedElement.y = Math.max(0, selectedElement.y - 1);
} else {
selectedElement.y = Math.max(0, Math.floor((selectedElement.y - 1) / gridSize) * gridSize);
}
moved = true; moved = true;
e.preventDefault(); e.preventDefault();
break; break;
case 'ArrowDown': case 'ArrowDown':
if (e.shiftKey) { selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), selectedElement.y + stepY);
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);
}
moved = true; moved = true;
e.preventDefault(); e.preventDefault();
break; break;
case 'ArrowLeft': case 'ArrowLeft':
if (e.shiftKey) { selectedElement.x = Math.max(0, selectedElement.x - stepX);
selectedElement.x = Math.max(0, selectedElement.x - 1);
} else {
selectedElement.x = Math.max(0, Math.floor((selectedElement.x - 1) / gridSize) * gridSize);
}
moved = true; moved = true;
e.preventDefault(); e.preventDefault();
break; break;
case 'ArrowRight': case 'ArrowRight':
if (e.shiftKey) { selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), selectedElement.x + stepX);
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);
}
moved = true; moved = true;
e.preventDefault(); e.preventDefault();
break; break;
@@ -933,6 +981,7 @@ window.initProfileInvoiceGenerator = function() {
width: 150, width: 150,
height: 30, height: 30,
fontSize: 14, fontSize: 14,
fontFamily: 'Arial',
color: '#333333', color: '#333333',
isStatic: isStatic || false, isStatic: isStatic || false,
variable: variable || null, variable: variable || null,
@@ -959,7 +1008,7 @@ window.initProfileInvoiceGenerator = function() {
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 sample rows var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 sample rows
var summaryRowHeight = el.fontSize * 1.6; var summaryRowHeight = el.fontSize * 1.6;
var summaryGap = el.fontSize * 0.5; var summaryGap = el.fontSize * 0.5;
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; var summaryHeight = 3 * summaryRowHeight + summaryGap;
var totalHeight = tableHeight + summaryHeight; var totalHeight = tableHeight + summaryHeight;
el.height = Math.round(totalHeight); el.height = Math.round(totalHeight);
} else { } else {
@@ -1019,6 +1068,7 @@ window.initProfileInvoiceGenerator = function() {
elements.push(el); elements.push(el);
selectedElement = el; selectedElement = el;
autosaveEnabled = true;
draw(); draw();
notifyElementSelected(el); notifyElementSelected(el);
@@ -1032,6 +1082,46 @@ window.initProfileInvoiceGenerator = function() {
if (el) { if (el) {
pushUndoState(); pushUndoState();
el.text = text; 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(); draw();
} }
}; };
@@ -1051,12 +1141,17 @@ window.initProfileInvoiceGenerator = function() {
if (el) { if (el) {
pushUndoState(); pushUndoState();
el.fontSize = size; 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') { if (el.type !== 'line' && el.type !== 'image') {
var lines = (el.text || '').split('\n'); var lines = (el.text || '').split('\n');
var lineHeight = size * 1.2; var lineHeight = size * 1.2;
var textHeight = lines.length * lineHeight; var textHeight = lines.length * lineHeight;
el.height = Math.max(textHeight + 6, 20); // Minimum height of 20 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(); draw();
} }
@@ -1192,6 +1287,7 @@ window.initProfileInvoiceGenerator = function() {
heightPercent: toPercentY(el.height), heightPercent: toPercentY(el.height),
fontSize: el.fontSize, fontSize: el.fontSize,
fontStyle: el.fontStyle, fontStyle: el.fontStyle,
fontFamily: el.fontFamily,
textAlign: el.textAlign, textAlign: el.textAlign,
color: el.color, color: el.color,
isStatic: el.isStatic, isStatic: el.isStatic,
@@ -1200,11 +1296,19 @@ window.initProfileInvoiceGenerator = function() {
imageData: el.imageData 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 { return {
elements: elementsWithPercent elements: elementsWithPercent
}; };
}; };
window.updateProfileCanvasBackground = function(dataUrl) {
window.profileInvoiceState.backgroundImage = dataUrl || null;
draw();
};
window.updateProfileVatRate = function(rate) { window.updateProfileVatRate = function(rate) {
if (rate == null || isNaN(rate)) return; if (rate == null || isNaN(rate)) return;
window.profileInvoiceVatRate = rate; window.profileInvoiceVatRate = rate;
@@ -1275,6 +1379,7 @@ window.initProfileInvoiceGenerator = function() {
try { try {
console.log('loadProfileTemplate called with data:', JSON.stringify(templateData).substring(0, 200)); console.log('loadProfileTemplate called with data:', JSON.stringify(templateData).substring(0, 200));
var data = (typeof templateData === 'string') ? JSON.parse(templateData) : templateData; var data = (typeof templateData === 'string') ? JSON.parse(templateData) : templateData;
window.profileInvoiceState.backgroundImage = data.backgroundImage || null;
if (data.elements && Array.isArray(data.elements)) { if (data.elements && Array.isArray(data.elements)) {
console.log('Loading ' + data.elements.length + ' elements'); console.log('Loading ' + data.elements.length + ' elements');
console.log('Current elements array before clear:', elements.length); console.log('Current elements array before clear:', elements.length);
@@ -1387,6 +1492,10 @@ window.initProfileInvoiceGenerator = function() {
// Save to global state // Save to global state
saveState(); 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('Calling draw(), elements count:', elements.length);
console.log('Canvas dimensions:', canvas.width, 'x', canvas.height); console.log('Canvas dimensions:', canvas.width, 'x', canvas.height);
draw(); draw();
@@ -26,6 +26,11 @@ public class Address {
private String email; private String email;
private String phone; private String phone;
// Bankverbindung (v. a. für Rechnungssteller relevant)
private String bankName;
private String iban;
private String bic;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@@ -114,6 +119,30 @@ public class Address {
this.phone = phone; this.phone = phone;
} }
public String getBankName() {
return bankName;
}
public void setBankName(String bankName) {
this.bankName = bankName;
}
public String getIban() {
return iban;
}
public void setIban(String iban) {
this.iban = iban;
}
public String getBic() {
return bic;
}
public void setBic(String bic) {
this.bic = bic;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }
@@ -0,0 +1,29 @@
package de.assecutor.pdftool.invoice;
/**
* Prüft IBANs nach ISO 13616 (MOD 97-10).
*/
public final class IbanValidator {
private IbanValidator() {
}
public static boolean isValid(String value) {
String normalized = normalize(value);
if (!normalized.matches("[A-Z]{2}\\d{2}[A-Z0-9]{11,30}")) {
return false;
}
String rearranged = normalized.substring(4) + normalized.substring(0, 4);
int mod = 0;
for (char c : rearranged.toCharArray()) {
int digit = Character.isDigit(c) ? c - '0' : c - 'A' + 10;
mod = digit < 10 ? (mod * 10 + digit) % 97 : (mod * 100 + digit) % 97;
}
return mod == 1;
}
/** Entfernt Leerzeichen und wandelt in Großbuchstaben um. */
public static String normalize(String value) {
return value.replaceAll("\\s", "").toUpperCase();
}
}
@@ -31,6 +31,12 @@ public class InvoiceTemplateService {
/** Name des Standard-Templates (Ziel der einmaligen Datei-Migration). */ /** Name des Standard-Templates (Ziel der einmaligen Datei-Migration). */
public static final String DEFAULT_TEMPLATE_NAME = "standard"; 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 static final String LEGACY_TEMPLATE_FILE_NAME = "rechnungstemplate.json";
private final InvoiceTemplateRepository repository; private final InvoiceTemplateRepository repository;
@@ -64,10 +70,28 @@ public class InvoiceTemplateService {
migrateLegacyTemplateIfMissing(); migrateLegacyTemplateIfMissing();
return repository.findAll().stream() return repository.findAll().stream()
.map(template -> template.getName()) .map(template -> template.getName())
.filter(name -> !AUTOSAVE_NAME.equals(name))
.sorted(String.CASE_INSENSITIVE_ORDER) .sorted(String.CASE_INSENSITIVE_ORDER)
.toList(); .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<String> 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. */ /** @return das gespeicherte Template mit dem Namen oder leer, wenn keines existiert. */
public Optional<String> loadTemplate(String name) { public Optional<String> loadTemplate(String name) {
if (DEFAULT_TEMPLATE_NAME.equals(name)) { if (DEFAULT_TEMPLATE_NAME.equals(name)) {
@@ -3,13 +3,17 @@ package de.assecutor.pdftool.template;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.itextpdf.html2pdf.ConverterProperties;
import com.itextpdf.html2pdf.HtmlConverter; import com.itextpdf.html2pdf.HtmlConverter;
import com.itextpdf.html2pdf.resolver.font.DefaultFontProvider;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -48,6 +52,20 @@ public class TemplatePdfService {
JsonNode rootNode = mapper.readTree(jsonTemplateData); JsonNode rootNode = mapper.readTree(jsonTemplateData);
JsonNode elements = rootNode.get("elements"); JsonNode elements = rootNode.get("elements");
// Hintergrundbild: als body-Hintergrund auf Seitengröße skaliert; alle
// Elemente liegen darüber. (Ein <img> 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(); StringBuilder htmlBuilder = new StringBuilder();
htmlBuilder.append("<!DOCTYPE html>"); htmlBuilder.append("<!DOCTYPE html>");
htmlBuilder.append("<html><head>"); htmlBuilder.append("<html><head>");
@@ -55,7 +73,8 @@ public class TemplatePdfService {
htmlBuilder.append("<style>"); htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }"); htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append( htmlBuilder.append(
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }"); "body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif;"
+ backgroundCss + " }");
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }"); htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }"); htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }"); htmlBuilder.append(".line { border-top: 1px solid #333; }");
@@ -72,8 +91,22 @@ public class TemplatePdfService {
htmlBuilder.append("</body></html>"); htmlBuilder.append("</body></html>");
// Standard-PDF-Fonts + mitgelieferte Noto-Fonts + Systemschriften, damit
// auch Schriften wie Verdana oder echtes Arial aufgelöst werden können.
// Der FontProvider darf nicht über Konvertierungen hinweg wiederverwendet
// werden, daher pro Aufruf eine neue Instanz.
ConverterProperties converterProperties = new ConverterProperties();
DefaultFontProvider fontProvider = new DefaultFontProvider(true, true, true);
// macOS legt viele Schriften (u. a. Verdana) unter "Supplemental" ab;
// dieses Verzeichnis gehört nicht zu den von iText gescannten Pfaden.
Path supplementalFonts = Path.of("/System/Library/Fonts/Supplemental");
if (Files.isDirectory(supplementalFonts)) {
fontProvider.addDirectory(supplementalFonts.toString());
}
converterProperties.setFontProvider(fontProvider);
ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream out = new ByteArrayOutputStream();
HtmlConverter.convertToPdf(htmlBuilder.toString(), out); HtmlConverter.convertToPdf(htmlBuilder.toString(), out, converterProperties);
return out.toByteArray(); return out.toByteArray();
} }
@@ -103,6 +136,7 @@ public class TemplatePdfService {
int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14; int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14;
String color = element.has("color") ? element.get("color").asText("#333333") : "#333333"; String color = element.has("color") ? element.get("color").asText("#333333") : "#333333";
String textAlign = element.has("textAlign") ? element.get("textAlign").asText("left") : "left"; String textAlign = element.has("textAlign") ? element.get("textAlign").asText("left") : "left";
String fontFamily = element.has("fontFamily") ? element.get("fontFamily").asText("Arial") : "Arial";
// Prozent -> mm (A4: 210mm x 297mm) // Prozent -> mm (A4: 210mm x 297mm)
double mmX = xPercent / 100.0 * 210.0; double mmX = xPercent / 100.0 * 210.0;
@@ -126,6 +160,7 @@ public class TemplatePdfService {
htmlBuilder.append("font-size:").append(fontSize).append("pt;"); htmlBuilder.append("font-size:").append(fontSize).append("pt;");
htmlBuilder.append("line-height:").append(String.format(Locale.US, "%.2f", fontSize * 1.2)).append("pt;"); htmlBuilder.append("line-height:").append(String.format(Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
htmlBuilder.append("color:").append(color).append(";"); htmlBuilder.append("color:").append(color).append(";");
htmlBuilder.append("font-family:").append(cssFontStack(fontFamily)).append(";");
// services.list als Block, damit die Tabelle die Breite füllen kann // services.list als Block, damit die Tabelle die Breite füllen kann
if ("services.list".equals(variable)) { if ("services.list".equals(variable)) {
htmlBuilder.append("display:block;overflow:visible;padding:0;"); htmlBuilder.append("display:block;overflow:visible;padding:0;");
@@ -184,9 +219,9 @@ public class TemplatePdfService {
} }
} else if ("services.list".equals(variable)) { } else if ("services.list".equals(variable)) {
if (variables.containsKey("services.json")) { if (variables.containsKey("services.json")) {
htmlBuilder.append(generateServicesTableHtmlWithData(variables)); htmlBuilder.append(generateServicesTableHtmlWithData(variables, fontSize, mmHeight));
} else { } else {
htmlBuilder.append(generateServicesTableHtml(effectiveVatRate)); htmlBuilder.append(generateServicesTableHtml(effectiveVatRate, fontSize, mmHeight));
} }
} else if (text.contains("<br>")) { } else if (text.contains("<br>")) {
// Mehrzeiliger Text: ohne nowrap rendern, damit <br> wirkt // Mehrzeiliger Text: ohne nowrap rendern, damit <br> wirkt
@@ -199,7 +234,7 @@ public class TemplatePdfService {
} }
/** Positionstabelle mit Beispieldaten, wenn keine echten Positionen übergeben wurden. */ /** Positionstabelle mit Beispieldaten, wenn keine echten Positionen übergeben wurden. */
private String generateServicesTableHtml(BigDecimal vatRate) { private String generateServicesTableHtml(BigDecimal vatRate, int fontSize, double mmHeight) {
BigDecimal pct = vatRate.multiply(new BigDecimal("100")).setScale(2, RoundingMode.HALF_UP) BigDecimal pct = vatRate.multiply(new BigDecimal("100")).setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros(); .stripTrailingZeros();
if (pct.scale() < 0) { if (pct.scale() < 0) {
@@ -207,47 +242,23 @@ public class TemplatePdfService {
} }
String vatLabel = pct.toPlainString().replace('.', ',') + "%"; String vatLabel = pct.toPlainString().replace('.', ',') + "%";
String[][] sampleData = { { "Beratungsleistung", vatLabel, "450,00 €" }, List<Map<String, String>> rows = List.of(
{ "Softwareentwicklung", vatLabel, "1.200,00 €" }, { "Support-Pauschale", vatLabel, "150,00" } }; Map.of("quantity", "1", "name", "Beratungsleistung", "unitPrice", "450,00", "netAmount", "450,00"),
Map.of("quantity", "1", "name", "Softwareentwicklung", "unitPrice", "1.200,00", "netAmount",
"1.200,00"),
Map.of("quantity", "1", "name", "Support-Pauschale", "unitPrice", "150,00", "netAmount", "150,00"));
double netTotal = 1800.00; double netTotal = 1800.00;
double grossTotal = netTotal + (netTotal * vatRate.doubleValue()); double vatTotal = netTotal * vatRate.doubleValue();
return renderServicesTable(rows,
StringBuilder html = new StringBuilder(); String.format(Locale.GERMANY, "%,.2f €", netTotal),
html.append("<div style='width:100%;box-sizing:border-box;'>"); String.format(Locale.GERMANY, "%,.2f €", vatTotal),
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>"); String.format(Locale.GERMANY, "%,.2f €", netTotal + vatTotal),
html.append(tableHeaderRow()); vatLabel, fontSize, mmHeight);
for (int i = 0; i < sampleData.length; i++) {
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : "";
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
html.append(
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'>")
.append(sampleData[i][0]).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;'>").append(sampleData[i][1])
.append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;'>").append(sampleData[i][2])
.append("</td>");
html.append("</tr>");
}
html.append("</table>");
html.append("<div style='margin-top:8px;width:100%;'>");
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
html.append(summaryRow("Nettosumme:", String.format(Locale.GERMANY, "%,.2f €", netTotal), false));
html.append(summaryRow("Gesamtsumme:", String.format(Locale.GERMANY, "%,.2f €", grossTotal), true));
html.append("</table>");
html.append("</div>");
html.append("</div>");
return html.toString();
} }
/** Positionstabelle aus echten Daten (services.json + invoice.*-Summen). */ /** Positionstabelle aus echten Daten (services.json + invoice.*-Summen). */
private String generateServicesTableHtmlWithData(Map<String, String> variables) { private String generateServicesTableHtmlWithData(Map<String, String> variables, int fontSize, double mmHeight) {
String netTotal = variables.getOrDefault("invoice.net_total", "0,00 €");
String vatTotal = variables.getOrDefault("invoice.vat_total", "0,00 €");
String grossTotal = variables.getOrDefault("invoice.gross_total", "0,00 €");
String vatRateLabel = variables.getOrDefault("invoice.vat_rate", "19%");
List<Map<String, String>> servicesData = new ArrayList<>(); List<Map<String, String>> servicesData = new ArrayList<>();
String servicesJson = variables.get("services.json"); String servicesJson = variables.get("services.json");
if (servicesJson != null && !servicesJson.isEmpty() && !servicesJson.equals("[]")) { if (servicesJson != null && !servicesJson.isEmpty() && !servicesJson.equals("[]")) {
@@ -258,72 +269,120 @@ public class TemplatePdfService {
log.warn("Positionsdaten (services.json) konnten nicht gelesen werden: {}", e.getMessage()); log.warn("Positionsdaten (services.json) konnten nicht gelesen werden: {}", e.getMessage());
} }
} }
return renderServicesTable(servicesData,
variables.getOrDefault("invoice.net_total", "0,00 €"),
variables.getOrDefault("invoice.vat_total", "0,00 €"),
variables.getOrDefault("invoice.gross_total", "0,00 €"),
variables.getOrDefault("invoice.vat_rate", "19%"),
fontSize, mmHeight);
}
/**
* Rendert die Positionstabelle im Briefbogen-Layout: Spalten Menge,
* Bezeichnung, Einzelpreis und Gesamt, durchgezogene Spaltenlinien bis zum
* Summenblock und die Summen (Nettobetrag, MwSt., Endbetrag) rechts unten.
*/
private String renderServicesTable(List<Map<String, String>> rows, String netTotal, String vatTotal,
String grossTotal, String vatRateLabel, int fontSize, double mmHeight) {
String border = "0.5px solid #000000";
// Höhe der Füllzeile: Spaltenlinien bis zum Summenblock durchziehen,
// damit die Tabelle die Bausteinhöhe füllt (pt -> mm: Faktor 0,3528)
double lineMm = fontSize * 1.2 * 0.3528;
double headerMm = fontSize * 0.9 * 0.3528 + 1.6;
double rowMm = lineMm + 1.6;
double summaryMm = 3 * (lineMm + 1.2) + 2.0;
double fillerMm = Math.max(0, mmHeight - headerMm - rows.size() * rowMm - summaryMm);
StringBuilder html = new StringBuilder(); StringBuilder html = new StringBuilder();
html.append("<div style='width:100%;box-sizing:border-box;'>"); html.append("<div style='width:100%;box-sizing:border-box;'>");
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>"); html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
html.append(tableHeaderRow()); html.append("<colgroup><col style='width:9%;'/><col style='width:61%;'/>")
.append("<col style='width:15%;'/><col style='width:15%;'/></colgroup>");
if (servicesData.isEmpty()) { String headStyle = "background-color:#eeeeee;font-size:0.75em;font-weight:normal;color:#333333;"
html.append("<tr style='border-bottom:1px solid #eeeeee;'>"); + "text-align:left;padding:2px 6px;white-space:nowrap;";
html.append( html.append("<tr>");
"<td colspan='3' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Positionen vorhanden</td>"); html.append("<th style='").append(headStyle).append("'>Menge</th>");
html.append("</tr>"); html.append("<th style='").append(headStyle).append("border-left:").append(border)
} else { .append(";'>Bezeichnung</th>");
for (int i = 0; i < servicesData.size(); i++) { html.append("<th style='").append(headStyle).append("border-left:").append(border)
Map<String, String> service = servicesData.get(i); .append(";'>Einzelpreis</th>");
String name = service.getOrDefault("name", "Unbekannte Position"); html.append("<th style='").append(headStyle).append("border-left:").append(border).append(";'>Gesamt</th>");
String netAmount = service.getOrDefault("netAmount", "0,00"); html.append("</tr>");
// USt-Satz pro Position, Fallback: einheitlicher Satz der Rechnung
String rowVat = service.getOrDefault("vat", vatRateLabel);
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : ""; if (rows.isEmpty()) {
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>"); html.append("<tr><td style='text-align:center;padding:2px 6px;'></td>")
html.append( .append("<td style='padding:2px 6px;border-left:").append(border)
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:55%;'>") .append(";'>Keine Positionen vorhanden</td>")
.append(escapeHtml(name)).append("</td>"); .append("<td style='border-left:").append(border).append(";'></td>")
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>") .append("<td style='border-left:").append(border).append(";'></td></tr>");
.append(escapeHtml(rowVat)).append("</td>");
// € nur an rein numerische Beträge anhängen; Werte wie "15 %" unverändert
String amountDisplay = netAmount.matches("[0-9.,]+") ? netAmount + "" : escapeHtml(netAmount);
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
.append(amountDisplay).append("</td>");
html.append("</tr>");
}
} }
for (Map<String, String> row : rows) {
html.append("<tr>");
html.append("<td style='text-align:center;padding:2px 6px;white-space:nowrap;'>")
.append(escapeHtml(row.getOrDefault("quantity", ""))).append("</td>");
html.append("<td style='text-align:left;padding:2px 6px;white-space:nowrap;overflow:hidden;")
.append("text-overflow:ellipsis;border-left:").append(border).append(";'>")
.append(escapeHtml(row.getOrDefault("name", ""))).append("</td>");
html.append("<td style='text-align:right;padding:2px 6px;white-space:nowrap;border-left:").append(border)
.append(";'>").append(amountDisplay(row.getOrDefault("unitPrice", ""))).append("</td>");
html.append("<td style='text-align:right;padding:2px 6px;white-space:nowrap;border-left:").append(border)
.append(";'>").append(amountDisplay(row.getOrDefault("netAmount", ""))).append("</td>");
html.append("</tr>");
}
// Füllzeile: hält die Spaltenlinien bis zum Summenblock durch
html.append("<tr>");
html.append("<td style='height:").append(String.format(Locale.US, "%.2f", fillerMm)).append("mm;'></td>");
html.append("<td style='border-left:").append(border).append(";'></td>");
html.append("<td style='border-left:").append(border).append(";'></td>");
html.append("<td style='border-left:").append(border).append(";'></td>");
html.append("</tr>");
html.append("</table>"); html.append("</table>");
html.append("<div style='margin-top:8px;width:100%;'>"); // Summenblock rechts unten: Nettobetrag, MwSt., Endbetrag
String vatLabelText = vatRateLabel == null || vatRateLabel.isEmpty()
? "+ MwSt."
: "+ " + escapeHtml(vatRateLabel) + " MwSt.";
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>"); html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
// Bei gemischten USt-Sätzen (leeres Label) ohne Satzangabe beschriften html.append("<colgroup><col style='width:70%;'/><col style='width:15%;'/><col style='width:15%;'/>")
String vatSummaryLabel = vatRateLabel.isEmpty() ? "zzgl. USt:" .append("</colgroup>");
: "zzgl. " + escapeHtml(vatRateLabel) + " USt:"; html.append(summaryRow("Nettobetrag", escapeHtml(netTotal), true, border));
html.append(summaryRow("Nettosumme:", netTotal, false)); html.append(summaryRow(vatLabelText, escapeHtml(vatTotal), false, border));
html.append(summaryRow(vatSummaryLabel, vatTotal, false)); html.append(summaryRow("Endbetrag", escapeHtml(grossTotal), true, border));
html.append(summaryRow("Gesamtsumme:", grossTotal, true));
html.append("</table>"); html.append("</table>");
html.append("</div>"); html.append("</div>");
html.append("</div>");
return html.toString(); return html.toString();
} }
private String tableHeaderRow() { /** Eine Zeile des Summenblocks; jede zweite Zeile ist grau hinterlegt. */
return "<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>" private String summaryRow(String label, String value, boolean shaded, String border) {
+ "<th style='text-align:left;padding:4px 8px;font-weight:bold;width:55%;white-space:nowrap;'>Name</th>" String bg = shaded ? "background-color:#eeeeee;" : "";
+ "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:20%;white-space:nowrap;'>Steuersatz</th>" return "<tr>"
+ "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>" + "<td></td>"
+ "<td style='" + bg + "padding:2px 6px;white-space:nowrap;border-left:" + border + ";'>"
+ label + "</td>"
+ "<td style='" + bg + "padding:2px 6px;text-align:right;white-space:nowrap;'>" + value + "</td>"
+ "</tr>"; + "</tr>";
} }
private String summaryRow(String label, String value, boolean emphasized) { /** € nur an rein numerische Beträge anhängen; andere Werte escaped übernehmen. */
String labelStyle = emphasized ? "padding:4px 8px;font-weight:bold;font-size:1.05em;" : "padding:2px 8px;"; private String amountDisplay(String amount) {
String valueStyle = emphasized ? "padding:4px 8px;font-weight:bold;font-size:1.05em;" return amount.matches("[0-9.,]+") ? amount + "" : escapeHtml(amount);
: "padding:2px 8px;font-weight:bold;"; }
return "<tr>"
+ "<td style='width:55%;padding:2px 0;'></td>" /**
+ "<td style='width:20%;text-align:left;white-space:nowrap;" + labelStyle + "'>" + label + "</td>" * CSS-Schriftstapel zur Schriftart des Elements; nur bekannte Werte werden
+ "<td style='width:25%;text-align:right;white-space:nowrap;" + valueStyle + "'>" + value + "</td>" * übernommen (Whitelist), alles andere fällt auf Arial zurück.
+ "</tr>"; */
private static String cssFontStack(String fontFamily) {
return switch (fontFamily) {
case "Times New Roman" -> "'Times New Roman', Times, serif";
case "Courier New" -> "'Courier New', Courier, monospace";
case "Verdana" -> "Verdana, 'DejaVu Sans', Arial, sans-serif";
default -> "Arial, Helvetica, sans-serif";
};
} }
private String escapeHtml(String input) { private String escapeHtml(String input) {
@@ -38,6 +38,8 @@ public final class TemplateVariables {
BigDecimal net = lineNet(item); BigDecimal net = lineNet(item);
Map<String, String> position = new HashMap<>(); Map<String, String> position = new HashMap<>();
position.put("name", item.description()); position.put("name", item.description());
position.put("quantity", quantityLabel(item.quantity()));
position.put("unitPrice", formatAmount(item.unitPriceNet()));
position.put("netAmount", formatAmount(net)); position.put("netAmount", formatAmount(net));
position.put("vat", vatLabel(item.vatPercent())); position.put("vat", vatLabel(item.vatPercent()));
positions.add(position); positions.add(position);
@@ -71,6 +73,8 @@ public final class TemplateVariables {
BigDecimal net = lineNet(item); BigDecimal net = lineNet(item);
Map<String, String> row = new HashMap<>(); Map<String, String> row = new HashMap<>();
row.put("name", item.description()); row.put("name", item.description());
row.put("quantity", quantityLabel(item.quantity()));
row.put("unitPrice", formatAmount(item.unitPriceNet()) + "");
row.put("vat", vatLabel(item.vatPercent())); row.put("vat", vatLabel(item.vatPercent()));
row.put("net", formatAmount(net) + ""); row.put("net", formatAmount(net) + "");
rows.add(row); rows.add(row);
@@ -97,6 +101,15 @@ public final class TemplateVariables {
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ","); return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",");
} }
/** Menge als Anzeige-Label ohne überflüssige Nullen, z. B. "1" oder "2,5". */
public static String quantityLabel(BigDecimal quantity) {
BigDecimal normalized = quantity.stripTrailingZeros();
if (normalized.scale() < 0) {
normalized = normalized.setScale(0);
}
return normalized.toPlainString().replace('.', ',');
}
/** USt-Satz als Anzeige-Label, z. B. "19%" oder "7,5%". */ /** USt-Satz als Anzeige-Label, z. B. "19%" oder "7,5%". */
public static String vatLabel(BigDecimal percent) { public static String vatLabel(BigDecimal percent) {
BigDecimal normalized = percent.stripTrailingZeros(); BigDecimal normalized = percent.stripTrailingZeros();
@@ -20,6 +20,7 @@ import com.vaadin.flow.router.Route;
import de.assecutor.pdftool.address.Address; import de.assecutor.pdftool.address.Address;
import de.assecutor.pdftool.address.AddressRepository; import de.assecutor.pdftool.address.AddressRepository;
import de.assecutor.pdftool.address.AddressType; import de.assecutor.pdftool.address.AddressType;
import de.assecutor.pdftool.invoice.IbanValidator;
import de.assecutor.pdftool.invoice.VatIdValidator; import de.assecutor.pdftool.invoice.VatIdValidator;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -122,7 +123,7 @@ public class AddressBookView extends VerticalLayout {
Address target = address == null ? new Address() : address; Address target = address == null ? new Address() : address;
Dialog dialog = new Dialog(); Dialog dialog = new Dialog();
dialog.setHeaderTitle(isNew ? "Neue Adresse" : "Adresse bearbeiten"); dialog.setHeaderTitle(isNew ? "Neues Unternehmen" : "Unternehmen bearbeiten");
ComboBox<AddressType> type = new ComboBox<>("Typ"); ComboBox<AddressType> type = new ComboBox<>("Typ");
type.setItems(AddressType.values()); type.setItems(AddressType.values());
@@ -154,7 +155,17 @@ public class AddressBookView extends VerticalLayout {
TextField phone = new TextField("Telefon"); TextField phone = new TextField("Telefon");
phone.setValue(target.getPhone() != null ? target.getPhone() : ""); phone.setValue(target.getPhone() != null ? target.getPhone() : "");
FormLayout form = new FormLayout(type, name, street, zip, city, countryCode, vatId, email, phone); // Bankverbindung (optional)
TextField bankName = new TextField("Bank");
bankName.setValue(target.getBankName() != null ? target.getBankName() : "");
TextField iban = new TextField("IBAN");
iban.setValue(target.getIban() != null ? target.getIban() : "");
iban.setHelperText("z. B. DE02 1203 0000 0000 2020 51");
TextField bic = new TextField("BIC");
bic.setValue(target.getBic() != null ? target.getBic() : "");
FormLayout form = new FormLayout(type, name, street, zip, city, countryCode, vatId, email, phone,
bankName, iban, bic);
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("500px", 2)); form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("500px", 2));
form.setWidth("560px"); form.setWidth("560px");
dialog.add(form); dialog.add(form);
@@ -189,6 +200,17 @@ public class AddressBookView extends VerticalLayout {
email.setInvalid(true); email.setInvalid(true);
errors.add("E-Mail-Adresse ist ungültig."); errors.add("E-Mail-Adresse ist ungültig.");
} }
if (!iban.getValue().isBlank() && !IbanValidator.isValid(iban.getValue())) {
iban.setErrorMessage("IBAN ist ungültig (Prüfsumme).");
iban.setInvalid(true);
errors.add("IBAN ist ungültig (Prüfsumme).");
}
if (!bic.getValue().isBlank()
&& !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) {
bic.setErrorMessage("BIC ist ungültig (8 oder 11 Stellen).");
bic.setInvalid(true);
errors.add("BIC ist ungültig (8 oder 11 Stellen).");
}
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
Notification.show(String.join("\n", errors), 5000, Notification.Position.MIDDLE) Notification.show(String.join("\n", errors), 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR); .addThemeVariants(NotificationVariant.LUMO_ERROR);
@@ -204,6 +226,9 @@ public class AddressBookView extends VerticalLayout {
target.setVatId(vatId.getValue().isBlank() ? "" : VatIdValidator.normalize(vatId.getValue())); target.setVatId(vatId.getValue().isBlank() ? "" : VatIdValidator.normalize(vatId.getValue()));
target.setEmail(email.getValue().trim()); target.setEmail(email.getValue().trim());
target.setPhone(phone.getValue().trim()); target.setPhone(phone.getValue().trim());
target.setBankName(bankName.getValue().trim());
target.setIban(iban.getValue().isBlank() ? "" : IbanValidator.normalize(iban.getValue()));
target.setBic(bic.getValue().trim().toUpperCase());
target.touch(); target.touch();
repository.save(target); repository.save(target);
@@ -27,12 +27,11 @@ import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.StreamResource;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import de.assecutor.pdftool.address.Address; import de.assecutor.pdftool.address.Address;
import de.assecutor.pdftool.address.AddressRepository; import de.assecutor.pdftool.address.AddressRepository;
import de.assecutor.pdftool.address.AddressType; import de.assecutor.pdftool.address.AddressType;
import de.assecutor.pdftool.invoice.CapturedInvoiceData; import de.assecutor.pdftool.invoice.CapturedInvoiceData;
import de.assecutor.pdftool.invoice.IbanValidator;
import de.assecutor.pdftool.invoice.InvoiceDraft; import de.assecutor.pdftool.invoice.InvoiceDraft;
import de.assecutor.pdftool.invoice.InvoiceDraftService; import de.assecutor.pdftool.invoice.InvoiceDraftService;
import de.assecutor.pdftool.invoice.InvoiceMetadata; import de.assecutor.pdftool.invoice.InvoiceMetadata;
@@ -107,9 +106,6 @@ public class MainView extends VerticalLayout {
private final DatePicker dueDate = new DatePicker("Fälligkeitsdatum"); private final DatePicker dueDate = new DatePicker("Fälligkeitsdatum");
private final TextField currency = new TextField("Währung"); private final TextField currency = new TextField("Währung");
private final TextField paymentTerms = new TextField("Zahlungsbedingungen"); private final TextField paymentTerms = new TextField("Zahlungsbedingungen");
private final TextField buyerReference = new TextField("Käuferreferenz / Leitweg-ID");
private final TextField iban = new TextField("IBAN (Rechnungssteller)");
private final TextField bic = new TextField("BIC");
private final PartyForm sender = new PartyForm(true); private final PartyForm sender = new PartyForm(true);
private final PartyForm recipient = new PartyForm(false); private final PartyForm recipient = new PartyForm(false);
@@ -128,8 +124,7 @@ public class MainView extends VerticalLayout {
public MainView(ZugferdService zugferdService, CapturedInvoiceData capturedInvoiceData, public MainView(ZugferdService zugferdService, CapturedInvoiceData capturedInvoiceData,
InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService, InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService,
AddressRepository addressRepository, InvoiceDraftService invoiceDraftService, AddressRepository addressRepository, InvoiceDraftService invoiceDraftService) {
Environment environment) {
this.zugferdService = zugferdService; this.zugferdService = zugferdService;
this.capturedInvoiceData = capturedInvoiceData; this.capturedInvoiceData = capturedInvoiceData;
this.invoiceTemplateService = invoiceTemplateService; this.invoiceTemplateService = invoiceTemplateService;
@@ -209,10 +204,8 @@ public class MainView extends VerticalLayout {
currency.setPattern("[A-Za-z]{3}"); currency.setPattern("[A-Za-z]{3}");
currency.setErrorMessage("Währung als dreistelliger ISO-Code, z. B. EUR."); currency.setErrorMessage("Währung als dreistelliger ISO-Code, z. B. EUR.");
currency.setValue("EUR"); currency.setValue("EUR");
buyerReference.setHelperText("BT-10 — für Rechnungen an Behörden erforderlich");
iban.setHelperText("Für die Zahlungsangaben (BG-16) empfohlen");
FormLayout invoiceForm = new FormLayout(invoiceNumber, issueDate, deliveryDate, dueDate, currency, FormLayout invoiceForm = new FormLayout(invoiceNumber, issueDate, deliveryDate, dueDate, currency,
paymentTerms, buyerReference, iban, bic); paymentTerms);
invoiceForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("600px", 3)); invoiceForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("600px", 3));
add(new H3("2. Rechnungsdaten"), invoiceForm); add(new H3("2. Rechnungsdaten"), invoiceForm);
@@ -240,10 +233,6 @@ public class MainView extends VerticalLayout {
registerDraftDirtyTracking(); registerDraftDirtyTracking();
updateSaveDraftEnabled(); updateSaveDraftEnabled();
if (environment.acceptsProfiles(Profiles.of("dev"))) {
prefillDevData();
}
} }
@Override @Override
@@ -325,9 +314,9 @@ public class MainView extends VerticalLayout {
issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(), issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(),
currency.getValue().trim(), currency.getValue().trim(),
paymentTerms.getValue(), paymentTerms.getValue(),
buyerReference.getValue().trim(), "",
iban.getValue().trim(), sender.ibanNormalized(),
bic.getValue().trim(), sender.bicNormalized(),
sender.toParty(), recipient.toParty(), sender.toParty(), recipient.toParty(),
List.copyOf(lineItems), List.copyOf(lineItems),
LocalDateTime.now()); LocalDateTime.now());
@@ -358,10 +347,8 @@ public class MainView extends VerticalLayout {
dueDate.setValue(draft.dueDate()); dueDate.setValue(draft.dueDate());
currency.setValue(nullToEmpty(draft.currency())); currency.setValue(nullToEmpty(draft.currency()));
paymentTerms.setValue(nullToEmpty(draft.paymentTerms())); paymentTerms.setValue(nullToEmpty(draft.paymentTerms()));
buyerReference.setValue(nullToEmpty(draft.buyerReference()));
iban.setValue(nullToEmpty(draft.iban()));
bic.setValue(nullToEmpty(draft.bic()));
applyParty(sender, draft.sender()); applyParty(sender, draft.sender());
sender.prefillBank(nullToEmpty(draft.iban()), nullToEmpty(draft.bic()));
applyParty(recipient, draft.recipient()); applyParty(recipient, draft.recipient());
lineItems.clear(); lineItems.clear();
if (draft.items() != null) { if (draft.items() != null) {
@@ -382,7 +369,7 @@ public class MainView extends VerticalLayout {
form.prefill(nullToEmpty(party.name()), nullToEmpty(party.street()), form.prefill(nullToEmpty(party.name()), nullToEmpty(party.street()),
nullToEmpty(party.zip()), nullToEmpty(party.city()), nullToEmpty(party.zip()), nullToEmpty(party.city()),
nullToEmpty(party.countryCode()), nullToEmpty(party.vatId()), nullToEmpty(party.countryCode()), nullToEmpty(party.vatId()),
nullToEmpty(party.email()), nullToEmpty(party.phone())); nullToEmpty(party.email()));
} }
/** Konfiguriert eine Adressbuch-Auswahl, die bei Auswahl das zugehörige Adressformular vorbelegt. */ /** Konfiguriert eine Adressbuch-Auswahl, die bei Auswahl das zugehörige Adressformular vorbelegt. */
@@ -397,7 +384,8 @@ public class MainView extends VerticalLayout {
form.prefill(nullToEmpty(address.getName()), nullToEmpty(address.getStreet()), form.prefill(nullToEmpty(address.getName()), nullToEmpty(address.getStreet()),
nullToEmpty(address.getZip()), nullToEmpty(address.getCity()), nullToEmpty(address.getZip()), nullToEmpty(address.getCity()),
nullToEmpty(address.getCountryCode()), nullToEmpty(address.getVatId()), nullToEmpty(address.getCountryCode()), nullToEmpty(address.getVatId()),
nullToEmpty(address.getEmail()), nullToEmpty(address.getPhone())); nullToEmpty(address.getEmail()));
form.prefillBank(nullToEmpty(address.getIban()), nullToEmpty(address.getBic()));
} }
}); });
} }
@@ -415,22 +403,6 @@ public class MainView extends VerticalLayout {
return List.copyOf(lineItems); return List.copyOf(lineItems);
} }
/** Belegt das Formular im dev-Profil mit Testdaten vor, um manuelles Tippen zu sparen. */
private void prefillDevData() {
invoiceNumber.setValue("11111");
deliveryDate.setValue(LocalDate.now());
dueDate.setValue(LocalDate.now().plusDays(14));
paymentTerms.setValue("14 Tage");
buyerReference.setValue("11111");
iban.setValue("DE64 6001 0070 0379 0907 00");
bic.setValue("PBNKDEFFXXX");
// Rechnungssteller und -empfänger bleiben leer und werden über die
// Adressbuch-Auswahl befüllt.
lineItems.add(new InvoiceMetadata.LineItem("sdf", BigDecimal.ONE, new BigDecimal("100"),
new BigDecimal("19")));
refreshItemsGrid();
}
/** Tabelle der Rechnungspositionen mit Bearbeiten-/Löschen-Icons pro Zeile. */ /** Tabelle der Rechnungspositionen mit Bearbeiten-/Löschen-Icons pro Zeile. */
private void configureItemsGrid() { private void configureItemsGrid() {
itemsGrid.addColumn(item -> item.description()).setHeader("Beschreibung").setFlexGrow(1); itemsGrid.addColumn(item -> item.description()).setHeader("Beschreibung").setFlexGrow(1);
@@ -471,20 +443,28 @@ public class MainView extends VerticalLayout {
dueDate.addValueChangeListener(event -> updateSaveDraftEnabled()); dueDate.addValueChangeListener(event -> updateSaveDraftEnabled());
currency.addValueChangeListener(event -> updateSaveDraftEnabled()); currency.addValueChangeListener(event -> updateSaveDraftEnabled());
paymentTerms.addValueChangeListener(event -> updateSaveDraftEnabled()); paymentTerms.addValueChangeListener(event -> updateSaveDraftEnabled());
buyerReference.addValueChangeListener(event -> updateSaveDraftEnabled());
iban.addValueChangeListener(event -> updateSaveDraftEnabled());
bic.addValueChangeListener(event -> updateSaveDraftEnabled());
templateSelect.addValueChangeListener(event -> updateSaveDraftEnabled()); templateSelect.addValueChangeListener(event -> updateSaveDraftEnabled());
sender.onAnyChange(this::updateSaveDraftEnabled); sender.onAnyChange(this::updateSaveDraftEnabled);
recipient.onAnyChange(this::updateSaveDraftEnabled); recipient.onAnyChange(this::updateSaveDraftEnabled);
} }
/** /**
* Der Speichern-Button ist nur aktiv, wenn keine Vorbelegung geladen wurde * Der Speichern-Button ist nur aktiv, wenn alle Pflichtfelder gefüllt sind
* oder sich die Eingaben seit dem Laden bzw. Speichern geändert haben. * und keine Vorbelegung geladen wurde oder sich die Eingaben seit dem Laden
* bzw. Speichern geändert haben.
*/ */
private void updateSaveDraftEnabled() { private void updateSaveDraftEnabled() {
saveDraftButton.setEnabled(loadedDraftState == null || !comparableState().equals(loadedDraftState)); boolean changed = loadedDraftState == null || !comparableState().equals(loadedDraftState);
saveDraftButton.setEnabled(changed && requiredFieldsFilled());
}
/** @return true, wenn alle Pflichtfelder der Maske gefüllt sind. */
private boolean requiredFieldsFilled() {
return !invoiceNumber.getValue().isBlank()
&& issueDate.getValue() != null
&& !currency.getValue().isBlank()
&& sender.requiredFieldsFilled()
&& recipient.requiredFieldsFilled();
} }
/** Der aktuelle Formularstand ohne Name und Zeitstempel, zum Vergleich mit der geladenen Vorbelegung. */ /** Der aktuelle Formularstand ohne Name und Zeitstempel, zum Vergleich mit der geladenen Vorbelegung. */
@@ -495,9 +475,9 @@ public class MainView extends VerticalLayout {
issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(), issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(),
currency.getValue().trim(), currency.getValue().trim(),
paymentTerms.getValue(), paymentTerms.getValue(),
buyerReference.getValue().trim(), "",
iban.getValue().trim(), sender.ibanNormalized(),
bic.getValue().trim(), sender.bicNormalized(),
sender.toParty(), recipient.toParty(), sender.toParty(), recipient.toParty(),
List.copyOf(lineItems), List.copyOf(lineItems),
null); null);
@@ -694,16 +674,6 @@ public class MainView extends VerticalLayout {
&& !currency.getValue().trim().matches("[A-Za-z]{3}")) { && !currency.getValue().trim().matches("[A-Za-z]{3}")) {
markInvalid(currency, "Währung als dreistelliger ISO-Code, z. B. EUR.", errors); markInvalid(currency, "Währung als dreistelliger ISO-Code, z. B. EUR.", errors);
} }
if (!iban.isEmpty() && !isValidIban(iban.getValue())) {
markInvalid(iban, "IBAN ist ungültig (Prüfsumme).", errors);
} else {
iban.setInvalid(false);
}
if (!bic.isEmpty() && !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) {
markInvalid(bic, "BIC muss 8 oder 11 Stellen haben, z. B. NOLADE21RZB.", errors);
} else {
bic.setInvalid(false);
}
errors.addAll(sender.validateFields("Rechnungssteller")); errors.addAll(sender.validateFields("Rechnungssteller"));
errors.addAll(recipient.validateFields("Rechnungsempfänger")); errors.addAll(recipient.validateFields("Rechnungsempfänger"));
if (lineItems.isEmpty()) { if (lineItems.isEmpty()) {
@@ -720,9 +690,9 @@ public class MainView extends VerticalLayout {
dueDate.getValue(), dueDate.getValue(),
currency.getValue().trim().toUpperCase(), currency.getValue().trim().toUpperCase(),
paymentTerms.getValue(), paymentTerms.getValue(),
buyerReference.getValue().trim(), "",
iban.getValue().replaceAll("\\s", "").toUpperCase(), sender.ibanNormalized(),
bic.getValue().trim().toUpperCase(), sender.bicNormalized(),
sender.toParty(), sender.toParty(),
recipient.toParty(), recipient.toParty(),
lineItems lineItems
@@ -760,21 +730,6 @@ public class MainView extends VerticalLayout {
errors.add(message); errors.add(message);
} }
/** IBAN-Prüfung nach ISO 13616 (MOD 97-10). */
private static boolean isValidIban(String value) {
String normalized = value.replaceAll("\\s", "").toUpperCase();
if (!normalized.matches("[A-Z]{2}\\d{2}[A-Z0-9]{11,30}")) {
return false;
}
String rearranged = normalized.substring(4) + normalized.substring(0, 4);
int mod = 0;
for (char c : rearranged.toCharArray()) {
int digit = Character.isDigit(c) ? c - '0' : c - 'A' + 10;
mod = digit < 10 ? (mod * 10 + digit) % 97 : (mod * 100 + digit) % 97;
}
return mod == 1;
}
private void showErrors(List<String> messages) { private void showErrors(List<String> messages) {
Notification notification = new Notification(); Notification notification = new Notification();
notification.addThemeVariants(NotificationVariant.LUMO_ERROR); notification.addThemeVariants(NotificationVariant.LUMO_ERROR);
@@ -802,7 +757,10 @@ public class MainView extends VerticalLayout {
private final TextField countryCode = new TextField("Land (ISO-Code, z.B. DE)"); private final TextField countryCode = new TextField("Land (ISO-Code, z.B. DE)");
private final TextField vatId = new TextField("USt-IdNr."); private final TextField vatId = new TextField("USt-IdNr.");
private final EmailField email = new EmailField("E-Mail"); private final EmailField email = new EmailField("E-Mail");
private final TextField phone = new TextField("Telefon");
// Bankverbindung; nur beim Rechnungssteller sichtbar und Pflicht
private final TextField iban = new TextField("IBAN");
private final TextField bic = new TextField("BIC");
PartyForm(boolean vatRequired) { PartyForm(boolean vatRequired) {
this.vatRequired = vatRequired; this.vatRequired = vatRequired;
@@ -824,15 +782,35 @@ public class MainView extends VerticalLayout {
email.setErrorMessage("Gültige E-Mail-Adresse angeben."); email.setErrorMessage("Gültige E-Mail-Adresse angeben.");
// E-Mail direkt beim Verlassen des Feldes prüfen // E-Mail direkt beim Verlassen des Feldes prüfen
email.addValueChangeListener(event -> checkEmail()); email.addValueChangeListener(event -> checkEmail());
add(name, street, zip, city, countryCode, vatId, email);
if (vatRequired) { if (vatRequired) {
phone.setHelperText("Für den Verkäufer-Kontakt (BR-DE-6) empfohlen"); iban.setRequired(true);
iban.setHelperText("Für die Zahlungsangaben (BG-16)");
bic.setRequired(true);
bic.setHelperText("8 oder 11 Stellen, z. B. NOLADE21RZB");
add(iban, bic);
} }
add(name, street, zip, city, countryCode, vatId, email, phone);
setResponsiveSteps(new ResponsiveStep("0", 1), new ResponsiveStep("600px", 3)); setResponsiveSteps(new ResponsiveStep("0", 1), new ResponsiveStep("600px", 3));
} }
/** Belegt die Bankverbindung vor, z. B. aus dem Adressbuch. */
void prefillBank(String ibanValue, String bicValue) {
iban.setValue(ibanValue);
bic.setValue(bicValue);
}
/** IBAN ohne Leerzeichen in Großschreibung. */
String ibanNormalized() {
return iban.getValue().replaceAll("\\s", "").toUpperCase();
}
/** BIC ohne führende/abschließende Leerzeichen in Großschreibung. */
String bicNormalized() {
return bic.getValue().trim().toUpperCase();
}
void prefill(String nameValue, String streetValue, String zipValue, String cityValue, void prefill(String nameValue, String streetValue, String zipValue, String cityValue,
String countryValue, String vatIdValue, String emailValue, String phoneValue) { String countryValue, String vatIdValue, String emailValue) {
name.setValue(nameValue); name.setValue(nameValue);
street.setValue(streetValue); street.setValue(streetValue);
zip.setValue(zipValue); zip.setValue(zipValue);
@@ -840,7 +818,18 @@ public class MainView extends VerticalLayout {
countryCode.setValue(countryValue); countryCode.setValue(countryValue);
vatId.setValue(vatIdValue); vatId.setValue(vatIdValue);
email.setValue(emailValue); email.setValue(emailValue);
phone.setValue(phoneValue); }
/** @return true, wenn alle Pflichtfelder des Adressformulars gefüllt sind. */
boolean requiredFieldsFilled() {
return !name.getValue().isBlank()
&& !street.getValue().isBlank()
&& !zip.getValue().isBlank()
&& !city.getValue().isBlank()
&& !countryCode.getValue().isBlank()
&& (!vatRequired || !vatId.getValue().isBlank())
&& !email.getValue().isBlank()
&& (!vatRequired || (!iban.getValue().isBlank() && !bic.getValue().isBlank()));
} }
/** Meldet jede Wertänderung eines Feldes, z. B. für die Dirty-Erkennung. */ /** Meldet jede Wertänderung eines Feldes, z. B. für die Dirty-Erkennung. */
@@ -852,12 +841,14 @@ public class MainView extends VerticalLayout {
countryCode.addValueChangeListener(event -> listener.run()); countryCode.addValueChangeListener(event -> listener.run());
vatId.addValueChangeListener(event -> listener.run()); vatId.addValueChangeListener(event -> listener.run());
email.addValueChangeListener(event -> listener.run()); email.addValueChangeListener(event -> listener.run());
phone.addValueChangeListener(event -> listener.run()); iban.addValueChangeListener(event -> listener.run());
bic.addValueChangeListener(event -> listener.run());
} }
/** Leert alle Felder; das Land behält den Standardwert "DE". */ /** Leert alle Felder; das Land behält den Standardwert "DE". */
void clearFields() { void clearFields() {
prefill("", "", "", "", "DE", "", "", ""); prefill("", "", "", "", "DE", "", "");
prefillBank("", "");
name.setInvalid(false); name.setInvalid(false);
street.setInvalid(false); street.setInvalid(false);
zip.setInvalid(false); zip.setInvalid(false);
@@ -865,6 +856,8 @@ public class MainView extends VerticalLayout {
countryCode.setInvalid(false); countryCode.setInvalid(false);
vatId.setInvalid(false); vatId.setInvalid(false);
email.setInvalid(false); email.setInvalid(false);
iban.setInvalid(false);
bic.setInvalid(false);
} }
/** @return true, wenn die E-Mail-Adresse gültig (oder leer) ist. */ /** @return true, wenn die E-Mail-Adresse gültig (oder leer) ist. */
@@ -915,6 +908,16 @@ public class MainView extends VerticalLayout {
} else if (!checkEmail()) { } else if (!checkEmail()) {
errors.add(partyLabel + ": E-Mail-Adresse ist ungültig."); errors.add(partyLabel + ": E-Mail-Adresse ist ungültig.");
} }
if (vatRequired) {
if (requireFilled(iban, partyLabel + ": IBAN ist ein Pflichtfeld.", errors)
&& !IbanValidator.isValid(iban.getValue())) {
markInvalid(iban, partyLabel + ": IBAN ist ungültig (Prüfsumme).", errors);
}
if (requireFilled(bic, partyLabel + ": BIC ist ein Pflichtfeld.", errors)
&& !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) {
markInvalid(bic, partyLabel + ": BIC muss 8 oder 11 Stellen haben, z. B. NOLADE21RZB.", errors);
}
}
return errors; return errors;
} }
@@ -927,7 +930,7 @@ public class MainView extends VerticalLayout {
countryCode.getValue().trim().toUpperCase(), countryCode.getValue().trim().toUpperCase(),
vatId.isEmpty() ? "" : VatIdValidator.normalize(vatId.getValue()), vatId.isEmpty() ? "" : VatIdValidator.normalize(vatId.getValue()),
email.getValue().trim(), email.getValue().trim(),
phone.getValue().trim() ""
); );
} }
} }
@@ -0,0 +1,37 @@
package de.assecutor.pdftool.ui;
import com.vaadin.flow.server.CustomizedSystemMessages;
import com.vaadin.flow.server.ServiceInitEvent;
import com.vaadin.flow.server.VaadinServiceInitListener;
import org.springframework.stereotype.Component;
/**
* Verhindert das stille Neuladen der Seite durch Vaadin: Standardmäßig lädt
* Vaadin bei abgelaufener Session (z. B. nach einem Neustart der Anwendung)
* oder internen Fehlern die Seite sofort und ohne Hinweis neu — mitten in der
* Arbeit im Rechnungsgenerator. Stattdessen wird jetzt eine Meldung angezeigt;
* die Seite lädt erst neu, wenn der Benutzer die Meldung bestätigt.
*/
@Component
public class SystemMessagesConfiguration implements VaadinServiceInitListener {
@Override
public void serviceInit(ServiceInitEvent event) {
event.getSource().setSystemMessagesProvider(info -> {
CustomizedSystemMessages messages = new CustomizedSystemMessages();
messages.setSessionExpiredNotificationEnabled(true);
messages.setSessionExpiredCaption("Sitzung abgelaufen");
messages.setSessionExpiredMessage("Die Sitzung ist abgelaufen, z. B. durch einen Neustart der "
+ "Anwendung. Zum Fortfahren klicken — nicht gespeicherte Änderungen im "
+ "Rechnungsgenerator werden wiederhergestellt.");
messages.setInternalErrorNotificationEnabled(true);
messages.setInternalErrorCaption("Interner Fehler");
messages.setInternalErrorMessage("Es ist ein interner Fehler aufgetreten. "
+ "Zum Fortfahren klicken; die Seite wird danach neu geladen.");
return messages;
});
}
}
@@ -19,6 +19,7 @@ import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.upload.Upload; import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer; import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
@@ -38,6 +39,7 @@ import java.time.format.DateTimeFormatter;
import java.util.Base64; import java.util.Base64;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -58,6 +60,13 @@ public class TemplateGeneratorView extends VerticalLayout {
private static final BigDecimal VAT_RATE = new BigDecimal("0.19"); private static final BigDecimal VAT_RATE = new BigDecimal("0.19");
// Canvas-Basisgröße (px) und A4-Maße (mm) zur Umrechnung der
// Positionsangaben in der Eigenschaften-Sidebar
private static final double BASE_PAGE_WIDTH_PX = 595;
private static final double BASE_PAGE_HEIGHT_PX = 842;
private static final double A4_WIDTH_MM = 210;
private static final double A4_HEIGHT_MM = 297;
private final TemplatePdfService templatePdfService; private final TemplatePdfService templatePdfService;
private final InvoiceTemplateService invoiceTemplateService; private final InvoiceTemplateService invoiceTemplateService;
private final CapturedInvoiceData capturedInvoiceData; private final CapturedInvoiceData capturedInvoiceData;
@@ -70,6 +79,15 @@ public class TemplateGeneratorView extends VerticalLayout {
/** Name des zuletzt geladenen bzw. gespeicherten Templates. */ /** Name des zuletzt geladenen bzw. gespeicherten Templates. */
private String currentTemplateName; private String currentTemplateName;
/**
* Hintergrundbild der Zeichenfläche als Data-URL. Es wird serverseitig
* gehalten und erst beim Speichern bzw. für die Vorschau ins Template-JSON
* übernommen — die große Bild-Payload würde sonst bei jedem
* getProfileCanvasData-Aufruf das Limit für Client-zu-Server-Übertragungen
* sprengen.
*/
private String backgroundImage;
public TemplateGeneratorView(TemplatePdfService templatePdfService, public TemplateGeneratorView(TemplatePdfService templatePdfService,
InvoiceTemplateService invoiceTemplateService, CapturedInvoiceData capturedInvoiceData) { InvoiceTemplateService invoiceTemplateService, CapturedInvoiceData capturedInvoiceData) {
this.templatePdfService = templatePdfService; this.templatePdfService = templatePdfService;
@@ -141,18 +159,67 @@ public class TemplateGeneratorView extends VerticalLayout {
loadInitialTemplate(); loadInitialTemplate();
} }
/** Lädt beim Start das Standard-Template bzw. das erste vorhandene Template. */ /**
* Beim Öffnen der Seite wird kein Template geladen; die Zeichenfläche
* bleibt leer, bis der Benutzer ein Template in der Combobox auswählt.
* Nur ein automatisch gesicherter Arbeitsstand (z. B. nach einem
* Seiten-Reload durch Session-Ablauf) wird wiederhergestellt.
*/
private void loadInitialTemplate() { private void loadInitialTemplate() {
List<String> names = invoiceTemplateService.templateNames(); List<String> names = invoiceTemplateService.templateNames();
templateSelect.setItems(names); templateSelect.setItems(names);
if (names.isEmpty()) {
return; Optional<String> autosave = invoiceTemplateService.loadAutosave();
if (autosave.isPresent()) {
restoreAutosave(autosave.get(), names);
}
}
/** Stellt den automatisch gesicherten Arbeitsstand auf der Zeichenfläche wieder her. */
private void restoreAutosave(String autosaveData, List<String> names) {
String templateName = readJsonTextField(autosaveData, "autosaveOf");
if (templateName != null && names.contains(templateName)) {
currentTemplateName = templateName;
templateSelect.setValue(templateName);
}
backgroundImage = readBackgroundImage(autosaveData);
getElement().executeJs("setTimeout(function() { "
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
+ " window.loadProfileTemplate(JSON.parse($0)); "
+ " } else { console.error('loadProfileTemplate or canvas not available'); } "
+ "}, 300);", autosaveData);
showNotification("Nicht gespeicherte Änderungen wurden wiederhergestellt.");
}
/**
* Sichert den Canvas-Stand als Autosave (Aufruf aus dem JavaScript,
* zeitversetzt nach jeder Änderung). Das Hintergrundbild und der Name des
* zugrunde liegenden Templates werden serverseitig ergänzt.
*/
@ClientCallable
public void autosaveCanvas(String templateData) {
try {
ObjectMapper mapper = new ObjectMapper();
com.fasterxml.jackson.databind.node.ObjectNode root =
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(withBackgroundImage(templateData));
if (currentTemplateName != null && !currentTemplateName.isBlank()) {
root.put("autosaveOf", currentTemplateName);
}
invoiceTemplateService.saveAutosave(mapper.writeValueAsString(root));
} catch (Exception ex) {
log.warn("Autosave des Canvas fehlgeschlagen: {}", ex.getMessage());
}
}
/** Liest ein Textfeld aus dem Template-JSON; {@code null}, wenn nicht vorhanden. */
private static String readJsonTextField(String templateData, String fieldName) {
try {
com.fasterxml.jackson.databind.JsonNode node =
new ObjectMapper().readTree(templateData).get(fieldName);
return node == null || node.isNull() || node.asText().isEmpty() ? null : node.asText();
} catch (Exception ex) {
return null;
} }
String initial = names.contains(InvoiceTemplateService.DEFAULT_TEMPLATE_NAME)
? InvoiceTemplateService.DEFAULT_TEMPLATE_NAME
: names.get(0);
templateSelect.setValue(initial);
loadTemplateIntoCanvas(initial);
} }
/** Lädt das Template mit dem Namen auf die Zeichenfläche. */ /** Lädt das Template mit dem Namen auf die Zeichenfläche. */
@@ -164,6 +231,7 @@ public class TemplateGeneratorView extends VerticalLayout {
return; return;
} }
currentTemplateName = name; currentTemplateName = name;
backgroundImage = readBackgroundImage(templateData.get());
getElement().executeJs("setTimeout(function() { " getElement().executeJs("setTimeout(function() { "
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { " + " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
+ " window.loadProfileTemplate(JSON.parse($0)); " + " window.loadProfileTemplate(JSON.parse($0)); "
@@ -234,6 +302,32 @@ public class TemplateGeneratorView extends VerticalLayout {
return TemplateVariables.canvasPositionsJson(effectiveItems()); return TemplateVariables.canvasPositionsJson(effectiveItems());
} }
/** Liest das Hintergrundbild aus dem Template-JSON; {@code null}, wenn keines gesetzt ist. */
private static String readBackgroundImage(String templateData) {
return readJsonTextField(templateData, "backgroundImage");
}
/**
* Ergänzt das serverseitig gehaltene Hintergrundbild im Template-JSON aus
* der Zeichenfläche (das JavaScript liefert es aus Größengründen nicht mit).
*/
private String withBackgroundImage(String templateData) {
try {
ObjectMapper mapper = new ObjectMapper();
com.fasterxml.jackson.databind.node.ObjectNode root =
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(templateData);
if (backgroundImage != null && !backgroundImage.isEmpty()) {
root.put("backgroundImage", backgroundImage);
} else {
root.remove("backgroundImage");
}
return mapper.writeValueAsString(root);
} catch (Exception ex) {
log.warn("Hintergrundbild konnte nicht ins Template übernommen werden: {}", ex.getMessage());
return templateData;
}
}
private String buildMasterdataJson() { private String buildMasterdataJson() {
try { try {
return new ObjectMapper().writeValueAsString(buildVariables()); return new ObjectMapper().writeValueAsString(buildVariables());
@@ -518,9 +612,13 @@ public class TemplateGeneratorView extends VerticalLayout {
return info; return info;
} }
/** Die im Designer und im PDF-Renderer verfügbaren Schriftarten. */
private static final List<String> FONT_FAMILIES = List.of("Arial", "Times New Roman", "Courier New", "Verdana");
@ClientCallable @ClientCallable
public void updatePropertiesPanel(String elementId, String elementType, String text, Double x, Double y, public void updatePropertiesPanel(String elementId, String elementType, String text, Double x, Double y,
Integer fontSize, String color, Double width, Double height, Boolean isStatic, String variable) { Integer fontSize, String color, Double width, Double height, Boolean isStatic, String variable,
String fontFamily) {
getUI().ifPresent(ui -> ui.access(() -> { getUI().ifPresent(ui -> ui.access(() -> {
propertiesPanel.removeAll(); propertiesPanel.removeAll();
@@ -573,11 +671,13 @@ public class TemplateGeneratorView extends VerticalLayout {
propertiesPanel.add(upload); propertiesPanel.add(upload);
} }
// Textfeld (nur für Text-Elemente) // Textfeld (nur für Text-Elemente); mehrzeilig, Umbrüche werden
// als Zeilenumbrüche in das Element übernommen
if (!"line".equals(elementType) && !"image".equals(elementType)) { if (!"line".equals(elementType) && !"image".equals(elementType)) {
TextField textField = new TextField("Text"); TextArea textField = new TextArea("Text");
textField.setValue(text != null ? text : ""); textField.setValue(text != null ? text : "");
textField.setWidthFull(); textField.setWidthFull();
textField.setMinHeight("6em");
if (Boolean.TRUE.equals(isStatic)) { if (Boolean.TRUE.equals(isStatic)) {
textField.setReadOnly(true); textField.setReadOnly(true);
textField.setHelperText("Wert wird aus den Stammdaten befüllt"); textField.setHelperText("Wert wird aus den Stammdaten befüllt");
@@ -589,40 +689,90 @@ public class TemplateGeneratorView extends VerticalLayout {
propertiesPanel.add(textField); propertiesPanel.add(textField);
} }
// X Position // X Position (Anzeige in mm, Canvas rechnet intern in px)
TextField xField = new TextField("X Position"); TextField xField = new TextField("X Position (mm)");
xField.setValue(x != null ? String.valueOf(Math.round(x)) : "0"); xField.setValue(formatMm(x != null ? pxToMm(x, BASE_PAGE_WIDTH_PX, A4_WIDTH_MM) : 0));
xField.setWidthFull(); xField.setWidthFull();
xField.addValueChangeListener(e -> { xField.addValueChangeListener(e -> {
try { try {
double newX = Double.parseDouble(e.getValue()); double newXPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_WIDTH_PX, A4_WIDTH_MM);
getElement().executeJs( getElement().executeJs(
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('" "if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
+ elementId + "', $0, null); }", + elementId + "', $0, null); }",
newX); newXPx);
} catch (NumberFormatException ignored) { } catch (NumberFormatException ignored) {
} }
}); });
propertiesPanel.add(xField); propertiesPanel.add(xField);
// Y Position // Y Position (Anzeige in mm, Canvas rechnet intern in px)
TextField yField = new TextField("Y Position"); TextField yField = new TextField("Y Position (mm)");
yField.setValue(y != null ? String.valueOf(Math.round(y)) : "0"); yField.setValue(formatMm(y != null ? pxToMm(y, BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM) : 0));
yField.setWidthFull(); yField.setWidthFull();
yField.addValueChangeListener(e -> { yField.addValueChangeListener(e -> {
try { try {
double newY = Double.parseDouble(e.getValue()); double newYPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM);
getElement().executeJs( getElement().executeJs(
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('" "if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
+ elementId + "', null, $0); }", + elementId + "', null, $0); }",
newY); newYPx);
} catch (NumberFormatException ignored) { } catch (NumberFormatException ignored) {
} }
}); });
propertiesPanel.add(yField); propertiesPanel.add(yField);
// Schriftgröße und Farbe (nur für Text-Elemente) // Breite (Anzeige in mm); bei Linien bestimmt sie die Länge
TextField widthField = new TextField("Breite (mm)");
widthField.setValue(formatMm(width != null ? pxToMm(width, BASE_PAGE_WIDTH_PX, A4_WIDTH_MM) : 0));
widthField.setWidthFull();
widthField.addValueChangeListener(e -> {
try {
double newWidthPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_WIDTH_PX, A4_WIDTH_MM);
getElement().executeJs(
"if (window.updateProfileElementSize) { window.updateProfileElementSize('"
+ elementId + "', $0, null); }",
newWidthPx);
} catch (NumberFormatException ignored) {
}
});
propertiesPanel.add(widthField);
// Höhe (Anzeige in mm); für Linien ohne Bedeutung
if (!"line".equals(elementType)) {
TextField heightField = new TextField("Höhe (mm)");
heightField.setValue(formatMm(height != null ? pxToMm(height, BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM) : 0));
heightField.setWidthFull();
heightField.addValueChangeListener(e -> {
try {
double newHeightPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM);
getElement().executeJs(
"if (window.updateProfileElementSize) { window.updateProfileElementSize('"
+ elementId + "', null, $0); }",
newHeightPx);
} catch (NumberFormatException ignored) {
}
});
propertiesPanel.add(heightField);
}
// Schriftart, Schriftgröße und Farbe (nur für Text-Elemente)
if (!"line".equals(elementType) && !"image".equals(elementType)) { if (!"line".equals(elementType) && !"image".equals(elementType)) {
ComboBox<String> fontFamilySelect = new ComboBox<>("Schriftart");
fontFamilySelect.setItems(FONT_FAMILIES);
fontFamilySelect.setValue(fontFamily != null && FONT_FAMILIES.contains(fontFamily)
? fontFamily
: "Arial");
fontFamilySelect.setWidthFull();
fontFamilySelect.addValueChangeListener(e -> {
if (e.getValue() != null) {
getElement().executeJs(
"if (window.updateProfileElementFontFamily) { window.updateProfileElementFontFamily('"
+ elementId + "', $0); }",
e.getValue());
}
});
propertiesPanel.add(fontFamilySelect);
TextField fontSizeField = new TextField("Schriftgröße"); TextField fontSizeField = new TextField("Schriftgröße");
fontSizeField.setValue(fontSize != null ? String.valueOf(fontSize) : "16"); fontSizeField.setValue(fontSize != null ? String.valueOf(fontSize) : "16");
fontSizeField.setWidthFull(); fontSizeField.setWidthFull();
@@ -692,6 +842,73 @@ public class TemplateGeneratorView extends VerticalLayout {
})); }));
} }
/**
* Zeigt die Einstellungen der Zeichenfläche in der Sidebar an (Aufruf aus
* dem JavaScript bei Klick auf die leere Zeichenfläche). Einzige
* Einstellung ist derzeit das Hintergrundbild: Es wird auf Seitengröße
* skaliert und liegt immer hinter allen Elementen.
*/
@ClientCallable
public void showCanvasProperties(Boolean hasBackground) {
getUI().ifPresent(ui -> ui.access(() -> {
propertiesPanel.removeAll();
Span typeLabel = new Span("Zeichenfläche");
typeLabel.addClassName("invoice-generator-info");
propertiesPanel.add(propertiesHeader(), typeLabel);
Span backgroundLabel = new Span("Hintergrundbild");
backgroundLabel.getStyle().set("font-weight", "bold")
.set("font-size", "var(--lumo-font-size-s)");
propertiesPanel.add(backgroundLabel);
MemoryBuffer buffer = new MemoryBuffer();
Upload upload = new Upload(buffer);
upload.setAcceptedFileTypes("image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp");
upload.setMaxFileSize(5 * 1024 * 1024); // 5 MB
upload.setDropLabel(new Span("Bild hierher ziehen oder klicken"));
upload.setWidthFull();
upload.addSucceededListener(event -> {
try {
byte[] bytes = buffer.getInputStream().readAllBytes();
String dataUrl = "data:" + event.getMIMEType() + ";base64,"
+ Base64.getEncoder().encodeToString(bytes);
backgroundImage = dataUrl;
getElement().executeJs(
"if (window.updateProfileCanvasBackground) { window.updateProfileCanvasBackground($0); }",
dataUrl);
showNotification("Hintergrundbild übernommen.");
showCanvasProperties(true);
} catch (Exception ex) {
showNotification("Bild konnte nicht geladen werden: " + ex.getMessage());
}
});
upload.addFileRejectedListener(event ->
showNotification("Datei abgelehnt: " + event.getErrorMessage()));
propertiesPanel.add(upload);
Div info = new Div();
info.setText("Das Bild wird auf die Seitengröße skaliert und hinter allen Elementen angezeigt.");
info.addClassName("invoice-generator-info");
propertiesPanel.add(info);
if (Boolean.TRUE.equals(hasBackground)) {
Button removeBackground = new Button("Hintergrundbild entfernen", new Icon(VaadinIcon.TRASH));
removeBackground.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
removeBackground.setWidthFull();
removeBackground.addClickListener(e -> {
backgroundImage = null;
getElement().executeJs(
"if (window.updateProfileCanvasBackground) { window.updateProfileCanvasBackground(null); }");
showNotification("Hintergrundbild entfernt.");
showCanvasProperties(false);
});
propertiesPanel.add(removeBackground);
}
}));
}
@ClientCallable @ClientCallable
public void resetPropertiesPanel() { public void resetPropertiesPanel() {
getUI().ifPresent(ui -> ui.access(() -> { getUI().ifPresent(ui -> ui.access(() -> {
@@ -703,6 +920,26 @@ public class TemplateGeneratorView extends VerticalLayout {
})); }));
} }
/** Rechnet eine Canvas-Position (px) in mm auf der A4-Seite um. */
private static double pxToMm(double px, double basePx, double sizeMm) {
return px / basePx * sizeMm;
}
/** Rechnet eine mm-Angabe in die Canvas-Position (px) um. */
private static double mmToPx(double mm, double basePx, double sizeMm) {
return mm / sizeMm * basePx;
}
/** Formatiert eine mm-Angabe mit einer Nachkommastelle, z. B. "20,5". */
private static String formatMm(double mm) {
return String.format(Locale.GERMANY, "%.1f", mm);
}
/** Liest eine mm-Angabe; Komma und Punkt sind als Dezimaltrenner erlaubt. */
private static double parseMm(String value) {
return Double.parseDouble(value.trim().replace(',', '.'));
}
private String getVariableDescription(String variable) { private String getVariableDescription(String variable) {
return switch (variable) { return switch (variable) {
case "masterdata.company_name" -> "Firmenname des Rechnungsstellers"; case "masterdata.company_name" -> "Firmenname des Rechnungsstellers";
@@ -778,7 +1015,7 @@ public class TemplateGeneratorView extends VerticalLayout {
showNotification("Zeichenfläche ist nicht bereit."); showNotification("Zeichenfläche ist nicht bereit.");
return; return;
} }
openSaveDialog(templateData); openSaveDialog(withBackgroundImage(templateData));
}); });
} }
@@ -831,6 +1068,8 @@ public class TemplateGeneratorView extends VerticalLayout {
private void doSaveTemplate(String name, String templateData) { private void doSaveTemplate(String name, String templateData) {
try { try {
invoiceTemplateService.saveTemplate(name, templateData); invoiceTemplateService.saveTemplate(name, templateData);
// Explizit gespeichert: der Autosave-Arbeitsstand ist damit erledigt.
invoiceTemplateService.clearAutosave();
currentTemplateName = name; currentTemplateName = name;
templateSelect.setItems(invoiceTemplateService.templateNames()); templateSelect.setItems(invoiceTemplateService.templateNames());
templateSelect.setValue(name); templateSelect.setValue(name);
@@ -849,7 +1088,8 @@ public class TemplateGeneratorView extends VerticalLayout {
return; return;
} }
try { try {
byte[] pdfBytes = templatePdfService.generatePdf(templateData, buildVariables(), VAT_RATE); byte[] pdfBytes = templatePdfService.generatePdf(withBackgroundImage(templateData),
buildVariables(), VAT_RATE);
showPdfInDialog(pdfBytes); showPdfInDialog(pdfBytes);
} catch (Exception ex) { } catch (Exception ex) {
showNotification("Vorschau konnte nicht erzeugt werden: " + ex.getMessage()); showNotification("Vorschau konnte nicht erzeugt werden: " + ex.getMessage());
+17
View File
@@ -1,12 +1,29 @@
spring.application.name=pdf-tool spring.application.name=pdf-tool
server.port=${SERVER_PORT:8083} server.port=${SERVER_PORT:8083}
# Session erst nach 12 h Inaktivität verwerfen: Der Standard von 30 Minuten
# führte dazu, dass Vaadin nach längerer Untätigkeit (Tab inaktiv, Standby)
# die Seite neu lädt und ungespeicherte Canvas-Änderungen verloren gehen.
server.servlet.session.timeout=12h
# Eigener Name für den Session-Cookie: Der Standardname JSESSIONID gilt pro
# Hostname (nicht pro Port). Andere lokal laufende Java-Anwendungen auf
# demselben Rechner überschreiben sich sonst gegenseitig den Cookie, und die
# Session des PDF-Tools ist scheinbar sofort "abgelaufen".
server.servlet.session.cookie.name=PDFTOOL_SESSION
# Wird beim Build durch die Version aus der pom.xml ersetzt (Resource-Filtering) # Wird beim Build durch die Version aus der pom.xml ersetzt (Resource-Filtering)
pdftool.version=@project.version@ pdftool.version=@project.version@
spring.servlet.multipart.max-file-size=25MB spring.servlet.multipart.max-file-size=25MB
spring.servlet.multipart.max-request-size=30MB spring.servlet.multipart.max-request-size=30MB
# Auch normale (Nicht-Multipart-)Requests bis 30 MB zulassen: Canvas-Daten mit
# eingebetteten Bild-Elementen können das Tomcat-Standardlimit von 2 MB
# überschreiten; ein abgelehnter Request führt im Browser sonst zu einem
# Vaadin-Kommunikationsfehler mit anschließendem Seiten-Reload.
server.tomcat.max-http-form-post-size=30MB
vaadin.launch-browser=false vaadin.launch-browser=false
# Zugangsdaten aus der .env-Datei im Projektverzeichnis laden (falls vorhanden) # Zugangsdaten aus der .env-Datei im Projektverzeichnis laden (falls vorhanden)