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