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 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,10 +451,19 @@ 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,7 +475,7 @@ 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
@@ -440,7 +484,7 @@ window.initProfileInvoiceGenerator = function() {
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
@@ -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,6 +1030,7 @@ 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();
} }
@@ -980,6 +1039,7 @@ window.initProfileInvoiceGenerator = function() {
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();
@@ -989,6 +1049,7 @@ window.initProfileInvoiceGenerator = function() {
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,6 +1065,7 @@ 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();
} }
@@ -1012,6 +1074,7 @@ window.initProfileInvoiceGenerator = function() {
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();
}; };

View File

@@ -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)

View File

@@ -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);

View File

@@ -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")));

View File

@@ -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 + "'); "

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.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;
} }

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>"); "<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>");
} }
} }

View File

@@ -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

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.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

View File

@@ -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

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.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

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.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

View File

@@ -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

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.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

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.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

View File

@@ -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=Отменить

View File

@@ -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