From a7ea4fa8035f9e0b527644157e81abf2c6696836 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Mon, 6 Jul 2026 17:53:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Admin-Firmendaten,=20Rechnungserstellun?= =?UTF-8?q?g=20f=C3=BCr=20Kunden=20mit=20Pro-rata-Abrechnung=20und=20Ultim?= =?UTF-8?q?o-Erinnerung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Seite /admin-profile: Firmendaten des Systembetreibers bearbeiten (MongoDB-persistiert), erreichbar über "Profil anzeigen" im Admin-Menü; Rechnungssteller-Daten aller System-Rechnungen kommen aus dem gespeicherten Profil - Neue Seite /admin-create-invoices mit Sidebar-Menüpunkt "Rechnungen erstellen": alle aktiven Kunden mit wählbarem Abrechnungsmonat (aktuell bis 12 Monate zurück), Fälligkeit zum Ultimo, anteilige Berechnung bei Monatsstart mitten im Monat, Einzel- und Sammel-PDF (zusammengeführt, eine Rechnung pro Seite) aus dem System-Template - Rechnungsgenerator: Undo-Vorbereitung aus Vorsession ergänzt um Firmendaten-Anbindung; Menüpunkt "Admin-Einstellungen" entfernt - MonthlySchedulerService: tägliche Prüfung, drei Tage vor Ultimo Erinnerungsmail an alle Administratoren - Session-Persistenz profilabhängig: dev und production aktiviert, Basis-Default aus (behebt SESSIONS.ser-Deserialisierungsfehler in anderen Kontexten) - ShowJobsView: überflüssiges Feld entfernt; AssertJ catchThrowableOfType auf neue Signatur umgestellt; tsconfig ignoreDeprecations - Übersetzungen für alle neuen Oberflächen in 10 Sprachen Co-Authored-By: Claude Fable 5 --- .../votianlt/model/SystemCompanyProfile.java | 52 ++ .../pages/base/ui/view/AdminLayout.java | 9 +- .../pages/view/AdminCreateInvoicesView.java | 488 ++++++++++++++++++ .../votianlt/pages/view/AdminProfileView.java | 155 ++++++ .../pages/view/InvoiceGeneratorView.java | 17 +- .../votianlt/pages/view/MyInvoicesView.java | 8 +- .../votianlt/pages/view/ShowJobsView.java | 2 - .../SystemCompanyProfileRepository.java | 9 + .../service/MonthlySchedulerService.java | 60 +++ .../service/SystemCompanyProfileService.java | 78 +++ .../service/SystemInvoiceService.java | 8 +- .../main/resources/application-dev.properties | 5 + .../application-production.properties | 5 + .../src/main/resources/application.properties | 3 + .../src/main/resources/messages_de.properties | 39 ++ .../src/main/resources/messages_ee.properties | 39 ++ .../src/main/resources/messages_en.properties | 39 ++ .../src/main/resources/messages_es.properties | 39 ++ .../src/main/resources/messages_fr.properties | 39 ++ .../src/main/resources/messages_lt.properties | 39 ++ .../src/main/resources/messages_lv.properties | 39 ++ .../src/main/resources/messages_pl.properties | 39 ++ .../src/main/resources/messages_ru.properties | 39 ++ .../src/main/resources/messages_tr.properties | 39 ++ .../InvoiceComplianceValidatorTest.java | 24 +- backend/tsconfig.json | 1 + 26 files changed, 1283 insertions(+), 31 deletions(-) create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/SystemCompanyProfile.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/pages/view/AdminProfileView.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/repository/SystemCompanyProfileRepository.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/SystemCompanyProfileService.java diff --git a/backend/src/main/java/de/assecutor/votianlt/model/SystemCompanyProfile.java b/backend/src/main/java/de/assecutor/votianlt/model/SystemCompanyProfile.java new file mode 100644 index 0000000..156796f --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/SystemCompanyProfile.java @@ -0,0 +1,52 @@ +package de.assecutor.votianlt.model; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +/** + * Editable company data of the system operator (admin). Used as issuer data + * on system invoices to the users; falls back to the defaults hardcoded in + * SystemInvoiceData as long as no profile has been saved. + */ +@Data +@Document(collection = "system_company_profile") +public class SystemCompanyProfile { + + @Id + private String id; + + @Field("company_name") + private String companyName; + + @Field("company_subtitle") + private String companySubtitle; + + @Field("company_street") + private String companyStreet; + + @Field("company_city") + private String companyCity; + + @Field("company_phone") + private String companyPhone; + + @Field("company_fax") + private String companyFax; + + @Field("company_email") + private String companyEmail; + + @Field("company_website") + private String companyWebsite; + + @Field("sender_line") + private String senderLine; + + @Field("payment_terms") + private String paymentTerms; + + @Field("footer_text") + private String footerText; +} diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java index 70e1470..eedce96 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java @@ -19,7 +19,6 @@ import com.vaadin.flow.component.sidenav.SideNavItem; import com.vaadin.flow.router.Layout; import com.vaadin.flow.server.auth.AnonymousAllowed; import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; -import de.assecutor.votianlt.pages.view.EditProfileView; import de.assecutor.votianlt.security.SecurityService; import static com.vaadin.flow.theme.lumo.LumoUtility.*; @@ -105,6 +104,8 @@ public final class AdminLayout extends AppLayout { SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD)); SideNavItem invoiceGenerator = new SideNavItem("Rechnungsgenerator", "invoice-generator", new Icon(VaadinIcon.FILE_PROCESS)); + SideNavItem createInvoices = new SideNavItem("Rechnungen erstellen", "admin-create-invoices", + new Icon(VaadinIcon.INVOICE)); SideNavItem priceTable = new SideNavItem("Preis-Tabelle", "admin-price-table", new Icon(VaadinIcon.COG)); // SideNavItem systemSettings = new SideNavItem("Systemeinstellungen", // "admin-settings", new Icon(VaadinIcon.COG)); @@ -115,6 +116,7 @@ public final class AdminLayout extends AppLayout { nav.addItem(dashboard); nav.addItem(invoiceGenerator); + nav.addItem(createInvoices); nav.addItem(priceTable); // nav.addItem(systemSettings); // nav.addItem(userManagement); @@ -149,9 +151,8 @@ public final class AdminLayout extends AppLayout { var userMenuItem = userMenu.addItem(avatar); userMenuItem.add(userNameSpan); - // Profile display with navigation - userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate(EditProfileView.class)); - userMenuItem.getSubMenu().addItem("Admin-Einstellungen"); + // Profile display with navigation to the admin company profile + userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate("admin-profile")); userMenuItem.getSubMenu().addItem("Abmelden", e -> openLogoutConfirmDialog()); // Update function for username and avatar diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java new file mode 100644 index 0000000..5eebb7b --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java @@ -0,0 +1,488 @@ +package de.assecutor.votianlt.pages.view; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.IFrame; +import com.vaadin.flow.component.html.Main; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.notification.NotificationVariant; +import com.vaadin.flow.component.select.Select; +import com.vaadin.flow.router.HasDynamicTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.theme.lumo.LumoUtility; +import de.assecutor.votianlt.model.PriceTable; +import de.assecutor.votianlt.model.User; +import de.assecutor.votianlt.model.invoices.SystemInvoiceData; +import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; +import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; +import de.assecutor.votianlt.pages.base.ui.view.AdminLayout; +import de.assecutor.votianlt.repository.PriceTableRepository; +import de.assecutor.votianlt.repository.UserRepository; +import de.assecutor.votianlt.service.CustomerInvoiceService; +import de.assecutor.votianlt.service.InvoiceTemplateService; +import de.assecutor.votianlt.service.SystemCompanyProfileService; +import de.assecutor.votianlt.util.DateTimeFormatUtil; +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.YearMonth; +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 view to create the monthly system invoices for all current customers + * (the users of the system). Invoices are always due at the end of the month; + * customers starting mid-month are only billed pro rata for the remaining days + * until the last day of the month. + */ +@Route(value = "admin-create-invoices", layout = AdminLayout.class) +@RolesAllowed("ADMIN") +@Slf4j +public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { + + private static final BigDecimal SYSTEM_VAT_RATE = new BigDecimal("0.19"); + + private final UserRepository userRepository; + private final PriceTableRepository priceTableRepository; + private final InvoiceTemplateService invoiceTemplateService; + private final CustomerInvoiceService customerInvoiceService; + private final SystemCompanyProfileService systemCompanyProfileService; + + /** Billing month, selectable by the user; defaults to the current month. */ + private YearMonth billingMonth = YearMonth.now(); + + public AdminCreateInvoicesView(UserRepository userRepository, PriceTableRepository priceTableRepository, + InvoiceTemplateService invoiceTemplateService, CustomerInvoiceService customerInvoiceService, + SystemCompanyProfileService systemCompanyProfileService) { + this.userRepository = userRepository; + this.priceTableRepository = priceTableRepository; + this.invoiceTemplateService = invoiceTemplateService; + this.customerInvoiceService = customerInvoiceService; + this.systemCompanyProfileService = systemCompanyProfileService; + + setSizeFull(); + addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, + LumoUtility.Padding.MEDIUM, LumoUtility.Gap.SMALL); + addClassName("data-view"); + + Button createSelectedButton = new Button(getTranslation("admincreateinvoices.button.createselected", 0)); + createSelectedButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + createSelectedButton.setEnabled(false); + + // Billing month selection: current month and up to 12 months back + Select monthSelect = new Select<>(); + monthSelect.setLabel(getTranslation("admincreateinvoices.field.month")); + List months = new ArrayList<>(); + for (int offset = 0; offset >= -12; offset--) { + months.add(YearMonth.now().plusMonths(offset)); + } + monthSelect.setItems(months); + monthSelect.setItemLabelGenerator( + month -> month.format(DateTimeFormatter.ofPattern("LLLL yyyy", getLocale()))); + monthSelect.setValue(billingMonth); + + add(new ViewToolbar(getTranslation("admincreateinvoices.title"))); + + // Month selection left-aligned below the heading + monthSelect.getStyle().set("align-self", "flex-start"); + add(monthSelect); + + Paragraph hint = new Paragraph(getTranslation("admincreateinvoices.hint")); + hint.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL, LumoUtility.Margin.NONE); + add(hint); + + Grid grid = new Grid<>(); + grid.setItems(loadBillingInfos()); + grid.addColumn(info -> customerDisplayName(info.user())) + .setHeader(getTranslation("admincreateinvoices.column.customer")).setFlexGrow(2).setSortable(true); + grid.addColumn(info -> info.user().getEmail()).setHeader(getTranslation("admincreateinvoices.column.email")) + .setFlexGrow(2); + grid.addColumn(info -> info.startDate() != null ? DateTimeFormatUtil.formatDate(info.startDate()) : "") + .setHeader(getTranslation("admincreateinvoices.column.start")).setFlexGrow(1).setSortable(true); + grid.addColumn(info -> info.billedDays() < info.daysInMonth() + ? getTranslation("admincreateinvoices.days.partial", info.billedDays(), info.daysInMonth()) + : getTranslation("admincreateinvoices.days.full")) + .setHeader(getTranslation("admincreateinvoices.column.days")).setFlexGrow(1); + grid.addColumn(info -> DateTimeFormatUtil.formatDate(info.dueDate())) + .setHeader(getTranslation("admincreateinvoices.column.due")).setFlexGrow(1); + grid.addColumn(info -> formatAmount(info.netTotal()) + " €") + .setHeader(getTranslation("admincreateinvoices.column.net")).setFlexGrow(1) + .setTextAlign(com.vaadin.flow.component.grid.ColumnTextAlign.END); + grid.addComponentColumn(info -> { + Button createButton = new Button(getTranslation("admincreateinvoices.button.create")); + createButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL); + createButton.addClickListener(e -> createInvoicePdf(info)); + return createButton; + }).setAutoWidth(true).setFlexGrow(0).setFrozenToEnd(true); + grid.setSelectionMode(Grid.SelectionMode.MULTI); + grid.addSelectionListener(event -> { + int count = event.getAllSelectedItems().size(); + createSelectedButton.setText(getTranslation("admincreateinvoices.button.createselected", count)); + createSelectedButton.setEnabled(count > 0); + }); + createSelectedButton.addClickListener(e -> createInvoicesPdf(new ArrayList<>(grid.getSelectedItems()))); + monthSelect.addValueChangeListener(event -> { + if (event.getValue() != null) { + billingMonth = event.getValue(); + grid.deselectAll(); + grid.setItems(loadBillingInfos()); + } + }); + grid.setSizeFull(); + grid.addClassName("data-grid"); + + Div gridPanel = new Div(grid); + gridPanel.addClassNames("surface-panel", "data-grid-panel"); + gridPanel.setSizeFull(); + gridPanel.getStyle().set("min-width", "0"); + add(gridPanel); + + // Action bar below the table + com.vaadin.flow.component.orderedlayout.HorizontalLayout actionLayout = new com.vaadin.flow.component.orderedlayout.HorizontalLayout( + createSelectedButton); + actionLayout.setWidthFull(); + actionLayout.setJustifyContentMode( + com.vaadin.flow.component.orderedlayout.FlexComponent.JustifyContentMode.START); + actionLayout.getStyle().set("flex-shrink", "0"); + add(actionLayout); + } + + // ========================================== + // Billing calculation + // ========================================== + + /** + * Billing data of one customer for the current month. + */ + public record BillingInfo(User user, LocalDate startDate, int billedDays, int daysInMonth, LocalDate dueDate, + List> positions, BigDecimal netTotal, BigDecimal vatTotal, BigDecimal grossTotal) { + } + + private List loadBillingInfos() { + PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null); + return userRepository.findAll().stream().filter(this::isCurrentCustomer) + .filter(this::existedInBillingMonth).map(user -> computeBilling(user, priceTable)).toList(); + } + + /** + * Customers who only started after the billing month are not billed for it. + */ + private boolean existedInBillingMonth(User user) { + if (user.getCreatedAt() == null) { + return true; + } + return !user.getCreatedAt().toLocalDate().isAfter(billingMonth.atEndOfMonth()); + } + + /** + * Current customers are all activated users without the ADMIN role. + */ + private boolean isCurrentCustomer(User user) { + boolean isAdmin = user.getRoles() != null && user.getRoles().contains("ADMIN"); + return !isAdmin && user.getIsActivated() == 1; + } + + private BillingInfo computeBilling(User user, PriceTable priceTable) { + YearMonth month = billingMonth; + int daysInMonth = month.lengthOfMonth(); + LocalDate dueDate = month.atEndOfMonth(); + + LocalDate startDate = user.getCreatedAt() != null ? user.getCreatedAt().toLocalDate() : null; + int billedDays = daysInMonth; + if (startDate != null && YearMonth.from(startDate).equals(month)) { + // Customer started mid-month: bill only the remaining days until + // the last day of the month (start day included) + billedDays = daysInMonth - startDate.getDayOfMonth() + 1; + } + + BigDecimal factor = new BigDecimal(billedDays).divide(new BigDecimal(daysInMonth), 10, RoundingMode.HALF_UP); + boolean proRata = billedDays < daysInMonth; + + List> positions = new ArrayList<>(); + BigDecimal netTotal = BigDecimal.ZERO; + String[][] entries = { + { 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 } }; + + for (String[] entry : entries) { + String rawValue = entry[1] != null ? entry[1].trim() : ""; + BigDecimal amount = parseAmount(rawValue); + Map position = new HashMap<>(); + if (amount != null) { + BigDecimal billed = amount.multiply(factor).setScale(2, RoundingMode.HALF_UP); + String name = entry[0]; + if (proRata) { + name += " " + getTranslation("admincreateinvoices.prorata.suffix", billedDays, daysInMonth); + } + position.put("name", name); + position.put("netAmount", formatAmount(billed)); + netTotal = netTotal.add(billed); + } else { + // Non-monetary values (e.g. "15 %" revenue participation) are + // shown as-is and are not part of the totals + position.put("name", entry[0]); + position.put("netAmount", rawValue.isEmpty() ? "-" : rawValue); + } + positions.add(position); + } + + BigDecimal vatTotal = netTotal.multiply(SYSTEM_VAT_RATE).setScale(2, RoundingMode.HALF_UP); + BigDecimal grossTotal = netTotal.add(vatTotal); + + return new BillingInfo(user, startDate, billedDays, daysInMonth, dueDate, positions, netTotal, vatTotal, + grossTotal); + } + + // ========================================== + // PDF generation + // ========================================== + + private void createInvoicePdf(BillingInfo info) { + String templateData = loadSystemTemplateData(); + if (templateData == null) { + return; + } + try { + byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplateWithData(templateData, + buildInvoiceVariables(info), null); + showPdfInDialog(pdfBytes, buildInvoiceNumber(info.user())); + } catch (Exception ex) { + log.error("Error creating system invoice for user {}: {}", info.user().getEmail(), ex.getMessage(), ex); + Notification + .show(getTranslation("admincreateinvoices.notification.error", ex.getMessage()), 5000, + Notification.Position.BOTTOM_CENTER) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + } + } + + /** + * Creates the invoices of all selected customers as one merged PDF, one + * invoice per page. + */ + private void createInvoicesPdf(List infos) { + if (infos.isEmpty()) { + return; + } + String templateData = loadSystemTemplateData(); + if (templateData == null) { + return; + } + try { + List pdfs = new ArrayList<>(); + for (BillingInfo info : infos) { + pdfs.add(customerInvoiceService.generatePdfFromCanvasTemplateWithData(templateData, + buildInvoiceVariables(info), null)); + } + String fileName = "rechnungen-" + billingMonth.format(DateTimeFormatter.ofPattern("yyyyMM")); + showPdfInDialog(mergePdfs(pdfs), fileName); + } catch (Exception ex) { + log.error("Error creating system invoices for {} users: {}", infos.size(), ex.getMessage(), ex); + Notification + .show(getTranslation("admincreateinvoices.notification.error", ex.getMessage()), 5000, + Notification.Position.BOTTOM_CENTER) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + } + } + + /** + * Returns the stored system template data, or null (with a notification) + * if no template has been saved yet. + */ + private String loadSystemTemplateData() { + Optional template = invoiceTemplateService + .getTemplateByUserId(InvoiceGeneratorView.SYSTEM_TEMPLATE_USER_ID); + if (template.isEmpty() || template.get().getTemplateData() == null + || template.get().getTemplateData().isEmpty()) { + Notification + .show(getTranslation("admincreateinvoices.notification.notemplate"), 5000, + Notification.Position.BOTTOM_CENTER) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + return null; + } + return template.get().getTemplateData(); + } + + private byte[] mergePdfs(List pdfs) throws Exception { + if (pdfs.size() == 1) { + return pdfs.get(0); + } + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + com.itextpdf.kernel.pdf.PdfDocument merged = new com.itextpdf.kernel.pdf.PdfDocument( + new com.itextpdf.kernel.pdf.PdfWriter(baos)); + com.itextpdf.kernel.utils.PdfMerger merger = new com.itextpdf.kernel.utils.PdfMerger(merged); + for (byte[] pdf : pdfs) { + com.itextpdf.kernel.pdf.PdfDocument source = new com.itextpdf.kernel.pdf.PdfDocument( + new com.itextpdf.kernel.pdf.PdfReader(new java.io.ByteArrayInputStream(pdf))); + merger.merge(source, 1, source.getNumberOfPages()); + source.close(); + } + merged.close(); + return baos.toByteArray(); + } + + private Map buildInvoiceVariables(BillingInfo info) throws Exception { + SystemInvoiceData issuer = systemCompanyProfileService.createSystemInvoiceData(); + User user = info.user(); + Map variables = new LinkedHashMap<>(); + + String companyFull = (safe(issuer.getCompanyName()) + " " + safe(issuer.getCompanySubtitle())).trim(); + variables.put("masterdata.company_name", companyFull); + variables.put("masterdata.street", safe(issuer.getCompanyStreet())); + variables.put("masterdata.city", safe(issuer.getCompanyCity())); + variables.put("masterdata.phone", safe(issuer.getCompanyPhone())); + variables.put("masterdata.email", safe(issuer.getCompanyEmail())); + variables.put("masterdata.website", safe(issuer.getCompanyWebsite())); + variables.put("masterdata.sender_line", safe(issuer.getSenderLine())); + variables.put("masterdata.payment_terms", safe(issuer.getPaymentTerms())); + variables.put("masterdata.footer", safe(issuer.getFooterText()).replace("
", "\n")); + variables.put("masterdata.invoice_number", buildInvoiceNumber(user)); + variables.put("masterdata.invoice_date", + LocalDate.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))); + + // Recipient: the user's invoice address if a different one is set + if (user.isDiffInvoiceAddress()) { + variables.put("customer.company_name", firstNonBlank(user.getInvCompany(), user.getCompany())); + variables.put("customer.contact_name", + (safe(user.getInvFirstname()) + " " + safe(user.getInvLastname())).trim()); + variables.put("customer.street", + (safe(user.getInvStreet()) + " " + safe(user.getInvHouseNumber())).trim()); + variables.put("customer.city", (safe(user.getInvZip()) + " " + safe(user.getInvCity())).trim()); + } else { + variables.put("customer.company_name", safe(user.getCompany())); + variables.put("customer.contact_name", (safe(user.getFirstname()) + " " + safe(user.getName())).trim()); + variables.put("customer.street", (safe(user.getStreet()) + " " + safe(user.getHouseNumber())).trim()); + variables.put("customer.city", (safe(user.getZip()) + " " + safe(user.getCity())).trim()); + } + variables.put("customer.email", safe(user.getEmail())); + variables.put("customer.phone", safe(user.getPhone())); + + // Positions and totals + variables.put("services.json", + new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(info.positions())); + variables.put("services.count", String.valueOf(info.positions().size())); + variables.put("invoice.net_total", formatAmount(info.netTotal()) + " €"); + variables.put("invoice.vat_total", formatAmount(info.vatTotal()) + " €"); + variables.put("invoice.gross_total", formatAmount(info.grossTotal()) + " €"); + variables.put("invoice.vat_rate", + SYSTEM_VAT_RATE.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + "%"); + variables.put("invoice.due_date", DateTimeFormatUtil.formatDate(info.dueDate())); + variables.put("services.net_total", formatAmount(info.netTotal()) + " €"); + variables.put("services.gross_total", formatAmount(info.grossTotal()) + " €"); + + return variables; + } + + private String buildInvoiceNumber(User user) { + String monthPart = billingMonth.format(DateTimeFormatter.ofPattern("yyyyMM")); + String userPart = user.getUsrId() > 0 ? String.valueOf(user.getUsrId()) + : (user.getId() != null ? user.getId().toHexString().substring(20) : "0"); + return "SYS-" + monthPart + "-" + userPart; + } + + private void showPdfInDialog(byte[] pdfBytes, String invoiceNumber) { + Dialog pdfDialog = DialogStylingHelper.createStyledDialog(getTranslation("invoicegenerator.pdf.preview.title"), + "90vw"); + pdfDialog.setHeight("90vh"); + + Div pdfContainer = new Div(); + pdfContainer.setWidth("100%"); + pdfContainer.setHeight("100%"); + + String base64Pdf = Base64.getEncoder().encodeToString(pdfBytes); + + IFrame pdfFrame = new IFrame(); + pdfFrame.setWidth("100%"); + pdfFrame.setHeight("100%"); + pdfFrame.getElement().setAttribute("src", "data:application/pdf;base64," + base64Pdf); + pdfContainer.add(pdfFrame); + + Button closeButton = new Button(getTranslation("button.close"), e -> pdfDialog.close()); + closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button downloadButton = new Button(getTranslation("button.download"), e -> getElement() + .executeJs("const link = document.createElement('a');" + "link.href = 'data:application/pdf;base64," + + base64Pdf + "';" + "link.download = '" + invoiceNumber + ".pdf';" + "link.click();")); + downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + pdfDialog.add(DialogStylingHelper.wrapContent(pdfContainer, true)); + pdfDialog.getFooter().add(downloadButton, closeButton); + pdfDialog.open(); + } + + // ========================================== + // Helpers + // ========================================== + + private String customerDisplayName(User user) { + String company = safe(user.getCompany()).trim(); + if (!company.isEmpty()) { + return company; + } + return (safe(user.getFirstname()) + " " + safe(user.getName())).trim(); + } + + /** + * Parse a price table value like "99,00 €" or "1.234,56" into an 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 safe(String value) { + return value != null ? value : ""; + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return ""; + } + + @Override + public String getPageTitle() { + return getTranslation("page.title.admin.createinvoices"); + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminProfileView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminProfileView.java new file mode 100644 index 0000000..3e79674 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminProfileView.java @@ -0,0 +1,155 @@ +package de.assecutor.votianlt.pages.view; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.notification.NotificationVariant; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.HasDynamicTitle; +import com.vaadin.flow.router.Route; +import de.assecutor.votianlt.model.SystemCompanyProfile; +import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; +import de.assecutor.votianlt.pages.base.ui.view.AdminLayout; +import de.assecutor.votianlt.service.SystemCompanyProfileService; +import jakarta.annotation.security.RolesAllowed; +import lombok.extern.slf4j.Slf4j; + +/** + * Admin profile page for editing the company data of the system operator. + * These values are used as issuer data on system invoices to the users. + */ +@Route(value = "admin-profile", layout = AdminLayout.class) +@RolesAllowed("ADMIN") +@Slf4j +public class AdminProfileView extends VerticalLayout implements HasDynamicTitle { + + private final SystemCompanyProfileService profileService; + + private final TextField companyName = new TextField(); + private final TextField companySubtitle = new TextField(); + private final TextField companyStreet = new TextField(); + private final TextField companyCity = new TextField(); + private final TextField companyPhone = new TextField(); + private final TextField companyFax = new TextField(); + private final TextField companyEmail = new TextField(); + private final TextField companyWebsite = new TextField(); + private final TextField senderLine = new TextField(); + private final TextArea paymentTerms = new TextArea(); + private final TextArea footerText = new TextArea(); + + private SystemCompanyProfile profile; + + public AdminProfileView(SystemCompanyProfileService profileService) { + this.profileService = profileService; + + setSpacing(false); + setPadding(false); + setWidthFull(); + addClassName("admin-form-view"); + add(new ViewToolbar(getTranslation("adminprofile.title"))); + + companyName.setLabel(getTranslation("adminprofile.field.companyname")); + companySubtitle.setLabel(getTranslation("adminprofile.field.companysubtitle")); + companyStreet.setLabel(getTranslation("adminprofile.field.street")); + companyCity.setLabel(getTranslation("adminprofile.field.city")); + companyPhone.setLabel(getTranslation("adminprofile.field.phone")); + companyFax.setLabel(getTranslation("adminprofile.field.fax")); + companyEmail.setLabel(getTranslation("adminprofile.field.email")); + companyWebsite.setLabel(getTranslation("adminprofile.field.website")); + senderLine.setLabel(getTranslation("adminprofile.field.senderline")); + senderLine.setHelperText(getTranslation("adminprofile.field.senderline.helper")); + paymentTerms.setLabel(getTranslation("adminprofile.field.paymentterms")); + paymentTerms.setMinHeight("80px"); + footerText.setLabel(getTranslation("adminprofile.field.footer")); + footerText.setHelperText(getTranslation("adminprofile.field.footer.helper")); + footerText.setMinHeight("120px"); + + FormLayout form = new FormLayout(); + form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("600px", 2)); + form.add(companyName, companySubtitle, companyStreet, companyCity, companyPhone, companyFax, companyEmail, + companyWebsite); + form.add(senderLine, 2); + form.add(paymentTerms, 2); + form.add(footerText, 2); + + VerticalLayout fieldsLayout = new VerticalLayout(form); + fieldsLayout.setSpacing(true); + fieldsLayout.setPadding(false); + fieldsLayout.addClassNames("form-shell", "form-card"); + add(fieldsLayout); + + Button saveButton = new Button(getTranslation("button.savechanges"), e -> saveProfile()); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Div actions = new Div(saveButton); + actions.addClassNames("form-shell", "simple-card"); + add(actions); + + loadProfile(); + } + + private void loadProfile() { + try { + profile = profileService.getOrCreateProfile(); + companyName.setValue(nvl(profile.getCompanyName())); + companySubtitle.setValue(nvl(profile.getCompanySubtitle())); + companyStreet.setValue(nvl(profile.getCompanyStreet())); + companyCity.setValue(nvl(profile.getCompanyCity())); + companyPhone.setValue(nvl(profile.getCompanyPhone())); + companyFax.setValue(nvl(profile.getCompanyFax())); + companyEmail.setValue(nvl(profile.getCompanyEmail())); + companyWebsite.setValue(nvl(profile.getCompanyWebsite())); + senderLine.setValue(nvl(profile.getSenderLine())); + paymentTerms.setValue(nvl(profile.getPaymentTerms())); + // Footer is stored with
line breaks; edit it as multi-line text + footerText.setValue(nvl(profile.getFooterText()).replace("
", "\n")); + } catch (Exception ex) { + log.error("Error loading system company profile: {}", ex.getMessage(), ex); + Notification + .show(getTranslation("adminprofile.notification.load.error", ex.getMessage()), 5000, + Notification.Position.BOTTOM_CENTER) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + } + } + + private void saveProfile() { + try { + profile.setCompanyName(companyName.getValue().trim()); + profile.setCompanySubtitle(companySubtitle.getValue().trim()); + profile.setCompanyStreet(companyStreet.getValue().trim()); + profile.setCompanyCity(companyCity.getValue().trim()); + profile.setCompanyPhone(companyPhone.getValue().trim()); + profile.setCompanyFax(companyFax.getValue().trim()); + profile.setCompanyEmail(companyEmail.getValue().trim()); + profile.setCompanyWebsite(companyWebsite.getValue().trim()); + profile.setSenderLine(senderLine.getValue().trim()); + profile.setPaymentTerms(paymentTerms.getValue().trim()); + profile.setFooterText(footerText.getValue().trim().replace("\n", "
")); + + profile = profileService.save(profile); + Notification + .show(getTranslation("adminprofile.notification.saved"), 3000, + Notification.Position.BOTTOM_CENTER) + .addThemeVariants(NotificationVariant.LUMO_SUCCESS); + } catch (Exception ex) { + log.error("Error saving system company profile: {}", ex.getMessage(), ex); + Notification + .show(getTranslation("adminprofile.notification.save.error", ex.getMessage()), 5000, + Notification.Position.BOTTOM_CENTER) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + } + } + + private String nvl(String value) { + return value != null ? value : ""; + } + + @Override + public String getPageTitle() { + return getTranslation("page.title.admin.profile"); + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java index ce8eaf9..8c316e6 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java @@ -27,6 +27,7 @@ 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 de.assecutor.votianlt.service.SystemCompanyProfileService; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; @@ -68,14 +69,17 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi private final CustomerInvoiceService customerInvoiceService; private final InvoiceTemplateService invoiceTemplateService; private final PriceTableRepository priceTableRepository; + private final SystemCompanyProfileService systemCompanyProfileService; private VerticalLayout propertiesPanel; public InvoiceGeneratorView(CustomerInvoiceService customerInvoiceService, - InvoiceTemplateService invoiceTemplateService, PriceTableRepository priceTableRepository) { + InvoiceTemplateService invoiceTemplateService, PriceTableRepository priceTableRepository, + SystemCompanyProfileService systemCompanyProfileService) { this.customerInvoiceService = customerInvoiceService; this.invoiceTemplateService = invoiceTemplateService; this.priceTableRepository = priceTableRepository; + this.systemCompanyProfileService = systemCompanyProfileService; setId("invoice-generator-view"); addClassNames("invoice-generator-view", "admin-form-view"); @@ -174,7 +178,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi * invoice is generated. */ private Map buildSystemVariables() { - SystemInvoiceData data = new SystemInvoiceData(); + SystemInvoiceData data = systemCompanyProfileService.createSystemInvoiceData(); Map variables = new LinkedHashMap<>(); String companyFull = (safe(data.getCompanyName()) + " " + safe(data.getCompanySubtitle())).trim(); variables.put("masterdata.company_name", companyFull); @@ -853,13 +857,6 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi layout.setAlignItems(Alignment.CENTER); layout.addClassName("invoice-generator-actionbar"); - Button clearButton = new Button(getTranslation("invoicegenerator.button.clear"), new Icon(VaadinIcon.TRASH)); - clearButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY); - clearButton.addClickListener(e -> { - getElement().executeJs("if (window.clearProfileCanvas) { window.clearProfileCanvas(); }"); - showNotification(getTranslation("invoicegenerator.notification.cleared")); - }); - Button undoButton = new Button(getTranslation("button.undo"), new Icon(VaadinIcon.ARROW_BACKWARD)); undoButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); undoButton.addClickListener( @@ -873,7 +870,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi saveTemplateButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); saveTemplateButton.addClickListener(e -> saveSystemTemplate()); - layout.add(undoButton, clearButton, previewButton, saveTemplateButton); + layout.add(undoButton, previewButton, saveTemplateButton); return layout; } diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/MyInvoicesView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/MyInvoicesView.java index 7775f42..58cf298 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/MyInvoicesView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/MyInvoicesView.java @@ -23,6 +23,7 @@ import jakarta.annotation.security.RolesAllowed; import com.vaadin.flow.component.UI; import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.StreamRegistration; +import de.assecutor.votianlt.service.SystemCompanyProfileService; import de.assecutor.votianlt.service.SystemInvoiceService; import de.assecutor.votianlt.util.DateTimeFormatUtil; import de.assecutor.votianlt.model.invoices.SystemInvoiceData; @@ -49,11 +50,14 @@ public class MyInvoicesView extends Main implements HasDynamicTitle { private final List allRows = new ArrayList<>(); // zunächst leer private final Div emptyState = new Div(); private final SystemInvoiceService systemInvoiceService; + private final SystemCompanyProfileService systemCompanyProfileService; private static final NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY); - public MyInvoicesView(SystemInvoiceService systemInvoiceService) { + public MyInvoicesView(SystemInvoiceService systemInvoiceService, + SystemCompanyProfileService systemCompanyProfileService) { this.systemInvoiceService = systemInvoiceService; + this.systemCompanyProfileService = systemCompanyProfileService; addClassName("data-view"); getStyle().set("max-width", "1100px"); getStyle().set("margin-left", "auto"); @@ -272,7 +276,7 @@ public class MyInvoicesView extends Main implements HasDynamicTitle { } private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception { - SystemInvoiceData data = new SystemInvoiceData(); + SystemInvoiceData data = systemCompanyProfileService.createSystemInvoiceData(); data.setInvoiceNumber(row.invoiceNumber()); data.setInvoiceDate(DateTimeFormatUtil.formatDate(row.date())); data.setInvoiceText("Rechnung " + row.invoiceNumber()); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java index 8c9f73b..479c877 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java @@ -50,7 +50,6 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle { private final SecurityService securityService; private final ClientConnectionService clientConnectionService; private final MessagingPublisher messagingPublisher; - private final CustomerInvoiceRepository customerInvoiceRepository; private final Grid grid = new Grid<>(Job.class, false); @Autowired @@ -62,7 +61,6 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle { this.securityService = securityService; this.clientConnectionService = clientConnectionService; this.messagingPublisher = messagingPublisher; - this.customerInvoiceRepository = customerInvoiceRepository; setSizeFull(); setPadding(true); setSpacing(true); diff --git a/backend/src/main/java/de/assecutor/votianlt/repository/SystemCompanyProfileRepository.java b/backend/src/main/java/de/assecutor/votianlt/repository/SystemCompanyProfileRepository.java new file mode 100644 index 0000000..331afcf --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/repository/SystemCompanyProfileRepository.java @@ -0,0 +1,9 @@ +package de.assecutor.votianlt.repository; + +import de.assecutor.votianlt.model.SystemCompanyProfile; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface SystemCompanyProfileRepository extends MongoRepository { +} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/MonthlySchedulerService.java b/backend/src/main/java/de/assecutor/votianlt/service/MonthlySchedulerService.java index 7bbaa88..be509f0 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/MonthlySchedulerService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/MonthlySchedulerService.java @@ -1,5 +1,7 @@ package de.assecutor.votianlt.service; +import de.assecutor.votianlt.model.User; +import de.assecutor.votianlt.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -7,6 +9,9 @@ import org.springframework.stereotype.Service; import java.time.LocalDate; import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; /** * Service für monatliche Scheduler-Aufgaben, die am letzten Tag des Monats @@ -17,6 +22,61 @@ public class MonthlySchedulerService { private static final Logger logger = LoggerFactory.getLogger(MonthlySchedulerService.class); + private final EmailService emailService; + private final UserRepository userRepository; + + public MonthlySchedulerService(EmailService emailService, UserRepository userRepository) { + this.emailService = emailService; + this.userRepository = userRepository; + } + + /** + * Läuft täglich um 08:00 Uhr und erinnert die Administratoren drei Tage vor + * Ultimo per E-Mail daran, die Monatsrechnungen fertigzustellen. + */ + @Scheduled(cron = "0 0 8 * * *") + public void checkAndSendInvoiceReminder() { + LocalDate today = LocalDate.now(); + LocalDate lastDayOfMonth = YearMonth.from(today).atEndOfMonth(); + + if (!today.equals(lastDayOfMonth.minusDays(3))) { + return; + } + + logger.info("Drei Tage vor Ultimo ({}): Sende Rechnungs-Erinnerung an die Administratoren.", lastDayOfMonth); + sendInvoiceReminderToAdmins(lastDayOfMonth); + } + + private void sendInvoiceReminderToAdmins(LocalDate lastDayOfMonth) { + List admins = userRepository.findAll().stream() + .filter(user -> user.getRoles() != null && user.getRoles().contains("ADMIN")) + .filter(user -> user.getEmail() != null && !user.getEmail().isBlank()).toList(); + + if (admins.isEmpty()) { + logger.warn("Keine Administratoren mit E-Mail-Adresse gefunden, Rechnungs-Erinnerung entfällt."); + return; + } + + String monthLabel = YearMonth.from(lastDayOfMonth) + .format(DateTimeFormatter.ofPattern("LLLL yyyy", Locale.GERMAN)); + String dueDateLabel = lastDayOfMonth.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")); + String subject = "Erinnerung: Rechnungen für " + monthLabel + " erstellen"; + String body = "Hallo,\n\n" // + + "in drei Tagen ist Ultimo (" + dueDateLabel + ").\n" + + "Bitte die Monatsrechnungen für " + monthLabel + + " fertigstellen: Seite \"Rechnungen erstellen\" im Admin-Bereich (/admin-create-invoices).\n\n" + + "Diese Nachricht wurde automatisch von VotianLT versendet."; + + for (User admin : admins) { + try { + emailService.sendSimpleEmail(admin.getEmail(), subject, body); + logger.info("Rechnungs-Erinnerung an {} gesendet.", admin.getEmail()); + } catch (Exception e) { + logger.error("Rechnungs-Erinnerung an {} fehlgeschlagen: {}", admin.getEmail(), e.getMessage(), e); + } + } + } + /** * Scheduler, der täglich um 23:00 Uhr läuft und prüft, ob heute der letzte Tag * des Monats ist. Wenn ja, wird die monatliche Aufgabe ausgeführt. diff --git a/backend/src/main/java/de/assecutor/votianlt/service/SystemCompanyProfileService.java b/backend/src/main/java/de/assecutor/votianlt/service/SystemCompanyProfileService.java new file mode 100644 index 0000000..7e92bf6 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/SystemCompanyProfileService.java @@ -0,0 +1,78 @@ +package de.assecutor.votianlt.service; + +import de.assecutor.votianlt.model.SystemCompanyProfile; +import de.assecutor.votianlt.model.invoices.SystemInvoiceData; +import de.assecutor.votianlt.repository.SystemCompanyProfileRepository; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +/** + * Manages the company data of the system operator. As long as no profile has + * been saved, the defaults from SystemInvoiceData are used. + */ +@Service +public class SystemCompanyProfileService { + + private final SystemCompanyProfileRepository repository; + + public SystemCompanyProfileService(SystemCompanyProfileRepository repository) { + this.repository = repository; + } + + /** + * Returns the stored profile, or a new unsaved instance prefilled with the + * SystemInvoiceData defaults for first-time editing. + */ + public SystemCompanyProfile getOrCreateProfile() { + Optional stored = repository.findAll().stream().findFirst(); + if (stored.isPresent()) { + return stored.get(); + } + SystemInvoiceData defaults = new SystemInvoiceData(); + SystemCompanyProfile profile = new SystemCompanyProfile(); + profile.setCompanyName(defaults.getCompanyName()); + profile.setCompanySubtitle(defaults.getCompanySubtitle()); + profile.setCompanyStreet(defaults.getCompanyStreet()); + profile.setCompanyCity(defaults.getCompanyCity()); + profile.setCompanyPhone(defaults.getCompanyPhone()); + profile.setCompanyFax(defaults.getCompanyFax()); + profile.setCompanyEmail(defaults.getCompanyEmail()); + profile.setCompanyWebsite(defaults.getCompanyWebsite()); + profile.setSenderLine(defaults.getSenderLine()); + profile.setPaymentTerms(defaults.getPaymentTerms()); + profile.setFooterText(defaults.getFooterText()); + return profile; + } + + public SystemCompanyProfile save(SystemCompanyProfile profile) { + return repository.save(profile); + } + + /** + * Creates a SystemInvoiceData whose issuer fields come from the stored + * profile. Once a profile exists it is the source of truth for all issuer + * fields; without one the hardcoded defaults remain. + */ + public SystemInvoiceData createSystemInvoiceData() { + SystemInvoiceData data = new SystemInvoiceData(); + repository.findAll().stream().findFirst().ifPresent(profile -> { + data.setCompanyName(nvl(profile.getCompanyName())); + data.setCompanySubtitle(nvl(profile.getCompanySubtitle())); + data.setCompanyStreet(nvl(profile.getCompanyStreet())); + data.setCompanyCity(nvl(profile.getCompanyCity())); + data.setCompanyPhone(nvl(profile.getCompanyPhone())); + data.setCompanyFax(nvl(profile.getCompanyFax())); + data.setCompanyEmail(nvl(profile.getCompanyEmail())); + data.setCompanyWebsite(nvl(profile.getCompanyWebsite())); + data.setSenderLine(nvl(profile.getSenderLine())); + data.setPaymentTerms(nvl(profile.getPaymentTerms())); + data.setFooterText(nvl(profile.getFooterText())); + }); + return data; + } + + private String nvl(String value) { + return value != null ? value : ""; + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/SystemInvoiceService.java b/backend/src/main/java/de/assecutor/votianlt/service/SystemInvoiceService.java index 3e38647..4ff5957 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/SystemInvoiceService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/SystemInvoiceService.java @@ -13,6 +13,12 @@ import java.util.List; @Service public class SystemInvoiceService { + private final SystemCompanyProfileService systemCompanyProfileService; + + public SystemInvoiceService(SystemCompanyProfileService systemCompanyProfileService) { + this.systemCompanyProfileService = systemCompanyProfileService; + } + public byte[] generateInvoicePdfFromHtml() throws Exception { // Read the HTML template String htmlContent = readHtmlTemplate(); @@ -35,7 +41,7 @@ public class SystemInvoiceService { } public SystemInvoiceData createSampleInvoiceData() { - SystemInvoiceData data = new SystemInvoiceData(); + SystemInvoiceData data = systemCompanyProfileService.createSystemInvoiceData(); // Set sample data data.setInvoiceNumber("HHA-2021-007"); diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties index b38f4d0..8f121f5 100644 --- a/backend/src/main/resources/application-dev.properties +++ b/backend/src/main/resources/application-dev.properties @@ -6,6 +6,11 @@ spring.data.mongodb.uri=${MONGODB_URI} # Enable browser launch in development vaadin.launch-browser=true +# Sessions über Neustarts persistieren (eingeloggt bleiben nach DevTools-Restart). +# Hinweis: Nach inkompatiblen Code-Änderungen kann beim Start eine harmlose +# SESSIONS.ser-Deserialisierungs-Warnung im Log erscheinen. +server.servlet.session.persistent=true + # Development logging levels logging.level.de.assecutor.votianlt=DEBUG logging.level.root=INFO diff --git a/backend/src/main/resources/application-production.properties b/backend/src/main/resources/application-production.properties index 2a3495a..3efa6f9 100644 --- a/backend/src/main/resources/application-production.properties +++ b/backend/src/main/resources/application-production.properties @@ -6,6 +6,11 @@ spring.data.mongodb.uri=${MONGODB_URI} # Disable browser launch in production vaadin.launch-browser=false +# Sessions über Neustarts persistieren: Nutzer bleiben nach einem +# Deployment/Neustart eingeloggt. Nach inkompatiblen Code-Änderungen verwirft +# Tomcat die alte SESSIONS.ser mit einer harmlosen Log-Warnung. +server.servlet.session.persistent=true + # 2FA Configuration - Enabled for production app.security.two-factor.enabled=true diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index e3b1f50..5d6336a 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,5 +1,8 @@ server.port=${PORT:8082} server.address=0.0.0.0 +# Sessions standardmäßig nicht über Neustarts persistieren; nur im dev-Profil aktiviert +# (siehe application-dev.properties) +server.servlet.session.persistent=false # Default active profile spring.profiles.active=dev diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties index 300978b..898f630 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -1086,3 +1086,42 @@ admindashboard.customers.empty=Keine Kunden vorhanden invoicegenerator.positions.header=Positionen (Preistabelle) invoicegenerator.positions.list=Positionen auflisten button.undo=Rückgängig + +# Admin-Profil (Firmendaten) +page.title.admin.profile=Firmendaten bearbeiten +adminprofile.title=Firmendaten +adminprofile.field.companyname=Firmenname +adminprofile.field.companysubtitle=Firmenzusatz +adminprofile.field.street=Straße und Hausnummer +adminprofile.field.city=PLZ und Ort +adminprofile.field.phone=Telefon +adminprofile.field.fax=Fax +adminprofile.field.email=E-Mail +adminprofile.field.website=Webseite +adminprofile.field.senderline=Absenderzeile +adminprofile.field.senderline.helper=Einzeilige Absenderangabe über der Empfängeradresse auf Rechnungen +adminprofile.field.paymentterms=Zahlungsbedingungen +adminprofile.field.footer=Fußzeile +adminprofile.field.footer.helper=Geschäftsführer, Steuernummer, Bankverbindung – jede Zeile erscheint als eigene Zeile auf der Rechnung +adminprofile.notification.saved=Firmendaten gespeichert +adminprofile.notification.save.error=Fehler beim Speichern: {0} +adminprofile.notification.load.error=Fehler beim Laden: {0} + +# Rechnungen erstellen (Admin) +page.title.admin.createinvoices=Rechnungen erstellen +admincreateinvoices.title=Rechnungen erstellen +admincreateinvoices.hint=Rechnungen werden zum Monatsende (Ultimo) fällig. Beginnt ein Kunde im laufenden Monat, werden nur die verbleibenden Tage bis Ultimo berechnet. +admincreateinvoices.column.customer=Kunde +admincreateinvoices.column.email=E-Mail +admincreateinvoices.column.start=Kunde seit +admincreateinvoices.column.days=Abgerechnete Tage +admincreateinvoices.column.due=Fällig am +admincreateinvoices.column.net=Nettobetrag +admincreateinvoices.days.full=Voller Monat +admincreateinvoices.days.partial={0} von {1} Tagen +admincreateinvoices.prorata.suffix=(anteilig {0}/{1} Tage) +admincreateinvoices.button.create=Rechnung erstellen +admincreateinvoices.notification.notemplate=Kein Rechnungstemplate vorhanden. Bitte zuerst im Rechnungsgenerator ein Template speichern. +admincreateinvoices.notification.error=Fehler beim Erstellen der Rechnung: {0} +admincreateinvoices.button.createselected=Rechnungen erstellen ({0}) +admincreateinvoices.field.month=Abrechnungsmonat diff --git a/backend/src/main/resources/messages_ee.properties b/backend/src/main/resources/messages_ee.properties index 1241858..243412c 100644 --- a/backend/src/main/resources/messages_ee.properties +++ b/backend/src/main/resources/messages_ee.properties @@ -916,3 +916,42 @@ admindashboard.customers.empty=Kliente pole invoicegenerator.positions.header=Positsioonid (hinnatabel) invoicegenerator.positions.list=Positsioonide loend button.undo=Võta tagasi + +# Admini profiil (ettevõtte andmed) +page.title.admin.profile=Ettevõtte andmete muutmine +adminprofile.title=Ettevõtte andmed +adminprofile.field.companyname=Ettevõtte nimi +adminprofile.field.companysubtitle=Ettevõtte lisand +adminprofile.field.street=Tänav ja majanumber +adminprofile.field.city=Sihtnumber ja linn +adminprofile.field.phone=Telefon +adminprofile.field.fax=Faks +adminprofile.field.email=E-post +adminprofile.field.website=Veebileht +adminprofile.field.senderline=Saatja rida +adminprofile.field.senderline.helper=Üherealine saatja teave arve saaja aadressi kohal +adminprofile.field.paymentterms=Maksetingimused +adminprofile.field.footer=Jalus +adminprofile.field.footer.helper=Tegevjuhid, maksunumber, pangaandmed – iga rida kuvatakse arvel eraldi reana +adminprofile.notification.saved=Ettevõtte andmed salvestatud +adminprofile.notification.save.error=Viga salvestamisel: {0} +adminprofile.notification.load.error=Viga laadimisel: {0} + +# Arvete koostamine (admin) +page.title.admin.createinvoices=Arvete koostamine +admincreateinvoices.title=Arvete koostamine +admincreateinvoices.hint=Arved kuuluvad tasumisele kuu lõpus. Kui klient alustab kuu keskel, arvestatakse ainult kuu lõpuni jäänud päevad. +admincreateinvoices.column.customer=Klient +admincreateinvoices.column.email=E-post +admincreateinvoices.column.start=Klient alates +admincreateinvoices.column.days=Arvestatud päevad +admincreateinvoices.column.due=Tähtaeg +admincreateinvoices.column.net=Netosumma +admincreateinvoices.days.full=Terve kuu +admincreateinvoices.days.partial={0} / {1} päeva +admincreateinvoices.prorata.suffix=(proportsionaalselt {0}/{1} päeva) +admincreateinvoices.button.create=Koosta arve +admincreateinvoices.notification.notemplate=Arve mall puudub. Palun salvestage kõigepealt mall arvegeneraatoris. +admincreateinvoices.notification.error=Viga arve koostamisel: {0} +admincreateinvoices.button.createselected=Koosta arved ({0}) +admincreateinvoices.field.month=Arveldusperiood diff --git a/backend/src/main/resources/messages_en.properties b/backend/src/main/resources/messages_en.properties index 6cdd798..054a68e 100644 --- a/backend/src/main/resources/messages_en.properties +++ b/backend/src/main/resources/messages_en.properties @@ -1086,3 +1086,42 @@ admindashboard.customers.empty=No customers available invoicegenerator.positions.header=Positions (price table) invoicegenerator.positions.list=List positions button.undo=Undo + +# Admin profile (company data) +page.title.admin.profile=Edit company data +adminprofile.title=Company data +adminprofile.field.companyname=Company name +adminprofile.field.companysubtitle=Company addition +adminprofile.field.street=Street and number +adminprofile.field.city=ZIP and city +adminprofile.field.phone=Phone +adminprofile.field.fax=Fax +adminprofile.field.email=Email +adminprofile.field.website=Website +adminprofile.field.senderline=Sender line +adminprofile.field.senderline.helper=Single-line sender information above the recipient address on invoices +adminprofile.field.paymentterms=Payment terms +adminprofile.field.footer=Footer +adminprofile.field.footer.helper=Managing directors, tax number, bank details – each line appears as its own line on the invoice +adminprofile.notification.saved=Company data saved +adminprofile.notification.save.error=Error while saving: {0} +adminprofile.notification.load.error=Error while loading: {0} + +# Create invoices (admin) +page.title.admin.createinvoices=Create invoices +admincreateinvoices.title=Create invoices +admincreateinvoices.hint=Invoices are due at the end of the month. If a customer starts mid-month, only the remaining days until the end of the month are billed. +admincreateinvoices.column.customer=Customer +admincreateinvoices.column.email=Email +admincreateinvoices.column.start=Customer since +admincreateinvoices.column.days=Billed days +admincreateinvoices.column.due=Due on +admincreateinvoices.column.net=Net amount +admincreateinvoices.days.full=Full month +admincreateinvoices.days.partial={0} of {1} days +admincreateinvoices.prorata.suffix=(pro rata {0}/{1} days) +admincreateinvoices.button.create=Create invoice +admincreateinvoices.notification.notemplate=No invoice template available. Please save a template in the invoice generator first. +admincreateinvoices.notification.error=Error creating the invoice: {0} +admincreateinvoices.button.createselected=Create invoices ({0}) +admincreateinvoices.field.month=Billing month diff --git a/backend/src/main/resources/messages_es.properties b/backend/src/main/resources/messages_es.properties index 3a988ea..5809e58 100644 --- a/backend/src/main/resources/messages_es.properties +++ b/backend/src/main/resources/messages_es.properties @@ -1017,3 +1017,42 @@ admindashboard.customers.empty=No hay clientes invoicegenerator.positions.header=Posiciones (tabla de precios) invoicegenerator.positions.list=Listar posiciones button.undo=Deshacer + +# Perfil de administrador (datos de la empresa) +page.title.admin.profile=Editar datos de la empresa +adminprofile.title=Datos de la empresa +adminprofile.field.companyname=Nombre de la empresa +adminprofile.field.companysubtitle=Complemento de la empresa +adminprofile.field.street=Calle y número +adminprofile.field.city=Código postal y ciudad +adminprofile.field.phone=Teléfono +adminprofile.field.fax=Fax +adminprofile.field.email=Correo electrónico +adminprofile.field.website=Sitio web +adminprofile.field.senderline=Línea de remitente +adminprofile.field.senderline.helper=Información del remitente en una línea sobre la dirección del destinatario en las facturas +adminprofile.field.paymentterms=Condiciones de pago +adminprofile.field.footer=Pie de página +adminprofile.field.footer.helper=Gerentes, número fiscal, datos bancarios – cada línea aparece como línea propia en la factura +adminprofile.notification.saved=Datos de la empresa guardados +adminprofile.notification.save.error=Error al guardar: {0} +adminprofile.notification.load.error=Error al cargar: {0} + +# Crear facturas (admin) +page.title.admin.createinvoices=Crear facturas +admincreateinvoices.title=Crear facturas +admincreateinvoices.hint=Las facturas vencen a fin de mes. Si un cliente comienza a mitad de mes, solo se facturan los días restantes hasta fin de mes. +admincreateinvoices.column.customer=Cliente +admincreateinvoices.column.email=Correo electrónico +admincreateinvoices.column.start=Cliente desde +admincreateinvoices.column.days=Días facturados +admincreateinvoices.column.due=Vence el +admincreateinvoices.column.net=Importe neto +admincreateinvoices.days.full=Mes completo +admincreateinvoices.days.partial={0} de {1} días +admincreateinvoices.prorata.suffix=(prorrateado {0}/{1} días) +admincreateinvoices.button.create=Crear factura +admincreateinvoices.notification.notemplate=No hay plantilla de factura. Guarde primero una plantilla en el generador de facturas. +admincreateinvoices.notification.error=Error al crear la factura: {0} +admincreateinvoices.button.createselected=Crear facturas ({0}) +admincreateinvoices.field.month=Mes de facturación diff --git a/backend/src/main/resources/messages_fr.properties b/backend/src/main/resources/messages_fr.properties index ffab5bf..e67ab06 100644 --- a/backend/src/main/resources/messages_fr.properties +++ b/backend/src/main/resources/messages_fr.properties @@ -1017,3 +1017,42 @@ admindashboard.customers.empty=Aucun client invoicegenerator.positions.header=Positions (grille tarifaire) invoicegenerator.positions.list=Lister les positions button.undo=Annuler + +# Profil administrateur (données de l'entreprise) +page.title.admin.profile=Modifier les données de l'entreprise +adminprofile.title=Données de l'entreprise +adminprofile.field.companyname=Nom de l'entreprise +adminprofile.field.companysubtitle=Complément de l'entreprise +adminprofile.field.street=Rue et numéro +adminprofile.field.city=Code postal et ville +adminprofile.field.phone=Téléphone +adminprofile.field.fax=Fax +adminprofile.field.email=E-mail +adminprofile.field.website=Site web +adminprofile.field.senderline=Ligne d'expéditeur +adminprofile.field.senderline.helper=Mention de l'expéditeur sur une ligne au-dessus de l'adresse du destinataire sur les factures +adminprofile.field.paymentterms=Conditions de paiement +adminprofile.field.footer=Pied de page +adminprofile.field.footer.helper=Gérants, numéro fiscal, coordonnées bancaires – chaque ligne apparaît comme une ligne distincte sur la facture +adminprofile.notification.saved=Données de l'entreprise enregistrées +adminprofile.notification.save.error=Erreur lors de l'enregistrement : {0} +adminprofile.notification.load.error=Erreur lors du chargement : {0} + +# Créer des factures (admin) +page.title.admin.createinvoices=Créer des factures +admincreateinvoices.title=Créer des factures +admincreateinvoices.hint=Les factures sont exigibles en fin de mois. Si un client commence en cours de mois, seuls les jours restants jusqu'à la fin du mois sont facturés. +admincreateinvoices.column.customer=Client +admincreateinvoices.column.email=E-mail +admincreateinvoices.column.start=Client depuis +admincreateinvoices.column.days=Jours facturés +admincreateinvoices.column.due=Échéance le +admincreateinvoices.column.net=Montant net +admincreateinvoices.days.full=Mois complet +admincreateinvoices.days.partial={0} sur {1} jours +admincreateinvoices.prorata.suffix=(au prorata {0}/{1} jours) +admincreateinvoices.button.create=Créer la facture +admincreateinvoices.notification.notemplate=Aucun modèle de facture disponible. Veuillez d'abord enregistrer un modèle dans le générateur de factures. +admincreateinvoices.notification.error=Erreur lors de la création de la facture : {0} +admincreateinvoices.button.createselected=Créer les factures ({0}) +admincreateinvoices.field.month=Mois de facturation diff --git a/backend/src/main/resources/messages_lt.properties b/backend/src/main/resources/messages_lt.properties index b8e47ff..c1bd28d 100644 --- a/backend/src/main/resources/messages_lt.properties +++ b/backend/src/main/resources/messages_lt.properties @@ -1019,3 +1019,42 @@ admindashboard.customers.empty=Klientų nėra invoicegenerator.positions.header=Pozicijos (kainų lentelė) invoicegenerator.positions.list=Pozicijų sąrašas button.undo=Anuliuoti + +# Administratoriaus profilis (įmonės duomenys) +page.title.admin.profile=Redaguoti įmonės duomenis +adminprofile.title=Įmonės duomenys +adminprofile.field.companyname=Įmonės pavadinimas +adminprofile.field.companysubtitle=Įmonės priedas +adminprofile.field.street=Gatvė ir namo numeris +adminprofile.field.city=Pašto kodas ir miestas +adminprofile.field.phone=Telefonas +adminprofile.field.fax=Faksas +adminprofile.field.email=El. paštas +adminprofile.field.website=Svetainė +adminprofile.field.senderline=Siuntėjo eilutė +adminprofile.field.senderline.helper=Vienos eilutės siuntėjo informacija virš gavėjo adreso sąskaitose +adminprofile.field.paymentterms=Mokėjimo sąlygos +adminprofile.field.footer=Poraštė +adminprofile.field.footer.helper=Vadovai, mokesčių numeris, banko rekvizitai – kiekviena eilutė sąskaitoje rodoma atskirai +adminprofile.notification.saved=Įmonės duomenys išsaugoti +adminprofile.notification.save.error=Klaida išsaugant: {0} +adminprofile.notification.load.error=Klaida įkeliant: {0} + +# Sąskaitų kūrimas (admin) +page.title.admin.createinvoices=Kurti sąskaitas +admincreateinvoices.title=Kurti sąskaitas +admincreateinvoices.hint=Sąskaitos apmokamos mėnesio pabaigoje. Jei klientas pradeda mėnesio viduryje, apmokestinamos tik likusios dienos iki mėnesio pabaigos. +admincreateinvoices.column.customer=Klientas +admincreateinvoices.column.email=El. paštas +admincreateinvoices.column.start=Klientas nuo +admincreateinvoices.column.days=Apmokestintos dienos +admincreateinvoices.column.due=Terminas +admincreateinvoices.column.net=Neto suma +admincreateinvoices.days.full=Visas mėnuo +admincreateinvoices.days.partial={0} iš {1} dienų +admincreateinvoices.prorata.suffix=(proporcingai {0}/{1} dienų) +admincreateinvoices.button.create=Sukurti sąskaitą +admincreateinvoices.notification.notemplate=Nėra sąskaitos šablono. Pirmiausia išsaugokite šabloną sąskaitų generatoriuje. +admincreateinvoices.notification.error=Klaida kuriant sąskaitą: {0} +admincreateinvoices.button.createselected=Sukurti sąskaitas ({0}) +admincreateinvoices.field.month=Atsiskaitymo mėnuo diff --git a/backend/src/main/resources/messages_lv.properties b/backend/src/main/resources/messages_lv.properties index d5b61e8..b8aa101 100644 --- a/backend/src/main/resources/messages_lv.properties +++ b/backend/src/main/resources/messages_lv.properties @@ -1017,3 +1017,42 @@ admindashboard.customers.empty=Nav klientu invoicegenerator.positions.header=Pozīcijas (cenu tabula) invoicegenerator.positions.list=Pozīciju saraksts button.undo=Atsaukt + +# Administratora profils (uzņēmuma dati) +page.title.admin.profile=Rediģēt uzņēmuma datus +adminprofile.title=Uzņēmuma dati +adminprofile.field.companyname=Uzņēmuma nosaukums +adminprofile.field.companysubtitle=Uzņēmuma papildinājums +adminprofile.field.street=Iela un mājas numurs +adminprofile.field.city=Pasta indekss un pilsēta +adminprofile.field.phone=Tālrunis +adminprofile.field.fax=Fakss +adminprofile.field.email=E-pasts +adminprofile.field.website=Tīmekļa vietne +adminprofile.field.senderline=Sūtītāja rinda +adminprofile.field.senderline.helper=Vienas rindas sūtītāja informācija virs saņēmēja adreses rēķinos +adminprofile.field.paymentterms=Maksājuma nosacījumi +adminprofile.field.footer=Kājene +adminprofile.field.footer.helper=Vadītāji, nodokļu numurs, bankas rekvizīti – katra rinda rēķinā parādās atsevišķi +adminprofile.notification.saved=Uzņēmuma dati saglabāti +adminprofile.notification.save.error=Kļūda saglabājot: {0} +adminprofile.notification.load.error=Kļūda ielādējot: {0} + +# Rēķinu izveide (admin) +page.title.admin.createinvoices=Izveidot rēķinus +admincreateinvoices.title=Izveidot rēķinus +admincreateinvoices.hint=Rēķini jāapmaksā mēneša beigās. Ja klients sāk mēneša vidū, tiek aprēķinātas tikai atlikušās dienas līdz mēneša beigām. +admincreateinvoices.column.customer=Klients +admincreateinvoices.column.email=E-pasts +admincreateinvoices.column.start=Klients kopš +admincreateinvoices.column.days=Aprēķinātās dienas +admincreateinvoices.column.due=Termiņš +admincreateinvoices.column.net=Neto summa +admincreateinvoices.days.full=Pilns mēnesis +admincreateinvoices.days.partial={0} no {1} dienām +admincreateinvoices.prorata.suffix=(proporcionāli {0}/{1} dienas) +admincreateinvoices.button.create=Izveidot rēķinu +admincreateinvoices.notification.notemplate=Nav rēķina veidnes. Lūdzu, vispirms saglabājiet veidni rēķinu ģeneratorā. +admincreateinvoices.notification.error=Kļūda, veidojot rēķinu: {0} +admincreateinvoices.button.createselected=Izveidot rēķinus ({0}) +admincreateinvoices.field.month=Norēķinu mēnesis diff --git a/backend/src/main/resources/messages_pl.properties b/backend/src/main/resources/messages_pl.properties index 2f0600d..a32f794 100644 --- a/backend/src/main/resources/messages_pl.properties +++ b/backend/src/main/resources/messages_pl.properties @@ -1017,3 +1017,42 @@ admindashboard.customers.empty=Brak klientów invoicegenerator.positions.header=Pozycje (cennik) invoicegenerator.positions.list=Lista pozycji button.undo=Cofnij + +# Profil administratora (dane firmy) +page.title.admin.profile=Edytuj dane firmy +adminprofile.title=Dane firmy +adminprofile.field.companyname=Nazwa firmy +adminprofile.field.companysubtitle=Dodatek do nazwy +adminprofile.field.street=Ulica i numer +adminprofile.field.city=Kod pocztowy i miasto +adminprofile.field.phone=Telefon +adminprofile.field.fax=Faks +adminprofile.field.email=E-mail +adminprofile.field.website=Strona internetowa +adminprofile.field.senderline=Linia nadawcy +adminprofile.field.senderline.helper=Jednoliniowa informacja o nadawcy nad adresem odbiorcy na fakturach +adminprofile.field.paymentterms=Warunki płatności +adminprofile.field.footer=Stopka +adminprofile.field.footer.helper=Zarząd, numer podatkowy, dane bankowe – każda linia pojawia się jako osobny wiersz na fakturze +adminprofile.notification.saved=Dane firmy zapisane +adminprofile.notification.save.error=Błąd podczas zapisywania: {0} +adminprofile.notification.load.error=Błąd podczas ładowania: {0} + +# Tworzenie faktur (admin) +page.title.admin.createinvoices=Utwórz faktury +admincreateinvoices.title=Utwórz faktury +admincreateinvoices.hint=Faktury są płatne na koniec miesiąca. Jeśli klient rozpoczyna w trakcie miesiąca, rozliczane są tylko dni pozostałe do końca miesiąca. +admincreateinvoices.column.customer=Klient +admincreateinvoices.column.email=E-mail +admincreateinvoices.column.start=Klient od +admincreateinvoices.column.days=Rozliczone dni +admincreateinvoices.column.due=Termin płatności +admincreateinvoices.column.net=Kwota netto +admincreateinvoices.days.full=Pełny miesiąc +admincreateinvoices.days.partial={0} z {1} dni +admincreateinvoices.prorata.suffix=(proporcjonalnie {0}/{1} dni) +admincreateinvoices.button.create=Utwórz fakturę +admincreateinvoices.notification.notemplate=Brak szablonu faktury. Najpierw zapisz szablon w generatorze faktur. +admincreateinvoices.notification.error=Błąd podczas tworzenia faktury: {0} +admincreateinvoices.button.createselected=Utwórz faktury ({0}) +admincreateinvoices.field.month=Miesiąc rozliczeniowy diff --git a/backend/src/main/resources/messages_ru.properties b/backend/src/main/resources/messages_ru.properties index 086079c..01a3513 100644 --- a/backend/src/main/resources/messages_ru.properties +++ b/backend/src/main/resources/messages_ru.properties @@ -1017,3 +1017,42 @@ admindashboard.customers.empty=Клиенты отсутствуют invoicegenerator.positions.header=Позиции (прайс-лист) invoicegenerator.positions.list=Список позиций button.undo=Отменить + +# Профиль администратора (данные компании) +page.title.admin.profile=Редактировать данные компании +adminprofile.title=Данные компании +adminprofile.field.companyname=Название компании +adminprofile.field.companysubtitle=Дополнение к названию +adminprofile.field.street=Улица и номер дома +adminprofile.field.city=Индекс и город +adminprofile.field.phone=Телефон +adminprofile.field.fax=Факс +adminprofile.field.email=Эл. почта +adminprofile.field.website=Веб-сайт +adminprofile.field.senderline=Строка отправителя +adminprofile.field.senderline.helper=Однострочная информация об отправителе над адресом получателя в счетах +adminprofile.field.paymentterms=Условия оплаты +adminprofile.field.footer=Нижний колонтитул +adminprofile.field.footer.helper=Руководители, налоговый номер, банковские реквизиты – каждая строка отображается отдельной строкой в счете +adminprofile.notification.saved=Данные компании сохранены +adminprofile.notification.save.error=Ошибка при сохранении: {0} +adminprofile.notification.load.error=Ошибка при загрузке: {0} + +# Создание счетов (админ) +page.title.admin.createinvoices=Создать счета +admincreateinvoices.title=Создать счета +admincreateinvoices.hint=Счета подлежат оплате в конце месяца. Если клиент начинает в середине месяца, оплачиваются только оставшиеся дни до конца месяца. +admincreateinvoices.column.customer=Клиент +admincreateinvoices.column.email=Эл. почта +admincreateinvoices.column.start=Клиент с +admincreateinvoices.column.days=Расчетные дни +admincreateinvoices.column.due=Срок оплаты +admincreateinvoices.column.net=Сумма нетто +admincreateinvoices.days.full=Полный месяц +admincreateinvoices.days.partial={0} из {1} дней +admincreateinvoices.prorata.suffix=(пропорционально {0}/{1} дней) +admincreateinvoices.button.create=Создать счет +admincreateinvoices.notification.notemplate=Шаблон счета отсутствует. Сначала сохраните шаблон в генераторе счетов. +admincreateinvoices.notification.error=Ошибка при создании счета: {0} +admincreateinvoices.button.createselected=Создать счета ({0}) +admincreateinvoices.field.month=Расчетный месяц diff --git a/backend/src/main/resources/messages_tr.properties b/backend/src/main/resources/messages_tr.properties index 16e70e6..86ba0ab 100644 --- a/backend/src/main/resources/messages_tr.properties +++ b/backend/src/main/resources/messages_tr.properties @@ -1017,3 +1017,42 @@ admindashboard.customers.empty=Müşteri yok invoicegenerator.positions.header=Kalemler (fiyat tablosu) invoicegenerator.positions.list=Kalemleri listele button.undo=Geri al + +# Yönetici profili (şirket verileri) +page.title.admin.profile=Şirket verilerini düzenle +adminprofile.title=Şirket verileri +adminprofile.field.companyname=Şirket adı +adminprofile.field.companysubtitle=Şirket eki +adminprofile.field.street=Cadde ve numara +adminprofile.field.city=Posta kodu ve şehir +adminprofile.field.phone=Telefon +adminprofile.field.fax=Faks +adminprofile.field.email=E-posta +adminprofile.field.website=Web sitesi +adminprofile.field.senderline=Gönderen satırı +adminprofile.field.senderline.helper=Faturalarda alıcı adresinin üzerindeki tek satırlık gönderen bilgisi +adminprofile.field.paymentterms=Ödeme koşulları +adminprofile.field.footer=Alt bilgi +adminprofile.field.footer.helper=Yöneticiler, vergi numarası, banka bilgileri – her satır faturada ayrı satır olarak görünür +adminprofile.notification.saved=Şirket verileri kaydedildi +adminprofile.notification.save.error=Kaydetme hatası: {0} +adminprofile.notification.load.error=Yükleme hatası: {0} + +# Fatura oluşturma (admin) +page.title.admin.createinvoices=Fatura oluştur +admincreateinvoices.title=Fatura oluştur +admincreateinvoices.hint=Faturalar ay sonunda ödenir. Bir müşteri ay ortasında başlarsa, yalnızca ay sonuna kadar kalan günler faturalandırılır. +admincreateinvoices.column.customer=Müşteri +admincreateinvoices.column.email=E-posta +admincreateinvoices.column.start=Müşteri başlangıcı +admincreateinvoices.column.days=Faturalanan günler +admincreateinvoices.column.due=Vade tarihi +admincreateinvoices.column.net=Net tutar +admincreateinvoices.days.full=Tam ay +admincreateinvoices.days.partial={0} / {1} gün +admincreateinvoices.prorata.suffix=(orantılı {0}/{1} gün) +admincreateinvoices.button.create=Fatura oluştur +admincreateinvoices.notification.notemplate=Fatura şablonu yok. Lütfen önce fatura oluşturucuda bir şablon kaydedin. +admincreateinvoices.notification.error=Fatura oluşturulurken hata: {0} +admincreateinvoices.button.createselected=Fatura oluştur ({0}) +admincreateinvoices.field.month=Fatura ayı diff --git a/backend/src/test/java/de/assecutor/votianlt/service/InvoiceComplianceValidatorTest.java b/backend/src/test/java/de/assecutor/votianlt/service/InvoiceComplianceValidatorTest.java index 0f58cd1..129c6f0 100644 --- a/backend/src/test/java/de/assecutor/votianlt/service/InvoiceComplianceValidatorTest.java +++ b/backend/src/test/java/de/assecutor/votianlt/service/InvoiceComplianceValidatorTest.java @@ -141,8 +141,8 @@ class InvoiceComplianceValidatorTest { void rejectsItemWithNegativeUnitPrice() { CustomerInvoice invoice = validInvoice(); invoice.getItems().get(0).setUnitPrice(new BigDecimal("-5.00")); - InvoiceComplianceException ex = catchThrowableOfType( - () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); + InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class, + () -> validator.validateForIssuance(invoice)); assertThat(ex).isNotNull(); assertThat(ex.getViolations()).anyMatch(v -> v.contains("Einzelpreis")); } @@ -151,8 +151,8 @@ class InvoiceComplianceValidatorTest { void rejectsInconsistentTotals() { CustomerInvoice invoice = validInvoice(); invoice.setTotalAmount(new BigDecimal("999.99")); - InvoiceComplianceException ex = catchThrowableOfType( - () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); + InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class, + () -> validator.validateForIssuance(invoice)); assertThat(ex).isNotNull(); assertThat(ex.getViolations()).anyMatch(v -> v.contains("Bruttobetrag passt nicht")); } @@ -164,8 +164,8 @@ class InvoiceComplianceValidatorTest { invoice.setNetAmount(new BigDecimal("80.00")); invoice.setVatAmount(new BigDecimal("15.20")); invoice.setTotalAmount(new BigDecimal("95.20")); - InvoiceComplianceException ex = catchThrowableOfType( - () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); + InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class, + () -> validator.validateForIssuance(invoice)); assertThat(ex).isNotNull(); assertThat(ex.getViolations()).anyMatch(v -> v.contains("Summe der Positionen")); } @@ -207,8 +207,8 @@ class InvoiceComplianceValidatorTest { // Erwartet wären 19,00 € — wir tragen 25,00 € ein. invoice.setVatAmount(new BigDecimal("25.00")); invoice.setTotalAmount(new BigDecimal("125.00")); - InvoiceComplianceException ex = catchThrowableOfType( - () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); + InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class, + () -> validator.validateForIssuance(invoice)); assertThat(ex).isNotNull(); assertThat(ex.getViolations()).anyMatch(v -> v.contains("Steuerbetrag")); } @@ -219,8 +219,8 @@ class InvoiceComplianceValidatorTest { invoice.setInvoiceNumber(null); invoice.setSenderName(null); invoice.setItems(new ArrayList<>()); - InvoiceComplianceException ex = catchThrowableOfType( - () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); + InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class, + () -> validator.validateForIssuance(invoice)); assertThat(ex).isNotNull(); assertThat(ex.getViolations()) .as("Validator soll alle Verstöße sammeln, nicht beim ersten abbrechen") @@ -250,8 +250,8 @@ class InvoiceComplianceValidatorTest { } private void assertSingleViolation(CustomerInvoice invoice, String fragment) { - InvoiceComplianceException ex = catchThrowableOfType( - () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); + InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class, + () -> validator.validateForIssuance(invoice)); assertThat(ex).as("erwartete InvoiceComplianceException").isNotNull(); assertThat(ex.getViolations()) .as("Verstoß mit Fragment '%s' erwartet, war: %s", fragment, ex.getViolations()) diff --git a/backend/tsconfig.json b/backend/tsconfig.json index e6840ae..c7a7722 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -6,6 +6,7 @@ { "_version": "9.1", "compilerOptions": { + "ignoreDeprecations": "6.0", "sourceMap": true, "jsx": "react-jsx", "inlineSources": true,