Files
votianlt/backend/src/main/frontend/invoice-generator/profile-invoice-generator.js
Sven Carstensen 2313618c05 feat: Admin-Dashboard mit Kundenübersicht, System-Rechnung auf Preistabellen-Positionen umgestellt und Undo im Rechnungsgenerator
- Admin-Dashboard: neue Sektion mit Grid aller Kunden (Kundennummer, Firma, Name, Kontakt, Ort, zugehöriger Nutzer)
- Rechnungsgenerator (System-Template): Positionen ausschließlich aus den drei Parametern der Preistabelle statt Beispiel-Leistungen, in Canvas und PDF-Vorschau; Bausteine Netto-/Bruttosumme entfernt
- AdminLayout: gleicher view-container-Rahmen wie MainLayout, Admin-Seiten damit optisch an Nutzer-Seiten angeglichen
- Canvas-Generatoren (Admin und Profil): Undo-Historie mit bis zu 10 Schritten per Button und Strg+Z
- Übersetzungen für alle 10 Sprachen ergänzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:10:20 +02:00

1404 lines
48 KiB
JavaScript

// Profile Invoice Generator - Initializes canvas on the edit-profile page
// Global state to persist between tab switches
window.profileInvoiceState = window.profileInvoiceState || {
elements: [],
selectedElement: null,
elementCounter: 0,
initialized: false
};
window.initProfileInvoiceGenerator = function() {
var containerId = 'invoice-canvas-container-profile';
var container = document.getElementById(containerId);
if (!container) {
console.error('Canvas container not found:', containerId);
return;
}
// Always clear and recreate canvas to ensure clean state
container.innerHTML = '';
window.profileInvoiceState.initialized = false;
// Get container dimensions
var rect = container.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
console.error('Container has no size');
return;
}
// Create canvas element
var canvas = document.createElement('canvas');
canvas.style.display = 'block';
canvas.width = rect.width;
canvas.height = rect.height;
container.appendChild(canvas);
var ctx = canvas.getContext('2d');
// Restore state from global or initialize
var elements = window.profileInvoiceState.elements;
var selectedElement = window.profileInvoiceState.selectedElement;
var elementCounter = window.profileInvoiceState.elementCounter;
// Undo history: snapshots of the elements array, max 10 steps back.
// Reset on every init so a canvas never restores states of the other
// generator page (profile vs. admin system template).
var undoStack = [];
var MAX_UNDO_STEPS = 10;
function pushUndoState() {
try {
var snapshot = JSON.stringify(elements);
if (undoStack.length && undoStack[undoStack.length - 1] === snapshot) return;
undoStack.push(snapshot);
if (undoStack.length > MAX_UNDO_STEPS) undoStack.shift();
} catch (err) {
console.error('Could not record undo state:', err);
}
}
window.undoProfileCanvas = function() {
if (!undoStack.length) return false;
var snapshot = JSON.parse(undoStack.pop());
// Keep the same array reference (shared with profileInvoiceState)
elements.length = 0;
snapshot.forEach(function(el) { elements.push(el); });
selectedElement = null;
saveState();
notifyElementDeselected();
draw();
return true;
};
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 = {};
// Page dimensions
var padding = 10;
var pageX, pageY, pageWidth, pageHeight;
var zoomFactor = 1;
var basePageWidth = 595; // A4 width in pixels at 96 DPI
var basePageHeight = 842; // A4 height in pixels at 96 DPI
function updatePageDimensions() {
var w = canvas.width;
var h = canvas.height;
var availableWidth = w - padding * 2;
var availableHeight = h - padding * 2;
// Calculate zoom factor based on available space
var zoomX = availableWidth / basePageWidth;
var zoomY = availableHeight / basePageHeight;
zoomFactor = Math.min(zoomX, zoomY);
pageWidth = basePageWidth * zoomFactor;
pageHeight = basePageHeight * zoomFactor;
pageX = (w - pageWidth) / 2;
pageY = (h - pageHeight) / 2;
}
function snapToGrid(value) {
return Math.round(value / gridSize) * gridSize;
}
// Notify Java about element selection
function notifyElementSelected(el) {
if (window.invoiceGeneratorViewProfile && window.invoiceGeneratorViewProfile.$server) {
window.invoiceGeneratorViewProfile.$server.updatePropertiesPanel(
el.id,
el.type,
el.text || '',
el.x,
el.y,
el.fontSize || 14,
el.color || '#333333',
el.width || 100,
el.height || 30,
el.isStatic || false,
el.variable || null
);
}
}
function notifyElementDeselected() {
if (window.invoiceGeneratorViewProfile && window.invoiceGeneratorViewProfile.$server) {
window.invoiceGeneratorViewProfile.$server.resetPropertiesPanel();
}
}
// Draw function
function draw() {
console.log('draw() called, elements:', elements.length);
var w = canvas.width;
var h = canvas.height;
console.log('Canvas size:', w, 'x', h);
// Clear background
ctx.fillStyle = '#e8e8e8';
ctx.fillRect(0, 0, w, h);
updatePageDimensions();
// Page shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillRect(pageX + 4, pageY + pageHeight, pageWidth, 4);
ctx.fillRect(pageX + pageWidth, pageY + 4, 4, pageHeight);
// White page
ctx.fillStyle = '#ffffff';
ctx.fillRect(pageX, pageY, pageWidth, pageHeight);
ctx.strokeStyle = '#cccccc';
ctx.lineWidth = 1;
ctx.strokeRect(pageX, pageY, pageWidth, pageHeight);
// Grid (scaled by zoom factor)
ctx.strokeStyle = 'rgba(200, 200, 200, 0.3)';
ctx.lineWidth = 0.5;
var scaledGridSize = gridSize * zoomFactor;
for (var x = pageX; x <= pageX + pageWidth; x += scaledGridSize) {
ctx.beginPath();
ctx.moveTo(x, pageY);
ctx.lineTo(x, pageY + pageHeight);
ctx.stroke();
}
for (var y = pageY; y <= pageY + pageHeight; y += scaledGridSize) {
ctx.beginPath();
ctx.moveTo(pageX, y);
ctx.lineTo(pageX + pageWidth, y);
ctx.stroke();
}
// Draw elements
console.log('Drawing', elements.length, 'elements');
elements.forEach(function(el) {
console.log('Drawing element:', el.id, 'at', el.x, el.y, 'text:', el.text);
drawElement(el);
});
// Draw selection
if (selectedElement) {
drawSelection(selectedElement);
}
}
function drawElement(el) {
ctx.save();
// Apply zoom factor to position and size
var x = pageX + (el.x * zoomFactor);
var y = pageY + (el.y * zoomFactor);
var w = (el.width || 100) * zoomFactor;
var h = (el.height || 30) * zoomFactor;
var fontSize = (el.fontSize || 14) * zoomFactor;
var textAlign = el.textAlign || 'left';
if (el.type === 'line') {
ctx.strokeStyle = el.color || '#333333';
ctx.lineWidth = Math.max(1, zoomFactor);
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + w, y);
ctx.stroke();
} else if (el.type === 'image') {
if (el.imageData) {
// Check if image is already cached
if (imageCache[el.imageData]) {
// Use cached image for flicker-free rendering
var img = imageCache[el.imageData];
// Calculate dimensions to maintain aspect ratio (object-fit: contain)
var imgAspect = img.width / img.height;
var boxAspect = w / h;
var drawW, drawH, drawX, drawY;
if (imgAspect > boxAspect) {
drawW = w;
drawH = w / imgAspect;
drawX = x;
drawY = y + (h - drawH) / 2;
} else {
drawW = h * imgAspect;
drawH = h;
drawX = x + (w - drawW) / 2;
drawY = y;
}
ctx.drawImage(img, drawX, drawY, drawW, drawH);
} else {
// Load and cache the image
var img = new Image();
img.onload = function() {
imageCache[el.imageData] = img;
// Calculate dimensions to maintain aspect ratio
var imgAspect = img.width / img.height;
var boxAspect = w / h;
var drawW, drawH, drawX, drawY;
if (imgAspect > boxAspect) {
drawW = w;
drawH = w / imgAspect;
drawX = x;
drawY = y + (h - drawH) / 2;
} else {
drawW = h * imgAspect;
drawH = h;
drawX = x + (w - drawW) / 2;
drawY = y;
}
ctx.drawImage(img, drawX, drawY, drawW, drawH);
// Redraw selection if this element is selected
if (selectedElement && selectedElement.id === el.id) {
drawSelection(el);
}
};
img.src = el.imageData;
}
} else {
// Draw placeholder
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#999999';
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = '#666666';
ctx.font = Math.max(8, 12 * zoomFactor) + 'px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Bild', x + w / 2, y + h / 2);
}
} else if (el.variable === 'services.list') {
// Draw services list as a table
drawServicesTable(el, x, y, w, h, fontSize);
} else {
// Text elements
var lines = (el.text || '').split('\n');
var lineHeight = fontSize * 1.2;
var totalTextHeight = lines.length * lineHeight;
// Vertically center the text in the element
var ty = y + (h - totalTextHeight) / 2;
// Draw background highlight for static elements
if (el.isStatic) {
var textWidth = ctx.measureText(el.text || '').width + (10 * 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);
}
// 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';
ctx.textBaseline = 'top';
ctx.textAlign = textAlign;
var textX = x;
if (textAlign === 'center') {
textX = x + (w / 2);
} else if (textAlign === 'right') {
textX = x + w;
}
lines.forEach(function(line) {
ctx.fillText(line, textX, ty);
ty += lineHeight;
});
}
ctx.restore();
}
// Draw services list as a table with columns: Name, Steuersatz, Nettobetrag
// Plus summary section below: Nettosumme, USt, Gesamtsumme
function drawServicesTable(el, x, y, w, h, fontSize) {
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;
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 €' }
];
// Calculate actual content height based on table + summary
var tableOnlyHeight = rowHeight * (rows.length + 1); // Header + data rows
var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight;
// Ensure background covers at least the element height or the calculated content
var bgHeight = Math.max(h, calculatedContentHeight * zoomFactor);
// Draw orange background highlight for entire service variable element
var bgPadding = 3 * zoomFactor;
ctx.fillStyle = 'rgba(255, 152, 0, 0.15)';
ctx.fillRect(x - bgPadding, y - (2 * zoomFactor), w + (2 * bgPadding), bgHeight + (4 * zoomFactor));
// Draw table header (overlays the background)
ctx.fillStyle = '#f5f5f5';
ctx.fillRect(x, y, w, rowHeight);
// Header border
ctx.strokeStyle = '#cccccc';
ctx.lineWidth = Math.max(0.5, zoomFactor);
ctx.beginPath();
ctx.moveTo(x, y + rowHeight);
ctx.lineTo(x + w, y + rowHeight);
ctx.stroke();
// Header text
ctx.fillStyle = '#333333';
ctx.font = 'bold ' + fontSize + 'px Arial';
ctx.textBaseline = 'middle';
// Name column header (left-aligned)
ctx.textAlign = 'left';
ctx.fillText('Name', colNameX + padding, y + rowHeight / 2);
// Steuersatz column header (right-aligned)
ctx.textAlign = 'right';
ctx.fillText('Steuersatz', colVatX + colVatWidth - padding, y + rowHeight / 2);
// Nettobetrag column header (right-aligned)
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
var currentY = y + rowHeight;
// 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.fillStyle = '#333333';
// Name (left-aligned)
ctx.textAlign = 'left';
ctx.fillText(row.name, colNameX + padding, currentY + rowHeight / 2);
// Steuersatz (right-aligned)
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);
currentY += rowHeight;
});
// Draw column separator lines
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5);
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.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
var netTotalLabel, vatTotalLabel, grossTotalLabel;
if (serviceData && serviceData.netTotal) {
netTotalLabel = serviceData.netTotal;
vatTotalLabel = serviceData.vatTotal;
grossTotalLabel = serviceData.grossTotal;
} 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('.', ',') + ' €';
}
// Draw summary lines
ctx.textBaseline = 'middle';
// Nettosumme - label left, value right
ctx.fillStyle = '#333333';
ctx.font = fontSize + 'px Arial';
ctx.textAlign = 'left';
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2);
ctx.font = 'bold ' + fontSize + 'px Arial';
ctx.textAlign = 'right';
ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2);
summaryY += summaryRowHeight;
// Umsatzsteuer - label left, value right
ctx.font = fontSize + 'px Arial';
ctx.textAlign = 'left';
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
ctx.font = 'bold ' + fontSize + 'px Arial';
ctx.textAlign = 'right';
ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2);
summaryY += summaryRowHeight;
// Gesamtsumme - label left, value right
summaryY += summaryGap; // Extra gap before total
ctx.fillStyle = '#000000';
ctx.font = 'bold ' + (fontSize * 1.1) + 'px Arial';
ctx.textAlign = 'left';
ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2);
ctx.textAlign = 'right';
ctx.fillText(grossTotalLabel, valueX, summaryY + summaryRowHeight / 2);
}
function drawSelection(el) {
var x = pageX + (el.x * zoomFactor);
var y = pageY + (el.y * zoomFactor);
var w = (el.width || 100) * zoomFactor;
var h = (el.height || 30) * zoomFactor;
// 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';
// For services.list, calculate table height including summary section
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;
var totalHeight = tableHeight + summaryHeight;
h = Math.max(h, totalHeight);
} 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);
});
// Use the larger of defined width or actual text width
w = Math.max(w, maxLineWidth + (10 * zoomFactor));
// Calculate actual height based on number of lines
var lineHeight = fontSize * 1.2;
var textHeight = lines.length * lineHeight;
h = Math.max(h, textHeight + (6 * zoomFactor));
}
}
ctx.strokeStyle = '#1976d2';
ctx.lineWidth = Math.max(1, 2 * zoomFactor);
ctx.setLineDash([5, 3]);
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
// Don't show resize handles for static elements (except services.list)
if (el.isStatic && el.variable !== 'services.list') {
return;
}
// Handles
var hs = Math.max(6, 8 * zoomFactor); // handle size scaled
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#1976d2';
ctx.lineWidth = Math.max(1, 2 * zoomFactor);
var positions = [
[x - hs/2, y - hs/2],
[x + w/2 - hs/2, y - hs/2],
[x + w - hs/2, y - hs/2],
[x - hs/2, y + h/2 - hs/2],
[x + w - hs/2, y + h/2 - hs/2],
[x - hs/2, y + h - hs/2],
[x + w/2 - hs/2, y + h - hs/2],
[x + w - hs/2, y + h - hs/2]
];
positions.forEach(function(pos) {
ctx.fillRect(pos[0], pos[1], hs, hs);
ctx.strokeRect(pos[0], pos[1], hs, hs);
});
}
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));
}
}
return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh;
}
// Resizing state
var isResizing = false;
var resizeHandle = -1;
var resizeStart = { x: 0, y: 0, width: 0, height: 0 };
// Helper function to check if a point is on a resize handle
function getResizeHandle(x, y, el) {
if (!el) return -1;
// Allow resizing for services.list even though it's static
if (el.isStatic && el.variable !== 'services.list') return -1;
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 services.list, calculate table height including summary
if (el.variable === 'services.list') {
var fontSize = (el.fontSize || 14) * zoomFactor;
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);
}
var hs = Math.max(6, 8 * zoomFactor);
var halfHs = hs / 2;
// Handle positions: 0=TL, 1=TC, 2=TR, 3=ML, 4=MR, 5=BL, 6=BC, 7=BR
var handles = [
{ x: ex - halfHs, y: ey - halfHs },
{ x: ex + ew/2 - halfHs, y: ey - halfHs },
{ x: ex + ew - halfHs, y: ey - halfHs },
{ x: ex - halfHs, y: ey + eh/2 - halfHs },
{ x: ex + ew - halfHs, y: ey + eh/2 - halfHs },
{ x: ex - halfHs, y: ey + eh - halfHs },
{ x: ex + ew/2 - halfHs, y: ey + eh - halfHs },
{ x: ex + ew - halfHs, y: ey + eh - halfHs }
];
for (var i = 0; i < handles.length; i++) {
if (x >= handles[i].x && x <= handles[i].x + hs &&
y >= handles[i].y && y <= handles[i].y + hs) {
return i;
}
}
return -1;
}
// Mouse events
canvas.addEventListener('mousedown', function(e) {
var rect = canvas.getBoundingClientRect();
var x = e.clientX - rect.left;
var y = e.clientY - rect.top;
// Check if clicking on a resize handle of selected element
if (selectedElement) {
var handle = getResizeHandle(x, y, selectedElement);
if (handle >= 0) {
pushUndoState();
isResizing = true;
resizeHandle = handle;
resizeStart = {
x: x,
y: y,
width: selectedElement.width || 100,
height: selectedElement.height || 30,
elemX: selectedElement.x,
elemY: selectedElement.y
};
e.preventDefault();
return;
}
}
// Check if clicking on an element
var clickedElement = null;
for (var i = elements.length - 1; i >= 0; i--) {
if (hitTest(x, y, elements[i])) {
clickedElement = elements[i];
break;
}
}
if (clickedElement) {
pushUndoState();
selectedElement = clickedElement;
isDragging = true;
dragStart = { x: x, y: y };
elementStart = { x: clickedElement.x, y: clickedElement.y };
canvas.style.cursor = 'move';
notifyElementSelected(selectedElement);
} else {
selectedElement = null;
notifyElementDeselected();
}
draw();
});
canvas.addEventListener('mousemove', function(e) {
var rect = canvas.getBoundingClientRect();
var x = e.clientX - rect.left;
var y = e.clientY - rect.top;
if (isResizing && selectedElement) {
// Calculate delta in base coordinates
var dx = (x - resizeStart.x) / zoomFactor;
var dy = (y - resizeStart.y) / zoomFactor;
var newWidth = resizeStart.width;
var newHeight = resizeStart.height;
var newX = resizeStart.elemX;
var newY = resizeStart.elemY;
// 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);
newX = resizeStart.elemX + (resizeStart.width - newWidth);
newY = resizeStart.elemY + (resizeStart.height - newHeight);
break;
case 1: // Top-center
newHeight = Math.max(20, 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);
newY = resizeStart.elemY + (resizeStart.height - newHeight);
break;
case 3: // Middle-left
newWidth = Math.max(20, resizeStart.width - dx);
newX = resizeStart.elemX + (resizeStart.width - newWidth);
break;
case 4: // Middle-right
newWidth = Math.max(20, resizeStart.width + dx);
break;
case 5: // Bottom-left
newWidth = Math.max(20, resizeStart.width - dx);
newHeight = Math.max(20, resizeStart.height + dy);
newX = resizeStart.elemX + (resizeStart.width - newWidth);
break;
case 6: // Bottom-center
newHeight = Math.max(20, resizeStart.height + dy);
break;
case 7: // Bottom-right
newWidth = Math.max(20, resizeStart.width + dx);
newHeight = Math.max(20, resizeStart.height + dy);
break;
}
selectedElement.width = snapToGrid(newWidth);
selectedElement.height = snapToGrid(newHeight);
selectedElement.x = snapToGrid(newX);
selectedElement.y = snapToGrid(newY);
draw();
notifyElementSelected(selectedElement);
} else if (isDragging && selectedElement) {
// Adjust mouse movement by zoom factor to get stored coordinates
var dx = (x - dragStart.x) / zoomFactor;
var dy = (y - dragStart.y) / zoomFactor;
var newX = elementStart.x + dx;
var newY = elementStart.y + dy;
// Constrain to page (using base coordinates, not zoomed)
newX = Math.max(0, Math.min(newX, basePageWidth - (selectedElement.width || 100)));
newY = Math.max(0, Math.min(newY, basePageHeight - (selectedElement.height || 30)));
// Snap to grid
selectedElement.x = snapToGrid(newX);
selectedElement.y = snapToGrid(newY);
draw();
notifyElementSelected(selectedElement);
} else {
// Update cursor based on resize handles or element hover
if (selectedElement && !selectedElement.isStatic) {
var handle = getResizeHandle(x, y, selectedElement);
if (handle >= 0) {
// Set cursor based on handle type
var cursors = ['nwse-resize', 'ns-resize', 'nesw-resize', 'ew-resize', 'ew-resize', 'nesw-resize', 'ns-resize', 'nwse-resize'];
canvas.style.cursor = cursors[handle];
} else {
var hovering = false;
for (var i = elements.length - 1; i >= 0; i--) {
if (hitTest(x, y, elements[i])) {
hovering = true;
break;
}
}
canvas.style.cursor = hovering ? 'move' : 'default';
}
} else {
var hovering = false;
for (var i = elements.length - 1; i >= 0; i--) {
if (hitTest(x, y, elements[i])) {
hovering = true;
break;
}
}
canvas.style.cursor = hovering ? 'move' : 'default';
}
}
});
canvas.addEventListener('mouseup', function() {
isDragging = false;
isResizing = false;
resizeHandle = -1;
canvas.style.cursor = 'default';
});
canvas.addEventListener('mouseleave', function() {
isDragging = false;
isResizing = false;
resizeHandle = -1;
canvas.style.cursor = 'default';
});
// Keyboard navigation
canvas.setAttribute('tabindex', '0');
canvas.addEventListener('keydown', function(e) {
// Undo with Ctrl+Z / Cmd+Z (works without a selected element)
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
window.undoProfileCanvas();
e.preventDefault();
return;
}
if (!selectedElement) return;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Delete'].indexOf(e.key) > -1) {
pushUndoState();
}
var moved = false;
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);
}
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);
}
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);
}
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);
}
moved = true;
e.preventDefault();
break;
case 'Delete':
var index = elements.indexOf(selectedElement);
if (index > -1) {
elements.splice(index, 1);
selectedElement = null;
notifyElementDeselected();
draw();
}
e.preventDefault();
break;
}
if (moved) {
draw();
notifyElementSelected(selectedElement);
}
});
// Add element function
window.addProfileElement = function(type, label, dropX, dropY, isStatic, staticText, variable, isCustomer) {
pushUndoState();
elementCounter++;
var id = 'element-' + elementCounter;
// Convert to page coordinates (adjusting for zoom factor)
var x = (dropX - pageX - 50 * zoomFactor) / zoomFactor;
var y = (dropY - pageY - 15 * zoomFactor) / zoomFactor;
x = Math.max(10, x);
y = Math.max(10, y);
x = snapToGrid(x);
y = snapToGrid(y);
var el = {
id: id,
type: type,
x: x,
y: y,
width: 150,
height: 30,
fontSize: 14,
color: '#333333',
isStatic: isStatic || false,
variable: variable || null,
isCustomer: isCustomer || false
};
// Helper function to calculate height based on font size and lines
function calculateHeight(fontSize, lineCount) {
var lineHeight = fontSize * 1.2;
return Math.round(lineCount * lineHeight + 6);
}
// Handle static elements (user data or customer data)
if (isStatic && staticText) {
el.text = staticText;
el.color = '#000000'; // Black text for static elements
el.fontStyle = 'bold';
// For services.list, set larger dimensions for the table with summary section
if (variable === 'services.list') {
el.width = 450;
var lineHeight = el.fontSize * 1.4;
var padding = 4;
var rowHeight = lineHeight + padding * 2;
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 totalHeight = tableHeight + summaryHeight;
el.height = Math.round(totalHeight);
} else {
el.height = calculateHeight(el.fontSize, 1);
}
} else {
switch (type) {
case 'text':
el.text = 'Text eingeben...';
el.height = calculateHeight(el.fontSize, 1);
break;
case 'header':
el.text = 'Überschrift';
el.fontSize = 24;
el.fontStyle = 'bold';
el.color = '#000000';
el.width = 200;
el.height = calculateHeight(el.fontSize, 1);
break;
case 'date':
el.text = 'Datum: ' + new Date().toLocaleDateString('de-DE');
el.fontSize = 12;
el.color = '#666666';
el.height = calculateHeight(el.fontSize, 1);
break;
case 'customer':
el.text = 'Kundenname\nStraße Nr.\nPLZ Ort';
el.fontSize = 12;
el.height = calculateHeight(el.fontSize, 3);
break;
case 'company':
el.text = 'Ihr Unternehmen\nIhre Straße\nIhre PLZ Ort';
el.fontSize = 12;
el.height = calculateHeight(el.fontSize, 3);
break;
case 'amount':
el.text = 'Gesamtbetrag: 0,00 €';
el.fontStyle = 'bold';
el.width = 180;
el.height = calculateHeight(el.fontSize, 1);
break;
case 'line':
el.text = '';
el.width = 200;
el.height = 2;
break;
case 'image':
el.text = 'Bild';
el.width = 100;
el.height = 100;
break;
default:
el.text = label || 'Neues Element';
el.height = calculateHeight(el.fontSize, 1);
}
}
elements.push(el);
selectedElement = el;
draw();
notifyElementSelected(el);
// Focus canvas for keyboard navigation
canvas.focus();
};
// Update element functions
window.updateProfileElementText = function(id, text) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
el.text = text;
draw();
}
};
window.updateProfileElementPosition = function(id, x, y) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
if (x !== null) el.x = snapToGrid(x);
if (y !== null) el.y = snapToGrid(y);
draw();
}
};
window.updateProfileElementFontSize = function(id, size) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
el.fontSize = size;
// Update height based on text content and new font size
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
}
draw();
}
};
window.updateProfileElementColor = function(id, color) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
el.color = color;
draw();
}
};
window.updateProfileElementSize = function(id, width, height) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
if (width !== null) el.width = width;
if (height !== null) el.height = height;
draw();
}
};
window.updateProfileElementImage = function(id, imageData) {
var el = elements.find(function(e) { return e.id === id; });
if (!el) return;
// Load image to get dimensions
var img = new Image();
img.onload = function() {
pushUndoState();
var MAX_WIDTH = 1090;
var width = img.width;
var height = img.height;
// Only resize if image is larger than MAX_WIDTH
if (width > MAX_WIDTH) {
height = Math.round(height * (MAX_WIDTH / width));
width = MAX_WIDTH;
// Create canvas to resize image
var canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
var ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// Get resized image data (maintain original format if possible)
var resizedDataUrl = canvas.toDataURL('image/jpeg', 0.9);
el.imageData = resizedDataUrl;
} else {
el.imageData = imageData;
}
el.text = 'Bild';
draw();
};
img.src = imageData;
};
window.deleteProfileElement = function(id) {
var index = elements.findIndex(function(e) { return e.id === id; });
if (index > -1) {
pushUndoState();
elements.splice(index, 1);
if (selectedElement && selectedElement.id === id) {
selectedElement = null;
notifyElementDeselected();
}
draw();
}
};
// Save state to global before functions are defined
function saveState() {
window.profileInvoiceState.elements = elements;
window.profileInvoiceState.selectedElement = selectedElement;
window.profileInvoiceState.elementCounter = elementCounter;
}
// Clear canvas function
window.clearProfileCanvas = function() {
if (elements.length) {
pushUndoState();
}
// Keep the same array reference (shared with profileInvoiceState and undo)
elements.length = 0;
selectedElement = null;
saveState();
notifyElementDeselected();
draw();
};
// Get canvas data
// Helper functions for percentage conversion
function toPercentX(value) {
return (value / basePageWidth * 100).toFixed(2);
}
function toPercentY(value) {
return (value / basePageHeight * 100).toFixed(2);
}
function fromPercentX(percent) {
return Math.round(parseFloat(percent) / 100 * basePageWidth);
}
function fromPercentY(percent) {
return Math.round(parseFloat(percent) / 100 * basePageHeight);
}
window.getProfileCanvasData = function() {
saveState();
// Convert pixel values to percentages for storage
var elementsWithPercent = elements.map(function(el) {
// For static elements with variables, handle text storage differently:
// - masterdata variables: text is resolved from user data, don't store
// - customer variables: store the placeholder text from canvas
var textToStore = el.text;
if (el.isStatic && el.variable) {
if (el.variable.startsWith('masterdata.')) {
// Don't store the text for masterdata variables - will be resolved from user data
textToStore = null;
}
// For customer variables, keep the text (placeholder) as is
}
return {
id: el.id,
type: el.type,
text: textToStore,
xPercent: toPercentX(el.x),
yPercent: toPercentY(el.y),
widthPercent: toPercentX(el.width),
heightPercent: toPercentY(el.height),
fontSize: el.fontSize,
fontStyle: el.fontStyle,
textAlign: el.textAlign,
color: el.color,
isStatic: el.isStatic,
isCustomer: el.isCustomer,
variable: el.variable,
imageData: el.imageData
};
});
return {
elements: elementsWithPercent
};
};
window.updateProfileVatRate = function(rate) {
if (rate == null || isNaN(rate)) return;
window.profileInvoiceVatRate = rate;
draw();
};
window.updateProfileMasterdataValue = function(key, value) {
if (!window.masterdataValues) window.masterdataValues = {};
window.masterdataValues[key] = value;
elements.forEach(function(el) {
if (el.variable === key) {
el.text = value;
}
});
draw();
};
draw();
window.profileInvoiceState.initialized = true;
console.log('Profile canvas initialized with ' + elements.length + ' elements');
// Handle window resize
window.addEventListener('resize', function() {
var newRect = container.getBoundingClientRect();
if (newRect.width > 0 && newRect.height > 0) {
canvas.width = newRect.width;
canvas.height = newRect.height;
draw();
}
});
// Setup drop zone
if (!container._dropSetup) {
container._dropSetup = true;
container.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
container.style.borderColor = 'var(--lumo-primary-color)';
});
container.addEventListener('dragleave', function(e) {
container.style.borderColor = 'var(--lumo-contrast-20pct)';
});
container.addEventListener('drop', function(e) {
e.preventDefault();
container.style.borderColor = 'var(--lumo-contrast-20pct)';
var templateType = e.dataTransfer.getData('template-type');
var templateLabel = e.dataTransfer.getData('template-label');
var isStatic = e.dataTransfer.getData('is-static') === 'true';
var staticText = e.dataTransfer.getData('static-text');
var variable = e.dataTransfer.getData('variable');
var isCustomer = e.dataTransfer.getData('is-customer') === 'true';
if (templateType && window.addProfileElement) {
var rect = container.getBoundingClientRect();
var x = e.clientX - rect.left;
var y = e.clientY - rect.top;
window.addProfileElement(templateType, templateLabel, x, y, isStatic, staticText, variable, isCustomer);
}
});
}
// Load template function - defined INSIDE initProfileInvoiceGenerator to access closure variables
window.loadProfileTemplate = function(templateData) {
try {
console.log('loadProfileTemplate called with data:', JSON.stringify(templateData).substring(0, 200));
var data = (typeof templateData === 'string') ? JSON.parse(templateData) : templateData;
if (data.elements && Array.isArray(data.elements)) {
console.log('Loading ' + data.elements.length + ' elements');
console.log('Current elements array before clear:', elements.length);
// Clear existing elements - use length = 0 to keep the same array reference
elements.length = 0;
selectedElement = null;
console.log('Elements array after clear:', elements.length);
// Master data mapping - these should be filled from user data
var masterdata = window.masterdataValues || {};
// Load new elements
data.elements.forEach(function(el) {
console.log('Loading element:', el.id, 'type:', el.type, 'variable:', el.variable);
// Ensure element has an ID
if (!el.id) {
elementCounter++;
el.id = 'element-' + elementCounter;
}
// Convert percentages to pixels if they exist, otherwise use legacy pixel values
if (el.xPercent !== undefined) {
el.x = fromPercentX(el.xPercent);
}
if (el.yPercent !== undefined) {
el.y = fromPercentY(el.yPercent);
}
if (el.widthPercent !== undefined) {
el.width = fromPercentX(el.widthPercent);
}
if (el.heightPercent !== undefined) {
el.height = fromPercentY(el.heightPercent);
}
// Handle elements with variables
if (el.variable) {
el.isStatic = true;
if (el.variable.startsWith('customer.')) {
el.isCustomer = true;
// Customer variables - use saved text if available, otherwise use placeholder
if (!el.text || el.text.trim() === '') {
el.text = '[' + el.variable.replace('customer.', '') + ']';
}
// If text is already set (from saved template), keep it
} else if (el.variable.startsWith('masterdata.')) {
// Masterdata variables - use actual value from masterdata or placeholder
var value = masterdata[el.variable];
if (value && value.trim() !== '') {
el.text = value;
} else {
el.text = '[' + el.variable.replace('masterdata.', '') + ']';
}
}
}
// Legacy: If text contains a variable placeholder {{variable}}|Display Text, extract it
else if (el.text && el.text.startsWith('{{')) {
var pipeIndex = el.text.indexOf('|');
if (pipeIndex > -1) {
// Format: {{variable}}|Display Text
var placeholderPart = el.text.substring(0, pipeIndex);
var displayText = el.text.substring(pipeIndex + 1);
if (placeholderPart.startsWith('{{') && placeholderPart.endsWith('}}')) {
var varName = placeholderPart.substring(2, placeholderPart.length - 2);
el.variable = varName;
if (varName.startsWith('masterdata.')) {
el.isStatic = true;
// Use masterdata value or fallback to display text
var value = masterdata[varName];
el.text = (value && value.trim() !== '') ? value : displayText;
} else if (varName.startsWith('customer.')) {
el.isStatic = true;
el.isCustomer = true;
// Use saved text if available, otherwise use placeholder
if (!el.text || el.text.trim() === '' || el.text.startsWith('{{')) {
el.text = '[' + varName.replace('customer.', '') + ']';
}
}
}
} else if (el.text.endsWith('}}')) {
// Legacy format: {{variable}} without display text
var varName = el.text.substring(2, el.text.length - 2);
el.variable = varName;
if (varName.startsWith('masterdata.')) {
el.isStatic = true;
var value = masterdata[varName];
el.text = (value && value.trim() !== '') ? value : '[' + varName.replace('masterdata.', '') + ']';
} else if (varName.startsWith('customer.')) {
el.isStatic = true;
el.isCustomer = true;
el.text = '[' + varName.replace('customer.', '') + ']';
}
}
}
console.log('Element processed:', el.id, 'x:', el.x, 'y:', el.y, 'text:', el.text);
elements.push(el);
console.log('Element pushed, elements count now:', elements.length);
});
// Update counter to be higher than any loaded element
elements.forEach(function(el) {
if (el.id && el.id.startsWith('element-')) {
var num = parseInt(el.id.replace('element-', ''));
if (!isNaN(num) && num > elementCounter) {
elementCounter = num;
}
}
});
// Save to global state
saveState();
console.log('Calling draw(), elements count:', elements.length);
console.log('Canvas dimensions:', canvas.width, 'x', canvas.height);
draw();
console.log('draw() completed');
notifyElementDeselected();
console.log('Template loaded with ' + elements.length + ' elements');
} else {
console.log('No elements array found in template data');
}
} catch (e) {
console.error('Error loading template:', e);
}
};
};