From 2313618c0533002680164be248b4f862abd70318 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Mon, 6 Jul 2026 13:10:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Admin-Dashboard=20mit=20Kunden=C3=BCber?= =?UTF-8?q?sicht,=20System-Rechnung=20auf=20Preistabellen-Positionen=20umg?= =?UTF-8?q?estellt=20und=20Undo=20im=20Rechnungsgenerator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../profile-invoice-generator.js | 128 ++++++++++---- .../pages/base/ui/view/AdminLayout.java | 15 ++ .../pages/view/AdminDashboardView.java | 73 +++++++- .../pages/view/AdminPricetableView.java | 3 +- .../votianlt/pages/view/EditProfileView.java | 11 +- .../pages/view/InvoiceGeneratorView.java | 166 ++++++++++++++++-- .../service/CustomerInvoiceService.java | 14 +- .../src/main/resources/messages_de.properties | 9 + .../src/main/resources/messages_ee.properties | 9 + .../src/main/resources/messages_en.properties | 9 + .../src/main/resources/messages_es.properties | 9 + .../src/main/resources/messages_fr.properties | 9 + .../src/main/resources/messages_lt.properties | 9 + .../src/main/resources/messages_lv.properties | 9 + .../src/main/resources/messages_pl.properties | 9 + .../src/main/resources/messages_ru.properties | 9 + .../src/main/resources/messages_tr.properties | 9 + 17 files changed, 448 insertions(+), 52 deletions(-) diff --git a/backend/src/main/frontend/invoice-generator/profile-invoice-generator.js b/backend/src/main/frontend/invoice-generator/profile-invoice-generator.js index e0de776..b4611e8 100644 --- a/backend/src/main/frontend/invoice-generator/profile-invoice-generator.js +++ b/backend/src/main/frontend/invoice-generator/profile-invoice-generator.js @@ -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,11 +451,20 @@ 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,18 +475,18 @@ 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 ctx.font = fontSize + 'px Arial'; ctx.textAlign = 'left'; ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2); ctx.font = 'bold ' + fontSize + 'px Arial'; ctx.textAlign = 'right'; - ctx.fillText(vatTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2); + ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2); summaryY += summaryRowHeight; - + // Gesamtsumme - label left, value right summaryY += summaryGap; // Extra gap before total ctx.fillStyle = '#000000'; @@ -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,23 +1030,26 @@ 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(); } }; - + window.updateProfileElementPosition = function(id, x, y) { var el = elements.find(function(e) { return e.id === id; }); if (el) { + pushUndoState(); if (x !== null) el.x = snapToGrid(x); if (y !== null) el.y = snapToGrid(y); draw(); } }; - + window.updateProfileElementFontSize = function(id, size) { var el = elements.find(function(e) { return e.id === id; }); if (el) { + pushUndoState(); el.fontSize = size; // Update height based on text content and new font size if (el.type !== 'line' && el.type !== 'image') { @@ -1004,14 +1065,16 @@ 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(); } }; - + 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(); }; diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java index 08806b1..70e1470 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java @@ -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) diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminDashboardView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminDashboardView.java index e892917..582ea43 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminDashboardView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminDashboardView.java @@ -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 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 usersById = userRepository.findAll().stream().filter(user -> user.getId() != null) + .collect(Collectors.toMap(User::getId, Function.identity(), (first, second) -> first)); + + Grid 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); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminPricetableView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminPricetableView.java index 7995c6a..a0b12df 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminPricetableView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminPricetableView.java @@ -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"))); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java index bd08a74..f5833d1 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java @@ -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 + "'); " diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java index cf0bf5e..ce8eaf9 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java @@ -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> 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 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 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 variables) { + List> positions = new ArrayList<>(); + BigDecimal netTotal = BigDecimal.ZERO; + + for (String[] entry : priceTableEntries()) { + String rawValue = entry[1] != null ? entry[1].trim() : ""; + Map 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; } diff --git a/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java b/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java index 14c7f4d..6e06cb1 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java @@ -476,8 +476,13 @@ public class CustomerInvoiceService { "
[Bild]
"); } } 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("
")) { // Multi-line text: render without nowrap so
tags work htmlBuilder.append("").append(text).append(""); @@ -877,8 +882,11 @@ public class CustomerInvoiceService { .append(escapeHtml(name)).append(""); html.append("") .append(escapeHtml(vatRateLabel)).append(""); + // 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("") - .append(netAmount).append(" €"); + .append(amountDisplay).append(""); html.append(""); } } diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties index 3003993..300978b 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -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 diff --git a/backend/src/main/resources/messages_ee.properties b/backend/src/main/resources/messages_ee.properties index 17b87d1..1241858 100644 --- a/backend/src/main/resources/messages_ee.properties +++ b/backend/src/main/resources/messages_ee.properties @@ -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 diff --git a/backend/src/main/resources/messages_en.properties b/backend/src/main/resources/messages_en.properties index 2661fb1..6cdd798 100644 --- a/backend/src/main/resources/messages_en.properties +++ b/backend/src/main/resources/messages_en.properties @@ -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 diff --git a/backend/src/main/resources/messages_es.properties b/backend/src/main/resources/messages_es.properties index a31512b..3a988ea 100644 --- a/backend/src/main/resources/messages_es.properties +++ b/backend/src/main/resources/messages_es.properties @@ -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 diff --git a/backend/src/main/resources/messages_fr.properties b/backend/src/main/resources/messages_fr.properties index 6ae577c..ffab5bf 100644 --- a/backend/src/main/resources/messages_fr.properties +++ b/backend/src/main/resources/messages_fr.properties @@ -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 diff --git a/backend/src/main/resources/messages_lt.properties b/backend/src/main/resources/messages_lt.properties index 3fbeaef..b8e47ff 100644 --- a/backend/src/main/resources/messages_lt.properties +++ b/backend/src/main/resources/messages_lt.properties @@ -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 diff --git a/backend/src/main/resources/messages_lv.properties b/backend/src/main/resources/messages_lv.properties index f0e6f2a..d5b61e8 100644 --- a/backend/src/main/resources/messages_lv.properties +++ b/backend/src/main/resources/messages_lv.properties @@ -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 diff --git a/backend/src/main/resources/messages_pl.properties b/backend/src/main/resources/messages_pl.properties index c296fa6..2f0600d 100644 --- a/backend/src/main/resources/messages_pl.properties +++ b/backend/src/main/resources/messages_pl.properties @@ -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 diff --git a/backend/src/main/resources/messages_ru.properties b/backend/src/main/resources/messages_ru.properties index 926a290..086079c 100644 --- a/backend/src/main/resources/messages_ru.properties +++ b/backend/src/main/resources/messages_ru.properties @@ -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=Отменить diff --git a/backend/src/main/resources/messages_tr.properties b/backend/src/main/resources/messages_tr.properties index 7db2e13..16e70e6 100644 --- a/backend/src/main/resources/messages_tr.properties +++ b/backend/src/main/resources/messages_tr.properties @@ -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