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:
2026-07-06 13:10:20 +02:00
parent 09db861064
commit 2313618c05
17 changed files with 448 additions and 52 deletions

View File

@@ -40,6 +40,36 @@ window.initProfileInvoiceGenerator = function() {
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 };
@@ -306,8 +336,23 @@ window.initProfileInvoiceGenerator = function() {
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 * 4; // Header + 3 data rows
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
@@ -346,21 +391,11 @@ window.initProfileInvoiceGenerator = function() {
// Nettobetrag column header (right-aligned)
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;
// Draw sample rows
// Draw data rows
ctx.font = fontSize + 'px Arial';
sampleData.forEach(function(row, index) {
rows.forEach(function(row, index) {
// Draw row background (alternating)
if (index % 2 === 1) {
ctx.fillStyle = 'rgba(0,0,0,0.02)';
@@ -416,10 +451,19 @@ window.initProfileInvoiceGenerator = function() {
var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned)
var valueX = x + w - padding; // Value column (right-aligned)
// Calculate totals from sample data
var netTotal = 655.00; // 450 + 85 + 120
var vatTotal = netTotal * vatRate;
var grossTotal = netTotal + vatTotal;
// 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';
@@ -431,7 +475,7 @@ window.initProfileInvoiceGenerator = function() {
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2);
ctx.font = 'bold ' + fontSize + 'px Arial';
ctx.textAlign = 'right';
ctx.fillText(netTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2);
summaryY += summaryRowHeight;
// Umsatzsteuer - label left, value right
@@ -440,7 +484,7 @@ window.initProfileInvoiceGenerator = function() {
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
ctx.font = 'bold ' + fontSize + 'px Arial';
ctx.textAlign = 'right';
ctx.fillText(vatTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2);
summaryY += summaryRowHeight;
// Gesamtsumme - label left, value right
@@ -450,7 +494,7 @@ window.initProfileInvoiceGenerator = function() {
ctx.textAlign = 'left';
ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2);
ctx.textAlign = 'right';
ctx.fillText(grossTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
ctx.fillText(grossTotalLabel, valueX, summaryY + summaryRowHeight / 2);
}
function drawSelection(el) {
@@ -630,6 +674,7 @@ window.initProfileInvoiceGenerator = function() {
if (selectedElement) {
var handle = getResizeHandle(x, y, selectedElement);
if (handle >= 0) {
pushUndoState();
isResizing = true;
resizeHandle = handle;
resizeStart = {
@@ -655,6 +700,7 @@ window.initProfileInvoiceGenerator = function() {
}
if (clickedElement) {
pushUndoState();
selectedElement = clickedElement;
isDragging = true;
dragStart = { x: x, y: y };
@@ -795,8 +841,19 @@ window.initProfileInvoiceGenerator = function() {
// 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) {
@@ -856,6 +913,7 @@ window.initProfileInvoiceGenerator = function() {
// Add element function
window.addProfileElement = function(type, label, dropX, dropY, isStatic, staticText, variable, isCustomer) {
pushUndoState();
elementCounter++;
var id = 'element-' + elementCounter;
@@ -972,6 +1030,7 @@ window.initProfileInvoiceGenerator = function() {
window.updateProfileElementText = function(id, text) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
el.text = text;
draw();
}
@@ -980,6 +1039,7 @@ window.initProfileInvoiceGenerator = function() {
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();
@@ -989,6 +1049,7 @@ window.initProfileInvoiceGenerator = function() {
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') {
@@ -1004,6 +1065,7 @@ window.initProfileInvoiceGenerator = function() {
window.updateProfileElementColor = function(id, color) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
el.color = color;
draw();
}
@@ -1012,6 +1074,7 @@ window.initProfileInvoiceGenerator = function() {
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();
@@ -1025,6 +1088,7 @@ window.initProfileInvoiceGenerator = function() {
// 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;
@@ -1057,6 +1121,7 @@ window.initProfileInvoiceGenerator = function() {
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;
@@ -1075,10 +1140,13 @@ window.initProfileInvoiceGenerator = function() {
// Clear canvas function
window.clearProfileCanvas = function() {
elements = [];
if (elements.length) {
pushUndoState();
}
// Keep the same array reference (shared with profileInvoiceState and undo)
elements.length = 0;
selectedElement = null;
window.profileInvoiceState.elements = [];
window.profileInvoiceState.selectedElement = null;
saveState();
notifyElementDeselected();
draw();
};

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.pages.base.ui.view;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.HasElement;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.avatar.Avatar;
@@ -31,6 +32,7 @@ public final class AdminLayout extends AppLayout {
private Div headerRef;
private Scroller navRef;
private Component userMenuRef;
private final Div viewContainer = new Div();
public AdminLayout(SecurityService securityService) {
this.securityService = securityService;
@@ -47,12 +49,25 @@ public final class AdminLayout extends AppLayout {
userMenuRef = createUserMenu();
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();
// Re-check on attach (new UI/session) and on every navigation cycle
addAttachListener(e -> updateDrawerVisibility());
}
@Override
public void showRouterLayoutContent(HasElement content) {
viewContainer.getElement().removeAllChildren();
if (content != null) {
viewContainer.getElement().appendChild(content.getElement());
}
}
private void updateDrawerVisibility() {
boolean loggedIn = securityService.isUserLoggedIn();
if (headerRef != null)

View File

@@ -1,5 +1,6 @@
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.H1;
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.Route;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.votianlt.model.Customer;
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.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
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)
@RolesAllowed("ADMIN")
@@ -39,6 +48,7 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
private final BarcodeRepository barcodeRepository;
private final SignatureRepository signatureRepository;
private final CommentRepository commentRepository;
private final CustomerRepository customerRepository;
private final Div statisticsContainer;
@@ -46,7 +56,8 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository,
AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository,
PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
SignatureRepository signatureRepository, CommentRepository commentRepository) {
SignatureRepository signatureRepository, CommentRepository commentRepository,
CustomerRepository customerRepository) {
this.jobRepository = jobRepository;
this.taskRepository = taskRepository;
@@ -57,6 +68,7 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
this.barcodeRepository = barcodeRepository;
this.signatureRepository = signatureRepository;
this.commentRepository = commentRepository;
this.customerRepository = customerRepository;
setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
@@ -124,6 +136,9 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
// System overview section
mainLayout.add(createSystemOverviewSection());
// Customers overview section
mainLayout.add(createCustomersSection());
// Job statistics section
mainLayout.add(createJobStatisticsSection());
@@ -174,6 +189,62 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
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() {
VerticalLayout section = new VerticalLayout();
section.setPadding(false);

View File

@@ -27,8 +27,7 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
setSpacing(false);
setPadding(false);
getStyle().set("margin", "14px");
setWidth("90%");
setWidthFull();
addClassName("admin-form-view");
add(new ViewToolbar(getTranslation("adminpricetable.title")));

View File

@@ -429,6 +429,11 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
actionLayout.setSpacing(true);
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));
previewPdfButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
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);
// 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
tabSheet.addSelectedChangeListener(e -> {
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(); "
+ " console.log('Canvas initialized, now loading template...'); "
+ " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); "
@@ -1466,6 +1472,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
getElement().executeJs("setTimeout(function() { "
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
+ " console.log('Loading template into canvas...'); "
+ " window.profileInvoiceServiceData = null; "
+ " window.profileInvoiceVatRate = " + vatRateJs + "; "
+ " window.masterdataValues = "
+ masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "

View File

@@ -20,21 +20,29 @@ import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.model.PriceTable;
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
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.InvoiceTemplateService;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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
@@ -59,13 +67,15 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
private final CustomerInvoiceService customerInvoiceService;
private final InvoiceTemplateService invoiceTemplateService;
private final PriceTableRepository priceTableRepository;
private VerticalLayout propertiesPanel;
public InvoiceGeneratorView(CustomerInvoiceService customerInvoiceService,
InvoiceTemplateService invoiceTemplateService) {
InvoiceTemplateService invoiceTemplateService, PriceTableRepository priceTableRepository) {
this.customerInvoiceService = customerInvoiceService;
this.invoiceTemplateService = invoiceTemplateService;
this.priceTableRepository = priceTableRepository;
setId("invoice-generator-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)
HorizontalLayout mainLayout = new HorizontalLayout();
mainLayout.setWidth("100%");
mainLayout.setHeight("calc(100vh - 60px)");
mainLayout.setHeight("calc(100% - 60px)");
mainLayout.setSpacing(true);
mainLayout.getStyle().set("overflow", "hidden");
mainLayout.addClassName("invoice-generator-main");
@@ -119,12 +129,13 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;"
+ "window.profileInvoiceVatRate = " + SYSTEM_VAT_RATE.doubleValue() + ";"
+ "window.masterdataValues = JSON.parse($1);"
+ "window.profileInvoiceServiceData = JSON.parse($2);"
+ "setTimeout(function() { "
+ " if (window.initProfileInvoiceGenerator) { "
+ " window.initProfileInvoiceGenerator(); "
+ " setTimeout(function() { $0.$server.onCanvasReady(); }, 200); "
+ " } 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.email", "nutzer@beispiel.de");
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;
}
/**
* 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() {
try {
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"),
VaadinIcon.CALENDAR_CLOCK, "masterdata.invoice_date", values.get("masterdata.invoice_date"));
// Bereich 2: Leistungen / Positionen
Span servicesHeader = createSectionHeader(getTranslation("profile.services.label"));
// Bereich 2: Positionen (Parameter der Preistabelle, keine Leistungen)
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 €");
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)
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,
issuerSenderLine, issuerPaymentTerms, issuerFooter, invoiceNumber, invoiceDate, servicesHeader,
servicesListBlock, servicesNetBlock, servicesGrossBlock, recipientHeader, recipientCompany,
recipientName, recipientStreet, recipientCity, recipientEmail, freeHeader, textBlock, headerBlock,
dateBlock, lineBlock, imageBlock);
servicesListBlock, recipientHeader, recipientCompany, recipientName, recipientStreet, recipientCity,
recipientEmail, freeHeader, textBlock, headerBlock, dateBlock, lineBlock, imageBlock);
return panel;
}
@@ -727,6 +860,11 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
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));
previewButton.addThemeVariants(ButtonVariant.LUMO_CONTRAST);
previewButton.addClickListener(e -> generatePreviewPdf());
@@ -735,7 +873,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
saveTemplateButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
saveTemplateButton.addClickListener(e -> saveSystemTemplate());
layout.add(clearButton, previewButton, saveTemplateButton);
layout.add(undoButton, clearButton, previewButton, saveTemplateButton);
return layout;
}

View File

@@ -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>");
}
} else if ("services.list".equals(variable)) {
// Render services list as a table
htmlBuilder.append(generateServicesTableHtml(mmWidth, effectiveVatRate));
// Render positions from services.json if provided (e.g. system
// 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>")) {
// Multi-line text: render without nowrap so <br> tags work
htmlBuilder.append("<span>").append(text).append("</span>");
@@ -877,8 +882,11 @@ public class CustomerInvoiceService {
.append(escapeHtml(name)).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
.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%;'>")
.append(netAmount).append("</td>");
.append(amountDisplay).append("</td>");
html.append("</tr>");
}
}

View File

@@ -1077,3 +1077,12 @@ invoicegenerator.notification.cleared=Canvas geleert
invoicegenerator.notification.canvas.error=Canvas-Daten konnten nicht gelesen werden
invoicegenerator.notification.preview.error=Fehler bei der Vorschau: {0}
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

View File

@@ -907,3 +907,12 @@ invoicegenerator.notification.cleared=Lõuend tühjendatud
invoicegenerator.notification.canvas.error=Lõuendi andmeid ei õnnestunud lugeda
invoicegenerator.notification.preview.error=Eelvaate viga: {0}
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

View File

@@ -1077,3 +1077,12 @@ invoicegenerator.notification.cleared=Canvas cleared
invoicegenerator.notification.canvas.error=Could not read canvas data
invoicegenerator.notification.preview.error=Preview error: {0}
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

View File

@@ -1008,3 +1008,12 @@ invoicegenerator.notification.cleared=Lienzo vaciado
invoicegenerator.notification.canvas.error=No se pudieron leer los datos del lienzo
invoicegenerator.notification.preview.error=Error de vista previa: {0}
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

View File

@@ -1008,3 +1008,12 @@ invoicegenerator.notification.cleared=Canevas vidé
invoicegenerator.notification.canvas.error=Impossible de lire les données du canevas
invoicegenerator.notification.preview.error=Erreur d''aperçu : {0}
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

View File

@@ -1010,3 +1010,12 @@ invoicegenerator.notification.cleared=Drobė išvalyta
invoicegenerator.notification.canvas.error=Nepavyko nuskaityti drobės duomenų
invoicegenerator.notification.preview.error=Peržiūros klaida: {0}
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

View File

@@ -1008,3 +1008,12 @@ invoicegenerator.notification.cleared=Audekls notīrīts
invoicegenerator.notification.canvas.error=Neizdevās nolasīt audekla datus
invoicegenerator.notification.preview.error=Priekšskatījuma kļūda: {0}
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

View File

@@ -1008,3 +1008,12 @@ invoicegenerator.notification.cleared=Obszar roboczy wyczyszczony
invoicegenerator.notification.canvas.error=Nie można odczytać danych obszaru roboczego
invoicegenerator.notification.preview.error=Błąd podglądu: {0}
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

View File

@@ -1008,3 +1008,12 @@ invoicegenerator.notification.cleared=Холст очищен
invoicegenerator.notification.canvas.error=Не удалось прочитать данные холста
invoicegenerator.notification.preview.error=Ошибка предпросмотра: {0}
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=Отменить

View File

@@ -1008,3 +1008,12 @@ invoicegenerator.notification.cleared=Tuval temizlendi
invoicegenerator.notification.canvas.error=Tuval verileri okunamadı
invoicegenerator.notification.preview.error=Önizleme hatası: {0}
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