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>
This commit is contained in:
@@ -40,6 +40,36 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
var elements = window.profileInvoiceState.elements;
|
var elements = window.profileInvoiceState.elements;
|
||||||
var selectedElement = window.profileInvoiceState.selectedElement;
|
var selectedElement = window.profileInvoiceState.selectedElement;
|
||||||
var elementCounter = window.profileInvoiceState.elementCounter;
|
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 isDragging = false;
|
||||||
var dragStart = { x: 0, y: 0 };
|
var dragStart = { x: 0, y: 0 };
|
||||||
var elementStart = { x: 0, y: 0 };
|
var elementStart = { x: 0, y: 0 };
|
||||||
@@ -306,8 +336,23 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
var colVatX = x + colNameWidth;
|
var colVatX = x + colNameWidth;
|
||||||
var colNetX = colVatX + colVatWidth;
|
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
|
// Calculate actual content height based on table + summary
|
||||||
var tableOnlyHeight = rowHeight * 4; // Header + 3 data rows
|
var tableOnlyHeight = rowHeight * (rows.length + 1); // Header + data rows
|
||||||
var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
||||||
var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight;
|
var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight;
|
||||||
// Ensure background covers at least the element height or the calculated content
|
// Ensure background covers at least the element height or the calculated content
|
||||||
@@ -346,21 +391,11 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
// Nettobetrag column header (right-aligned)
|
// Nettobetrag column header (right-aligned)
|
||||||
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
|
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
|
||||||
|
|
||||||
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
|
|
||||||
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
|
|
||||||
|
|
||||||
// Sample data rows (placeholder)
|
|
||||||
var sampleData = [
|
|
||||||
{ 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 €' }
|
|
||||||
];
|
|
||||||
|
|
||||||
var currentY = y + rowHeight;
|
var currentY = y + rowHeight;
|
||||||
|
|
||||||
// Draw sample rows
|
// Draw data rows
|
||||||
ctx.font = fontSize + 'px Arial';
|
ctx.font = fontSize + 'px Arial';
|
||||||
sampleData.forEach(function(row, index) {
|
rows.forEach(function(row, index) {
|
||||||
// Draw row background (alternating)
|
// Draw row background (alternating)
|
||||||
if (index % 2 === 1) {
|
if (index % 2 === 1) {
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.02)';
|
ctx.fillStyle = 'rgba(0,0,0,0.02)';
|
||||||
@@ -416,11 +451,20 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned)
|
var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned)
|
||||||
var valueX = x + w - padding; // Value column (right-aligned)
|
var valueX = x + w - padding; // Value column (right-aligned)
|
||||||
|
|
||||||
// Calculate totals from sample data
|
// Totals: provided with the service data or calculated from sample data
|
||||||
var netTotal = 655.00; // 450 + 85 + 120
|
var netTotalLabel, vatTotalLabel, grossTotalLabel;
|
||||||
var vatTotal = netTotal * vatRate;
|
if (serviceData && serviceData.netTotal) {
|
||||||
var grossTotal = netTotal + vatTotal;
|
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
|
// Draw summary lines
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
@@ -431,18 +475,18 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2);
|
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2);
|
||||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
ctx.font = 'bold ' + fontSize + 'px Arial';
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(netTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
|
ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
||||||
summaryY += summaryRowHeight;
|
summaryY += summaryRowHeight;
|
||||||
|
|
||||||
// Umsatzsteuer - label left, value right
|
// Umsatzsteuer - label left, value right
|
||||||
ctx.font = fontSize + 'px Arial';
|
ctx.font = fontSize + 'px Arial';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
|
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
|
||||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
ctx.font = 'bold ' + fontSize + 'px Arial';
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(vatTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
|
ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
||||||
summaryY += summaryRowHeight;
|
summaryY += summaryRowHeight;
|
||||||
|
|
||||||
// Gesamtsumme - label left, value right
|
// Gesamtsumme - label left, value right
|
||||||
summaryY += summaryGap; // Extra gap before total
|
summaryY += summaryGap; // Extra gap before total
|
||||||
ctx.fillStyle = '#000000';
|
ctx.fillStyle = '#000000';
|
||||||
@@ -450,7 +494,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2);
|
ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2);
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(grossTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
|
ctx.fillText(grossTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSelection(el) {
|
function drawSelection(el) {
|
||||||
@@ -630,6 +674,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
if (selectedElement) {
|
if (selectedElement) {
|
||||||
var handle = getResizeHandle(x, y, selectedElement);
|
var handle = getResizeHandle(x, y, selectedElement);
|
||||||
if (handle >= 0) {
|
if (handle >= 0) {
|
||||||
|
pushUndoState();
|
||||||
isResizing = true;
|
isResizing = true;
|
||||||
resizeHandle = handle;
|
resizeHandle = handle;
|
||||||
resizeStart = {
|
resizeStart = {
|
||||||
@@ -655,6 +700,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (clickedElement) {
|
if (clickedElement) {
|
||||||
|
pushUndoState();
|
||||||
selectedElement = clickedElement;
|
selectedElement = clickedElement;
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
dragStart = { x: x, y: y };
|
dragStart = { x: x, y: y };
|
||||||
@@ -795,8 +841,19 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
canvas.setAttribute('tabindex', '0');
|
canvas.setAttribute('tabindex', '0');
|
||||||
canvas.addEventListener('keydown', function(e) {
|
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 (!selectedElement) return;
|
||||||
|
|
||||||
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Delete'].indexOf(e.key) > -1) {
|
||||||
|
pushUndoState();
|
||||||
|
}
|
||||||
|
|
||||||
var moved = false;
|
var moved = false;
|
||||||
|
|
||||||
switch(e.key) {
|
switch(e.key) {
|
||||||
@@ -856,6 +913,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
|
|
||||||
// Add element function
|
// Add element function
|
||||||
window.addProfileElement = function(type, label, dropX, dropY, isStatic, staticText, variable, isCustomer) {
|
window.addProfileElement = function(type, label, dropX, dropY, isStatic, staticText, variable, isCustomer) {
|
||||||
|
pushUndoState();
|
||||||
elementCounter++;
|
elementCounter++;
|
||||||
var id = 'element-' + elementCounter;
|
var id = 'element-' + elementCounter;
|
||||||
|
|
||||||
@@ -972,23 +1030,26 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
window.updateProfileElementText = function(id, text) {
|
window.updateProfileElementText = function(id, text) {
|
||||||
var el = elements.find(function(e) { return e.id === id; });
|
var el = elements.find(function(e) { return e.id === id; });
|
||||||
if (el) {
|
if (el) {
|
||||||
|
pushUndoState();
|
||||||
el.text = text;
|
el.text = text;
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.updateProfileElementPosition = function(id, x, y) {
|
window.updateProfileElementPosition = function(id, x, y) {
|
||||||
var el = elements.find(function(e) { return e.id === id; });
|
var el = elements.find(function(e) { return e.id === id; });
|
||||||
if (el) {
|
if (el) {
|
||||||
|
pushUndoState();
|
||||||
if (x !== null) el.x = snapToGrid(x);
|
if (x !== null) el.x = snapToGrid(x);
|
||||||
if (y !== null) el.y = snapToGrid(y);
|
if (y !== null) el.y = snapToGrid(y);
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.updateProfileElementFontSize = function(id, size) {
|
window.updateProfileElementFontSize = function(id, size) {
|
||||||
var el = elements.find(function(e) { return e.id === id; });
|
var el = elements.find(function(e) { return e.id === id; });
|
||||||
if (el) {
|
if (el) {
|
||||||
|
pushUndoState();
|
||||||
el.fontSize = size;
|
el.fontSize = size;
|
||||||
// Update height based on text content and new font size
|
// Update height based on text content and new font size
|
||||||
if (el.type !== 'line' && el.type !== 'image') {
|
if (el.type !== 'line' && el.type !== 'image') {
|
||||||
@@ -1004,14 +1065,16 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
window.updateProfileElementColor = function(id, color) {
|
window.updateProfileElementColor = function(id, color) {
|
||||||
var el = elements.find(function(e) { return e.id === id; });
|
var el = elements.find(function(e) { return e.id === id; });
|
||||||
if (el) {
|
if (el) {
|
||||||
|
pushUndoState();
|
||||||
el.color = color;
|
el.color = color;
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.updateProfileElementSize = function(id, width, height) {
|
window.updateProfileElementSize = function(id, width, height) {
|
||||||
var el = elements.find(function(e) { return e.id === id; });
|
var el = elements.find(function(e) { return e.id === id; });
|
||||||
if (el) {
|
if (el) {
|
||||||
|
pushUndoState();
|
||||||
if (width !== null) el.width = width;
|
if (width !== null) el.width = width;
|
||||||
if (height !== null) el.height = height;
|
if (height !== null) el.height = height;
|
||||||
draw();
|
draw();
|
||||||
@@ -1025,6 +1088,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
// Load image to get dimensions
|
// Load image to get dimensions
|
||||||
var img = new Image();
|
var img = new Image();
|
||||||
img.onload = function() {
|
img.onload = function() {
|
||||||
|
pushUndoState();
|
||||||
var MAX_WIDTH = 1090;
|
var MAX_WIDTH = 1090;
|
||||||
var width = img.width;
|
var width = img.width;
|
||||||
var height = img.height;
|
var height = img.height;
|
||||||
@@ -1057,6 +1121,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
window.deleteProfileElement = function(id) {
|
window.deleteProfileElement = function(id) {
|
||||||
var index = elements.findIndex(function(e) { return e.id === id; });
|
var index = elements.findIndex(function(e) { return e.id === id; });
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
|
pushUndoState();
|
||||||
elements.splice(index, 1);
|
elements.splice(index, 1);
|
||||||
if (selectedElement && selectedElement.id === id) {
|
if (selectedElement && selectedElement.id === id) {
|
||||||
selectedElement = null;
|
selectedElement = null;
|
||||||
@@ -1075,10 +1140,13 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
|
|
||||||
// Clear canvas function
|
// Clear canvas function
|
||||||
window.clearProfileCanvas = function() {
|
window.clearProfileCanvas = function() {
|
||||||
elements = [];
|
if (elements.length) {
|
||||||
|
pushUndoState();
|
||||||
|
}
|
||||||
|
// Keep the same array reference (shared with profileInvoiceState and undo)
|
||||||
|
elements.length = 0;
|
||||||
selectedElement = null;
|
selectedElement = null;
|
||||||
window.profileInvoiceState.elements = [];
|
saveState();
|
||||||
window.profileInvoiceState.selectedElement = null;
|
|
||||||
notifyElementDeselected();
|
notifyElementDeselected();
|
||||||
draw();
|
draw();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.assecutor.votianlt.pages.base.ui.view;
|
package de.assecutor.votianlt.pages.base.ui.view;
|
||||||
|
|
||||||
import com.vaadin.flow.component.Component;
|
import com.vaadin.flow.component.Component;
|
||||||
|
import com.vaadin.flow.component.HasElement;
|
||||||
import com.vaadin.flow.component.UI;
|
import com.vaadin.flow.component.UI;
|
||||||
import com.vaadin.flow.component.applayout.AppLayout;
|
import com.vaadin.flow.component.applayout.AppLayout;
|
||||||
import com.vaadin.flow.component.avatar.Avatar;
|
import com.vaadin.flow.component.avatar.Avatar;
|
||||||
@@ -31,6 +32,7 @@ public final class AdminLayout extends AppLayout {
|
|||||||
private Div headerRef;
|
private Div headerRef;
|
||||||
private Scroller navRef;
|
private Scroller navRef;
|
||||||
private Component userMenuRef;
|
private Component userMenuRef;
|
||||||
|
private final Div viewContainer = new Div();
|
||||||
|
|
||||||
public AdminLayout(SecurityService securityService) {
|
public AdminLayout(SecurityService securityService) {
|
||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
@@ -47,12 +49,25 @@ public final class AdminLayout extends AppLayout {
|
|||||||
userMenuRef = createUserMenu();
|
userMenuRef = createUserMenu();
|
||||||
addToDrawer(headerRef, navRef, userMenuRef);
|
addToDrawer(headerRef, navRef, userMenuRef);
|
||||||
|
|
||||||
|
// Same content wrapper as MainLayout so admin pages get the identical
|
||||||
|
// page frame (margin, rounded corners, shell background)
|
||||||
|
viewContainer.addClassName("view-container");
|
||||||
|
getElement().appendChild(viewContainer.getElement());
|
||||||
|
|
||||||
updateDrawerVisibility();
|
updateDrawerVisibility();
|
||||||
|
|
||||||
// Re-check on attach (new UI/session) and on every navigation cycle
|
// Re-check on attach (new UI/session) and on every navigation cycle
|
||||||
addAttachListener(e -> updateDrawerVisibility());
|
addAttachListener(e -> updateDrawerVisibility());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void showRouterLayoutContent(HasElement content) {
|
||||||
|
viewContainer.getElement().removeAllChildren();
|
||||||
|
if (content != null) {
|
||||||
|
viewContainer.getElement().appendChild(content.getElement());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void updateDrawerVisibility() {
|
private void updateDrawerVisibility() {
|
||||||
boolean loggedIn = securityService.isUserLoggedIn();
|
boolean loggedIn = securityService.isUserLoggedIn();
|
||||||
if (headerRef != null)
|
if (headerRef != null)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.assecutor.votianlt.pages.view;
|
package de.assecutor.votianlt.pages.view;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
import com.vaadin.flow.component.html.Div;
|
import com.vaadin.flow.component.html.Div;
|
||||||
import com.vaadin.flow.component.html.H1;
|
import com.vaadin.flow.component.html.H1;
|
||||||
import com.vaadin.flow.component.html.H3;
|
import com.vaadin.flow.component.html.H3;
|
||||||
@@ -14,15 +15,23 @@ import com.vaadin.flow.router.HasDynamicTitle;
|
|||||||
import com.vaadin.flow.router.Menu;
|
import com.vaadin.flow.router.Menu;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
import com.vaadin.flow.theme.lumo.LumoUtility;
|
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||||
|
import de.assecutor.votianlt.model.Customer;
|
||||||
import de.assecutor.votianlt.model.JobStatus;
|
import de.assecutor.votianlt.model.JobStatus;
|
||||||
|
import de.assecutor.votianlt.model.User;
|
||||||
|
import de.assecutor.votianlt.pages.domain.CustomerRepository;
|
||||||
import de.assecutor.votianlt.repository.*;
|
import de.assecutor.votianlt.repository.*;
|
||||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class)
|
@Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class)
|
||||||
@RolesAllowed("ADMIN")
|
@RolesAllowed("ADMIN")
|
||||||
@@ -39,6 +48,7 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
|||||||
private final BarcodeRepository barcodeRepository;
|
private final BarcodeRepository barcodeRepository;
|
||||||
private final SignatureRepository signatureRepository;
|
private final SignatureRepository signatureRepository;
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
|
private final CustomerRepository customerRepository;
|
||||||
|
|
||||||
private final Div statisticsContainer;
|
private final Div statisticsContainer;
|
||||||
|
|
||||||
@@ -46,7 +56,8 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
|||||||
public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository,
|
public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository,
|
||||||
AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository,
|
AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository,
|
||||||
PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
||||||
SignatureRepository signatureRepository, CommentRepository commentRepository) {
|
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
||||||
|
CustomerRepository customerRepository) {
|
||||||
|
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
@@ -57,6 +68,7 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
|||||||
this.barcodeRepository = barcodeRepository;
|
this.barcodeRepository = barcodeRepository;
|
||||||
this.signatureRepository = signatureRepository;
|
this.signatureRepository = signatureRepository;
|
||||||
this.commentRepository = commentRepository;
|
this.commentRepository = commentRepository;
|
||||||
|
this.customerRepository = customerRepository;
|
||||||
|
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
||||||
@@ -124,6 +136,9 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
|||||||
// System overview section
|
// System overview section
|
||||||
mainLayout.add(createSystemOverviewSection());
|
mainLayout.add(createSystemOverviewSection());
|
||||||
|
|
||||||
|
// Customers overview section
|
||||||
|
mainLayout.add(createCustomersSection());
|
||||||
|
|
||||||
// Job statistics section
|
// Job statistics section
|
||||||
mainLayout.add(createJobStatisticsSection());
|
mainLayout.add(createJobStatisticsSection());
|
||||||
|
|
||||||
@@ -174,6 +189,62 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
|||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createCustomersSection() {
|
||||||
|
VerticalLayout section = new VerticalLayout();
|
||||||
|
section.setPadding(false);
|
||||||
|
section.addClassNames("surface-panel", "dashboard-section");
|
||||||
|
|
||||||
|
List<Customer> customers = customerRepository.findAll().stream().filter(customer -> !customer.isInternal())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
H3 title = new H3(getTranslation("admindashboard.section.customers", customers.size()));
|
||||||
|
title.addClassNames(LumoUtility.Margin.Bottom.MEDIUM, "dashboard-section-title");
|
||||||
|
|
||||||
|
if (customers.isEmpty()) {
|
||||||
|
Span emptyHint = new Span(getTranslation("admindashboard.customers.empty"));
|
||||||
|
section.add(title, emptyHint);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<ObjectId, User> usersById = userRepository.findAll().stream().filter(user -> user.getId() != null)
|
||||||
|
.collect(Collectors.toMap(User::getId, Function.identity(), (first, second) -> first));
|
||||||
|
|
||||||
|
Grid<Customer> grid = new Grid<>();
|
||||||
|
grid.setItems(customers);
|
||||||
|
grid.addColumn(Customer::getUsrId).setHeader(getTranslation("admindashboard.customers.column.number"))
|
||||||
|
.setAutoWidth(true).setFlexGrow(0).setSortable(true);
|
||||||
|
grid.addColumn(Customer::getCompanyName).setHeader(getTranslation("customers.column.company"))
|
||||||
|
.setAutoWidth(true).setSortable(true);
|
||||||
|
grid.addColumn(customer -> ((customer.getFirstname() != null ? customer.getFirstname() : "") + " "
|
||||||
|
+ (customer.getLastName() != null ? customer.getLastName() : "")).trim())
|
||||||
|
.setHeader(getTranslation("customers.column.name")).setAutoWidth(true).setSortable(true);
|
||||||
|
grid.addColumn(Customer::getMail).setHeader(getTranslation("customers.column.email")).setAutoWidth(true);
|
||||||
|
grid.addColumn(Customer::getTelephone).setHeader(getTranslation("customers.column.phone")).setAutoWidth(true);
|
||||||
|
grid.addColumn(customer -> ((customer.getZip() != null ? customer.getZip() : "") + " "
|
||||||
|
+ (customer.getCity() != null ? customer.getCity() : "")).trim())
|
||||||
|
.setHeader(getTranslation("customers.column.city")).setAutoWidth(true).setSortable(true);
|
||||||
|
grid.addColumn(customer -> formatOwner(usersById.get(customer.getOwner())))
|
||||||
|
.setHeader(getTranslation("admindashboard.customers.column.owner")).setAutoWidth(true)
|
||||||
|
.setSortable(true);
|
||||||
|
grid.addClassName("data-grid");
|
||||||
|
grid.setWidthFull();
|
||||||
|
grid.setHeight("400px");
|
||||||
|
|
||||||
|
section.add(title, grid);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatOwner(User owner) {
|
||||||
|
if (owner == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (owner.getCompany() != null && !owner.getCompany().isBlank()) {
|
||||||
|
return owner.getCompany();
|
||||||
|
}
|
||||||
|
return ((owner.getFirstname() != null ? owner.getFirstname() : "") + " "
|
||||||
|
+ (owner.getName() != null ? owner.getName() : "")).trim();
|
||||||
|
}
|
||||||
|
|
||||||
private VerticalLayout createJobStatisticsSection() {
|
private VerticalLayout createJobStatisticsSection() {
|
||||||
VerticalLayout section = new VerticalLayout();
|
VerticalLayout section = new VerticalLayout();
|
||||||
section.setPadding(false);
|
section.setPadding(false);
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
|
|||||||
|
|
||||||
setSpacing(false);
|
setSpacing(false);
|
||||||
setPadding(false);
|
setPadding(false);
|
||||||
getStyle().set("margin", "14px");
|
setWidthFull();
|
||||||
setWidth("90%");
|
|
||||||
addClassName("admin-form-view");
|
addClassName("admin-form-view");
|
||||||
add(new ViewToolbar(getTranslation("adminpricetable.title")));
|
add(new ViewToolbar(getTranslation("adminpricetable.title")));
|
||||||
|
|
||||||
|
|||||||
@@ -429,6 +429,11 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
actionLayout.setSpacing(true);
|
actionLayout.setSpacing(true);
|
||||||
actionLayout.getStyle().set("margin-top", "var(--lumo-space-s)");
|
actionLayout.getStyle().set("margin-top", "var(--lumo-space-s)");
|
||||||
|
|
||||||
|
Button undoButton = new Button(getTranslation("button.undo"), new Icon(VaadinIcon.ARROW_BACKWARD));
|
||||||
|
undoButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
undoButton.addClickListener(
|
||||||
|
e -> getElement().executeJs("if (window.undoProfileCanvas) { window.undoProfileCanvas(); }"));
|
||||||
|
|
||||||
Button previewPdfButton = new Button(getTranslation("button.preview"), new Icon(VaadinIcon.EYE));
|
Button previewPdfButton = new Button(getTranslation("button.preview"), new Icon(VaadinIcon.EYE));
|
||||||
previewPdfButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
|
previewPdfButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
|
||||||
previewPdfButton.addClickListener(e -> generatePreviewPdfFromProfile());
|
previewPdfButton.addClickListener(e -> generatePreviewPdfFromProfile());
|
||||||
@@ -462,7 +467,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
actionLayout.add(previewPdfButton, saveTemplateButton);
|
actionLayout.add(undoButton, previewPdfButton, saveTemplateButton);
|
||||||
billingTab.add(actionLayout);
|
billingTab.add(actionLayout);
|
||||||
|
|
||||||
// Initialen Zustand setzen (sichtbar da checkbox standardmäßig true)
|
// Initialen Zustand setzen (sichtbar da checkbox standardmäßig true)
|
||||||
@@ -486,7 +491,8 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
// Also register this view instance for JavaScript callbacks
|
// Also register this view instance for JavaScript callbacks
|
||||||
tabSheet.addSelectedChangeListener(e -> {
|
tabSheet.addSelectedChangeListener(e -> {
|
||||||
if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) {
|
if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) {
|
||||||
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;" + "setTimeout(function() { "
|
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;"
|
||||||
|
+ "window.profileInvoiceServiceData = null;" + "setTimeout(function() { "
|
||||||
+ " if (window.initProfileInvoiceGenerator) { " + " window.initProfileInvoiceGenerator(); "
|
+ " if (window.initProfileInvoiceGenerator) { " + " window.initProfileInvoiceGenerator(); "
|
||||||
+ " console.log('Canvas initialized, now loading template...'); "
|
+ " console.log('Canvas initialized, now loading template...'); "
|
||||||
+ " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); "
|
+ " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); "
|
||||||
@@ -1466,6 +1472,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
getElement().executeJs("setTimeout(function() { "
|
getElement().executeJs("setTimeout(function() { "
|
||||||
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
|
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
|
||||||
+ " console.log('Loading template into canvas...'); "
|
+ " console.log('Loading template into canvas...'); "
|
||||||
|
+ " window.profileInvoiceServiceData = null; "
|
||||||
+ " window.profileInvoiceVatRate = " + vatRateJs + "; "
|
+ " window.profileInvoiceVatRate = " + vatRateJs + "; "
|
||||||
+ " window.masterdataValues = "
|
+ " window.masterdataValues = "
|
||||||
+ masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "
|
+ masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "
|
||||||
|
|||||||
@@ -20,21 +20,29 @@ import com.vaadin.flow.component.upload.Upload;
|
|||||||
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
|
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
|
||||||
import com.vaadin.flow.router.HasDynamicTitle;
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
|
import de.assecutor.votianlt.model.PriceTable;
|
||||||
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.pages.base.ui.view.AdminLayout;
|
import de.assecutor.votianlt.pages.base.ui.view.AdminLayout;
|
||||||
|
import de.assecutor.votianlt.repository.PriceTableRepository;
|
||||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||||
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin canvas designer for the SYSTEM invoice template: the template used for
|
* Admin canvas designer for the SYSTEM invoice template: the template used for
|
||||||
@@ -59,13 +67,15 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
|||||||
|
|
||||||
private final CustomerInvoiceService customerInvoiceService;
|
private final CustomerInvoiceService customerInvoiceService;
|
||||||
private final InvoiceTemplateService invoiceTemplateService;
|
private final InvoiceTemplateService invoiceTemplateService;
|
||||||
|
private final PriceTableRepository priceTableRepository;
|
||||||
|
|
||||||
private VerticalLayout propertiesPanel;
|
private VerticalLayout propertiesPanel;
|
||||||
|
|
||||||
public InvoiceGeneratorView(CustomerInvoiceService customerInvoiceService,
|
public InvoiceGeneratorView(CustomerInvoiceService customerInvoiceService,
|
||||||
InvoiceTemplateService invoiceTemplateService) {
|
InvoiceTemplateService invoiceTemplateService, PriceTableRepository priceTableRepository) {
|
||||||
this.customerInvoiceService = customerInvoiceService;
|
this.customerInvoiceService = customerInvoiceService;
|
||||||
this.invoiceTemplateService = invoiceTemplateService;
|
this.invoiceTemplateService = invoiceTemplateService;
|
||||||
|
this.priceTableRepository = priceTableRepository;
|
||||||
|
|
||||||
setId("invoice-generator-view");
|
setId("invoice-generator-view");
|
||||||
addClassNames("invoice-generator-view", "admin-form-view");
|
addClassNames("invoice-generator-view", "admin-form-view");
|
||||||
@@ -81,7 +91,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
|||||||
// Hauptlayout: Links (Bausteine) | Mitte (Canvas) | Rechts (Eigenschaften)
|
// Hauptlayout: Links (Bausteine) | Mitte (Canvas) | Rechts (Eigenschaften)
|
||||||
HorizontalLayout mainLayout = new HorizontalLayout();
|
HorizontalLayout mainLayout = new HorizontalLayout();
|
||||||
mainLayout.setWidth("100%");
|
mainLayout.setWidth("100%");
|
||||||
mainLayout.setHeight("calc(100vh - 60px)");
|
mainLayout.setHeight("calc(100% - 60px)");
|
||||||
mainLayout.setSpacing(true);
|
mainLayout.setSpacing(true);
|
||||||
mainLayout.getStyle().set("overflow", "hidden");
|
mainLayout.getStyle().set("overflow", "hidden");
|
||||||
mainLayout.addClassName("invoice-generator-main");
|
mainLayout.addClassName("invoice-generator-main");
|
||||||
@@ -119,12 +129,13 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
|||||||
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;"
|
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;"
|
||||||
+ "window.profileInvoiceVatRate = " + SYSTEM_VAT_RATE.doubleValue() + ";"
|
+ "window.profileInvoiceVatRate = " + SYSTEM_VAT_RATE.doubleValue() + ";"
|
||||||
+ "window.masterdataValues = JSON.parse($1);"
|
+ "window.masterdataValues = JSON.parse($1);"
|
||||||
|
+ "window.profileInvoiceServiceData = JSON.parse($2);"
|
||||||
+ "setTimeout(function() { "
|
+ "setTimeout(function() { "
|
||||||
+ " if (window.initProfileInvoiceGenerator) { "
|
+ " if (window.initProfileInvoiceGenerator) { "
|
||||||
+ " window.initProfileInvoiceGenerator(); "
|
+ " window.initProfileInvoiceGenerator(); "
|
||||||
+ " setTimeout(function() { $0.$server.onCanvasReady(); }, 200); "
|
+ " setTimeout(function() { $0.$server.onCanvasReady(); }, 200); "
|
||||||
+ " } else { console.error('initProfileInvoiceGenerator not found'); } "
|
+ " } else { console.error('initProfileInvoiceGenerator not found'); } "
|
||||||
+ "}, 300);", getElement(), masterdataJson);
|
+ "}, 300);", getElement(), masterdataJson, buildCanvasPositionsJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,9 +197,136 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
|||||||
variables.put("customer.city", "12345 Musterstadt");
|
variables.put("customer.city", "12345 Musterstadt");
|
||||||
variables.put("customer.email", "nutzer@beispiel.de");
|
variables.put("customer.email", "nutzer@beispiel.de");
|
||||||
variables.put("customer.phone", "0123 456789");
|
variables.put("customer.phone", "0123 456789");
|
||||||
|
|
||||||
|
// Positionen: die Rechnung an die Nutzer enthält ausschließlich die drei
|
||||||
|
// Parameter der Preistabelle (/admin-price-table), keine Leistungen
|
||||||
|
addPriceTablePositions(variables);
|
||||||
return variables;
|
return variables;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three positions of the price table (/admin-price-table): label plus
|
||||||
|
* configured value. These are the only positions on system invoices.
|
||||||
|
*/
|
||||||
|
private String[][] priceTableEntries() {
|
||||||
|
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null);
|
||||||
|
return new String[][] {
|
||||||
|
{ getTranslation("adminpricetable.field.monthly"),
|
||||||
|
priceTable != null ? priceTable.getMonthlyBasePackage() : null },
|
||||||
|
{ getTranslation("adminpricetable.field.applicense"),
|
||||||
|
priceTable != null ? priceTable.getAppUsageLicense() : null },
|
||||||
|
{ getTranslation("adminpricetable.field.revenue"),
|
||||||
|
priceTable != null ? priceTable.getRevenueParticipation() : null } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON for the canvas rendering of the "services.list" block: the three
|
||||||
|
* price table positions plus totals (window.profileInvoiceServiceData).
|
||||||
|
*/
|
||||||
|
private String buildCanvasPositionsJson() {
|
||||||
|
String vatLabel = SYSTEM_VAT_RATE.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + "%";
|
||||||
|
List<Map<String, String>> rows = new ArrayList<>();
|
||||||
|
BigDecimal netTotal = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
for (String[] entry : priceTableEntries()) {
|
||||||
|
String rawValue = entry[1] != null ? entry[1].trim() : "";
|
||||||
|
BigDecimal amount = parseAmount(rawValue);
|
||||||
|
String netDisplay;
|
||||||
|
if (amount != null) {
|
||||||
|
netDisplay = formatAmount(amount) + " €";
|
||||||
|
netTotal = netTotal.add(amount);
|
||||||
|
} else {
|
||||||
|
netDisplay = rawValue.isEmpty() ? "-" : rawValue;
|
||||||
|
}
|
||||||
|
Map<String, String> row = new HashMap<>();
|
||||||
|
row.put("name", entry[0]);
|
||||||
|
row.put("vat", vatLabel);
|
||||||
|
row.put("net", netDisplay);
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal vatTotal = netTotal.multiply(SYSTEM_VAT_RATE);
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("rows", rows);
|
||||||
|
data.put("netTotal", formatAmount(netTotal) + " €");
|
||||||
|
data.put("vatTotal", formatAmount(vatTotal) + " €");
|
||||||
|
data.put("grossTotal", formatAmount(netTotal.add(vatTotal)) + " €");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(data);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Error serializing canvas positions: {}", ex.getMessage(), ex);
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addPriceTablePositions(Map<String, String> variables) {
|
||||||
|
List<Map<String, String>> positions = new ArrayList<>();
|
||||||
|
BigDecimal netTotal = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
for (String[] entry : priceTableEntries()) {
|
||||||
|
String rawValue = entry[1] != null ? entry[1].trim() : "";
|
||||||
|
Map<String, String> position = new HashMap<>();
|
||||||
|
position.put("name", entry[0]);
|
||||||
|
BigDecimal amount = parseAmount(rawValue);
|
||||||
|
if (amount != null) {
|
||||||
|
position.put("netAmount", formatAmount(amount));
|
||||||
|
netTotal = netTotal.add(amount);
|
||||||
|
} else {
|
||||||
|
// Nicht-numerische Werte (z.B. "15 %" Umsatzbeteiligung) unverändert anzeigen
|
||||||
|
position.put("netAmount", rawValue.isEmpty() ? "-" : rawValue);
|
||||||
|
}
|
||||||
|
positions.add(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal vatTotal = netTotal.multiply(SYSTEM_VAT_RATE);
|
||||||
|
BigDecimal grossTotal = netTotal.add(vatTotal);
|
||||||
|
|
||||||
|
try {
|
||||||
|
variables.put("services.json",
|
||||||
|
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(positions));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Error serializing price table positions: {}", ex.getMessage(), ex);
|
||||||
|
variables.put("services.json", "[]");
|
||||||
|
}
|
||||||
|
variables.put("services.count", String.valueOf(positions.size()));
|
||||||
|
variables.put("invoice.net_total", formatAmount(netTotal) + " €");
|
||||||
|
variables.put("invoice.vat_total", formatAmount(vatTotal) + " €");
|
||||||
|
variables.put("invoice.gross_total", formatAmount(grossTotal) + " €");
|
||||||
|
variables.put("invoice.vat_rate",
|
||||||
|
SYSTEM_VAT_RATE.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + "%");
|
||||||
|
variables.put("services.net_total", formatAmount(netTotal) + " €");
|
||||||
|
variables.put("services.gross_total", formatAmount(grossTotal) + " €");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a price table value like "99,00 €", "1.234,56" or "120 EUR" into a
|
||||||
|
* numeric amount. Returns null for non-monetary values such as "15 %".
|
||||||
|
*/
|
||||||
|
private BigDecimal parseAmount(String value) {
|
||||||
|
if (value == null || value.isBlank() || value.contains("%")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Matcher matcher = Pattern.compile("([0-9]{1,3}(?:\\.[0-9]{3})*(?:,[0-9]+)?|[0-9]+(?:[.,][0-9]+)?)")
|
||||||
|
.matcher(value);
|
||||||
|
if (!matcher.find()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String number = matcher.group(1);
|
||||||
|
if (number.contains(",")) {
|
||||||
|
number = number.replace(".", "").replace(',', '.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new BigDecimal(number);
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatAmount(BigDecimal amount) {
|
||||||
|
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",");
|
||||||
|
}
|
||||||
|
|
||||||
private String buildMasterdataJson() {
|
private String buildMasterdataJson() {
|
||||||
try {
|
try {
|
||||||
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(buildSystemVariables());
|
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(buildSystemVariables());
|
||||||
@@ -242,15 +380,11 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
|||||||
Div invoiceDate = createVariableTemplate(getTranslation("invoicegenerator.issuer.invoicedate"),
|
Div invoiceDate = createVariableTemplate(getTranslation("invoicegenerator.issuer.invoicedate"),
|
||||||
VaadinIcon.CALENDAR_CLOCK, "masterdata.invoice_date", values.get("masterdata.invoice_date"));
|
VaadinIcon.CALENDAR_CLOCK, "masterdata.invoice_date", values.get("masterdata.invoice_date"));
|
||||||
|
|
||||||
// Bereich 2: Leistungen / Positionen
|
// Bereich 2: Positionen (Parameter der Preistabelle, keine Leistungen)
|
||||||
Span servicesHeader = createSectionHeader(getTranslation("profile.services.label"));
|
Span servicesHeader = createSectionHeader(getTranslation("invoicegenerator.positions.header"));
|
||||||
|
|
||||||
Div servicesListBlock = createServicesVariableTemplate(getTranslation("profile.invoice.services.list"),
|
Div servicesListBlock = createServicesVariableTemplate(getTranslation("invoicegenerator.positions.list"),
|
||||||
VaadinIcon.LIST, "services.list", "Position 1: 100,00 €\nPosition 2: 50,00 €");
|
VaadinIcon.LIST, "services.list", "Position 1: 100,00 €\nPosition 2: 50,00 €");
|
||||||
Div servicesNetBlock = createServicesVariableTemplate(getTranslation("profile.invoice.net"),
|
|
||||||
VaadinIcon.COIN_PILES, "services.net_total", "150,00 €");
|
|
||||||
Div servicesGrossBlock = createServicesVariableTemplate(getTranslation("profile.invoice.gross"),
|
|
||||||
VaadinIcon.MONEY, "services.gross_total", "178,50 €");
|
|
||||||
|
|
||||||
// Bereich 3: Empfänger (Nutzer des Systems)
|
// Bereich 3: Empfänger (Nutzer des Systems)
|
||||||
Span recipientHeader = createSectionHeader(getTranslation("invoicegenerator.recipient.header"));
|
Span recipientHeader = createSectionHeader(getTranslation("invoicegenerator.recipient.header"));
|
||||||
@@ -282,9 +416,8 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
|||||||
|
|
||||||
panel.add(issuerHeader, issuerCompany, issuerStreet, issuerCity, issuerPhone, issuerEmail, issuerWebsite,
|
panel.add(issuerHeader, issuerCompany, issuerStreet, issuerCity, issuerPhone, issuerEmail, issuerWebsite,
|
||||||
issuerSenderLine, issuerPaymentTerms, issuerFooter, invoiceNumber, invoiceDate, servicesHeader,
|
issuerSenderLine, issuerPaymentTerms, issuerFooter, invoiceNumber, invoiceDate, servicesHeader,
|
||||||
servicesListBlock, servicesNetBlock, servicesGrossBlock, recipientHeader, recipientCompany,
|
servicesListBlock, recipientHeader, recipientCompany, recipientName, recipientStreet, recipientCity,
|
||||||
recipientName, recipientStreet, recipientCity, recipientEmail, freeHeader, textBlock, headerBlock,
|
recipientEmail, freeHeader, textBlock, headerBlock, dateBlock, lineBlock, imageBlock);
|
||||||
dateBlock, lineBlock, imageBlock);
|
|
||||||
|
|
||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
@@ -727,6 +860,11 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
|||||||
showNotification(getTranslation("invoicegenerator.notification.cleared"));
|
showNotification(getTranslation("invoicegenerator.notification.cleared"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Button undoButton = new Button(getTranslation("button.undo"), new Icon(VaadinIcon.ARROW_BACKWARD));
|
||||||
|
undoButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
undoButton.addClickListener(
|
||||||
|
e -> getElement().executeJs("if (window.undoProfileCanvas) { window.undoProfileCanvas(); }"));
|
||||||
|
|
||||||
Button previewButton = new Button(getTranslation("button.preview"), new Icon(VaadinIcon.EYE));
|
Button previewButton = new Button(getTranslation("button.preview"), new Icon(VaadinIcon.EYE));
|
||||||
previewButton.addThemeVariants(ButtonVariant.LUMO_CONTRAST);
|
previewButton.addThemeVariants(ButtonVariant.LUMO_CONTRAST);
|
||||||
previewButton.addClickListener(e -> generatePreviewPdf());
|
previewButton.addClickListener(e -> generatePreviewPdf());
|
||||||
@@ -735,7 +873,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
|||||||
saveTemplateButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
saveTemplateButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
saveTemplateButton.addClickListener(e -> saveSystemTemplate());
|
saveTemplateButton.addClickListener(e -> saveSystemTemplate());
|
||||||
|
|
||||||
layout.add(clearButton, previewButton, saveTemplateButton);
|
layout.add(undoButton, clearButton, previewButton, saveTemplateButton);
|
||||||
|
|
||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -476,8 +476,13 @@ public class CustomerInvoiceService {
|
|||||||
"<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>");
|
"<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>");
|
||||||
}
|
}
|
||||||
} else if ("services.list".equals(variable)) {
|
} else if ("services.list".equals(variable)) {
|
||||||
// Render services list as a table
|
// Render positions from services.json if provided (e.g. system
|
||||||
htmlBuilder.append(generateServicesTableHtml(mmWidth, effectiveVatRate));
|
// invoice: price table positions), otherwise sample data
|
||||||
|
if (variables.containsKey("services.json")) {
|
||||||
|
htmlBuilder.append(generateServicesTableHtmlWithData(mmWidth, variables));
|
||||||
|
} else {
|
||||||
|
htmlBuilder.append(generateServicesTableHtml(mmWidth, effectiveVatRate));
|
||||||
|
}
|
||||||
} else if (text.contains("<br>")) {
|
} else if (text.contains("<br>")) {
|
||||||
// Multi-line text: render without nowrap so <br> tags work
|
// Multi-line text: render without nowrap so <br> tags work
|
||||||
htmlBuilder.append("<span>").append(text).append("</span>");
|
htmlBuilder.append("<span>").append(text).append("</span>");
|
||||||
@@ -877,8 +882,11 @@ public class CustomerInvoiceService {
|
|||||||
.append(escapeHtml(name)).append("</td>");
|
.append(escapeHtml(name)).append("</td>");
|
||||||
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
|
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
|
||||||
.append(escapeHtml(vatRateLabel)).append("</td>");
|
.append(escapeHtml(vatRateLabel)).append("</td>");
|
||||||
|
// Append € only for plain numeric amounts; values like "15 %"
|
||||||
|
// (revenue participation from the price table) are shown as-is
|
||||||
|
String amountDisplay = netAmount.matches("[0-9.,]+") ? netAmount + " €" : escapeHtml(netAmount);
|
||||||
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
|
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
|
||||||
.append(netAmount).append(" €</td>");
|
.append(amountDisplay).append("</td>");
|
||||||
html.append("</tr>");
|
html.append("</tr>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1077,3 +1077,12 @@ invoicegenerator.notification.cleared=Canvas geleert
|
|||||||
invoicegenerator.notification.canvas.error=Canvas-Daten konnten nicht gelesen werden
|
invoicegenerator.notification.canvas.error=Canvas-Daten konnten nicht gelesen werden
|
||||||
invoicegenerator.notification.preview.error=Fehler bei der Vorschau: {0}
|
invoicegenerator.notification.preview.error=Fehler bei der Vorschau: {0}
|
||||||
invoicegenerator.pdf.preview.title=PDF-Vorschau
|
invoicegenerator.pdf.preview.title=PDF-Vorschau
|
||||||
|
|
||||||
|
# Admin Dashboard - Kundenübersicht
|
||||||
|
admindashboard.section.customers=Kunden ({0})
|
||||||
|
admindashboard.customers.column.number=Kundennummer
|
||||||
|
admindashboard.customers.column.owner=Nutzer
|
||||||
|
admindashboard.customers.empty=Keine Kunden vorhanden
|
||||||
|
invoicegenerator.positions.header=Positionen (Preistabelle)
|
||||||
|
invoicegenerator.positions.list=Positionen auflisten
|
||||||
|
button.undo=Rückgängig
|
||||||
|
|||||||
@@ -907,3 +907,12 @@ invoicegenerator.notification.cleared=Lõuend tühjendatud
|
|||||||
invoicegenerator.notification.canvas.error=Lõuendi andmeid ei õnnestunud lugeda
|
invoicegenerator.notification.canvas.error=Lõuendi andmeid ei õnnestunud lugeda
|
||||||
invoicegenerator.notification.preview.error=Eelvaate viga: {0}
|
invoicegenerator.notification.preview.error=Eelvaate viga: {0}
|
||||||
invoicegenerator.pdf.preview.title=PDF-i eelvaade
|
invoicegenerator.pdf.preview.title=PDF-i eelvaade
|
||||||
|
|
||||||
|
# Admin Dashboard - Klientide ülevaade
|
||||||
|
admindashboard.section.customers=Kliendid ({0})
|
||||||
|
admindashboard.customers.column.number=Kliendinumber
|
||||||
|
admindashboard.customers.column.owner=Kasutaja
|
||||||
|
admindashboard.customers.empty=Kliente pole
|
||||||
|
invoicegenerator.positions.header=Positsioonid (hinnatabel)
|
||||||
|
invoicegenerator.positions.list=Positsioonide loend
|
||||||
|
button.undo=Võta tagasi
|
||||||
|
|||||||
@@ -1077,3 +1077,12 @@ invoicegenerator.notification.cleared=Canvas cleared
|
|||||||
invoicegenerator.notification.canvas.error=Could not read canvas data
|
invoicegenerator.notification.canvas.error=Could not read canvas data
|
||||||
invoicegenerator.notification.preview.error=Preview error: {0}
|
invoicegenerator.notification.preview.error=Preview error: {0}
|
||||||
invoicegenerator.pdf.preview.title=PDF preview
|
invoicegenerator.pdf.preview.title=PDF preview
|
||||||
|
|
||||||
|
# Admin Dashboard - Customer overview
|
||||||
|
admindashboard.section.customers=Customers ({0})
|
||||||
|
admindashboard.customers.column.number=Customer number
|
||||||
|
admindashboard.customers.column.owner=User
|
||||||
|
admindashboard.customers.empty=No customers available
|
||||||
|
invoicegenerator.positions.header=Positions (price table)
|
||||||
|
invoicegenerator.positions.list=List positions
|
||||||
|
button.undo=Undo
|
||||||
|
|||||||
@@ -1008,3 +1008,12 @@ invoicegenerator.notification.cleared=Lienzo vaciado
|
|||||||
invoicegenerator.notification.canvas.error=No se pudieron leer los datos del lienzo
|
invoicegenerator.notification.canvas.error=No se pudieron leer los datos del lienzo
|
||||||
invoicegenerator.notification.preview.error=Error de vista previa: {0}
|
invoicegenerator.notification.preview.error=Error de vista previa: {0}
|
||||||
invoicegenerator.pdf.preview.title=Vista previa del PDF
|
invoicegenerator.pdf.preview.title=Vista previa del PDF
|
||||||
|
|
||||||
|
# Admin Dashboard - Resumen de clientes
|
||||||
|
admindashboard.section.customers=Clientes ({0})
|
||||||
|
admindashboard.customers.column.number=Número de cliente
|
||||||
|
admindashboard.customers.column.owner=Usuario
|
||||||
|
admindashboard.customers.empty=No hay clientes
|
||||||
|
invoicegenerator.positions.header=Posiciones (tabla de precios)
|
||||||
|
invoicegenerator.positions.list=Listar posiciones
|
||||||
|
button.undo=Deshacer
|
||||||
|
|||||||
@@ -1008,3 +1008,12 @@ invoicegenerator.notification.cleared=Canevas vidé
|
|||||||
invoicegenerator.notification.canvas.error=Impossible de lire les données du canevas
|
invoicegenerator.notification.canvas.error=Impossible de lire les données du canevas
|
||||||
invoicegenerator.notification.preview.error=Erreur d''aperçu : {0}
|
invoicegenerator.notification.preview.error=Erreur d''aperçu : {0}
|
||||||
invoicegenerator.pdf.preview.title=Aperçu PDF
|
invoicegenerator.pdf.preview.title=Aperçu PDF
|
||||||
|
|
||||||
|
# Admin Dashboard - Aperçu des clients
|
||||||
|
admindashboard.section.customers=Clients ({0})
|
||||||
|
admindashboard.customers.column.number=Numéro de client
|
||||||
|
admindashboard.customers.column.owner=Utilisateur
|
||||||
|
admindashboard.customers.empty=Aucun client
|
||||||
|
invoicegenerator.positions.header=Positions (grille tarifaire)
|
||||||
|
invoicegenerator.positions.list=Lister les positions
|
||||||
|
button.undo=Annuler
|
||||||
|
|||||||
@@ -1010,3 +1010,12 @@ invoicegenerator.notification.cleared=Drobė išvalyta
|
|||||||
invoicegenerator.notification.canvas.error=Nepavyko nuskaityti drobės duomenų
|
invoicegenerator.notification.canvas.error=Nepavyko nuskaityti drobės duomenų
|
||||||
invoicegenerator.notification.preview.error=Peržiūros klaida: {0}
|
invoicegenerator.notification.preview.error=Peržiūros klaida: {0}
|
||||||
invoicegenerator.pdf.preview.title=PDF peržiūra
|
invoicegenerator.pdf.preview.title=PDF peržiūra
|
||||||
|
|
||||||
|
# Admin Dashboard - Klientų apžvalga
|
||||||
|
admindashboard.section.customers=Klientai ({0})
|
||||||
|
admindashboard.customers.column.number=Kliento numeris
|
||||||
|
admindashboard.customers.column.owner=Naudotojas
|
||||||
|
admindashboard.customers.empty=Klientų nėra
|
||||||
|
invoicegenerator.positions.header=Pozicijos (kainų lentelė)
|
||||||
|
invoicegenerator.positions.list=Pozicijų sąrašas
|
||||||
|
button.undo=Anuliuoti
|
||||||
|
|||||||
@@ -1008,3 +1008,12 @@ invoicegenerator.notification.cleared=Audekls notīrīts
|
|||||||
invoicegenerator.notification.canvas.error=Neizdevās nolasīt audekla datus
|
invoicegenerator.notification.canvas.error=Neizdevās nolasīt audekla datus
|
||||||
invoicegenerator.notification.preview.error=Priekšskatījuma kļūda: {0}
|
invoicegenerator.notification.preview.error=Priekšskatījuma kļūda: {0}
|
||||||
invoicegenerator.pdf.preview.title=PDF priekšskatījums
|
invoicegenerator.pdf.preview.title=PDF priekšskatījums
|
||||||
|
|
||||||
|
# Admin Dashboard - Klientu pārskats
|
||||||
|
admindashboard.section.customers=Klienti ({0})
|
||||||
|
admindashboard.customers.column.number=Klienta numurs
|
||||||
|
admindashboard.customers.column.owner=Lietotājs
|
||||||
|
admindashboard.customers.empty=Nav klientu
|
||||||
|
invoicegenerator.positions.header=Pozīcijas (cenu tabula)
|
||||||
|
invoicegenerator.positions.list=Pozīciju saraksts
|
||||||
|
button.undo=Atsaukt
|
||||||
|
|||||||
@@ -1008,3 +1008,12 @@ invoicegenerator.notification.cleared=Obszar roboczy wyczyszczony
|
|||||||
invoicegenerator.notification.canvas.error=Nie można odczytać danych obszaru roboczego
|
invoicegenerator.notification.canvas.error=Nie można odczytać danych obszaru roboczego
|
||||||
invoicegenerator.notification.preview.error=Błąd podglądu: {0}
|
invoicegenerator.notification.preview.error=Błąd podglądu: {0}
|
||||||
invoicegenerator.pdf.preview.title=Podgląd PDF
|
invoicegenerator.pdf.preview.title=Podgląd PDF
|
||||||
|
|
||||||
|
# Admin Dashboard - Przegląd klientów
|
||||||
|
admindashboard.section.customers=Klienci ({0})
|
||||||
|
admindashboard.customers.column.number=Numer klienta
|
||||||
|
admindashboard.customers.column.owner=Użytkownik
|
||||||
|
admindashboard.customers.empty=Brak klientów
|
||||||
|
invoicegenerator.positions.header=Pozycje (cennik)
|
||||||
|
invoicegenerator.positions.list=Lista pozycji
|
||||||
|
button.undo=Cofnij
|
||||||
|
|||||||
@@ -1008,3 +1008,12 @@ invoicegenerator.notification.cleared=Холст очищен
|
|||||||
invoicegenerator.notification.canvas.error=Не удалось прочитать данные холста
|
invoicegenerator.notification.canvas.error=Не удалось прочитать данные холста
|
||||||
invoicegenerator.notification.preview.error=Ошибка предпросмотра: {0}
|
invoicegenerator.notification.preview.error=Ошибка предпросмотра: {0}
|
||||||
invoicegenerator.pdf.preview.title=Предпросмотр PDF
|
invoicegenerator.pdf.preview.title=Предпросмотр PDF
|
||||||
|
|
||||||
|
# Admin Dashboard - Обзор клиентов
|
||||||
|
admindashboard.section.customers=Клиенты ({0})
|
||||||
|
admindashboard.customers.column.number=Номер клиента
|
||||||
|
admindashboard.customers.column.owner=Пользователь
|
||||||
|
admindashboard.customers.empty=Клиенты отсутствуют
|
||||||
|
invoicegenerator.positions.header=Позиции (прайс-лист)
|
||||||
|
invoicegenerator.positions.list=Список позиций
|
||||||
|
button.undo=Отменить
|
||||||
|
|||||||
@@ -1008,3 +1008,12 @@ invoicegenerator.notification.cleared=Tuval temizlendi
|
|||||||
invoicegenerator.notification.canvas.error=Tuval verileri okunamadı
|
invoicegenerator.notification.canvas.error=Tuval verileri okunamadı
|
||||||
invoicegenerator.notification.preview.error=Önizleme hatası: {0}
|
invoicegenerator.notification.preview.error=Önizleme hatası: {0}
|
||||||
invoicegenerator.pdf.preview.title=PDF önizleme
|
invoicegenerator.pdf.preview.title=PDF önizleme
|
||||||
|
|
||||||
|
# Admin Dashboard - Müşteri genel bakışı
|
||||||
|
admindashboard.section.customers=Müşteriler ({0})
|
||||||
|
admindashboard.customers.column.number=Müşteri numarası
|
||||||
|
admindashboard.customers.column.owner=Kullanıcı
|
||||||
|
admindashboard.customers.empty=Müşteri yok
|
||||||
|
invoicegenerator.positions.header=Kalemler (fiyat tablosu)
|
||||||
|
invoicegenerator.positions.list=Kalemleri listele
|
||||||
|
button.undo=Geri al
|
||||||
|
|||||||
Reference in New Issue
Block a user