feat: E-Rechnungs-Vorschau mit Versand, USt-IdNr.-Validierung und Icon-Buttons

- Rechnungsvorschau (CreateInvoiceView): ZUGFeRD-ZIP wird schon vor der
  Vorschau über das PDF Tool erzeugt und das signierte PDF daraus angezeigt;
  "Speichern" heißt jetzt "Senden" und versendet die Rechnung nach dem
  Speichern als E-Rechnung per E-Mail (Status "Versendet")
- USt-IdNr.-Formatvalidierung (VatIdUtil, EU-Länderformate) in Profil-
  Rechnungsdaten sowie Kunde anlegen/bearbeiten, mit Unit-Tests
- Rechnungsliste: Aktions-Buttons durch Icon-Buttons mit Tooltip ersetzt
- Jobs-Seite: CSV-Export als Icon-Button, Filter greifen automatisch,
  "Filter anwenden"-Button entfernt
- Neue Übersetzungsschlüssel in allen 10 Sprachdateien

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 11:06:21 +02:00
co-authored by Claude Fable 5
parent 1741ccc67a
commit 5169527f1f
18 changed files with 398 additions and 65 deletions
@@ -63,7 +63,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
ustId.setRequiredIndicatorVisible(true);
ustId.setPlaceholder("DE123456789");
ustId.setWidthFull();
ustId.addBlurListener(e -> validateField(ustId));
ustId.addBlurListener(e -> validateUstId());
// Anrede (Dropdown)
title = new ComboBox<>(getTranslation("addjob.address.salutation"));
@@ -184,8 +184,10 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
binder.forField(taxNumber).asRequired(getTranslation("profile.validation.taxnumber.required"))
.bind(Customer::getTaxNumber, Customer::setTaxNumber);
binder.forField(ustId).asRequired(getTranslation("profile.validation.ustid.required")).bind(Customer::getUstId,
Customer::setUstId);
binder.forField(ustId).asRequired(getTranslation("profile.validation.ustid.required"))
.withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid,
getTranslation("profile.validation.ustid.invalid"))
.bind(Customer::getUstId, Customer::setUstId);
binder.forField(title).bind(Customer::getTitle, Customer::setTitle);
@@ -262,6 +264,24 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
}
}
/**
* Prüft die USt-IdNr. auf Pflichtangabe und gültiges Format
* (Länderkennzeichen + länderspezifischer Aufbau, z. B. DE123456789).
*/
private void validateUstId() {
String value = ustId.getValue();
if (value == null || value.trim().isEmpty()) {
ustId.setInvalid(true);
ustId.setErrorMessage(getTranslation("profile.validation.ustid.required"));
} else if (!de.assecutor.votianlt.util.VatIdUtil.isValid(value)) {
ustId.setInvalid(true);
ustId.setErrorMessage(getTranslation("profile.validation.ustid.invalid"));
} else {
ustId.setInvalid(false);
ustId.setErrorMessage("");
}
}
private void validateField(TextField field) {
String value = field.getValue();
if (value == null || value.trim().isEmpty()) {
@@ -286,7 +306,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
private boolean validateAllFields() {
validateField(taxNumber);
validateField(ustId);
validateUstId();
validateField(firstName);
validateField(lastName);
validateField(telephone);
@@ -33,9 +33,12 @@ import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoiceTemplateService;
import de.assecutor.votianlt.service.PdfToolService;
import de.assecutor.votianlt.service.UserInvoiceZugferdService;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
@@ -66,6 +69,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
private final UserInvoiceDataService userInvoiceDataService;
private final CustomerService customerService;
private final InvoiceLifecycleService invoiceLifecycleService;
private final UserInvoiceZugferdService userInvoiceZugferdService;
private final EmailService emailService;
private User currentUser;
private Job currentJob;
private List<ServiceRow> gridRows = new ArrayList<>();
@@ -120,7 +125,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
InvoiceLifecycleService invoiceLifecycleService) {
InvoiceLifecycleService invoiceLifecycleService, UserInvoiceZugferdService userInvoiceZugferdService,
EmailService emailService) {
this.jobRepository = jobRepository;
this.serviceRepository = serviceRepository;
this.userRepository = userRepository;
@@ -130,6 +136,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
this.userInvoiceDataService = userInvoiceDataService;
this.customerService = customerService;
this.invoiceLifecycleService = invoiceLifecycleService;
this.userInvoiceZugferdService = userInvoiceZugferdService;
this.emailService = emailService;
setSizeFull();
setPadding(true);
setSpacing(true);
@@ -557,7 +565,19 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
byte[] pdfBytes = generateInvoicePdfFromTemplate(templateData, currentUser,
getTranslation("createinvoice.preview.number"));
showPdfPreviewDialog(pdfBytes, templateData, currentUser);
// Schon für die Vorschau das ZUGFeRD-ZIP über das PDF Tool erzeugen
// und das signierte PDF aus dem ZIP anzeigen — so sieht der Nutzer
// exakt das Dokument, das später versendet wird.
CustomerInvoice previewInvoice = buildInvoice(getTranslation("createinvoice.preview.number"), pdfBytes,
currentUser);
PdfToolService.SignedInvoice signed = userInvoiceZugferdService.sign(previewInvoice);
if (!signed.valid()) {
Notification.show(getTranslation("invoices.notification.zugferd.invalid"), 7000,
Notification.Position.MIDDLE);
}
byte[] signedPdf = extractPdfFromZip(signed.zip());
showPdfPreviewDialog(signedPdf != null ? signedPdf : pdfBytes, templateData, currentUser);
} catch (Exception ex) {
log.error("Fehler beim Erstellen der Rechnungsvorschau", ex);
@@ -566,36 +586,47 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
}
}
private void saveInvoice(String templateData, User user, Dialog previewDialog) {
/**
* Baut die (noch nicht persistierte) Rechnung mit allen Pflichtangaben nach
* §14 UStG auf — auch Grundlage der ZUGFeRD-Metadaten für die E-Rechnung.
* Wird sowohl für die Vorschau (Platzhalter-Nummer, transient) als auch für
* das endgültige Speichern verwendet.
*/
private CustomerInvoice buildInvoice(String invoiceNumber, byte[] pdfBytes, User user) {
BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = effectiveVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount);
CustomerInvoice invoice = new CustomerInvoice();
invoice.setInvoiceNumber(invoiceNumber);
invoice.setInvoiceDate(java.time.LocalDate.now());
invoice.setJobId(currentJob.getId().toHexString());
invoice.setUserId(user.getId().toHexString());
invoice.setNetAmount(netAmount);
invoice.setVatRate(vatRate);
invoice.setVatAmount(vatAmount);
invoice.setTotalAmount(totalAmount);
invoice.setDeliveryDate(currentJob.getDeliveryDate() != null ? currentJob.getDeliveryDate()
: java.time.LocalDate.now());
applySenderData(invoice, user);
applyRecipientData(invoice);
invoice.setItems(buildInvoiceItems(vatRate));
invoice.setPdfData(pdfBytes);
return invoice;
}
/**
* Speichert die Rechnung endgültig und versendet sie anschließend als
* signierte ZUGFeRD-E-Rechnung (ZIP) per E-Mail an den Kunden.
*/
private void saveAndSendInvoice(String templateData, User user, Dialog previewDialog) {
try {
String invoiceNumber = userInvoiceDataService.generateNextInvoiceNumber(user.getId());
BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = effectiveVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount);
byte[] pdfBytes = generateInvoicePdfFromTemplate(templateData, user, invoiceNumber);
CustomerInvoice invoice = new CustomerInvoice();
invoice.setInvoiceNumber(invoiceNumber);
invoice.setInvoiceDate(java.time.LocalDate.now());
invoice.setJobId(currentJob.getId().toHexString());
invoice.setUserId(user.getId().toHexString());
invoice.setNetAmount(netAmount);
invoice.setVatRate(vatRate);
invoice.setVatAmount(vatAmount);
invoice.setTotalAmount(totalAmount);
// Pflichtangaben nach §14 UStG: Leistungsdatum, Absender, Empfänger und
// Positionen — auch Grundlage der ZUGFeRD-Metadaten für die E-Rechnung.
invoice.setDeliveryDate(currentJob.getDeliveryDate() != null ? currentJob.getDeliveryDate()
: java.time.LocalDate.now());
applySenderData(invoice, user);
applyRecipientData(invoice);
invoice.setItems(buildInvoiceItems(vatRate));
invoice.setPdfData(pdfBytes);
CustomerInvoice invoice = buildInvoice(invoiceNumber, pdfBytes, user);
// Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice,
@@ -608,6 +639,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000,
Notification.Position.BOTTOM_END);
sendInvoiceEmail(savedInvoice);
} catch (InvoiceLifecycleException lifecycleEx) {
log.warn("Lifecycle-Verstoß beim Speichern der Rechnung: {}", lifecycleEx.getMessage());
Notification.show(lifecycleEx.getMessage(), 5000, Notification.Position.MIDDLE);
@@ -618,6 +651,66 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
}
}
/**
* Versendet die gespeicherte Rechnung als E-Rechnungs-ZIP per E-Mail.
* Besteht die Rechnung die Validierung nicht, wird der Versand abgebrochen —
* die Rechnung bleibt gespeichert und kann aus der Rechnungsliste erneut
* versendet werden.
*/
private void sendInvoiceEmail(CustomerInvoice savedInvoice) {
String recipientEmail = savedInvoice.getRecipientEmail();
if (recipientEmail == null || recipientEmail.isBlank()) {
Notification.show(getTranslation("invoices.notification.email.missing"), 6000,
Notification.Position.MIDDLE);
return;
}
try {
PdfToolService.SignedInvoice signed = userInvoiceZugferdService.sign(savedInvoice);
if (!signed.valid()) {
log.warn("Versand der Rechnung {} abgebrochen, ZUGFeRD-Validierung fehlgeschlagen",
savedInvoice.getInvoiceNumber());
Notification.show(
getTranslation("invoices.notification.zugferd.invalidsend", savedInvoice.getInvoiceNumber()),
7000, Notification.Position.MIDDLE);
return;
}
emailService.sendEmailWithAttachment(recipientEmail,
getTranslation("invoices.mail.subject", savedInvoice.getInvoiceNumber()),
getTranslation("invoices.mail.body", savedInvoice.getInvoiceNumber(),
safe(savedInvoice.getSenderName())),
signed.zip(), signed.fileName());
invoiceLifecycleService.markAsSent(savedInvoice.getId(),
"Als E-Rechnung per E-Mail versendet an " + recipientEmail);
Notification.show(
getTranslation("createinvoice.notification.sent", savedInvoice.getInvoiceNumber(), recipientEmail),
5000, Notification.Position.BOTTOM_END);
} catch (Exception ex) {
log.error("Fehler beim Versand der Rechnung {} an {}: {}", savedInvoice.getInvoiceNumber(), recipientEmail,
ex.getMessage(), ex);
Notification.show(getTranslation("invoices.notification.zugferd.error", ex.getMessage()), 7000,
Notification.Position.MIDDLE);
}
}
/**
* Extrahiert das signierte PDF (PDF/A-3) aus dem vom PDF Tool gelieferten
* ZIP; {@code null}, wenn kein PDF-Eintrag enthalten ist.
*/
private byte[] extractPdfFromZip(byte[] zipBytes) throws java.io.IOException {
try (java.util.zip.ZipInputStream zip = new java.util.zip.ZipInputStream(
new java.io.ByteArrayInputStream(zipBytes))) {
java.util.zip.ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (!entry.isDirectory() && entry.getName().toLowerCase().endsWith(".pdf")) {
return zip.readAllBytes();
}
}
}
return null;
}
/**
* Absenderdaten aus dem Nutzerprofil und den Rechnungs-Stammdaten
* (USt-IdNr./Steuernummer, Bankverbindung, Zahlungsbedingungen) übernehmen.
@@ -844,21 +937,21 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
Button closeButton = new Button(getTranslation("button.close"), e -> pdfDialog.close());
closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button saveButton = new Button(getTranslation("createinvoice.button.save"), e -> {
Button sendButton = new Button(getTranslation("createinvoice.button.send"), e -> {
Dialog confirm = DialogStylingHelper.createConfirmationDialog(
getTranslation("createinvoice.confirm.save.title"),
getTranslation("createinvoice.confirm.save.message"),
getTranslation("createinvoice.confirm.send.title"),
getTranslation("createinvoice.confirm.send.message"),
"560px",
getTranslation("button.cancel"),
getTranslation("createinvoice.confirm.save.confirm"),
() -> saveInvoice(templateData, user, pdfDialog),
getTranslation("createinvoice.confirm.send.confirm"),
() -> saveAndSendInvoice(templateData, user, pdfDialog),
ButtonVariant.LUMO_PRIMARY);
confirm.open();
});
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
pdfDialog.add(DialogStylingHelper.wrapContent(pdfFrame, true));
pdfDialog.getFooter().add(closeButton, saveButton);
pdfDialog.getFooter().add(closeButton, sendButton);
pdfDialog.open();
}
@@ -146,6 +146,8 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
binder.forField(taxNumberField).asRequired(getTranslation("profile.validation.taxnumber.required"))
.bind(Customer::getTaxNumber, Customer::setTaxNumber);
binder.forField(ustIdField).asRequired(getTranslation("profile.validation.ustid.required"))
.withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid,
getTranslation("profile.validation.ustid.invalid"))
.bind(Customer::getUstId, Customer::setUstId);
binder.forField(firstnameField).bind(Customer::getFirstname, Customer::setFirstname);
binder.forField(lastNameField).bind(Customer::getLastName, Customer::setLastName);
@@ -378,7 +378,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
ustIdField.setPlaceholder("DE123456789");
ustIdField.setRequiredIndicatorVisible(true);
ustIdField.addBlurListener(e -> {
validateField(ustIdField, getTranslation("profile.validation.ustid.required"));
validateUstIdField();
saveInvoiceData();
});
taxNumberField.setLabel(getTranslation("profile.billing.taxnumber"));
@@ -650,7 +650,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
// Steuernummer und USt-IdNr. sind Pflicht, sobald die Rechnungslegung
// aktiviert ist
if (billingEnabled.getValue()) {
validateField(ustIdField, getTranslation("profile.validation.ustid.required"));
validateUstIdField();
validateField(taxNumberField, getTranslation("profile.validation.taxnumber.required"));
isValid = isValid && !ustIdField.isInvalid() && !taxNumberField.isInvalid();
}
@@ -864,6 +864,24 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
return value != null ? value : "";
}
/**
* Prüft die USt-IdNr. auf Pflichtangabe und gültiges Format
* (Länderkennzeichen + länderspezifischer Aufbau, z. B. DE123456789).
*/
private void validateUstIdField() {
String value = ustIdField.getValue();
if (value == null || value.trim().isEmpty()) {
ustIdField.setInvalid(true);
ustIdField.setErrorMessage(getTranslation("profile.validation.ustid.required"));
} else if (!de.assecutor.votianlt.util.VatIdUtil.isValid(value)) {
ustIdField.setInvalid(true);
ustIdField.setErrorMessage(getTranslation("profile.validation.ustid.invalid"));
} else {
ustIdField.setInvalid(false);
ustIdField.setErrorMessage("");
}
}
private void validateField(TextField field, String errorMessage) {
String value = field.getValue();
if (value == null || value.trim().isEmpty()) {
@@ -4,6 +4,8 @@ import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
@@ -186,13 +188,15 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
actions.setSpacing(true);
actions.setPadding(false);
Button viewBtn = new Button(getTranslation("invoices.action.view"), e -> downloadInvoicePdf(invoice));
viewBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
Button viewBtn = actionIconButton(VaadinIcon.EYE, "invoices.action.view",
e -> downloadInvoicePdf(invoice));
viewBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
viewBtn.setEnabled(invoice.getPdfData() != null && invoice.getPdfData().length > 0);
actions.add(viewBtn);
Button historyBtn = new Button(getTranslation("invoices.action.history"), e -> openHistoryDialog(invoice));
historyBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
Button historyBtn = actionIconButton(VaadinIcon.TIME_BACKWARD, "invoices.action.history",
e -> openHistoryDialog(invoice));
historyBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
actions.add(historyBtn);
InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
@@ -205,40 +209,38 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
if (type == InvoiceType.INVOICE && status == InvoiceStatus.ISSUED
&& invoicePermissionService.canMarkAsSent(currentUser)) {
Button sentBtn = new Button(getTranslation("invoices.action.marksent"),
Button sentBtn = actionIconButton(VaadinIcon.CHECK, "invoices.action.marksent",
e -> markAsSent(invoice));
sentBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
actions.add(sentBtn);
}
// E-Rechnung (ZUGFeRD) über das externe PDF Tool — analog zu den
// System-Rechnungen der Admin-Seite
if (type == InvoiceType.INVOICE && invoice.getPdfData() != null && invoice.getPdfData().length > 0) {
Button zugferdBtn = new Button(getTranslation("invoices.action.zugferd"),
Button zugferdBtn = actionIconButton(VaadinIcon.FILE_ZIP, "invoices.action.zugferd",
e -> downloadZugferdZip(invoice));
zugferdBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
zugferdBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
actions.add(zugferdBtn);
if (isLiveInvoice && invoicePermissionService.canMarkAsSent(currentUser)) {
Button sendBtn = new Button(getTranslation("invoices.action.sendinvoice"),
Button sendBtn = actionIconButton(VaadinIcon.PAPERPLANE, "invoices.action.sendinvoice",
e -> sendAsEInvoice(invoice));
sendBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_PRIMARY);
sendBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
actions.add(sendBtn);
}
}
if (isLiveInvoice) {
if (invoicePermissionService.canCorrect(currentUser)) {
Button correctBtn = new Button(getTranslation("invoices.action.correct"),
Button correctBtn = actionIconButton(VaadinIcon.EDIT, "invoices.action.correct",
e -> openCorrectionDialog(invoice));
correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
actions.add(correctBtn);
}
if (invoicePermissionService.canCancel(currentUser)) {
Button cancelBtn = new Button(getTranslation("invoices.action.cancel"),
Button cancelBtn = actionIconButton(VaadinIcon.BAN, "invoices.action.cancel",
e -> openCancellationDialog(invoice));
cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR);
cancelBtn.addThemeVariants(ButtonVariant.LUMO_ERROR);
actions.add(cancelBtn);
}
}
@@ -246,20 +248,33 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
// Zahlung erfassen: nur für reguläre Rechnungen (R-25)
if (type == InvoiceType.INVOICE && status != InvoiceStatus.DRAFT
&& invoicePermissionService.canRecordPayment(currentUser)) {
Button payBtn = new Button(getTranslation("invoices.action.payment"),
Button payBtn = actionIconButton(VaadinIcon.EURO, "invoices.action.payment",
e -> openPaymentDialog(invoice));
payBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
actions.add(payBtn);
}
// Belegpaket exportieren (R-33/R-34)
Button exportBtn = new Button(getTranslation("invoices.action.export"), e -> exportPackage(invoice));
exportBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
Button exportBtn = actionIconButton(VaadinIcon.DOWNLOAD, "invoices.action.export",
e -> exportPackage(invoice));
exportBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
actions.add(exportBtn);
return actions;
}
/**
* Icon-Button für die Aktionsspalte: die bisherige Button-Beschriftung
* bleibt als Tooltip (Bubblehelp) und Aria-Label erhalten.
*/
private Button actionIconButton(VaadinIcon icon, String labelKey,
com.vaadin.flow.component.ComponentEventListener<com.vaadin.flow.component.ClickEvent<Button>> listener) {
Button button = new Button(new Icon(icon), listener);
button.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ICON);
button.setTooltipText(getTranslation(labelKey));
button.setAriaLabel(getTranslation(labelKey));
return button;
}
private void openPaymentDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.payment.title", invoice.getInvoiceNumber()), "480px");
@@ -70,8 +70,10 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
endDate.setLabel(getTranslation("jobs.filter.enddate"));
searchField.setLabel(getTranslation("jobs.filter.search"));
statusFilter.setLabel(getTranslation("jobs.filter.status"));
Button exportButton = new Button(getTranslation("jobs.button.csvexport"));
Button exportButton = new Button(new Icon(VaadinIcon.DOWNLOAD));
exportButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
exportButton.setTooltipText(getTranslation("jobs.button.csvexport"));
exportButton.setAriaLabel(getTranslation("jobs.button.csvexport"));
ViewToolbar toolbar = new ViewToolbar(getTranslation("nav.jobs"), exportButton);
add(toolbar);
@@ -85,9 +87,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
searchField.setClearButtonVisible(true);
// Filterleiste mit Export-Button am rechten Rand
Button applyFilter = new Button(getTranslation("jobs.filter.apply"));
applyFilter.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
HorizontalLayout leftFilters = new HorizontalLayout(startDate, endDate, searchField, statusFilter, applyFilter);
HorizontalLayout leftFilters = new HorizontalLayout(startDate, endDate, searchField, statusFilter);
leftFilters.setAlignItems(Alignment.END);
leftFilters.getStyle().set("flex-wrap", "wrap");
@@ -105,7 +105,6 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
java.time.LocalDate today = java.time.LocalDate.now();
startDate.setValue(today.minusDays(30));
endDate.setValue(today);
applyFilter.addClickListener(e -> loadData());
exportButton.addClickListener(e -> exportToCsv());
// Add real-time filtering
@@ -0,0 +1,81 @@
package de.assecutor.votianlt.util;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Formatvalidierung für Umsatzsteuer-Identifikationsnummern (USt-IdNr.).
*
* Prüft den Aufbau nach Länderkennzeichen gemäß den offiziellen Formaten der
* EU-Mitgliedstaaten (z. B. DE + 9 Ziffern). Für Länderkennzeichen ohne
* hinterlegtes Muster wird ein generisches Format (2 Buchstaben + 212
* alphanumerische Zeichen) akzeptiert. Es findet keine Online-Prüfung (VIES)
* statt — validiert wird ausschließlich das Format.
*/
public final class VatIdUtil {
/** Offizielle USt-IdNr.-Formate der EU-Mitgliedstaaten (ohne Länderpräfix). */
private static final Map<String, Pattern> COUNTRY_PATTERNS = Map.ofEntries(
Map.entry("AT", Pattern.compile("U\\d{8}")),
Map.entry("BE", Pattern.compile("[01]\\d{9}")),
Map.entry("BG", Pattern.compile("\\d{9,10}")),
Map.entry("CY", Pattern.compile("\\d{8}[A-Z]")),
Map.entry("CZ", Pattern.compile("\\d{8,10}")),
Map.entry("DE", Pattern.compile("\\d{9}")),
Map.entry("DK", Pattern.compile("\\d{8}")),
Map.entry("EE", Pattern.compile("\\d{9}")),
Map.entry("EL", Pattern.compile("\\d{9}")),
Map.entry("ES", Pattern.compile("[A-Z0-9]\\d{7}[A-Z0-9]")),
Map.entry("FI", Pattern.compile("\\d{8}")),
Map.entry("FR", Pattern.compile("[A-Z0-9]{2}\\d{9}")),
Map.entry("HR", Pattern.compile("\\d{11}")),
Map.entry("HU", Pattern.compile("\\d{8}")),
Map.entry("IE", Pattern.compile("\\d{7}[A-Z]{1,2}|\\d[A-Z0-9+*]\\d{5}[A-Z]")),
Map.entry("IT", Pattern.compile("\\d{11}")),
Map.entry("LT", Pattern.compile("(\\d{9}|\\d{12})")),
Map.entry("LU", Pattern.compile("\\d{8}")),
Map.entry("LV", Pattern.compile("\\d{11}")),
Map.entry("MT", Pattern.compile("\\d{8}")),
Map.entry("NL", Pattern.compile("\\d{9}B\\d{2}")),
Map.entry("PL", Pattern.compile("\\d{10}")),
Map.entry("PT", Pattern.compile("\\d{9}")),
Map.entry("RO", Pattern.compile("\\d{2,10}")),
Map.entry("SE", Pattern.compile("\\d{12}")),
Map.entry("SI", Pattern.compile("\\d{8}")),
Map.entry("SK", Pattern.compile("\\d{10}")));
/** Rückfallmuster für Länderkennzeichen ohne hinterlegtes Format. */
private static final Pattern GENERIC_PATTERN = Pattern.compile("[A-Z]{2}[A-Z0-9]{2,12}");
private VatIdUtil() {
}
/**
* Prüft, ob der Wert eine formal gültige USt-IdNr. ist. Leerzeichen,
* Punkte und Bindestriche werden vor der Prüfung entfernt,
* Kleinbuchstaben akzeptiert. {@code null} oder leer gilt als ungültig.
*/
public static boolean isValid(String vatId) {
String normalized = normalize(vatId);
if (normalized == null || normalized.length() < 4) {
return false;
}
String countryCode = normalized.substring(0, 2);
if (!countryCode.chars().allMatch(Character::isLetter)) {
return false;
}
Pattern pattern = COUNTRY_PATTERNS.get(countryCode);
if (pattern != null) {
return pattern.matcher(normalized.substring(2)).matches();
}
return GENERIC_PATTERN.matcher(normalized).matches();
}
/** Entfernt Trennzeichen und wandelt in Großbuchstaben um. */
public static String normalize(String vatId) {
if (vatId == null) {
return null;
}
return vatId.replaceAll("[\\s.\\-]", "").toUpperCase(java.util.Locale.ROOT);
}
}
@@ -86,6 +86,7 @@ profile.validation.zip.required=Postleitzahl ist erforderlich
profile.validation.city.required=Stadt ist erforderlich
profile.validation.taxnumber.required=Steuernummer ist erforderlich
profile.validation.ustid.required=USt-IdNr. ist erforderlich
profile.validation.ustid.invalid=Ungültiges Format der USt-IdNr. (z. B. DE123456789)
profile.validation.firstname.required=Vorname ist erforderlich
profile.validation.lastname.required=Nachname ist erforderlich
profile.validation.phone.required=Telefonnummer ist erforderlich
@@ -699,6 +700,11 @@ createinvoice.button.save=Speichern
createinvoice.confirm.save.title=Rechnung speichern
createinvoice.confirm.save.message=Diese Rechnung wird dauerhaft gespeichert und kann anschlie\u00dfend nicht mehr ver\u00e4ndert werden. Fortfahren?
createinvoice.confirm.save.confirm=Ja, speichern
createinvoice.button.send=Senden
createinvoice.confirm.send.title=Rechnung senden
createinvoice.confirm.send.message=Diese Rechnung wird dauerhaft gespeichert und als E-Rechnung (ZIP) per E-Mail an den Kunden versendet. Fortfahren?
createinvoice.confirm.send.confirm=Ja, senden
createinvoice.notification.sent=Rechnung {0} wurde als E-Rechnung an {1} versendet
# Invoices
invoices.title=Rechnungen
@@ -75,6 +75,7 @@ profile.validation.zip.required=Postiindeks on kohustuslik
profile.validation.city.required=Linn on kohustuslik
profile.validation.taxnumber.required=Maksunumber on kohustuslik
profile.validation.ustid.required=KMKR nr on kohustuslik
profile.validation.ustid.invalid=KMKR numbri vorming on vale (nt DE123456789)
profile.validation.firstname.required=Eesnimi on kohustuslik
profile.validation.lastname.required=Perekonnanimi on kohustuslik
profile.validation.phone.required=Telefoninumber on kohustuslik
@@ -639,6 +640,11 @@ createinvoice.button.save=Salvesta
createinvoice.confirm.save.title=Arve salvestamine
createinvoice.confirm.save.message=See arve salvestatakse p\u00fcsivalt ja seda ei saa hiljem muuta. Kas j\u00e4tkata?
createinvoice.confirm.save.confirm=Jah, salvesta
createinvoice.button.send=Saada
createinvoice.confirm.send.title=Saada arve
createinvoice.confirm.send.message=Arve salvestatakse jäädavalt ja saadetakse kliendile e-posti teel e-arvena (ZIP). Kas jätkata?
createinvoice.confirm.send.confirm=Jah, saada
createinvoice.notification.sent=Arve {0} saadeti e-arvena aadressile {1}
invoices.title=Arved
invoices.column.number=Number
invoices.column.customer=Klient
@@ -86,6 +86,7 @@ profile.validation.zip.required=Postal code is required
profile.validation.city.required=City is required
profile.validation.taxnumber.required=Tax number is required
profile.validation.ustid.required=VAT ID is required
profile.validation.ustid.invalid=Invalid VAT ID format (e.g. DE123456789)
profile.validation.firstname.required=First name is required
profile.validation.lastname.required=Last name is required
profile.validation.phone.required=Phone number is required
@@ -699,6 +700,11 @@ createinvoice.button.save=Save
createinvoice.confirm.save.title=Save Invoice
createinvoice.confirm.save.message=This invoice will be permanently saved and cannot be modified afterwards. Continue?
createinvoice.confirm.save.confirm=Yes, save
createinvoice.button.send=Send
createinvoice.confirm.send.title=Send Invoice
createinvoice.confirm.send.message=This invoice will be permanently saved and sent to the customer by email as an e-invoice (ZIP). Continue?
createinvoice.confirm.send.confirm=Yes, send
createinvoice.notification.sent=Invoice {0} has been sent as an e-invoice to {1}
# Invoices
invoices.title=Invoices
@@ -85,6 +85,7 @@ profile.validation.zip.required=C\u00f3digo postal es obligatorio
profile.validation.city.required=Ciudad es obligatorio
profile.validation.taxnumber.required=Número fiscal es obligatorio
profile.validation.ustid.required=NIF-IVA es obligatorio
profile.validation.ustid.invalid=Formato de NIF-IVA no válido (p. ej. DE123456789)
profile.validation.firstname.required=Nombre es obligatorio
profile.validation.lastname.required=Apellido es obligatorio
profile.validation.phone.required=N\u00famero de tel\u00e9fono es obligatorio
@@ -696,6 +697,11 @@ createinvoice.button.save=Guardar
createinvoice.confirm.save.title=Guardar factura
createinvoice.confirm.save.message=Esta factura se guardar\u00e1 de forma permanente y no podr\u00e1 ser modificada posteriormente. \u00bfDesea continuar?
createinvoice.confirm.save.confirm=S\u00ed, guardar
createinvoice.button.send=Enviar
createinvoice.confirm.send.title=Enviar factura
createinvoice.confirm.send.message=La factura se guardará de forma permanente y se enviará al cliente por correo electrónico como factura electrónica (ZIP). ¿Continuar?
createinvoice.confirm.send.confirm=Sí, enviar
createinvoice.notification.sent=La factura {0} se ha enviado como factura electrónica a {1}
# Invoices
invoices.title=Facturas
@@ -85,6 +85,7 @@ profile.validation.zip.required=Le code postal est obligatoire
profile.validation.city.required=La ville est obligatoire
profile.validation.taxnumber.required=Le numéro fiscal est obligatoire
profile.validation.ustid.required=Le n° TVA intracommunautaire est obligatoire
profile.validation.ustid.invalid=Format de numéro de TVA invalide (p. ex. DE123456789)
profile.validation.firstname.required=Le pr\u00e9nom est obligatoire
profile.validation.lastname.required=Le nom est obligatoire
profile.validation.phone.required=Le num\u00e9ro de t\u00e9l\u00e9phone est obligatoire
@@ -696,6 +697,11 @@ createinvoice.button.save=Enregistrer
createinvoice.confirm.save.title=Enregistrer la facture
createinvoice.confirm.save.message=Cette facture sera enregistr\u00e9e de mani\u00e8re permanente et ne pourra plus \u00eatre modifi\u00e9e par la suite. Continuer ?
createinvoice.confirm.save.confirm=Oui, enregistrer
createinvoice.button.send=Envoyer
createinvoice.confirm.send.title=Envoyer la facture
createinvoice.confirm.send.message=La facture sera enregistrée définitivement et envoyée au client par e-mail en tant que facture électronique (ZIP). Continuer ?
createinvoice.confirm.send.confirm=Oui, envoyer
createinvoice.notification.sent=La facture {0} a été envoyée en tant que facture électronique à {1}
# Invoices
invoices.title=Factures
@@ -85,6 +85,7 @@ profile.validation.zip.required=Pašto kodas yra privalomas
profile.validation.city.required=Miestas yra privalomas
profile.validation.taxnumber.required=Mokesčių mokėtojo numeris yra privalomas
profile.validation.ustid.required=PVM mokėtojo kodas yra privalomas
profile.validation.ustid.invalid=Neteisingas PVM mokėtojo kodo formatas (pvz., DE123456789)
profile.validation.firstname.required=Vardas yra privalomas
profile.validation.lastname.required=Pavardė yra privaloma
profile.validation.phone.required=Telefono numeris yra privalomas
@@ -698,6 +699,11 @@ createinvoice.button.save=Išsaugoti
createinvoice.confirm.save.title=Išsaugoti sąskaitą
createinvoice.confirm.save.message=Ši sąskaita bus išsaugota visam laikui ir vėliau jos nebebus galima keisti. Tęsti?
createinvoice.confirm.save.confirm=Taip, išsaugoti
createinvoice.button.send=Siųsti
createinvoice.confirm.send.title=Siųsti sąskaitą
createinvoice.confirm.send.message=Sąskaita bus visam laikui išsaugota ir išsiųsta klientui el. paštu kaip e. sąskaita (ZIP). Tęsti?
createinvoice.confirm.send.confirm=Taip, siųsti
createinvoice.notification.sent=Sąskaita {0} išsiųsta kaip e. sąskaita adresu {1}
# Invoices
invoices.title=Sąskaitos faktūros
@@ -85,6 +85,7 @@ profile.validation.zip.required=Pasta indekss ir obligāts
profile.validation.city.required=Pilsēta ir obligāta
profile.validation.taxnumber.required=Nodokļu maksātāja numurs ir obligāts
profile.validation.ustid.required=PVN numurs ir obligāts
profile.validation.ustid.invalid=Nederīgs PVN numura formāts (piem., DE123456789)
profile.validation.firstname.required=Vārds ir obligāts
profile.validation.lastname.required=Uzvārds ir obligāts
profile.validation.phone.required=Tālruņa numurs ir obligāts
@@ -696,6 +697,11 @@ createinvoice.button.save=Saglabāt
createinvoice.confirm.save.title=Saglabāt rēķinu
createinvoice.confirm.save.message=Šis rēķins tiks neatgriezeniski saglabāts un pēc tam to vairs nevarēs mainīt. Turpināt?
createinvoice.confirm.save.confirm=Jā, saglabāt
createinvoice.button.send=Sūtīt
createinvoice.confirm.send.title=Sūtīt rēķinu
createinvoice.confirm.send.message=Rēķins tiks neatgriezeniski saglabāts un nosūtīts klientam pa e-pastu kā e-rēķins (ZIP). Vai turpināt?
createinvoice.confirm.send.confirm=Jā, sūtīt
createinvoice.notification.sent=Rēķins {0} nosūtīts kā e-rēķins uz {1}
# Invoices
invoices.title=Rēķini
@@ -85,6 +85,7 @@ profile.validation.zip.required=Kod pocztowy jest wymagany
profile.validation.city.required=Miasto jest wymagane
profile.validation.taxnumber.required=Numer podatkowy jest wymagany
profile.validation.ustid.required=NIP UE jest wymagany
profile.validation.ustid.invalid=Nieprawidłowy format numeru VAT UE (np. DE123456789)
profile.validation.firstname.required=Imi\u0119 jest wymagane
profile.validation.lastname.required=Nazwisko jest wymagane
profile.validation.phone.required=Numer telefonu jest wymagany
@@ -696,6 +697,11 @@ createinvoice.button.save=Zapisz
createinvoice.confirm.save.title=Zapisz faktur\u0119
createinvoice.confirm.save.message=Ta faktura zostanie trwale zapisana i nie b\u0119dzie mog\u0142a by\u0107 p\u00f3\u017aniej zmieniona. Kontynuowa\u0107?
createinvoice.confirm.save.confirm=Tak, zapisz
createinvoice.button.send=Wyślij
createinvoice.confirm.send.title=Wyślij fakturę
createinvoice.confirm.send.message=Faktura zostanie trwale zapisana i wysłana do klienta e-mailem jako e-faktura (ZIP). Kontynuować?
createinvoice.confirm.send.confirm=Tak, wyślij
createinvoice.notification.sent=Faktura {0} została wysłana jako e-faktura na adres {1}
# Invoices
invoices.title=Faktury
@@ -85,6 +85,7 @@ profile.validation.zip.required=Почтовый индекс обязателе
profile.validation.city.required=Город обязателен
profile.validation.taxnumber.required=Налоговый номер обязателен
profile.validation.ustid.required=Идентификатор плательщика НДС обязателен
profile.validation.ustid.invalid=Неверный формат идентификационного номера НДС (напр. DE123456789)
profile.validation.firstname.required=Имя обязательно
profile.validation.lastname.required=Фамилия обязательна
profile.validation.phone.required=Номер телефона обязателен
@@ -696,6 +697,11 @@ createinvoice.button.save=Сохранить
createinvoice.confirm.save.title=Сохранить счёт
createinvoice.confirm.save.message=Этот счёт будет сохранён навсегда и не может быть изменён впоследствии. Продолжить?
createinvoice.confirm.save.confirm=Да, сохранить
createinvoice.button.send=Отправить
createinvoice.confirm.send.title=Отправить счёт
createinvoice.confirm.send.message=Счёт будет окончательно сохранён и отправлен клиенту по электронной почте как электронный счёт (ZIP). Продолжить?
createinvoice.confirm.send.confirm=Да, отправить
createinvoice.notification.sent=Счёт {0} отправлен как электронный счёт на {1}
# Invoices
invoices.title=Счета
@@ -85,6 +85,7 @@ profile.validation.zip.required=Posta kodu gereklidir
profile.validation.city.required=\u015eehir gereklidir
profile.validation.taxnumber.required=Vergi numarası gereklidir
profile.validation.ustid.required=KDV kimlik no gereklidir
profile.validation.ustid.invalid=Geçersiz KDV kimlik numarası biçimi (örn. DE123456789)
profile.validation.firstname.required=Ad gereklidir
profile.validation.lastname.required=Soyad gereklidir
profile.validation.phone.required=Telefon numaras\u0131 gereklidir
@@ -696,6 +697,11 @@ createinvoice.button.save=Kaydet
createinvoice.confirm.save.title=Faturay\u0131 Kaydet
createinvoice.confirm.save.message=Bu fatura kal\u0131c\u0131 olarak kaydedilecek ve sonras\u0131nda de\u011fi\u015ftirilemeyecektir. Devam edilsin mi?
createinvoice.confirm.save.confirm=Evet, kaydet
createinvoice.button.send=Gönder
createinvoice.confirm.send.title=Faturayı gönder
createinvoice.confirm.send.message=Fatura kalıcı olarak kaydedilecek ve müşteriye e-posta ile e-fatura (ZIP) olarak gönderilecektir. Devam edilsin mi?
createinvoice.confirm.send.confirm=Evet, gönder
createinvoice.notification.sent=Fatura {0}, e-fatura olarak {1} adresine gönderildi
# Invoices
invoices.title=Faturalar
@@ -0,0 +1,45 @@
package de.assecutor.votianlt.util;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class VatIdUtilTest {
@ParameterizedTest
@ValueSource(strings = { "DE123456789", "ATU12345678", "NL123456789B01", "FRXX123456789", "PL1234567890",
"IT12345678901", "BE0123456789", "SE123456789012", "IE1234567AB" })
void validVatIdsAreAccepted(String vatId) {
assertTrue(VatIdUtil.isValid(vatId), vatId + " sollte gültig sein");
}
@Test
void separatorsAndLowercaseAreNormalized() {
assertTrue(VatIdUtil.isValid("de 123.456-789"));
assertEquals("DE123456789", VatIdUtil.normalize("de 123.456-789"));
}
@ParameterizedTest
@ValueSource(strings = { "DE12345678", "DE1234567890", "DE12345678A", "ATU1234567", "NL123456789A01",
"123456789", "D123456789", "XX", "DE" })
void invalidVatIdsAreRejected(String vatId) {
assertFalse(VatIdUtil.isValid(vatId), vatId + " sollte ungültig sein");
}
@Test
void nullAndBlankAreInvalid() {
assertFalse(VatIdUtil.isValid(null));
assertFalse(VatIdUtil.isValid(""));
assertFalse(VatIdUtil.isValid(" "));
}
@Test
void unknownCountryFallsBackToGenericPattern() {
assertTrue(VatIdUtil.isValid("XI123456789"));
assertFalse(VatIdUtil.isValid("XI1"));
}
}