From 420072dc27ae1bc6f28859e88f62079bfd15115f Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Thu, 9 Jul 2026 16:25:20 +0200 Subject: [PATCH] Speichern-Button, eindeutige Namen und E-Mail-Validierung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Eingaben speichern" neben "E-Rechnung erzeugen"; aktiv nur, wenn keine Vorbelegung geladen ist oder sich Eingaben seither geändert haben - Unique-Indizes auf die Namen von Templates und Vorbelegungen (werden nach der Verbindungsprüfung beim Start angelegt); kein Namensvorschlag mehr im Speichern-Dialog - E-Mail-Felder validieren sofort beim Verlassen des Feldes (Maske und Adressbuch, gemeinsames Pattern) Co-Authored-By: Claude Fable 5 --- .../template/MongoConnectionCheck.java | 17 +++ .../assecutor/pdftool/ui/AddressBookView.java | 11 +- .../de/assecutor/pdftool/ui/MainView.java | 119 +++++++++++++++--- 3 files changed, 131 insertions(+), 16 deletions(-) diff --git a/src/main/java/de/assecutor/pdftool/template/MongoConnectionCheck.java b/src/main/java/de/assecutor/pdftool/template/MongoConnectionCheck.java index 7c56c1d..38762e8 100644 --- a/src/main/java/de/assecutor/pdftool/template/MongoConnectionCheck.java +++ b/src/main/java/de/assecutor/pdftool/template/MongoConnectionCheck.java @@ -1,12 +1,15 @@ package de.assecutor.pdftool.template; +import de.assecutor.pdftool.invoice.InvoiceDraft; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.autoconfigure.mongo.MongoClientSettingsBuilderCustomizer; import org.springframework.context.annotation.Bean; +import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.index.Index; import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; @@ -15,6 +18,12 @@ import java.util.concurrent.TimeUnit; * Prüft beim Start die Verbindung zum MongoDB-Server und bricht den Start ab, * wenn er nicht erreichbar ist — die Anwendung setzt die Datenbank zwingend * voraus. Konfiguriert wird die Verbindung über MONGODB_URI in der .env-Datei. + * + *

Nach erfolgreicher Prüfung werden die Unique-Indizes auf die Template- + * bzw. Vorbelegungs-Namen sichergestellt (Spring legt Indizes aus + * {@code @Indexed} nicht automatisch an; auto-index-creation bleibt bewusst + * aus, damit ein nicht erreichbarer Server zuerst diese klare Fehlermeldung + * liefert).

*/ @Component public class MongoConnectionCheck implements ApplicationRunner { @@ -38,6 +47,14 @@ public class MongoConnectionCheck implements ApplicationRunner { "Keine Verbindung zum MongoDB-Server. Die Anwendung benötigt die Datenbank zwingend — " + "bitte MONGODB_URI in der .env-Datei prüfen. Ursache: " + e.getMessage(), e); } + ensureUniqueNameIndexes(); + } + + /** Stellt sicher, dass Template- und Vorbelegungs-Namen in der Datenbank eindeutig sind. */ + private void ensureUniqueNameIndexes() { + Index uniqueName = new Index().on("name", Sort.Direction.ASC).unique(); + mongoTemplate.indexOps(InvoiceTemplate.class).createIndex(uniqueName); + mongoTemplate.indexOps(InvoiceDraft.class).createIndex(uniqueName); } /** Kurzer Auswahl-Timeout, damit ein nicht erreichbarer Server den Start schnell abbricht. */ diff --git a/src/main/java/de/assecutor/pdftool/ui/AddressBookView.java b/src/main/java/de/assecutor/pdftool/ui/AddressBookView.java index e14a46b..3258321 100644 --- a/src/main/java/de/assecutor/pdftool/ui/AddressBookView.java +++ b/src/main/java/de/assecutor/pdftool/ui/AddressBookView.java @@ -36,6 +36,8 @@ import java.util.Optional; @PageTitle("Adressbuch") public class AddressBookView extends VerticalLayout { + private static final String EMAIL_PATTERN = "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"; + private final AddressRepository repository; private final Grid
grid = new Grid<>(Address.class, false); @@ -142,6 +144,13 @@ public class AddressBookView extends VerticalLayout { vatId.setHelperText("z. B. DE261094748"); EmailField email = new EmailField("E-Mail"); email.setValue(target.getEmail() != null ? target.getEmail() : ""); + // E-Mail direkt beim Verlassen des Feldes prüfen + email.addValueChangeListener(event -> { + String value = event.getValue(); + boolean valid = value == null || value.isBlank() || value.matches(EMAIL_PATTERN); + email.setErrorMessage("Gültige E-Mail-Adresse angeben."); + email.setInvalid(!valid); + }); TextField phone = new TextField("Telefon"); phone.setValue(target.getPhone() != null ? target.getPhone() : ""); @@ -175,7 +184,7 @@ public class AddressBookView extends VerticalLayout { errors.add(vatError.get()); } } - if (!email.getValue().isBlank() && !email.getValue().matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")) { + if (!email.getValue().isBlank() && !email.getValue().matches(EMAIL_PATTERN)) { email.setErrorMessage("Gültige E-Mail-Adresse angeben."); email.setInvalid(true); errors.add("E-Mail-Adresse ist ungültig."); diff --git a/src/main/java/de/assecutor/pdftool/ui/MainView.java b/src/main/java/de/assecutor/pdftool/ui/MainView.java index 3569383..e5455f3 100644 --- a/src/main/java/de/assecutor/pdftool/ui/MainView.java +++ b/src/main/java/de/assecutor/pdftool/ui/MainView.java @@ -87,7 +87,17 @@ public class MainView extends VerticalLayout { private final InvoiceDraftService invoiceDraftService; /** Laden zuvor gespeicherter Eingaben dieser Maske. */ - private final ComboBox draftSelect = new ComboBox<>("Gespeicherte Eingaben"); + private final ComboBox draftSelect = new ComboBox<>("Vorbelegung laden"); + + private final Button saveDraftButton = new Button("Eingaben speichern", event -> openSaveDraftDialog()); + + /** + * Formularstand zum Zeitpunkt des letzten Ladens bzw. Speicherns einer + * Vorbelegung; {@code null}, solange keine geladen wurde. Der + * Speichern-Button ist nur aktiv, wenn keine Vorbelegung geladen ist oder + * sich die Eingaben seither geändert haben. + */ + private InvoiceDraft loadedDraftState; private final Button generate = new Button("E-Rechnung erzeugen", event -> generate()); @@ -135,18 +145,19 @@ public class MainView extends VerticalLayout { + "und als ZIP-Datei bereitgestellt.")); // Eingaben unter einem Namen speichern bzw. gespeicherte Eingaben laden. - draftSelect.setPlaceholder("Eingaben laden"); + draftSelect.setPlaceholder("Vorbelegung laden"); draftSelect.setClearButtonVisible(true); draftSelect.setWidth("300px"); draftSelect.addValueChangeListener(event -> { if (event.isFromClient() && event.getValue() != null) { loadDraft(event.getValue()); + } else if (event.isFromClient()) { + // Auswahl entfernt: es gilt wieder "keine Vorbelegung geladen". + loadedDraftState = null; + updateSaveDraftEnabled(); } }); - Button saveDraft = new Button("Eingaben speichern", event -> openSaveDraftDialog()); - HorizontalLayout draftBar = new HorizontalLayout(draftSelect, saveDraft); - draftBar.setAlignItems(Alignment.END); - add(draftBar); + add(draftSelect); Upload upload = new Upload(uploadBuffer); upload.setAcceptedFileTypes("application/pdf", ".pdf"); @@ -226,7 +237,10 @@ public class MainView extends VerticalLayout { downloadLink.getElement().setAttribute("download", true); downloadLink.setVisible(false); - add(new HorizontalLayout(generate, downloadLink)); + add(new HorizontalLayout(generate, saveDraftButton, downloadLink)); + + registerDraftDirtyTracking(); + updateSaveDraftEnabled(); if (environment.acceptsProfiles(Profiles.of("dev"))) { prefillDevData(); @@ -249,8 +263,6 @@ public class MainView extends VerticalLayout { dialog.setHeaderTitle("Eingaben speichern"); TextField nameField = new TextField("Name"); - nameField.setValue(draftSelect.getValue() != null ? draftSelect.getValue() - : invoiceNumber.getValue().trim()); nameField.setWidth("300px"); dialog.add(nameField); @@ -291,9 +303,17 @@ public class MainView extends VerticalLayout { } private void saveDraft(String name) { - invoiceDraftService.save(buildDraft(name)); + try { + invoiceDraftService.save(buildDraft(name)); + } catch (Exception ex) { + showErrors(List.of("Die Eingaben konnten nicht unter \"" + name + + "\" gespeichert werden: " + ex.getMessage())); + return; + } draftSelect.setItems(invoiceDraftService.names()); draftSelect.setValue(name); + loadedDraftState = comparableState(); + updateSaveDraftEnabled(); Notification.show("Eingaben \"" + name + "\" gespeichert.", 3000, Notification.Position.BOTTOM_START) .addThemeVariants(NotificationVariant.LUMO_SUCCESS); } @@ -350,6 +370,9 @@ public class MainView extends VerticalLayout { } refreshItemsGrid(); updateControlsEnabled(); + // Frisch geladen: Speichern erst wieder anbieten, wenn sich etwas ändert. + loadedDraftState = comparableState(); + updateSaveDraftEnabled(); } private void applyParty(PartyForm form, InvoiceMetadata.Party party) { @@ -438,6 +461,47 @@ public class MainView extends VerticalLayout { private void refreshItemsGrid() { itemsGrid.setItems(List.copyOf(lineItems)); + updateSaveDraftEnabled(); + } + + /** Aktualisiert den Speichern-Button bei jeder Änderung an den Eingabefeldern. */ + private void registerDraftDirtyTracking() { + invoiceNumber.addValueChangeListener(event -> updateSaveDraftEnabled()); + issueDate.addValueChangeListener(event -> updateSaveDraftEnabled()); + deliveryDate.addValueChangeListener(event -> updateSaveDraftEnabled()); + dueDate.addValueChangeListener(event -> updateSaveDraftEnabled()); + currency.addValueChangeListener(event -> updateSaveDraftEnabled()); + paymentTerms.addValueChangeListener(event -> updateSaveDraftEnabled()); + buyerReference.addValueChangeListener(event -> updateSaveDraftEnabled()); + iban.addValueChangeListener(event -> updateSaveDraftEnabled()); + bic.addValueChangeListener(event -> updateSaveDraftEnabled()); + templateSelect.addValueChangeListener(event -> updateSaveDraftEnabled()); + sender.onAnyChange(this::updateSaveDraftEnabled); + recipient.onAnyChange(this::updateSaveDraftEnabled); + } + + /** + * Der Speichern-Button ist nur aktiv, wenn keine Vorbelegung geladen wurde + * oder sich die Eingaben seit dem Laden bzw. Speichern geändert haben. + */ + private void updateSaveDraftEnabled() { + saveDraftButton.setEnabled(loadedDraftState == null || !comparableState().equals(loadedDraftState)); + } + + /** Der aktuelle Formularstand ohne Name und Zeitstempel, zum Vergleich mit der geladenen Vorbelegung. */ + private InvoiceDraft comparableState() { + return new InvoiceDraft(null, "", + templateSelect.getValue(), + invoiceNumber.getValue().trim(), + issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(), + currency.getValue().trim(), + paymentTerms.getValue(), + buyerReference.getValue().trim(), + iban.getValue().trim(), + bic.getValue().trim(), + sender.toParty(), recipient.toParty(), + List.copyOf(lineItems), + null); } /** Dialog zum Anlegen ({@code existing == null}) bzw. Bearbeiten einer Rechnungsposition. */ @@ -729,6 +793,8 @@ public class MainView extends VerticalLayout { */ private static class PartyForm extends FormLayout { + static final String EMAIL_PATTERN = "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"; + private final boolean vatRequired; private final TextField name = new TextField("Firma / Name"); private final TextField street = new TextField("Straße und Hausnummer"); @@ -757,6 +823,8 @@ public class MainView extends VerticalLayout { // Elektronische Adresse (BT-34/BT-49) ist nach PEPPOL-EN16931 Pflicht email.setRequiredIndicatorVisible(true); email.setErrorMessage("Gültige E-Mail-Adresse angeben."); + // E-Mail direkt beim Verlassen des Feldes prüfen + email.addValueChangeListener(event -> checkEmail()); if (vatRequired) { phone.setHelperText("Für den Verkäufer-Kontakt (BR-DE-6) empfohlen"); } @@ -776,6 +844,18 @@ public class MainView extends VerticalLayout { phone.setValue(phoneValue); } + /** Meldet jede Wertänderung eines Feldes, z. B. für die Dirty-Erkennung. */ + void onAnyChange(Runnable listener) { + name.addValueChangeListener(event -> listener.run()); + street.addValueChangeListener(event -> listener.run()); + zip.addValueChangeListener(event -> listener.run()); + city.addValueChangeListener(event -> listener.run()); + countryCode.addValueChangeListener(event -> listener.run()); + vatId.addValueChangeListener(event -> listener.run()); + email.addValueChangeListener(event -> listener.run()); + phone.addValueChangeListener(event -> listener.run()); + } + /** Leert alle Felder; das Land behält den Standardwert "DE". */ void clearFields() { prefill("", "", "", "", "DE", "", "", ""); @@ -788,6 +868,19 @@ public class MainView extends VerticalLayout { email.setInvalid(false); } + /** @return true, wenn die E-Mail-Adresse gültig (oder leer) ist. */ + private boolean checkEmail() { + String value = email.getValue(); + if (value == null || value.isBlank()) { + email.setInvalid(false); + return true; + } + boolean valid = value.matches(EMAIL_PATTERN); + email.setErrorMessage("Gültige E-Mail-Adresse angeben."); + email.setInvalid(!valid); + return valid; + } + /** @return true, wenn die USt-IdNr. gültig (oder leer) ist. */ private boolean checkVatId() { String value = vatId.getValue(); @@ -820,12 +913,8 @@ public class MainView extends VerticalLayout { email.setErrorMessage(partyLabel + ": E-Mail ist ein Pflichtfeld."); email.setInvalid(true); errors.add(partyLabel + ": E-Mail ist ein Pflichtfeld (elektronische Adresse der E-Rechnung)."); - } else if (!email.getValue().matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")) { - email.setErrorMessage("Gültige E-Mail-Adresse angeben."); - email.setInvalid(true); + } else if (!checkEmail()) { errors.add(partyLabel + ": E-Mail-Adresse ist ungültig."); - } else { - email.setInvalid(false); } return errors; }