Speichern-Button, eindeutige Namen und E-Mail-Validierung

- "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 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 16:25:20 +02:00
parent 960431914c
commit 420072dc27
3 changed files with 131 additions and 16 deletions

View File

@@ -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.
*
* <p>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).</p>
*/
@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. */

View File

@@ -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<Address> 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.");

View File

@@ -87,7 +87,17 @@ public class MainView extends VerticalLayout {
private final InvoiceDraftService invoiceDraftService;
/** Laden zuvor gespeicherter Eingaben dieser Maske. */
private final ComboBox<String> draftSelect = new ComboBox<>("Gespeicherte Eingaben");
private final ComboBox<String> 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) {
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;
}