Compare commits
4
Commits
722699f5d0
...
5bcaf97eba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bcaf97eba | ||
|
|
4d16455d17 | ||
|
|
9891a58fad | ||
|
|
48a5831600 |
@@ -7,6 +7,14 @@ ENV TZ=Europe/Berlin
|
||||
ENV LC_TIME=de_DE.UTF-8
|
||||
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
|
||||
EXPOSE 8083
|
||||
ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=production"]
|
||||
|
||||
Binary file not shown.
@@ -73,7 +73,6 @@ window.initProfileInvoiceGenerator = function() {
|
||||
var isDragging = false;
|
||||
var dragStart = { x: 0, y: 0 };
|
||||
var elementStart = { x: 0, y: 0 };
|
||||
var gridSize = 5;
|
||||
|
||||
// Image cache to prevent flickering during resize
|
||||
var imageCache = {};
|
||||
@@ -102,8 +101,43 @@ window.initProfileInvoiceGenerator = function() {
|
||||
pageY = (h - pageHeight) / 2;
|
||||
}
|
||||
|
||||
// Kein Raster mehr: Positionen werden pixelgenau übernommen
|
||||
function snapToGrid(value) {
|
||||
return Math.round(value / gridSize) * gridSize;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Freie Textelemente bleiben beim Verändern der Größe zentriert: Der Text
|
||||
// wird mittig im Rahmen dargestellt (Canvas und PDF nutzen dieselbe
|
||||
// textAlign-Eigenschaft, die Darstellung bleibt also identisch).
|
||||
function keepTextCentered(el) {
|
||||
if (el.type !== 'line' && el.type !== 'image' && !el.isStatic) {
|
||||
el.textAlign = 'center';
|
||||
}
|
||||
}
|
||||
|
||||
// Mindestgröße eines Textelements (Basis-px): der dargestellte Text darf
|
||||
// beim Verkleinern nicht abgeschnitten werden. Für andere Elementtypen
|
||||
// gibt es keine inhaltsabhängige Untergrenze (null).
|
||||
function minTextSize(el) {
|
||||
if (el.type === 'line' || el.type === 'image' || el.variable === 'services.list') {
|
||||
return null;
|
||||
}
|
||||
var fontSize = el.fontSize || 14;
|
||||
ctx.save();
|
||||
ctx.font = ((el.fontStyle || '') + ' ' + fontSize + 'px ' + (el.fontFamily || 'Arial')).trim();
|
||||
var lines = (el.text || '').split('\n');
|
||||
var maxLineWidth = 0;
|
||||
lines.forEach(function(line) {
|
||||
maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width);
|
||||
});
|
||||
ctx.restore();
|
||||
// Gleiche Polsterung wie der Auswahlrahmen (Breite +10, Höhe +6): so kann
|
||||
// das Element nie kleiner werden als der gezeichnete Rahmen und der Text
|
||||
// bleibt darin zentriert.
|
||||
return {
|
||||
width: Math.max(20, Math.ceil(maxLineWidth + 10)),
|
||||
height: Math.max(20, Math.ceil(lines.length * fontSize * 1.2 + 6))
|
||||
};
|
||||
}
|
||||
|
||||
// Notify Java about element selection
|
||||
@@ -120,7 +154,8 @@ window.initProfileInvoiceGenerator = function() {
|
||||
el.width || 100,
|
||||
el.height || 30,
|
||||
el.isStatic || false,
|
||||
el.variable || null
|
||||
el.variable || null,
|
||||
el.fontFamily || 'Arial'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
function draw() {
|
||||
console.log('draw() called, elements:', elements.length);
|
||||
@@ -156,21 +224,19 @@ window.initProfileInvoiceGenerator = function() {
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(pageX, pageY, pageWidth, pageHeight);
|
||||
|
||||
// Grid (scaled by zoom factor)
|
||||
ctx.strokeStyle = 'rgba(200, 200, 200, 0.3)';
|
||||
ctx.lineWidth = 0.5;
|
||||
var scaledGridSize = gridSize * zoomFactor;
|
||||
for (var x = pageX; x <= pageX + pageWidth; x += scaledGridSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, pageY);
|
||||
ctx.lineTo(x, pageY + pageHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (var y = pageY; y <= pageY + pageHeight; y += scaledGridSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pageX, y);
|
||||
ctx.lineTo(pageX + pageWidth, y);
|
||||
ctx.stroke();
|
||||
// Hintergrundbild: auf Seitengröße skaliert, hinter allen Elementen
|
||||
var backgroundImage = window.profileInvoiceState.backgroundImage;
|
||||
if (backgroundImage) {
|
||||
if (imageCache[backgroundImage]) {
|
||||
ctx.drawImage(imageCache[backgroundImage], pageX, pageY, pageWidth, pageHeight);
|
||||
} else {
|
||||
var bgImg = new Image();
|
||||
bgImg.onload = function() {
|
||||
imageCache[backgroundImage] = bgImg;
|
||||
draw();
|
||||
};
|
||||
bgImg.src = backgroundImage;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw elements
|
||||
@@ -184,6 +250,8 @@ window.initProfileInvoiceGenerator = function() {
|
||||
if (selectedElement) {
|
||||
drawSelection(selectedElement);
|
||||
}
|
||||
|
||||
scheduleAutosave();
|
||||
}
|
||||
|
||||
function drawElement(el) {
|
||||
@@ -282,23 +350,36 @@ window.initProfileInvoiceGenerator = function() {
|
||||
// Vertically center the text in the element
|
||||
var ty = y + (h - totalTextHeight) / 2;
|
||||
|
||||
// Draw background highlight for static elements
|
||||
// Masterdata elements are never bold; other elements respect their fontStyle
|
||||
var fontWeight = (el.isStatic && !el.isCustomer) ? '' : (el.fontStyle || '');
|
||||
// Set the font BEFORE measuring text, so the highlight width matches
|
||||
ctx.font = (fontWeight ? fontWeight + ' ' : '') + fontSize + 'px ' + (el.fontFamily || 'Arial');
|
||||
|
||||
// Draw background highlight for static elements, fitted to the widest
|
||||
// text line (not the element width) and following the text alignment
|
||||
if (el.isStatic) {
|
||||
var textWidth = ctx.measureText(el.text || '').width + (10 * zoomFactor);
|
||||
var maxLineWidth = 0;
|
||||
lines.forEach(function(line) {
|
||||
maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width);
|
||||
});
|
||||
var bgWidth = maxLineWidth + (10 * zoomFactor);
|
||||
var bgX = x - (3 * zoomFactor);
|
||||
if (textAlign === 'center') {
|
||||
bgX = x + (w - bgWidth) / 2;
|
||||
} else if (textAlign === 'right') {
|
||||
bgX = x + w - bgWidth + (3 * zoomFactor);
|
||||
}
|
||||
// Different background colors: green for customer, blue for masterdata
|
||||
if (el.isCustomer) {
|
||||
ctx.fillStyle = 'rgba(46, 204, 113, 0.15)'; // Light green for customer
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(25, 118, 210, 0.1)'; // Light blue for masterdata
|
||||
}
|
||||
ctx.fillRect(x - (3 * zoomFactor), y - (2 * zoomFactor), Math.max(w, textWidth), h);
|
||||
ctx.fillRect(bgX, y - (2 * zoomFactor), bgWidth, h);
|
||||
}
|
||||
|
||||
// Always use black text for static elements, otherwise use element color
|
||||
ctx.fillStyle = el.isStatic ? '#000000' : (el.color || '#333333');
|
||||
// Masterdata elements are never bold; other elements respect their fontStyle
|
||||
var fontWeight = (el.isStatic && !el.isCustomer) ? '' : (el.fontStyle || '');
|
||||
ctx.font = (fontWeight ? fontWeight + ' ' : '') + fontSize + 'px Arial';
|
||||
// Die gewählte Elementfarbe verwenden — wie im gerenderten PDF
|
||||
ctx.fillStyle = el.color || '#333333';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.textAlign = textAlign;
|
||||
|
||||
@@ -318,140 +399,94 @@ window.initProfileInvoiceGenerator = function() {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Draw services list as a table with columns: Name, Steuersatz, Nettobetrag
|
||||
// Plus summary section below: Nettosumme, USt, Gesamtsumme
|
||||
// Positionsliste im Briefbogen-Layout: Spalten Menge, Bezeichnung,
|
||||
// Einzelpreis und Gesamt, durchgezogene Spaltenlinien bis zum Summenblock
|
||||
// und die Summen (Nettobetrag, MwSt., Endbetrag) rechts unten.
|
||||
function drawServicesTable(el, x, y, w, h, fontSize) {
|
||||
var fontFamily = el.fontFamily || 'Arial';
|
||||
var lineHeight = fontSize * 1.4;
|
||||
var padding = 4 * zoomFactor;
|
||||
var rowHeight = lineHeight + padding * 2;
|
||||
var summaryRowHeight = fontSize * 1.6;
|
||||
var summaryGap = fontSize * 0.5;
|
||||
|
||||
// Column widths (percentages of total width)
|
||||
var colNameWidth = w * 0.55; // 55% for Name (left-aligned)
|
||||
var colVatWidth = w * 0.20; // 20% for Steuersatz (right-aligned)
|
||||
var colNetWidth = w * 0.25; // 25% for Nettobetrag (right-aligned)
|
||||
|
||||
var colNameX = x;
|
||||
var colVatX = x + colNameWidth;
|
||||
var colNetX = colVatX + colVatWidth;
|
||||
// Spaltengrenzen wie im PDF-Renderer (9 / 61 / 15 / 15 Prozent)
|
||||
var xName = x + w * 0.09;
|
||||
var xUnit = x + w * 0.70;
|
||||
var xTotal = x + w * 0.85;
|
||||
|
||||
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
|
||||
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
|
||||
|
||||
// Rows come from window.profileInvoiceServiceData when provided (system
|
||||
// invoice template: the three admin price table positions); otherwise
|
||||
// fall back to sample data (per-user profile invoice generator)
|
||||
var serviceData = window.profileInvoiceServiceData;
|
||||
var rows = (serviceData && serviceData.rows && serviceData.rows.length)
|
||||
? serviceData.rows
|
||||
: [
|
||||
{ name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 €' },
|
||||
{ name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 €' },
|
||||
{ name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 €' }
|
||||
{ quantity: '1', name: 'Umzugsleistung inkl. Verpackung', unitPrice: '450,00 \u20ac', net: '450,00 \u20ac' },
|
||||
{ quantity: '1', name: 'Entsorgung M\u00f6bel', unitPrice: '85,00 \u20ac', net: '85,00 \u20ac' },
|
||||
{ quantity: '1', name: 'Montage/De-Montage', unitPrice: '120,00 \u20ac', net: '120,00 \u20ac' }
|
||||
];
|
||||
|
||||
// Calculate actual content height based on table + summary
|
||||
var tableOnlyHeight = rowHeight * (rows.length + 1); // Header + data rows
|
||||
var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
||||
var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight;
|
||||
// Ensure background covers at least the element height or the calculated content
|
||||
var bgHeight = Math.max(h, calculatedContentHeight * zoomFactor);
|
||||
// Mindesthoehe: Kopf + Zeilen + Summenblock; ist das Element hoeher,
|
||||
// laufen die Spaltenlinien entsprechend weiter nach unten
|
||||
var summaryHeight = 3 * summaryRowHeight + summaryGap;
|
||||
var minHeight = rowHeight * (rows.length + 1) + summaryHeight;
|
||||
var totalHeight = Math.max(h, minHeight);
|
||||
var bodyBottom = y + totalHeight - summaryHeight;
|
||||
|
||||
// Draw orange background highlight for entire service variable element
|
||||
// Orangefarbene Markierung des gesamten Bausteins (nur im Designer)
|
||||
var bgPadding = 3 * zoomFactor;
|
||||
ctx.fillStyle = 'rgba(255, 152, 0, 0.15)';
|
||||
ctx.fillRect(x - bgPadding, y - (2 * zoomFactor), w + (2 * bgPadding), bgHeight + (4 * zoomFactor));
|
||||
ctx.fillRect(x - bgPadding, y - (2 * zoomFactor), w + (2 * bgPadding), totalHeight + (4 * zoomFactor));
|
||||
|
||||
// Draw table header (overlays the background)
|
||||
ctx.fillStyle = '#f5f5f5';
|
||||
// Kopfzeile: graue Flaeche mit kleinen Beschriftungen
|
||||
ctx.fillStyle = '#eeeeee';
|
||||
ctx.fillRect(x, y, w, rowHeight);
|
||||
|
||||
// Header border
|
||||
ctx.strokeStyle = '#cccccc';
|
||||
ctx.lineWidth = Math.max(0.5, zoomFactor);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y + rowHeight);
|
||||
ctx.lineTo(x + w, y + rowHeight);
|
||||
ctx.stroke();
|
||||
|
||||
// Header text
|
||||
ctx.fillStyle = '#333333';
|
||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
||||
ctx.font = (fontSize * 0.75) + 'px ' + fontFamily;
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// Name column header (left-aligned)
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('Name', colNameX + padding, y + rowHeight / 2);
|
||||
|
||||
// Steuersatz column header (right-aligned)
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('Steuersatz', colVatX + colVatWidth - padding, y + rowHeight / 2);
|
||||
|
||||
// Nettobetrag column header (right-aligned)
|
||||
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
|
||||
ctx.fillText('Menge', x + padding, y + rowHeight / 2);
|
||||
ctx.fillText('Bezeichnung', xName + padding, y + rowHeight / 2);
|
||||
ctx.fillText('Einzelpreis', xUnit + padding, y + rowHeight / 2);
|
||||
ctx.fillText('Gesamt', xTotal + padding, y + rowHeight / 2);
|
||||
|
||||
// Datenzeilen
|
||||
var currentY = y + rowHeight;
|
||||
|
||||
// Draw data rows
|
||||
ctx.font = fontSize + 'px Arial';
|
||||
rows.forEach(function(row, index) {
|
||||
// Draw row background (alternating)
|
||||
if (index % 2 === 1) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.02)';
|
||||
ctx.fillRect(x, currentY, w, rowHeight);
|
||||
}
|
||||
|
||||
// Row bottom border
|
||||
ctx.strokeStyle = '#eeeeee';
|
||||
ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, currentY + rowHeight);
|
||||
ctx.lineTo(x + w, currentY + rowHeight);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw cell text
|
||||
ctx.font = fontSize + 'px ' + fontFamily;
|
||||
rows.forEach(function(row) {
|
||||
ctx.fillStyle = '#333333';
|
||||
|
||||
// Name (left-aligned)
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(row.quantity || '', x + (xName - x) / 2, currentY + rowHeight / 2);
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(row.name, colNameX + padding, currentY + rowHeight / 2);
|
||||
|
||||
// Steuersatz (right-aligned)
|
||||
ctx.fillText(row.name || '', xName + padding, currentY + rowHeight / 2);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(row.vat, colVatX + colVatWidth - padding, currentY + rowHeight / 2);
|
||||
|
||||
// Nettobetrag (right-aligned)
|
||||
ctx.fillText(row.net, colNetX + colNetWidth - padding, currentY + rowHeight / 2);
|
||||
|
||||
ctx.fillText(row.unitPrice || '', xTotal - padding, currentY + rowHeight / 2);
|
||||
ctx.fillText(row.net || '', x + w - padding, currentY + rowHeight / 2);
|
||||
currentY += rowHeight;
|
||||
});
|
||||
|
||||
// Draw column separator lines
|
||||
ctx.strokeStyle = '#e0e0e0';
|
||||
ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5);
|
||||
// Spaltenlinien vom Kopf bis zum Summenblock (inkl. Abstand davor,
|
||||
// damit keine Luecke zur ersten Summenzeile entsteht). X-Koordinaten
|
||||
// aufs Pixelraster einrasten (n + 0,5), sonst verwischt der Browser
|
||||
// die 1px-Linien je nach Subpixel-Lage zu breiten grauen Baendern.
|
||||
var linesBottom = bodyBottom + summaryGap;
|
||||
var lineXName = Math.round(xName) + 0.5;
|
||||
var lineXUnit = Math.round(xUnit) + 0.5;
|
||||
var lineXTotal = Math.round(xTotal) + 0.5;
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
// Line between Name and Steuersatz
|
||||
ctx.moveTo(colVatX, y);
|
||||
ctx.lineTo(colVatX, currentY);
|
||||
// Line between Steuersatz and Nettobetrag
|
||||
ctx.moveTo(colNetX, y);
|
||||
ctx.lineTo(colNetX, currentY);
|
||||
ctx.moveTo(lineXName, y);
|
||||
ctx.lineTo(lineXName, linesBottom);
|
||||
ctx.moveTo(lineXUnit, y);
|
||||
ctx.lineTo(lineXUnit, linesBottom);
|
||||
ctx.moveTo(lineXTotal, y);
|
||||
ctx.lineTo(lineXTotal, linesBottom);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw outer border around table
|
||||
ctx.strokeStyle = '#cccccc';
|
||||
ctx.lineWidth = Math.max(0.5, zoomFactor);
|
||||
ctx.strokeRect(x, y, w, currentY - y);
|
||||
|
||||
// Draw summary section below the table
|
||||
var summaryY = currentY + summaryGap;
|
||||
|
||||
// Column positions for summary section
|
||||
var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned)
|
||||
var valueX = x + w - padding; // Value column (right-aligned)
|
||||
|
||||
// Totals: provided with the service data or calculated from sample data
|
||||
// Summen: Nettobetrag, MwSt., Endbetrag rechts unten
|
||||
var netTotalLabel, vatTotalLabel, grossTotalLabel;
|
||||
if (serviceData && serviceData.netTotal) {
|
||||
netTotalLabel = serviceData.netTotal;
|
||||
@@ -460,41 +495,41 @@ window.initProfileInvoiceGenerator = function() {
|
||||
} else {
|
||||
var netTotal = 655.00; // 450 + 85 + 120
|
||||
var vatTotal = netTotal * vatRate;
|
||||
netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' €';
|
||||
vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' €';
|
||||
grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' €';
|
||||
netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' \u20ac';
|
||||
vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' \u20ac';
|
||||
grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' \u20ac';
|
||||
}
|
||||
|
||||
// Draw summary lines
|
||||
var summaryRows = [
|
||||
{ label: 'Nettobetrag', value: netTotalLabel, shaded: true },
|
||||
{ label: '+ ' + vatPctLabel + ' MwSt.', value: vatTotalLabel, shaded: false },
|
||||
{ label: 'Endbetrag', value: grossTotalLabel, shaded: true }
|
||||
];
|
||||
|
||||
var summaryY = bodyBottom + summaryGap;
|
||||
ctx.textBaseline = 'middle';
|
||||
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.font = fontSize + 'px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2);
|
||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
||||
summaryY += summaryRowHeight;
|
||||
|
||||
// Umsatzsteuer - label left, value right
|
||||
ctx.font = fontSize + 'px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
|
||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
||||
summaryY += summaryRowHeight;
|
||||
|
||||
// Gesamtsumme - label left, value right
|
||||
summaryY += summaryGap; // Extra gap before total
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.font = 'bold ' + (fontSize * 1.1) + 'px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(grossTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
||||
ctx.fillStyle = '#333333';
|
||||
ctx.font = fontSize + 'px ' + fontFamily;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(row.label, xUnit + padding, summaryY + summaryRowHeight / 2);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(row.value, x + w - padding, summaryY + summaryRowHeight / 2);
|
||||
summaryY += summaryRowHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function drawSelection(el) {
|
||||
@@ -506,7 +541,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
// For text elements, calculate actual text dimensions
|
||||
if (el.type !== 'line' && el.type !== 'image') {
|
||||
var fontSize = (el.fontSize || 14) * zoomFactor;
|
||||
ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px Arial';
|
||||
ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px ' + (el.fontFamily || 'Arial');
|
||||
|
||||
// For services.list, calculate table height including summary section
|
||||
if (el.variable === 'services.list') {
|
||||
@@ -516,7 +551,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
|
||||
var summaryRowHeight = fontSize * 1.6;
|
||||
var summaryGap = fontSize * 0.5;
|
||||
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
||||
var summaryHeight = 3 * summaryRowHeight + summaryGap;
|
||||
var totalHeight = tableHeight + summaryHeight;
|
||||
h = Math.max(h, totalHeight);
|
||||
} else {
|
||||
@@ -571,44 +606,60 @@ window.initProfileInvoiceGenerator = function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Der anklickbare Bereich entspricht dem dargestellten Inhalt, nicht dem
|
||||
// (ggf. größeren) Elementrahmen.
|
||||
function hitTest(x, y, el) {
|
||||
var ex = pageX + (el.x * zoomFactor);
|
||||
var ey = pageY + (el.y * zoomFactor);
|
||||
var ew = (el.width || 100) * zoomFactor;
|
||||
var eh = (el.height || 30) * zoomFactor;
|
||||
|
||||
// For text elements, calculate actual text dimensions for hit testing
|
||||
if (el.type !== 'line' && el.type !== 'image') {
|
||||
var fontSize = (el.fontSize || 14) * zoomFactor;
|
||||
ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px Arial';
|
||||
|
||||
// For services.list, calculate table height
|
||||
if (el.variable === 'services.list') {
|
||||
var lineHeight = fontSize * 1.4;
|
||||
var padding = 4 * zoomFactor;
|
||||
var rowHeight = lineHeight + padding * 2;
|
||||
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
|
||||
var summaryRowHeight = fontSize * 1.6;
|
||||
var summaryGap = fontSize * 0.5;
|
||||
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
||||
eh = Math.max(eh, tableHeight + summaryHeight);
|
||||
} else {
|
||||
var lines = (el.text || '').split('\n');
|
||||
var maxLineWidth = 0;
|
||||
lines.forEach(function(line) {
|
||||
var lineWidth = ctx.measureText(line).width;
|
||||
maxLineWidth = Math.max(maxLineWidth, lineWidth);
|
||||
});
|
||||
|
||||
ew = Math.max(ew, maxLineWidth + (10 * zoomFactor));
|
||||
|
||||
var lineHeight = fontSize * 1.2;
|
||||
var textHeight = lines.length * lineHeight;
|
||||
eh = Math.max(eh, textHeight + (6 * zoomFactor));
|
||||
}
|
||||
if (el.type === 'line') {
|
||||
// Nur die gezeichnete Linie, mit kleiner vertikaler Toleranz
|
||||
var tolerance = Math.max(3, 4 * zoomFactor);
|
||||
return x >= ex && x <= ex + ew && y >= ey - tolerance && y <= ey + tolerance;
|
||||
}
|
||||
if (el.type === 'image') {
|
||||
return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh;
|
||||
}
|
||||
|
||||
return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh;
|
||||
var fontSize = (el.fontSize || 14) * zoomFactor;
|
||||
ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px ' + (el.fontFamily || 'Arial');
|
||||
|
||||
// services.list: die Tabelle füllt die Elementbreite, Höhe wie gezeichnet
|
||||
if (el.variable === 'services.list') {
|
||||
var lineHeight = fontSize * 1.4;
|
||||
var padding = 4 * zoomFactor;
|
||||
var rowHeight = lineHeight + padding * 2;
|
||||
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
|
||||
var summaryRowHeight = fontSize * 1.6;
|
||||
var summaryGap = fontSize * 0.5;
|
||||
var summaryHeight = 3 * summaryRowHeight + summaryGap;
|
||||
eh = Math.max(eh, tableHeight + summaryHeight);
|
||||
return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh;
|
||||
}
|
||||
|
||||
// Textelemente: Maße des gezeichneten Texts (breiteste Zeile, Zeilenhöhe),
|
||||
// horizontal gemäß Ausrichtung, vertikal zentriert wie beim Zeichnen
|
||||
var lines = (el.text || '').split('\n');
|
||||
var maxLineWidth = 0;
|
||||
lines.forEach(function(line) {
|
||||
maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width);
|
||||
});
|
||||
var textHeight = lines.length * fontSize * 1.2;
|
||||
|
||||
var textAlign = el.textAlign || 'left';
|
||||
var contentX = ex;
|
||||
if (textAlign === 'center') {
|
||||
contentX = ex + (ew - maxLineWidth) / 2;
|
||||
} else if (textAlign === 'right') {
|
||||
contentX = ex + ew - maxLineWidth;
|
||||
}
|
||||
var contentY = ey + (eh - textHeight) / 2;
|
||||
|
||||
var pad = 3 * zoomFactor;
|
||||
return x >= contentX - pad && x <= contentX + maxLineWidth + pad
|
||||
&& y >= contentY - pad && y <= contentY + textHeight + pad;
|
||||
}
|
||||
|
||||
// Resizing state
|
||||
@@ -636,7 +687,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
|
||||
var summaryRowHeight = fontSize * 1.6;
|
||||
var summaryGap = fontSize * 0.5;
|
||||
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
||||
var summaryHeight = 3 * summaryRowHeight + summaryGap;
|
||||
eh = Math.max(eh, tableHeight + summaryHeight);
|
||||
}
|
||||
|
||||
@@ -708,8 +759,9 @@ window.initProfileInvoiceGenerator = function() {
|
||||
canvas.style.cursor = 'move';
|
||||
notifyElementSelected(selectedElement);
|
||||
} else {
|
||||
// Klick auf die leere Zeichenfläche: Canvas-Einstellungen anzeigen
|
||||
selectedElement = null;
|
||||
notifyElementDeselected();
|
||||
notifyCanvasSelected();
|
||||
}
|
||||
|
||||
draw();
|
||||
@@ -730,41 +782,47 @@ window.initProfileInvoiceGenerator = function() {
|
||||
var newX = resizeStart.elemX;
|
||||
var newY = resizeStart.elemY;
|
||||
|
||||
// Textelemente lassen sich nur bis zur Größe des dargestellten Texts
|
||||
// verkleinern; alle anderen Typen behalten das bisherige Minimum.
|
||||
var minSize = minTextSize(selectedElement);
|
||||
var minW = minSize ? minSize.width : 20;
|
||||
var minH = minSize ? minSize.height : 20;
|
||||
|
||||
// Handle indices: 0=TL, 1=TC, 2=TR, 3=ML, 4=MR, 5=BL, 6=BC, 7=BR
|
||||
switch(resizeHandle) {
|
||||
case 0: // Top-left
|
||||
newWidth = Math.max(20, resizeStart.width - dx);
|
||||
newHeight = Math.max(20, resizeStart.height - dy);
|
||||
newWidth = Math.max(minW, resizeStart.width - dx);
|
||||
newHeight = Math.max(minH, resizeStart.height - dy);
|
||||
newX = resizeStart.elemX + (resizeStart.width - newWidth);
|
||||
newY = resizeStart.elemY + (resizeStart.height - newHeight);
|
||||
break;
|
||||
case 1: // Top-center
|
||||
newHeight = Math.max(20, resizeStart.height - dy);
|
||||
newHeight = Math.max(minH, resizeStart.height - dy);
|
||||
newY = resizeStart.elemY + (resizeStart.height - newHeight);
|
||||
break;
|
||||
case 2: // Top-right
|
||||
newWidth = Math.max(20, resizeStart.width + dx);
|
||||
newHeight = Math.max(20, resizeStart.height - dy);
|
||||
newWidth = Math.max(minW, resizeStart.width + dx);
|
||||
newHeight = Math.max(minH, resizeStart.height - dy);
|
||||
newY = resizeStart.elemY + (resizeStart.height - newHeight);
|
||||
break;
|
||||
case 3: // Middle-left
|
||||
newWidth = Math.max(20, resizeStart.width - dx);
|
||||
newWidth = Math.max(minW, resizeStart.width - dx);
|
||||
newX = resizeStart.elemX + (resizeStart.width - newWidth);
|
||||
break;
|
||||
case 4: // Middle-right
|
||||
newWidth = Math.max(20, resizeStart.width + dx);
|
||||
newWidth = Math.max(minW, resizeStart.width + dx);
|
||||
break;
|
||||
case 5: // Bottom-left
|
||||
newWidth = Math.max(20, resizeStart.width - dx);
|
||||
newHeight = Math.max(20, resizeStart.height + dy);
|
||||
newWidth = Math.max(minW, resizeStart.width - dx);
|
||||
newHeight = Math.max(minH, resizeStart.height + dy);
|
||||
newX = resizeStart.elemX + (resizeStart.width - newWidth);
|
||||
break;
|
||||
case 6: // Bottom-center
|
||||
newHeight = Math.max(20, resizeStart.height + dy);
|
||||
newHeight = Math.max(minH, resizeStart.height + dy);
|
||||
break;
|
||||
case 7: // Bottom-right
|
||||
newWidth = Math.max(20, resizeStart.width + dx);
|
||||
newHeight = Math.max(20, resizeStart.height + dy);
|
||||
newWidth = Math.max(minW, resizeStart.width + dx);
|
||||
newHeight = Math.max(minH, resizeStart.height + dy);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -772,6 +830,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
selectedElement.height = snapToGrid(newHeight);
|
||||
selectedElement.x = snapToGrid(newX);
|
||||
selectedElement.y = snapToGrid(newY);
|
||||
keepTextCentered(selectedElement);
|
||||
|
||||
draw();
|
||||
notifyElementSelected(selectedElement);
|
||||
@@ -856,40 +915,29 @@ window.initProfileInvoiceGenerator = function() {
|
||||
|
||||
var moved = false;
|
||||
|
||||
// Pfeiltasten bewegen um 0,1 mm, mit Shift um 0,5 mm (A4: 210 x 297 mm)
|
||||
var stepMm = e.shiftKey ? 0.5 : 0.1;
|
||||
var stepX = stepMm / 210 * basePageWidth;
|
||||
var stepY = stepMm / 297 * basePageHeight;
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowUp':
|
||||
if (e.shiftKey) {
|
||||
selectedElement.y = Math.max(0, selectedElement.y - 1);
|
||||
} else {
|
||||
selectedElement.y = Math.max(0, Math.floor((selectedElement.y - 1) / gridSize) * gridSize);
|
||||
}
|
||||
selectedElement.y = Math.max(0, selectedElement.y - stepY);
|
||||
moved = true;
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (e.shiftKey) {
|
||||
selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), selectedElement.y + 1);
|
||||
} else {
|
||||
selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), Math.ceil((selectedElement.y + 1) / gridSize) * gridSize);
|
||||
}
|
||||
selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), selectedElement.y + stepY);
|
||||
moved = true;
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (e.shiftKey) {
|
||||
selectedElement.x = Math.max(0, selectedElement.x - 1);
|
||||
} else {
|
||||
selectedElement.x = Math.max(0, Math.floor((selectedElement.x - 1) / gridSize) * gridSize);
|
||||
}
|
||||
selectedElement.x = Math.max(0, selectedElement.x - stepX);
|
||||
moved = true;
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (e.shiftKey) {
|
||||
selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), selectedElement.x + 1);
|
||||
} else {
|
||||
selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), Math.ceil((selectedElement.x + 1) / gridSize) * gridSize);
|
||||
}
|
||||
selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), selectedElement.x + stepX);
|
||||
moved = true;
|
||||
e.preventDefault();
|
||||
break;
|
||||
@@ -933,6 +981,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
width: 150,
|
||||
height: 30,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Arial',
|
||||
color: '#333333',
|
||||
isStatic: isStatic || false,
|
||||
variable: variable || null,
|
||||
@@ -959,7 +1008,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 sample rows
|
||||
var summaryRowHeight = el.fontSize * 1.6;
|
||||
var summaryGap = el.fontSize * 0.5;
|
||||
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
||||
var summaryHeight = 3 * summaryRowHeight + summaryGap;
|
||||
var totalHeight = tableHeight + summaryHeight;
|
||||
el.height = Math.round(totalHeight);
|
||||
} else {
|
||||
@@ -1019,6 +1068,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
|
||||
elements.push(el);
|
||||
selectedElement = el;
|
||||
autosaveEnabled = true;
|
||||
draw();
|
||||
notifyElementSelected(el);
|
||||
|
||||
@@ -1032,6 +1082,46 @@ window.initProfileInvoiceGenerator = function() {
|
||||
if (el) {
|
||||
pushUndoState();
|
||||
el.text = text;
|
||||
// Höhe an die Zeilenzahl anpassen, damit mehrzeiliger Text ins Element
|
||||
// passt; Breite mindestens auf die breiteste Zeile aufweiten
|
||||
if (el.type !== 'line' && el.type !== 'image') {
|
||||
var lines = (text || '').split('\n');
|
||||
var lineHeight = (el.fontSize || 14) * 1.2;
|
||||
el.height = Math.round(lines.length * lineHeight + 6);
|
||||
var minSize = minTextSize(el);
|
||||
if (minSize) {
|
||||
el.width = Math.max(el.width || 0, minSize.width);
|
||||
}
|
||||
}
|
||||
draw();
|
||||
}
|
||||
};
|
||||
|
||||
window.updateProfileElementSize = function(id, width, height) {
|
||||
var el = elements.find(function(e) { return e.id === id; });
|
||||
if (el) {
|
||||
pushUndoState();
|
||||
var minSize = minTextSize(el);
|
||||
var minW = minSize ? minSize.width : 1;
|
||||
var minH = minSize ? minSize.height : 1;
|
||||
if (width !== null) el.width = Math.max(minW, width);
|
||||
if (height !== null) el.height = Math.max(minH, height);
|
||||
keepTextCentered(el);
|
||||
draw();
|
||||
}
|
||||
};
|
||||
|
||||
window.updateProfileElementFontFamily = function(id, family) {
|
||||
var el = elements.find(function(e) { return e.id === id; });
|
||||
if (el) {
|
||||
pushUndoState();
|
||||
el.fontFamily = family;
|
||||
// Breite mindestens auf die breiteste Zeile in der neuen Schrift aufweiten
|
||||
var minSize = minTextSize(el);
|
||||
if (minSize) {
|
||||
el.width = Math.max(el.width || 0, minSize.width);
|
||||
el.height = Math.max(el.height || 0, minSize.height);
|
||||
}
|
||||
draw();
|
||||
}
|
||||
};
|
||||
@@ -1051,12 +1141,17 @@ window.initProfileInvoiceGenerator = function() {
|
||||
if (el) {
|
||||
pushUndoState();
|
||||
el.fontSize = size;
|
||||
// Update height based on text content and new font size
|
||||
// Update height based on text content and new font size;
|
||||
// Breite mindestens auf die breiteste Zeile aufweiten
|
||||
if (el.type !== 'line' && el.type !== 'image') {
|
||||
var lines = (el.text || '').split('\n');
|
||||
var lineHeight = size * 1.2;
|
||||
var textHeight = lines.length * lineHeight;
|
||||
el.height = Math.max(textHeight + 6, 20); // Minimum height of 20
|
||||
var minSize = minTextSize(el);
|
||||
if (minSize) {
|
||||
el.width = Math.max(el.width || 0, minSize.width);
|
||||
}
|
||||
}
|
||||
draw();
|
||||
}
|
||||
@@ -1192,6 +1287,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
heightPercent: toPercentY(el.height),
|
||||
fontSize: el.fontSize,
|
||||
fontStyle: el.fontStyle,
|
||||
fontFamily: el.fontFamily,
|
||||
textAlign: el.textAlign,
|
||||
color: el.color,
|
||||
isStatic: el.isStatic,
|
||||
@@ -1200,11 +1296,19 @@ window.initProfileInvoiceGenerator = function() {
|
||||
imageData: el.imageData
|
||||
};
|
||||
});
|
||||
// Das Hintergrundbild wird bewusst NICHT mitgeschickt: Es wird serverseitig
|
||||
// gehalten und dort ins Template-JSON übernommen — die große Bild-Payload
|
||||
// würde sonst das Limit für Client-zu-Server-Aufrufe sprengen.
|
||||
return {
|
||||
elements: elementsWithPercent
|
||||
};
|
||||
};
|
||||
|
||||
window.updateProfileCanvasBackground = function(dataUrl) {
|
||||
window.profileInvoiceState.backgroundImage = dataUrl || null;
|
||||
draw();
|
||||
};
|
||||
|
||||
window.updateProfileVatRate = function(rate) {
|
||||
if (rate == null || isNaN(rate)) return;
|
||||
window.profileInvoiceVatRate = rate;
|
||||
@@ -1275,6 +1379,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
try {
|
||||
console.log('loadProfileTemplate called with data:', JSON.stringify(templateData).substring(0, 200));
|
||||
var data = (typeof templateData === 'string') ? JSON.parse(templateData) : templateData;
|
||||
window.profileInvoiceState.backgroundImage = data.backgroundImage || null;
|
||||
if (data.elements && Array.isArray(data.elements)) {
|
||||
console.log('Loading ' + data.elements.length + ' elements');
|
||||
console.log('Current elements array before clear:', elements.length);
|
||||
@@ -1387,6 +1492,10 @@ window.initProfileInvoiceGenerator = function() {
|
||||
// Save to global state
|
||||
saveState();
|
||||
|
||||
// Autosave erst ab jetzt: der geladene Stand ist die neue Referenz
|
||||
lastAutosaved = JSON.stringify(window.getProfileCanvasData());
|
||||
autosaveEnabled = true;
|
||||
|
||||
console.log('Calling draw(), elements count:', elements.length);
|
||||
console.log('Canvas dimensions:', canvas.width, 'x', canvas.height);
|
||||
draw();
|
||||
|
||||
@@ -26,6 +26,11 @@ public class Address {
|
||||
private String email;
|
||||
private String phone;
|
||||
|
||||
// Bankverbindung (v. a. für Rechnungssteller relevant)
|
||||
private String bankName;
|
||||
private String iban;
|
||||
private String bic;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@@ -114,6 +119,30 @@ public class Address {
|
||||
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() {
|
||||
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). */
|
||||
public static final String DEFAULT_TEMPLATE_NAME = "standard";
|
||||
|
||||
/**
|
||||
* Reservierter Name für den automatisch gesicherten Arbeitsstand des
|
||||
* Rechnungsgenerators; taucht in der Template-Auswahl nicht auf.
|
||||
*/
|
||||
public static final String AUTOSAVE_NAME = "__autosave__";
|
||||
|
||||
private static final String LEGACY_TEMPLATE_FILE_NAME = "rechnungstemplate.json";
|
||||
|
||||
private final InvoiceTemplateRepository repository;
|
||||
@@ -64,10 +70,28 @@ public class InvoiceTemplateService {
|
||||
migrateLegacyTemplateIfMissing();
|
||||
return repository.findAll().stream()
|
||||
.map(template -> template.getName())
|
||||
.filter(name -> !AUTOSAVE_NAME.equals(name))
|
||||
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Sichert den aktuellen Arbeitsstand des Rechnungsgenerators. */
|
||||
public void saveAutosave(String templateData) {
|
||||
saveTemplate(AUTOSAVE_NAME, templateData);
|
||||
}
|
||||
|
||||
/** @return der zuletzt gesicherte Arbeitsstand oder leer, wenn keiner existiert. */
|
||||
public Optional<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. */
|
||||
public Optional<String> loadTemplate(String 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.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.itextpdf.html2pdf.ConverterProperties;
|
||||
import com.itextpdf.html2pdf.HtmlConverter;
|
||||
import com.itextpdf.html2pdf.resolver.font.DefaultFontProvider;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -48,6 +52,20 @@ public class TemplatePdfService {
|
||||
JsonNode rootNode = mapper.readTree(jsonTemplateData);
|
||||
JsonNode elements = rootNode.get("elements");
|
||||
|
||||
// Hintergrundbild: als body-Hintergrund auf Seitengröße skaliert; alle
|
||||
// Elemente liegen darüber. (Ein <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();
|
||||
htmlBuilder.append("<!DOCTYPE html>");
|
||||
htmlBuilder.append("<html><head>");
|
||||
@@ -55,7 +73,8 @@ public class TemplatePdfService {
|
||||
htmlBuilder.append("<style>");
|
||||
htmlBuilder.append("@page { size: A4; margin: 0; }");
|
||||
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(".text { white-space: nowrap; overflow: visible; }");
|
||||
htmlBuilder.append(".line { border-top: 1px solid #333; }");
|
||||
@@ -72,8 +91,22 @@ public class TemplatePdfService {
|
||||
|
||||
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();
|
||||
HtmlConverter.convertToPdf(htmlBuilder.toString(), out);
|
||||
HtmlConverter.convertToPdf(htmlBuilder.toString(), out, converterProperties);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
@@ -103,6 +136,7 @@ public class TemplatePdfService {
|
||||
int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14;
|
||||
String color = element.has("color") ? element.get("color").asText("#333333") : "#333333";
|
||||
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)
|
||||
double mmX = xPercent / 100.0 * 210.0;
|
||||
@@ -126,6 +160,7 @@ public class TemplatePdfService {
|
||||
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("color:").append(color).append(";");
|
||||
htmlBuilder.append("font-family:").append(cssFontStack(fontFamily)).append(";");
|
||||
// services.list als Block, damit die Tabelle die Breite füllen kann
|
||||
if ("services.list".equals(variable)) {
|
||||
htmlBuilder.append("display:block;overflow:visible;padding:0;");
|
||||
@@ -184,9 +219,9 @@ public class TemplatePdfService {
|
||||
}
|
||||
} else if ("services.list".equals(variable)) {
|
||||
if (variables.containsKey("services.json")) {
|
||||
htmlBuilder.append(generateServicesTableHtmlWithData(variables));
|
||||
htmlBuilder.append(generateServicesTableHtmlWithData(variables, fontSize, mmHeight));
|
||||
} else {
|
||||
htmlBuilder.append(generateServicesTableHtml(effectiveVatRate));
|
||||
htmlBuilder.append(generateServicesTableHtml(effectiveVatRate, fontSize, mmHeight));
|
||||
}
|
||||
} else if (text.contains("<br>")) {
|
||||
// Mehrzeiliger Text: ohne nowrap rendern, damit <br> wirkt
|
||||
@@ -199,7 +234,7 @@ public class TemplatePdfService {
|
||||
}
|
||||
|
||||
/** 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)
|
||||
.stripTrailingZeros();
|
||||
if (pct.scale() < 0) {
|
||||
@@ -207,47 +242,23 @@ public class TemplatePdfService {
|
||||
}
|
||||
String vatLabel = pct.toPlainString().replace('.', ',') + "%";
|
||||
|
||||
String[][] sampleData = { { "Beratungsleistung", vatLabel, "450,00 €" },
|
||||
{ "Softwareentwicklung", vatLabel, "1.200,00 €" }, { "Support-Pauschale", vatLabel, "150,00 €" } };
|
||||
List<Map<String, String>> rows = List.of(
|
||||
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 grossTotal = netTotal + (netTotal * vatRate.doubleValue());
|
||||
|
||||
StringBuilder html = new StringBuilder();
|
||||
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(tableHeaderRow());
|
||||
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();
|
||||
double vatTotal = netTotal * vatRate.doubleValue();
|
||||
return renderServicesTable(rows,
|
||||
String.format(Locale.GERMANY, "%,.2f €", netTotal),
|
||||
String.format(Locale.GERMANY, "%,.2f €", vatTotal),
|
||||
String.format(Locale.GERMANY, "%,.2f €", netTotal + vatTotal),
|
||||
vatLabel, fontSize, mmHeight);
|
||||
}
|
||||
|
||||
/** Positionstabelle aus echten Daten (services.json + invoice.*-Summen). */
|
||||
private String generateServicesTableHtmlWithData(Map<String, String> variables) {
|
||||
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%");
|
||||
|
||||
private String generateServicesTableHtmlWithData(Map<String, String> variables, int fontSize, double mmHeight) {
|
||||
List<Map<String, String>> servicesData = new ArrayList<>();
|
||||
String servicesJson = variables.get("services.json");
|
||||
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());
|
||||
}
|
||||
}
|
||||
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();
|
||||
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(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()) {
|
||||
html.append("<tr style='border-bottom:1px solid #eeeeee;'>");
|
||||
html.append(
|
||||
"<td colspan='3' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Positionen vorhanden</td>");
|
||||
html.append("</tr>");
|
||||
} else {
|
||||
for (int i = 0; i < servicesData.size(); i++) {
|
||||
Map<String, String> service = servicesData.get(i);
|
||||
String name = service.getOrDefault("name", "Unbekannte Position");
|
||||
String netAmount = service.getOrDefault("netAmount", "0,00");
|
||||
// USt-Satz pro Position, Fallback: einheitlicher Satz der Rechnung
|
||||
String rowVat = service.getOrDefault("vat", vatRateLabel);
|
||||
String headStyle = "background-color:#eeeeee;font-size:0.75em;font-weight:normal;color:#333333;"
|
||||
+ "text-align:left;padding:2px 6px;white-space:nowrap;";
|
||||
html.append("<tr>");
|
||||
html.append("<th style='").append(headStyle).append("'>Menge</th>");
|
||||
html.append("<th style='").append(headStyle).append("border-left:").append(border)
|
||||
.append(";'>Bezeichnung</th>");
|
||||
html.append("<th style='").append(headStyle).append("border-left:").append(border)
|
||||
.append(";'>Einzelpreis</th>");
|
||||
html.append("<th style='").append(headStyle).append("border-left:").append(border).append(";'>Gesamt</th>");
|
||||
html.append("</tr>");
|
||||
|
||||
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;width:55%;'>")
|
||||
.append(escapeHtml(name)).append("</td>");
|
||||
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
|
||||
.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>");
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
html.append("<tr><td style='text-align:center;padding:2px 6px;'></td>")
|
||||
.append("<td style='padding:2px 6px;border-left:").append(border)
|
||||
.append(";'>Keine Positionen vorhanden</td>")
|
||||
.append("<td style='border-left:").append(border).append(";'></td>")
|
||||
.append("<td style='border-left:").append(border).append(";'></td></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("<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;'>");
|
||||
// Bei gemischten USt-Sätzen (leeres Label) ohne Satzangabe beschriften
|
||||
String vatSummaryLabel = vatRateLabel.isEmpty() ? "zzgl. USt:"
|
||||
: "zzgl. " + escapeHtml(vatRateLabel) + " USt:";
|
||||
html.append(summaryRow("Nettosumme:", netTotal, false));
|
||||
html.append(summaryRow(vatSummaryLabel, vatTotal, false));
|
||||
html.append(summaryRow("Gesamtsumme:", grossTotal, true));
|
||||
html.append("<colgroup><col style='width:70%;'/><col style='width:15%;'/><col style='width:15%;'/>")
|
||||
.append("</colgroup>");
|
||||
html.append(summaryRow("Nettobetrag", escapeHtml(netTotal), true, border));
|
||||
html.append(summaryRow(vatLabelText, escapeHtml(vatTotal), false, border));
|
||||
html.append(summaryRow("Endbetrag", escapeHtml(grossTotal), true, border));
|
||||
html.append("</table>");
|
||||
html.append("</div>");
|
||||
html.append("</div>");
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private String tableHeaderRow() {
|
||||
return "<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>"
|
||||
+ "<th style='text-align:left;padding:4px 8px;font-weight:bold;width:55%;white-space:nowrap;'>Name</th>"
|
||||
+ "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:20%;white-space:nowrap;'>Steuersatz</th>"
|
||||
+ "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>"
|
||||
/** Eine Zeile des Summenblocks; jede zweite Zeile ist grau hinterlegt. */
|
||||
private String summaryRow(String label, String value, boolean shaded, String border) {
|
||||
String bg = shaded ? "background-color:#eeeeee;" : "";
|
||||
return "<tr>"
|
||||
+ "<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>";
|
||||
}
|
||||
|
||||
private String summaryRow(String label, String value, boolean emphasized) {
|
||||
String labelStyle = emphasized ? "padding:4px 8px;font-weight:bold;font-size:1.05em;" : "padding:2px 8px;";
|
||||
String valueStyle = emphasized ? "padding:4px 8px;font-weight:bold;font-size:1.05em;"
|
||||
: "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>"
|
||||
+ "<td style='width:25%;text-align:right;white-space:nowrap;" + valueStyle + "'>" + value + "</td>"
|
||||
+ "</tr>";
|
||||
/** € nur an rein numerische Beträge anhängen; andere Werte escaped übernehmen. */
|
||||
private String amountDisplay(String amount) {
|
||||
return amount.matches("[0-9.,]+") ? amount + " €" : escapeHtml(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS-Schriftstapel zur Schriftart des Elements; nur bekannte Werte werden
|
||||
* übernommen (Whitelist), alles andere fällt auf Arial zurück.
|
||||
*/
|
||||
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) {
|
||||
|
||||
@@ -38,6 +38,8 @@ public final class TemplateVariables {
|
||||
BigDecimal net = lineNet(item);
|
||||
Map<String, String> position = new HashMap<>();
|
||||
position.put("name", item.description());
|
||||
position.put("quantity", quantityLabel(item.quantity()));
|
||||
position.put("unitPrice", formatAmount(item.unitPriceNet()));
|
||||
position.put("netAmount", formatAmount(net));
|
||||
position.put("vat", vatLabel(item.vatPercent()));
|
||||
positions.add(position);
|
||||
@@ -71,6 +73,8 @@ public final class TemplateVariables {
|
||||
BigDecimal net = lineNet(item);
|
||||
Map<String, String> row = new HashMap<>();
|
||||
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("net", formatAmount(net) + " €");
|
||||
rows.add(row);
|
||||
@@ -97,6 +101,15 @@ public final class TemplateVariables {
|
||||
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%". */
|
||||
public static String vatLabel(BigDecimal percent) {
|
||||
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.AddressRepository;
|
||||
import de.assecutor.pdftool.address.AddressType;
|
||||
import de.assecutor.pdftool.invoice.IbanValidator;
|
||||
import de.assecutor.pdftool.invoice.VatIdValidator;
|
||||
import org.springframework.data.domain.Sort;
|
||||
|
||||
@@ -122,7 +123,7 @@ public class AddressBookView extends VerticalLayout {
|
||||
Address target = address == null ? new Address() : address;
|
||||
|
||||
Dialog dialog = new Dialog();
|
||||
dialog.setHeaderTitle(isNew ? "Neue Adresse" : "Adresse bearbeiten");
|
||||
dialog.setHeaderTitle(isNew ? "Neues Unternehmen" : "Unternehmen bearbeiten");
|
||||
|
||||
ComboBox<AddressType> type = new ComboBox<>("Typ");
|
||||
type.setItems(AddressType.values());
|
||||
@@ -154,7 +155,17 @@ public class AddressBookView extends VerticalLayout {
|
||||
TextField phone = new TextField("Telefon");
|
||||
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.setWidth("560px");
|
||||
dialog.add(form);
|
||||
@@ -189,6 +200,17 @@ public class AddressBookView extends VerticalLayout {
|
||||
email.setInvalid(true);
|
||||
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()) {
|
||||
Notification.show(String.join("\n", errors), 5000, Notification.Position.MIDDLE)
|
||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||
@@ -204,6 +226,9 @@ public class AddressBookView extends VerticalLayout {
|
||||
target.setVatId(vatId.getValue().isBlank() ? "" : VatIdValidator.normalize(vatId.getValue()));
|
||||
target.setEmail(email.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();
|
||||
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.Route;
|
||||
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.AddressRepository;
|
||||
import de.assecutor.pdftool.address.AddressType;
|
||||
import de.assecutor.pdftool.invoice.CapturedInvoiceData;
|
||||
import de.assecutor.pdftool.invoice.IbanValidator;
|
||||
import de.assecutor.pdftool.invoice.InvoiceDraft;
|
||||
import de.assecutor.pdftool.invoice.InvoiceDraftService;
|
||||
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 TextField currency = new TextField("Währung");
|
||||
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 recipient = new PartyForm(false);
|
||||
@@ -128,8 +124,7 @@ public class MainView extends VerticalLayout {
|
||||
|
||||
public MainView(ZugferdService zugferdService, CapturedInvoiceData capturedInvoiceData,
|
||||
InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService,
|
||||
AddressRepository addressRepository, InvoiceDraftService invoiceDraftService,
|
||||
Environment environment) {
|
||||
AddressRepository addressRepository, InvoiceDraftService invoiceDraftService) {
|
||||
this.zugferdService = zugferdService;
|
||||
this.capturedInvoiceData = capturedInvoiceData;
|
||||
this.invoiceTemplateService = invoiceTemplateService;
|
||||
@@ -209,10 +204,8 @@ public class MainView extends VerticalLayout {
|
||||
currency.setPattern("[A-Za-z]{3}");
|
||||
currency.setErrorMessage("Währung als dreistelliger ISO-Code, z. B. 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,
|
||||
paymentTerms, buyerReference, iban, bic);
|
||||
paymentTerms);
|
||||
invoiceForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("600px", 3));
|
||||
add(new H3("2. Rechnungsdaten"), invoiceForm);
|
||||
|
||||
@@ -240,10 +233,6 @@ public class MainView extends VerticalLayout {
|
||||
|
||||
registerDraftDirtyTracking();
|
||||
updateSaveDraftEnabled();
|
||||
|
||||
if (environment.acceptsProfiles(Profiles.of("dev"))) {
|
||||
prefillDevData();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -325,9 +314,9 @@ public class MainView extends VerticalLayout {
|
||||
issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(),
|
||||
currency.getValue().trim(),
|
||||
paymentTerms.getValue(),
|
||||
buyerReference.getValue().trim(),
|
||||
iban.getValue().trim(),
|
||||
bic.getValue().trim(),
|
||||
"",
|
||||
sender.ibanNormalized(),
|
||||
sender.bicNormalized(),
|
||||
sender.toParty(), recipient.toParty(),
|
||||
List.copyOf(lineItems),
|
||||
LocalDateTime.now());
|
||||
@@ -358,10 +347,8 @@ public class MainView extends VerticalLayout {
|
||||
dueDate.setValue(draft.dueDate());
|
||||
currency.setValue(nullToEmpty(draft.currency()));
|
||||
paymentTerms.setValue(nullToEmpty(draft.paymentTerms()));
|
||||
buyerReference.setValue(nullToEmpty(draft.buyerReference()));
|
||||
iban.setValue(nullToEmpty(draft.iban()));
|
||||
bic.setValue(nullToEmpty(draft.bic()));
|
||||
applyParty(sender, draft.sender());
|
||||
sender.prefillBank(nullToEmpty(draft.iban()), nullToEmpty(draft.bic()));
|
||||
applyParty(recipient, draft.recipient());
|
||||
lineItems.clear();
|
||||
if (draft.items() != null) {
|
||||
@@ -382,7 +369,7 @@ public class MainView extends VerticalLayout {
|
||||
form.prefill(nullToEmpty(party.name()), nullToEmpty(party.street()),
|
||||
nullToEmpty(party.zip()), nullToEmpty(party.city()),
|
||||
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. */
|
||||
@@ -397,7 +384,8 @@ public class MainView extends VerticalLayout {
|
||||
form.prefill(nullToEmpty(address.getName()), nullToEmpty(address.getStreet()),
|
||||
nullToEmpty(address.getZip()), nullToEmpty(address.getCity()),
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
private void configureItemsGrid() {
|
||||
itemsGrid.addColumn(item -> item.description()).setHeader("Beschreibung").setFlexGrow(1);
|
||||
@@ -471,20 +443,28 @@ public class MainView extends VerticalLayout {
|
||||
dueDate.addValueChangeListener(event -> updateSaveDraftEnabled());
|
||||
currency.addValueChangeListener(event -> updateSaveDraftEnabled());
|
||||
paymentTerms.addValueChangeListener(event -> updateSaveDraftEnabled());
|
||||
buyerReference.addValueChangeListener(event -> updateSaveDraftEnabled());
|
||||
iban.addValueChangeListener(event -> updateSaveDraftEnabled());
|
||||
bic.addValueChangeListener(event -> updateSaveDraftEnabled());
|
||||
templateSelect.addValueChangeListener(event -> updateSaveDraftEnabled());
|
||||
sender.onAnyChange(this::updateSaveDraftEnabled);
|
||||
recipient.onAnyChange(this::updateSaveDraftEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Speichern-Button ist nur aktiv, wenn keine Vorbelegung geladen wurde
|
||||
* oder sich die Eingaben seit dem Laden bzw. Speichern geändert haben.
|
||||
* Der Speichern-Button ist nur aktiv, wenn alle Pflichtfelder gefüllt sind
|
||||
* und keine Vorbelegung geladen wurde oder sich die Eingaben seit dem Laden
|
||||
* bzw. Speichern geändert haben.
|
||||
*/
|
||||
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. */
|
||||
@@ -495,9 +475,9 @@ public class MainView extends VerticalLayout {
|
||||
issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(),
|
||||
currency.getValue().trim(),
|
||||
paymentTerms.getValue(),
|
||||
buyerReference.getValue().trim(),
|
||||
iban.getValue().trim(),
|
||||
bic.getValue().trim(),
|
||||
"",
|
||||
sender.ibanNormalized(),
|
||||
sender.bicNormalized(),
|
||||
sender.toParty(), recipient.toParty(),
|
||||
List.copyOf(lineItems),
|
||||
null);
|
||||
@@ -694,16 +674,6 @@ public class MainView extends VerticalLayout {
|
||||
&& !currency.getValue().trim().matches("[A-Za-z]{3}")) {
|
||||
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(recipient.validateFields("Rechnungsempfänger"));
|
||||
if (lineItems.isEmpty()) {
|
||||
@@ -720,9 +690,9 @@ public class MainView extends VerticalLayout {
|
||||
dueDate.getValue(),
|
||||
currency.getValue().trim().toUpperCase(),
|
||||
paymentTerms.getValue(),
|
||||
buyerReference.getValue().trim(),
|
||||
iban.getValue().replaceAll("\\s", "").toUpperCase(),
|
||||
bic.getValue().trim().toUpperCase(),
|
||||
"",
|
||||
sender.ibanNormalized(),
|
||||
sender.bicNormalized(),
|
||||
sender.toParty(),
|
||||
recipient.toParty(),
|
||||
lineItems
|
||||
@@ -760,21 +730,6 @@ public class MainView extends VerticalLayout {
|
||||
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) {
|
||||
Notification notification = new Notification();
|
||||
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 vatId = new TextField("USt-IdNr.");
|
||||
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) {
|
||||
this.vatRequired = vatRequired;
|
||||
@@ -824,15 +782,35 @@ public class MainView extends VerticalLayout {
|
||||
email.setErrorMessage("Gültige E-Mail-Adresse angeben.");
|
||||
// E-Mail direkt beim Verlassen des Feldes prüfen
|
||||
email.addValueChangeListener(event -> checkEmail());
|
||||
add(name, street, zip, city, countryCode, vatId, email);
|
||||
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));
|
||||
}
|
||||
|
||||
/** 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,
|
||||
String countryValue, String vatIdValue, String emailValue, String phoneValue) {
|
||||
String countryValue, String vatIdValue, String emailValue) {
|
||||
name.setValue(nameValue);
|
||||
street.setValue(streetValue);
|
||||
zip.setValue(zipValue);
|
||||
@@ -840,7 +818,18 @@ public class MainView extends VerticalLayout {
|
||||
countryCode.setValue(countryValue);
|
||||
vatId.setValue(vatIdValue);
|
||||
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. */
|
||||
@@ -852,12 +841,14 @@ public class MainView extends VerticalLayout {
|
||||
countryCode.addValueChangeListener(event -> listener.run());
|
||||
vatId.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". */
|
||||
void clearFields() {
|
||||
prefill("", "", "", "", "DE", "", "", "");
|
||||
prefill("", "", "", "", "DE", "", "");
|
||||
prefillBank("", "");
|
||||
name.setInvalid(false);
|
||||
street.setInvalid(false);
|
||||
zip.setInvalid(false);
|
||||
@@ -865,6 +856,8 @@ public class MainView extends VerticalLayout {
|
||||
countryCode.setInvalid(false);
|
||||
vatId.setInvalid(false);
|
||||
email.setInvalid(false);
|
||||
iban.setInvalid(false);
|
||||
bic.setInvalid(false);
|
||||
}
|
||||
|
||||
/** @return true, wenn die E-Mail-Adresse gültig (oder leer) ist. */
|
||||
@@ -915,6 +908,16 @@ public class MainView extends VerticalLayout {
|
||||
} else if (!checkEmail()) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -927,7 +930,7 @@ public class MainView extends VerticalLayout {
|
||||
countryCode.getValue().trim().toUpperCase(),
|
||||
vatId.isEmpty() ? "" : VatIdValidator.normalize(vatId.getValue()),
|
||||
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.orderedlayout.HorizontalLayout;
|
||||
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.upload.Upload;
|
||||
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
|
||||
@@ -38,6 +39,7 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -58,6 +60,13 @@ public class TemplateGeneratorView extends VerticalLayout {
|
||||
|
||||
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 InvoiceTemplateService invoiceTemplateService;
|
||||
private final CapturedInvoiceData capturedInvoiceData;
|
||||
@@ -70,6 +79,15 @@ public class TemplateGeneratorView extends VerticalLayout {
|
||||
/** Name des zuletzt geladenen bzw. gespeicherten Templates. */
|
||||
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,
|
||||
InvoiceTemplateService invoiceTemplateService, CapturedInvoiceData capturedInvoiceData) {
|
||||
this.templatePdfService = templatePdfService;
|
||||
@@ -141,18 +159,67 @@ public class TemplateGeneratorView extends VerticalLayout {
|
||||
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() {
|
||||
List<String> names = invoiceTemplateService.templateNames();
|
||||
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. */
|
||||
@@ -164,6 +231,7 @@ public class TemplateGeneratorView extends VerticalLayout {
|
||||
return;
|
||||
}
|
||||
currentTemplateName = name;
|
||||
backgroundImage = readBackgroundImage(templateData.get());
|
||||
getElement().executeJs("setTimeout(function() { "
|
||||
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
|
||||
+ " window.loadProfileTemplate(JSON.parse($0)); "
|
||||
@@ -234,6 +302,32 @@ public class TemplateGeneratorView extends VerticalLayout {
|
||||
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() {
|
||||
try {
|
||||
return new ObjectMapper().writeValueAsString(buildVariables());
|
||||
@@ -518,9 +612,13 @@ public class TemplateGeneratorView extends VerticalLayout {
|
||||
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
|
||||
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(() -> {
|
||||
propertiesPanel.removeAll();
|
||||
|
||||
@@ -573,11 +671,13 @@ public class TemplateGeneratorView extends VerticalLayout {
|
||||
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)) {
|
||||
TextField textField = new TextField("Text");
|
||||
TextArea textField = new TextArea("Text");
|
||||
textField.setValue(text != null ? text : "");
|
||||
textField.setWidthFull();
|
||||
textField.setMinHeight("6em");
|
||||
if (Boolean.TRUE.equals(isStatic)) {
|
||||
textField.setReadOnly(true);
|
||||
textField.setHelperText("Wert wird aus den Stammdaten befüllt");
|
||||
@@ -589,40 +689,90 @@ public class TemplateGeneratorView extends VerticalLayout {
|
||||
propertiesPanel.add(textField);
|
||||
}
|
||||
|
||||
// X Position
|
||||
TextField xField = new TextField("X Position");
|
||||
xField.setValue(x != null ? String.valueOf(Math.round(x)) : "0");
|
||||
// X Position (Anzeige in mm, Canvas rechnet intern in px)
|
||||
TextField xField = new TextField("X Position (mm)");
|
||||
xField.setValue(formatMm(x != null ? pxToMm(x, BASE_PAGE_WIDTH_PX, A4_WIDTH_MM) : 0));
|
||||
xField.setWidthFull();
|
||||
xField.addValueChangeListener(e -> {
|
||||
try {
|
||||
double newX = Double.parseDouble(e.getValue());
|
||||
double newXPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_WIDTH_PX, A4_WIDTH_MM);
|
||||
getElement().executeJs(
|
||||
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
|
||||
+ elementId + "', $0, null); }",
|
||||
newX);
|
||||
newXPx);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
});
|
||||
propertiesPanel.add(xField);
|
||||
|
||||
// Y Position
|
||||
TextField yField = new TextField("Y Position");
|
||||
yField.setValue(y != null ? String.valueOf(Math.round(y)) : "0");
|
||||
// Y Position (Anzeige in mm, Canvas rechnet intern in px)
|
||||
TextField yField = new TextField("Y Position (mm)");
|
||||
yField.setValue(formatMm(y != null ? pxToMm(y, BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM) : 0));
|
||||
yField.setWidthFull();
|
||||
yField.addValueChangeListener(e -> {
|
||||
try {
|
||||
double newY = Double.parseDouble(e.getValue());
|
||||
double newYPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM);
|
||||
getElement().executeJs(
|
||||
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
|
||||
+ elementId + "', null, $0); }",
|
||||
newY);
|
||||
newYPx);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
});
|
||||
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)) {
|
||||
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");
|
||||
fontSizeField.setValue(fontSize != null ? String.valueOf(fontSize) : "16");
|
||||
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
|
||||
public void resetPropertiesPanel() {
|
||||
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) {
|
||||
return switch (variable) {
|
||||
case "masterdata.company_name" -> "Firmenname des Rechnungsstellers";
|
||||
@@ -778,7 +1015,7 @@ public class TemplateGeneratorView extends VerticalLayout {
|
||||
showNotification("Zeichenfläche ist nicht bereit.");
|
||||
return;
|
||||
}
|
||||
openSaveDialog(templateData);
|
||||
openSaveDialog(withBackgroundImage(templateData));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -831,6 +1068,8 @@ public class TemplateGeneratorView extends VerticalLayout {
|
||||
private void doSaveTemplate(String name, String templateData) {
|
||||
try {
|
||||
invoiceTemplateService.saveTemplate(name, templateData);
|
||||
// Explizit gespeichert: der Autosave-Arbeitsstand ist damit erledigt.
|
||||
invoiceTemplateService.clearAutosave();
|
||||
currentTemplateName = name;
|
||||
templateSelect.setItems(invoiceTemplateService.templateNames());
|
||||
templateSelect.setValue(name);
|
||||
@@ -849,7 +1088,8 @@ public class TemplateGeneratorView extends VerticalLayout {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
byte[] pdfBytes = templatePdfService.generatePdf(templateData, buildVariables(), VAT_RATE);
|
||||
byte[] pdfBytes = templatePdfService.generatePdf(withBackgroundImage(templateData),
|
||||
buildVariables(), VAT_RATE);
|
||||
showPdfInDialog(pdfBytes);
|
||||
} catch (Exception ex) {
|
||||
showNotification("Vorschau konnte nicht erzeugt werden: " + ex.getMessage());
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
spring.application.name=pdf-tool
|
||||
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)
|
||||
pdftool.version=@project.version@
|
||||
|
||||
spring.servlet.multipart.max-file-size=25MB
|
||||
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
|
||||
|
||||
# Zugangsdaten aus der .env-Datei im Projektverzeichnis laden (falls vorhanden)
|
||||
|
||||
Reference in New Issue
Block a user