feat: Admin-Dashboard mit Kundenübersicht, System-Rechnung auf Preistabellen-Positionen umgestellt und Undo im Rechnungsgenerator
- Admin-Dashboard: neue Sektion mit Grid aller Kunden (Kundennummer, Firma, Name, Kontakt, Ort, zugehöriger Nutzer) - Rechnungsgenerator (System-Template): Positionen ausschließlich aus den drei Parametern der Preistabelle statt Beispiel-Leistungen, in Canvas und PDF-Vorschau; Bausteine Netto-/Bruttosumme entfernt - AdminLayout: gleicher view-container-Rahmen wie MainLayout, Admin-Seiten damit optisch an Nutzer-Seiten angeglichen - Canvas-Generatoren (Admin und Profil): Undo-Historie mit bis zu 10 Schritten per Button und Strg+Z - Übersetzungen für alle 10 Sprachen ergänzt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,36 @@ window.initProfileInvoiceGenerator = function() {
|
||||
var elements = window.profileInvoiceState.elements;
|
||||
var selectedElement = window.profileInvoiceState.selectedElement;
|
||||
var elementCounter = window.profileInvoiceState.elementCounter;
|
||||
|
||||
// Undo history: snapshots of the elements array, max 10 steps back.
|
||||
// Reset on every init so a canvas never restores states of the other
|
||||
// generator page (profile vs. admin system template).
|
||||
var undoStack = [];
|
||||
var MAX_UNDO_STEPS = 10;
|
||||
|
||||
function pushUndoState() {
|
||||
try {
|
||||
var snapshot = JSON.stringify(elements);
|
||||
if (undoStack.length && undoStack[undoStack.length - 1] === snapshot) return;
|
||||
undoStack.push(snapshot);
|
||||
if (undoStack.length > MAX_UNDO_STEPS) undoStack.shift();
|
||||
} catch (err) {
|
||||
console.error('Could not record undo state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
window.undoProfileCanvas = function() {
|
||||
if (!undoStack.length) return false;
|
||||
var snapshot = JSON.parse(undoStack.pop());
|
||||
// Keep the same array reference (shared with profileInvoiceState)
|
||||
elements.length = 0;
|
||||
snapshot.forEach(function(el) { elements.push(el); });
|
||||
selectedElement = null;
|
||||
saveState();
|
||||
notifyElementDeselected();
|
||||
draw();
|
||||
return true;
|
||||
};
|
||||
var isDragging = false;
|
||||
var dragStart = { x: 0, y: 0 };
|
||||
var elementStart = { x: 0, y: 0 };
|
||||
@@ -306,8 +336,23 @@ window.initProfileInvoiceGenerator = function() {
|
||||
var colVatX = x + colNameWidth;
|
||||
var colNetX = colVatX + colVatWidth;
|
||||
|
||||
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
|
||||
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
|
||||
|
||||
// Rows come from window.profileInvoiceServiceData when provided (system
|
||||
// invoice template: the three admin price table positions); otherwise
|
||||
// fall back to sample data (per-user profile invoice generator)
|
||||
var serviceData = window.profileInvoiceServiceData;
|
||||
var rows = (serviceData && serviceData.rows && serviceData.rows.length)
|
||||
? serviceData.rows
|
||||
: [
|
||||
{ name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 €' },
|
||||
{ name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 €' },
|
||||
{ name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 €' }
|
||||
];
|
||||
|
||||
// Calculate actual content height based on table + summary
|
||||
var tableOnlyHeight = rowHeight * 4; // Header + 3 data rows
|
||||
var tableOnlyHeight = rowHeight * (rows.length + 1); // Header + data rows
|
||||
var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
||||
var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight;
|
||||
// Ensure background covers at least the element height or the calculated content
|
||||
@@ -346,21 +391,11 @@ window.initProfileInvoiceGenerator = function() {
|
||||
// Nettobetrag column header (right-aligned)
|
||||
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
|
||||
|
||||
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
|
||||
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
|
||||
|
||||
// Sample data rows (placeholder)
|
||||
var sampleData = [
|
||||
{ name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 €' },
|
||||
{ name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 €' },
|
||||
{ name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 €' }
|
||||
];
|
||||
|
||||
var currentY = y + rowHeight;
|
||||
|
||||
// Draw sample rows
|
||||
// Draw data rows
|
||||
ctx.font = fontSize + 'px Arial';
|
||||
sampleData.forEach(function(row, index) {
|
||||
rows.forEach(function(row, index) {
|
||||
// Draw row background (alternating)
|
||||
if (index % 2 === 1) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.02)';
|
||||
@@ -416,10 +451,19 @@ window.initProfileInvoiceGenerator = function() {
|
||||
var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned)
|
||||
var valueX = x + w - padding; // Value column (right-aligned)
|
||||
|
||||
// Calculate totals from sample data
|
||||
// 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;
|
||||
var grossTotal = netTotal + vatTotal;
|
||||
netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' €';
|
||||
vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' €';
|
||||
grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' €';
|
||||
}
|
||||
|
||||
// Draw summary lines
|
||||
ctx.textBaseline = 'middle';
|
||||
@@ -431,7 +475,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2);
|
||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(netTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
|
||||
ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
||||
summaryY += summaryRowHeight;
|
||||
|
||||
// Umsatzsteuer - label left, value right
|
||||
@@ -440,7 +484,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
|
||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(vatTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
|
||||
ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
||||
summaryY += summaryRowHeight;
|
||||
|
||||
// Gesamtsumme - label left, value right
|
||||
@@ -450,7 +494,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(grossTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
|
||||
ctx.fillText(grossTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
||||
}
|
||||
|
||||
function drawSelection(el) {
|
||||
@@ -630,6 +674,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
if (selectedElement) {
|
||||
var handle = getResizeHandle(x, y, selectedElement);
|
||||
if (handle >= 0) {
|
||||
pushUndoState();
|
||||
isResizing = true;
|
||||
resizeHandle = handle;
|
||||
resizeStart = {
|
||||
@@ -655,6 +700,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
}
|
||||
|
||||
if (clickedElement) {
|
||||
pushUndoState();
|
||||
selectedElement = clickedElement;
|
||||
isDragging = true;
|
||||
dragStart = { x: x, y: y };
|
||||
@@ -795,8 +841,19 @@ window.initProfileInvoiceGenerator = function() {
|
||||
// Keyboard navigation
|
||||
canvas.setAttribute('tabindex', '0');
|
||||
canvas.addEventListener('keydown', function(e) {
|
||||
// Undo with Ctrl+Z / Cmd+Z (works without a selected element)
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
|
||||
window.undoProfileCanvas();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedElement) return;
|
||||
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Delete'].indexOf(e.key) > -1) {
|
||||
pushUndoState();
|
||||
}
|
||||
|
||||
var moved = false;
|
||||
|
||||
switch(e.key) {
|
||||
@@ -856,6 +913,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
|
||||
// Add element function
|
||||
window.addProfileElement = function(type, label, dropX, dropY, isStatic, staticText, variable, isCustomer) {
|
||||
pushUndoState();
|
||||
elementCounter++;
|
||||
var id = 'element-' + elementCounter;
|
||||
|
||||
@@ -972,6 +1030,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
window.updateProfileElementText = function(id, text) {
|
||||
var el = elements.find(function(e) { return e.id === id; });
|
||||
if (el) {
|
||||
pushUndoState();
|
||||
el.text = text;
|
||||
draw();
|
||||
}
|
||||
@@ -980,6 +1039,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
window.updateProfileElementPosition = function(id, x, y) {
|
||||
var el = elements.find(function(e) { return e.id === id; });
|
||||
if (el) {
|
||||
pushUndoState();
|
||||
if (x !== null) el.x = snapToGrid(x);
|
||||
if (y !== null) el.y = snapToGrid(y);
|
||||
draw();
|
||||
@@ -989,6 +1049,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
window.updateProfileElementFontSize = function(id, size) {
|
||||
var el = elements.find(function(e) { return e.id === id; });
|
||||
if (el) {
|
||||
pushUndoState();
|
||||
el.fontSize = size;
|
||||
// Update height based on text content and new font size
|
||||
if (el.type !== 'line' && el.type !== 'image') {
|
||||
@@ -1004,6 +1065,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
window.updateProfileElementColor = function(id, color) {
|
||||
var el = elements.find(function(e) { return e.id === id; });
|
||||
if (el) {
|
||||
pushUndoState();
|
||||
el.color = color;
|
||||
draw();
|
||||
}
|
||||
@@ -1012,6 +1074,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
window.updateProfileElementSize = function(id, width, height) {
|
||||
var el = elements.find(function(e) { return e.id === id; });
|
||||
if (el) {
|
||||
pushUndoState();
|
||||
if (width !== null) el.width = width;
|
||||
if (height !== null) el.height = height;
|
||||
draw();
|
||||
@@ -1025,6 +1088,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
// Load image to get dimensions
|
||||
var img = new Image();
|
||||
img.onload = function() {
|
||||
pushUndoState();
|
||||
var MAX_WIDTH = 1090;
|
||||
var width = img.width;
|
||||
var height = img.height;
|
||||
@@ -1057,6 +1121,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
window.deleteProfileElement = function(id) {
|
||||
var index = elements.findIndex(function(e) { return e.id === id; });
|
||||
if (index > -1) {
|
||||
pushUndoState();
|
||||
elements.splice(index, 1);
|
||||
if (selectedElement && selectedElement.id === id) {
|
||||
selectedElement = null;
|
||||
@@ -1075,10 +1140,13 @@ window.initProfileInvoiceGenerator = function() {
|
||||
|
||||
// Clear canvas function
|
||||
window.clearProfileCanvas = function() {
|
||||
elements = [];
|
||||
if (elements.length) {
|
||||
pushUndoState();
|
||||
}
|
||||
// Keep the same array reference (shared with profileInvoiceState and undo)
|
||||
elements.length = 0;
|
||||
selectedElement = null;
|
||||
window.profileInvoiceState.elements = [];
|
||||
window.profileInvoiceState.selectedElement = null;
|
||||
saveState();
|
||||
notifyElementDeselected();
|
||||
draw();
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.assecutor.votianlt.pages.view;
|
||||
|
||||
import com.vaadin.flow.component.grid.Grid;
|
||||
import com.vaadin.flow.component.html.Div;
|
||||
import com.vaadin.flow.component.html.H1;
|
||||
import com.vaadin.flow.component.html.H3;
|
||||
@@ -14,15 +15,23 @@ import com.vaadin.flow.router.HasDynamicTitle;
|
||||
import com.vaadin.flow.router.Menu;
|
||||
import com.vaadin.flow.router.Route;
|
||||
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||
import de.assecutor.votianlt.model.Customer;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import de.assecutor.votianlt.model.User;
|
||||
import de.assecutor.votianlt.pages.domain.CustomerRepository;
|
||||
import de.assecutor.votianlt.repository.*;
|
||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class)
|
||||
@RolesAllowed("ADMIN")
|
||||
@@ -39,6 +48,7 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
||||
private final BarcodeRepository barcodeRepository;
|
||||
private final SignatureRepository signatureRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final CustomerRepository customerRepository;
|
||||
|
||||
private final Div statisticsContainer;
|
||||
|
||||
@@ -46,7 +56,8 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
||||
public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository,
|
||||
AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository,
|
||||
PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
||||
SignatureRepository signatureRepository, CommentRepository commentRepository) {
|
||||
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
||||
CustomerRepository customerRepository) {
|
||||
|
||||
this.jobRepository = jobRepository;
|
||||
this.taskRepository = taskRepository;
|
||||
@@ -57,6 +68,7 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
||||
this.barcodeRepository = barcodeRepository;
|
||||
this.signatureRepository = signatureRepository;
|
||||
this.commentRepository = commentRepository;
|
||||
this.customerRepository = customerRepository;
|
||||
|
||||
setSizeFull();
|
||||
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
||||
@@ -124,6 +136,9 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
||||
// System overview section
|
||||
mainLayout.add(createSystemOverviewSection());
|
||||
|
||||
// Customers overview section
|
||||
mainLayout.add(createCustomersSection());
|
||||
|
||||
// Job statistics section
|
||||
mainLayout.add(createJobStatisticsSection());
|
||||
|
||||
@@ -174,6 +189,62 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
||||
return section;
|
||||
}
|
||||
|
||||
private VerticalLayout createCustomersSection() {
|
||||
VerticalLayout section = new VerticalLayout();
|
||||
section.setPadding(false);
|
||||
section.addClassNames("surface-panel", "dashboard-section");
|
||||
|
||||
List<Customer> customers = customerRepository.findAll().stream().filter(customer -> !customer.isInternal())
|
||||
.toList();
|
||||
|
||||
H3 title = new H3(getTranslation("admindashboard.section.customers", customers.size()));
|
||||
title.addClassNames(LumoUtility.Margin.Bottom.MEDIUM, "dashboard-section-title");
|
||||
|
||||
if (customers.isEmpty()) {
|
||||
Span emptyHint = new Span(getTranslation("admindashboard.customers.empty"));
|
||||
section.add(title, emptyHint);
|
||||
return section;
|
||||
}
|
||||
|
||||
Map<ObjectId, User> usersById = userRepository.findAll().stream().filter(user -> user.getId() != null)
|
||||
.collect(Collectors.toMap(User::getId, Function.identity(), (first, second) -> first));
|
||||
|
||||
Grid<Customer> grid = new Grid<>();
|
||||
grid.setItems(customers);
|
||||
grid.addColumn(Customer::getUsrId).setHeader(getTranslation("admindashboard.customers.column.number"))
|
||||
.setAutoWidth(true).setFlexGrow(0).setSortable(true);
|
||||
grid.addColumn(Customer::getCompanyName).setHeader(getTranslation("customers.column.company"))
|
||||
.setAutoWidth(true).setSortable(true);
|
||||
grid.addColumn(customer -> ((customer.getFirstname() != null ? customer.getFirstname() : "") + " "
|
||||
+ (customer.getLastName() != null ? customer.getLastName() : "")).trim())
|
||||
.setHeader(getTranslation("customers.column.name")).setAutoWidth(true).setSortable(true);
|
||||
grid.addColumn(Customer::getMail).setHeader(getTranslation("customers.column.email")).setAutoWidth(true);
|
||||
grid.addColumn(Customer::getTelephone).setHeader(getTranslation("customers.column.phone")).setAutoWidth(true);
|
||||
grid.addColumn(customer -> ((customer.getZip() != null ? customer.getZip() : "") + " "
|
||||
+ (customer.getCity() != null ? customer.getCity() : "")).trim())
|
||||
.setHeader(getTranslation("customers.column.city")).setAutoWidth(true).setSortable(true);
|
||||
grid.addColumn(customer -> formatOwner(usersById.get(customer.getOwner())))
|
||||
.setHeader(getTranslation("admindashboard.customers.column.owner")).setAutoWidth(true)
|
||||
.setSortable(true);
|
||||
grid.addClassName("data-grid");
|
||||
grid.setWidthFull();
|
||||
grid.setHeight("400px");
|
||||
|
||||
section.add(title, grid);
|
||||
return section;
|
||||
}
|
||||
|
||||
private String formatOwner(User owner) {
|
||||
if (owner == null) {
|
||||
return "";
|
||||
}
|
||||
if (owner.getCompany() != null && !owner.getCompany().isBlank()) {
|
||||
return owner.getCompany();
|
||||
}
|
||||
return ((owner.getFirstname() != null ? owner.getFirstname() : "") + " "
|
||||
+ (owner.getName() != null ? owner.getName() : "")).trim();
|
||||
}
|
||||
|
||||
private VerticalLayout createJobStatisticsSection() {
|
||||
VerticalLayout section = new VerticalLayout();
|
||||
section.setPadding(false);
|
||||
|
||||
@@ -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")));
|
||||
|
||||
|
||||
@@ -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 + "'); "
|
||||
|
||||
@@ -20,21 +20,29 @@ import com.vaadin.flow.component.upload.Upload;
|
||||
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
|
||||
import com.vaadin.flow.router.HasDynamicTitle;
|
||||
import com.vaadin.flow.router.Route;
|
||||
import de.assecutor.votianlt.model.PriceTable;
|
||||
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
||||
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||
import de.assecutor.votianlt.pages.base.ui.view.AdminLayout;
|
||||
import de.assecutor.votianlt.repository.PriceTableRepository;
|
||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Admin canvas designer for the SYSTEM invoice template: the template used for
|
||||
@@ -59,13 +67,15 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
||||
|
||||
private final CustomerInvoiceService customerInvoiceService;
|
||||
private final InvoiceTemplateService invoiceTemplateService;
|
||||
private final PriceTableRepository priceTableRepository;
|
||||
|
||||
private VerticalLayout propertiesPanel;
|
||||
|
||||
public InvoiceGeneratorView(CustomerInvoiceService customerInvoiceService,
|
||||
InvoiceTemplateService invoiceTemplateService) {
|
||||
InvoiceTemplateService invoiceTemplateService, PriceTableRepository priceTableRepository) {
|
||||
this.customerInvoiceService = customerInvoiceService;
|
||||
this.invoiceTemplateService = invoiceTemplateService;
|
||||
this.priceTableRepository = priceTableRepository;
|
||||
|
||||
setId("invoice-generator-view");
|
||||
addClassNames("invoice-generator-view", "admin-form-view");
|
||||
@@ -81,7 +91,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
||||
// Hauptlayout: Links (Bausteine) | Mitte (Canvas) | Rechts (Eigenschaften)
|
||||
HorizontalLayout mainLayout = new HorizontalLayout();
|
||||
mainLayout.setWidth("100%");
|
||||
mainLayout.setHeight("calc(100vh - 60px)");
|
||||
mainLayout.setHeight("calc(100% - 60px)");
|
||||
mainLayout.setSpacing(true);
|
||||
mainLayout.getStyle().set("overflow", "hidden");
|
||||
mainLayout.addClassName("invoice-generator-main");
|
||||
@@ -119,12 +129,13 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
||||
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;"
|
||||
+ "window.profileInvoiceVatRate = " + SYSTEM_VAT_RATE.doubleValue() + ";"
|
||||
+ "window.masterdataValues = JSON.parse($1);"
|
||||
+ "window.profileInvoiceServiceData = JSON.parse($2);"
|
||||
+ "setTimeout(function() { "
|
||||
+ " if (window.initProfileInvoiceGenerator) { "
|
||||
+ " window.initProfileInvoiceGenerator(); "
|
||||
+ " setTimeout(function() { $0.$server.onCanvasReady(); }, 200); "
|
||||
+ " } else { console.error('initProfileInvoiceGenerator not found'); } "
|
||||
+ "}, 300);", getElement(), masterdataJson);
|
||||
+ "}, 300);", getElement(), masterdataJson, buildCanvasPositionsJson());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,9 +197,136 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
||||
variables.put("customer.city", "12345 Musterstadt");
|
||||
variables.put("customer.email", "nutzer@beispiel.de");
|
||||
variables.put("customer.phone", "0123 456789");
|
||||
|
||||
// Positionen: die Rechnung an die Nutzer enthält ausschließlich die drei
|
||||
// Parameter der Preistabelle (/admin-price-table), keine Leistungen
|
||||
addPriceTablePositions(variables);
|
||||
return variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three positions of the price table (/admin-price-table): label plus
|
||||
* configured value. These are the only positions on system invoices.
|
||||
*/
|
||||
private String[][] priceTableEntries() {
|
||||
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null);
|
||||
return new String[][] {
|
||||
{ getTranslation("adminpricetable.field.monthly"),
|
||||
priceTable != null ? priceTable.getMonthlyBasePackage() : null },
|
||||
{ getTranslation("adminpricetable.field.applicense"),
|
||||
priceTable != null ? priceTable.getAppUsageLicense() : null },
|
||||
{ getTranslation("adminpricetable.field.revenue"),
|
||||
priceTable != null ? priceTable.getRevenueParticipation() : null } };
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON for the canvas rendering of the "services.list" block: the three
|
||||
* price table positions plus totals (window.profileInvoiceServiceData).
|
||||
*/
|
||||
private String buildCanvasPositionsJson() {
|
||||
String vatLabel = SYSTEM_VAT_RATE.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + "%";
|
||||
List<Map<String, String>> rows = new ArrayList<>();
|
||||
BigDecimal netTotal = BigDecimal.ZERO;
|
||||
|
||||
for (String[] entry : priceTableEntries()) {
|
||||
String rawValue = entry[1] != null ? entry[1].trim() : "";
|
||||
BigDecimal amount = parseAmount(rawValue);
|
||||
String netDisplay;
|
||||
if (amount != null) {
|
||||
netDisplay = formatAmount(amount) + " €";
|
||||
netTotal = netTotal.add(amount);
|
||||
} else {
|
||||
netDisplay = rawValue.isEmpty() ? "-" : rawValue;
|
||||
}
|
||||
Map<String, String> row = new HashMap<>();
|
||||
row.put("name", entry[0]);
|
||||
row.put("vat", vatLabel);
|
||||
row.put("net", netDisplay);
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
BigDecimal vatTotal = netTotal.multiply(SYSTEM_VAT_RATE);
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("rows", rows);
|
||||
data.put("netTotal", formatAmount(netTotal) + " €");
|
||||
data.put("vatTotal", formatAmount(vatTotal) + " €");
|
||||
data.put("grossTotal", formatAmount(netTotal.add(vatTotal)) + " €");
|
||||
|
||||
try {
|
||||
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(data);
|
||||
} catch (Exception ex) {
|
||||
log.error("Error serializing canvas positions: {}", ex.getMessage(), ex);
|
||||
return "null";
|
||||
}
|
||||
}
|
||||
|
||||
private void addPriceTablePositions(Map<String, String> variables) {
|
||||
List<Map<String, String>> positions = new ArrayList<>();
|
||||
BigDecimal netTotal = BigDecimal.ZERO;
|
||||
|
||||
for (String[] entry : priceTableEntries()) {
|
||||
String rawValue = entry[1] != null ? entry[1].trim() : "";
|
||||
Map<String, String> position = new HashMap<>();
|
||||
position.put("name", entry[0]);
|
||||
BigDecimal amount = parseAmount(rawValue);
|
||||
if (amount != null) {
|
||||
position.put("netAmount", formatAmount(amount));
|
||||
netTotal = netTotal.add(amount);
|
||||
} else {
|
||||
// Nicht-numerische Werte (z.B. "15 %" Umsatzbeteiligung) unverändert anzeigen
|
||||
position.put("netAmount", rawValue.isEmpty() ? "-" : rawValue);
|
||||
}
|
||||
positions.add(position);
|
||||
}
|
||||
|
||||
BigDecimal vatTotal = netTotal.multiply(SYSTEM_VAT_RATE);
|
||||
BigDecimal grossTotal = netTotal.add(vatTotal);
|
||||
|
||||
try {
|
||||
variables.put("services.json",
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(positions));
|
||||
} catch (Exception ex) {
|
||||
log.error("Error serializing price table positions: {}", ex.getMessage(), ex);
|
||||
variables.put("services.json", "[]");
|
||||
}
|
||||
variables.put("services.count", String.valueOf(positions.size()));
|
||||
variables.put("invoice.net_total", formatAmount(netTotal) + " €");
|
||||
variables.put("invoice.vat_total", formatAmount(vatTotal) + " €");
|
||||
variables.put("invoice.gross_total", formatAmount(grossTotal) + " €");
|
||||
variables.put("invoice.vat_rate",
|
||||
SYSTEM_VAT_RATE.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + "%");
|
||||
variables.put("services.net_total", formatAmount(netTotal) + " €");
|
||||
variables.put("services.gross_total", formatAmount(grossTotal) + " €");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a price table value like "99,00 €", "1.234,56" or "120 EUR" into a
|
||||
* numeric amount. Returns null for non-monetary values such as "15 %".
|
||||
*/
|
||||
private BigDecimal parseAmount(String value) {
|
||||
if (value == null || value.isBlank() || value.contains("%")) {
|
||||
return null;
|
||||
}
|
||||
Matcher matcher = Pattern.compile("([0-9]{1,3}(?:\\.[0-9]{3})*(?:,[0-9]+)?|[0-9]+(?:[.,][0-9]+)?)")
|
||||
.matcher(value);
|
||||
if (!matcher.find()) {
|
||||
return null;
|
||||
}
|
||||
String number = matcher.group(1);
|
||||
if (number.contains(",")) {
|
||||
number = number.replace(".", "").replace(',', '.');
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(number);
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String formatAmount(BigDecimal amount) {
|
||||
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",");
|
||||
}
|
||||
|
||||
private String buildMasterdataJson() {
|
||||
try {
|
||||
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(buildSystemVariables());
|
||||
@@ -242,15 +380,11 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
||||
Div invoiceDate = createVariableTemplate(getTranslation("invoicegenerator.issuer.invoicedate"),
|
||||
VaadinIcon.CALENDAR_CLOCK, "masterdata.invoice_date", values.get("masterdata.invoice_date"));
|
||||
|
||||
// Bereich 2: Leistungen / Positionen
|
||||
Span servicesHeader = createSectionHeader(getTranslation("profile.services.label"));
|
||||
// Bereich 2: Positionen (Parameter der Preistabelle, keine Leistungen)
|
||||
Span servicesHeader = createSectionHeader(getTranslation("invoicegenerator.positions.header"));
|
||||
|
||||
Div servicesListBlock = createServicesVariableTemplate(getTranslation("profile.invoice.services.list"),
|
||||
Div servicesListBlock = createServicesVariableTemplate(getTranslation("invoicegenerator.positions.list"),
|
||||
VaadinIcon.LIST, "services.list", "Position 1: 100,00 €\nPosition 2: 50,00 €");
|
||||
Div servicesNetBlock = createServicesVariableTemplate(getTranslation("profile.invoice.net"),
|
||||
VaadinIcon.COIN_PILES, "services.net_total", "150,00 €");
|
||||
Div servicesGrossBlock = createServicesVariableTemplate(getTranslation("profile.invoice.gross"),
|
||||
VaadinIcon.MONEY, "services.gross_total", "178,50 €");
|
||||
|
||||
// Bereich 3: Empfänger (Nutzer des Systems)
|
||||
Span recipientHeader = createSectionHeader(getTranslation("invoicegenerator.recipient.header"));
|
||||
@@ -282,9 +416,8 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
||||
|
||||
panel.add(issuerHeader, issuerCompany, issuerStreet, issuerCity, issuerPhone, issuerEmail, issuerWebsite,
|
||||
issuerSenderLine, issuerPaymentTerms, issuerFooter, invoiceNumber, invoiceDate, servicesHeader,
|
||||
servicesListBlock, servicesNetBlock, servicesGrossBlock, recipientHeader, recipientCompany,
|
||||
recipientName, recipientStreet, recipientCity, recipientEmail, freeHeader, textBlock, headerBlock,
|
||||
dateBlock, lineBlock, imageBlock);
|
||||
servicesListBlock, recipientHeader, recipientCompany, recipientName, recipientStreet, recipientCity,
|
||||
recipientEmail, freeHeader, textBlock, headerBlock, dateBlock, lineBlock, imageBlock);
|
||||
|
||||
return panel;
|
||||
}
|
||||
@@ -727,6 +860,11 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
||||
showNotification(getTranslation("invoicegenerator.notification.cleared"));
|
||||
});
|
||||
|
||||
Button undoButton = new Button(getTranslation("button.undo"), new Icon(VaadinIcon.ARROW_BACKWARD));
|
||||
undoButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
undoButton.addClickListener(
|
||||
e -> getElement().executeJs("if (window.undoProfileCanvas) { window.undoProfileCanvas(); }"));
|
||||
|
||||
Button previewButton = new Button(getTranslation("button.preview"), new Icon(VaadinIcon.EYE));
|
||||
previewButton.addThemeVariants(ButtonVariant.LUMO_CONTRAST);
|
||||
previewButton.addClickListener(e -> generatePreviewPdf());
|
||||
@@ -735,7 +873,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
||||
saveTemplateButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||
saveTemplateButton.addClickListener(e -> saveSystemTemplate());
|
||||
|
||||
layout.add(clearButton, previewButton, saveTemplateButton);
|
||||
layout.add(undoButton, clearButton, previewButton, saveTemplateButton);
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
@@ -476,8 +476,13 @@ public class CustomerInvoiceService {
|
||||
"<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>");
|
||||
}
|
||||
} else if ("services.list".equals(variable)) {
|
||||
// Render services list as a table
|
||||
// Render positions from services.json if provided (e.g. system
|
||||
// invoice: price table positions), otherwise sample data
|
||||
if (variables.containsKey("services.json")) {
|
||||
htmlBuilder.append(generateServicesTableHtmlWithData(mmWidth, variables));
|
||||
} else {
|
||||
htmlBuilder.append(generateServicesTableHtml(mmWidth, effectiveVatRate));
|
||||
}
|
||||
} else if (text.contains("<br>")) {
|
||||
// Multi-line text: render without nowrap so <br> tags work
|
||||
htmlBuilder.append("<span>").append(text).append("</span>");
|
||||
@@ -877,8 +882,11 @@ public class CustomerInvoiceService {
|
||||
.append(escapeHtml(name)).append("</td>");
|
||||
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
|
||||
.append(escapeHtml(vatRateLabel)).append("</td>");
|
||||
// Append € only for plain numeric amounts; values like "15 %"
|
||||
// (revenue participation from the price table) are shown as-is
|
||||
String amountDisplay = netAmount.matches("[0-9.,]+") ? netAmount + " €" : escapeHtml(netAmount);
|
||||
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
|
||||
.append(netAmount).append(" €</td>");
|
||||
.append(amountDisplay).append("</td>");
|
||||
html.append("</tr>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=Отменить
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user