Rechnungsmaske: ZIP-Auto-Download, löschbare Vorbelegungen, Icon- und Textausrichtungs-Korrekturen

- Valides ZIP startet den Download direkt; der sichtbare Download-Link entfällt
- Vorbelegung laden: Löschen-Icon rechts an jedem Eintrag, Löschung aus MongoDB nach Sicherheitsabfrage
- Template-Wechsel leert die Rechnungssteller-Felder nicht mehr, damit "Eingaben speichern" aktiv wird
- Aktionsspalten der Grids mit fester Breite, damit die Icons nicht abgeschnitten werden
- Texte im PDF-Renderer und Designer-Canvas oben statt zentriert ausgerichtet, damit mehrzeilige Variablenwerte auf Höhe ihrer Nachbar-Elemente beginnen
- Vaadin-Frontend-Scaffolding (package.json, tsconfig, vite-Konfiguration) aufgenommen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 09:56:30 +02:00
co-authored by Claude Fable 5
parent 5bcaf97eba
commit 6838ce3b20
12 changed files with 10658 additions and 16 deletions
Binary file not shown.
@@ -345,10 +345,10 @@ window.initProfileInvoiceGenerator = function() {
// Text elements
var lines = (el.text || '').split('\n');
var lineHeight = fontSize * 1.2;
var totalTextHeight = lines.length * lineHeight;
// Vertically center the text in the element
var ty = y + (h - totalTextHeight) / 2;
// Oben ausrichten statt zentrieren — wie im gerenderten PDF, damit die
// erste Zeile unabhängig von der Zeilenzahl an der Box-Oberkante beginnt
var ty = y;
// Masterdata elements are never bold; other elements respect their fontStyle
var fontWeight = (el.isStatic && !el.isCustomer) ? '' : (el.fontStyle || '');
@@ -36,6 +36,11 @@ public class InvoiceDraftService {
return repository.existsByName(name);
}
/** Löscht die unter dem Namen gespeicherten Eingaben; ohne Treffer passiert nichts. */
public void delete(String name) {
repository.findByName(name).ifPresent(existing -> repository.deleteById(existing.id()));
}
/** @return die Namen aller gespeicherten Eingaben, alphabetisch sortiert. */
public List<String> names() {
return repository.findAll().stream()
@@ -165,7 +165,10 @@ public class TemplatePdfService {
if ("services.list".equals(variable)) {
htmlBuilder.append("display:block;overflow:visible;padding:0;");
} else {
htmlBuilder.append("display:flex;align-items:center;");
// Oben ausrichten statt zentrieren: Die erste Zeile muss an der
// Box-Oberkante beginnen, sonst verschiebt sich mehrzeiliger
// Variablentext gegenüber einzeiligen Nachbar-Elementen.
htmlBuilder.append("display:flex;align-items:flex-start;");
switch (textAlign) {
case "center":
htmlBuilder.append("justify-content:center;text-align:center;");
@@ -85,6 +85,8 @@ public class AddressBookView extends VerticalLayout {
.setAutoWidth(true);
grid.addColumn(address -> address.getName()).setHeader("Firma / Name").setSortable(true).setAutoWidth(true);
grid.addColumn(address -> address.getStreet()).setHeader("Straße").setAutoWidth(true);
// Feste Breite statt AutoWidth: Vaadin misst Komponentenspalten vor dem
// Rendern der Buttons, dadurch würden die Icons rechts abgeschnitten.
grid.addComponentColumn(address -> {
Button edit = new Button(new Icon(VaadinIcon.EDIT), event -> openEditDialog(address));
edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
@@ -93,8 +95,11 @@ public class AddressBookView extends VerticalLayout {
delete.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR,
ButtonVariant.LUMO_SMALL);
delete.setTooltipText("Löschen");
return new HorizontalLayout(edit, delete);
}).setHeader("").setFlexGrow(0).setAutoWidth(true);
HorizontalLayout actions = new HorizontalLayout(edit, delete);
actions.setSpacing(false);
actions.setPadding(false);
return actions;
}).setHeader("").setFlexGrow(0).setWidth("110px");
grid.addItemDoubleClickListener(event -> openEditDialog(event.getItem()));
grid.setSizeFull();
add(grid);
@@ -13,6 +13,7 @@ import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
@@ -24,6 +25,7 @@ import com.vaadin.flow.component.textfield.EmailField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamResource;
@@ -143,6 +145,25 @@ public class MainView extends VerticalLayout {
draftSelect.setPlaceholder("Vorbelegung laden");
draftSelect.setClearButtonVisible(true);
draftSelect.setWidth("300px");
// Jeder Eintrag zeigt rechts ein Löschen-Icon, das die Vorbelegung
// nach einer Sicherheitsabfrage aus MongoDB entfernt.
draftSelect.setRenderer(new ComponentRenderer<>(name -> {
Span label = new Span(name);
Icon delete = VaadinIcon.TRASH.create();
delete.setSize("var(--lumo-icon-size-s)");
delete.getStyle().set("color", "var(--lumo-error-color)").set("cursor", "pointer");
delete.getElement().setAttribute("title", "Vorbelegung löschen");
// Der Klick auf das Icon darf den Eintrag nicht auswählen; der
// Server-Listener auf dem Icon selbst wird davon nicht berührt.
delete.getElement().executeJs("this.addEventListener('click', function(e) { e.stopPropagation(); })");
delete.getElement().addEventListener("click", e -> confirmDeleteDraft(name));
HorizontalLayout row = new HorizontalLayout(label, delete);
row.setJustifyContentMode(JustifyContentMode.BETWEEN);
row.setAlignItems(Alignment.CENTER);
row.setWidthFull();
row.setPadding(false);
return row;
}));
draftSelect.addValueChangeListener(event -> {
if (event.isFromClient() && event.getValue() != null) {
loadDraft(event.getValue());
@@ -181,9 +202,14 @@ public class MainView extends VerticalLayout {
// Entweder PDF oder Template: Auswahl entfernt ein hochgeladenes PDF.
upload.clearFileList();
uploadedPdf = null;
// Rechnungssteller-Felder leer beginnen lassen.
senderAddressSelect.clear();
sender.clearFields();
// Rechnungssteller-Felder nur bei der ersten Template-Auswahl
// leer beginnen lassen; beim Wechsel zwischen Templates bleiben
// die Eingaben erhalten (sonst wäre u. a. der Speichern-Button
// wegen leerer Pflichtfelder nie aktiv).
if (event.getOldValue() == null) {
senderAddressSelect.clear();
sender.clearFields();
}
}
updateControlsEnabled();
});
@@ -226,8 +252,11 @@ public class MainView extends VerticalLayout {
recipientAddressSelect, recipient, itemsGrid, addItem, generate));
updateControlsEnabled();
// Unsichtbarer Anchor: dient nur als Träger für den automatischen
// Download nach dem Erzeugen (display:none statt setVisible, damit
// callJsFunction("click") den Klick sofort ausführen kann).
downloadLink.getElement().setAttribute("download", true);
downloadLink.setVisible(false);
downloadLink.getStyle().set("display", "none");
add(new HorizontalLayout(generate, saveDraftButton, downloadLink));
@@ -290,6 +319,39 @@ public class MainView extends VerticalLayout {
confirm.open();
}
/** Sicherheitsabfrage vor dem endgültigen Löschen einer Vorbelegung. */
private void confirmDeleteDraft(String name) {
ConfirmDialog confirm = new ConfirmDialog();
confirm.setHeader("Vorbelegung löschen?");
confirm.setText("Sollen die gespeicherten Eingaben \"" + name + "\" endgültig gelöscht werden?");
confirm.setConfirmText("Löschen");
confirm.setConfirmButtonTheme("error primary");
confirm.setCancelable(true);
confirm.setCancelText("Abbrechen");
confirm.addConfirmListener(event -> deleteDraft(name));
confirm.open();
}
private void deleteDraft(String name) {
try {
invoiceDraftService.delete(name);
} catch (Exception ex) {
showErrors(List.of("Die Vorbelegung \"" + name + "\" konnte nicht gelöscht werden: " + ex.getMessage()));
return;
}
boolean wasSelected = name.equals(draftSelect.getValue());
draftSelect.setItems(invoiceDraftService.names());
if (wasSelected) {
// Die geladene Vorbelegung existiert nicht mehr; das Formular
// behält seine Werte, gilt aber wieder als ungespeichert.
draftSelect.clear();
loadedDraftState = null;
updateSaveDraftEnabled();
}
Notification.show("Vorbelegung \"" + name + "\" gelöscht.", 3000, Notification.Position.BOTTOM_START)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
}
private void saveDraft(String name) {
try {
invoiceDraftService.save(buildDraft(name));
@@ -412,6 +474,8 @@ public class MainView extends VerticalLayout {
.setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0);
itemsGrid.addColumn(item -> formatDecimal(item.vatPercent())).setHeader("USt %")
.setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0);
// Feste Breite statt AutoWidth: Vaadin misst Komponentenspalten vor dem
// Rendern der Buttons, dadurch würden die Icons rechts abgeschnitten.
itemsGrid.addComponentColumn(item -> {
Button edit = new Button(new Icon(VaadinIcon.EDIT), event -> openItemDialog(item));
edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
@@ -423,8 +487,11 @@ public class MainView extends VerticalLayout {
delete.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR,
ButtonVariant.LUMO_SMALL);
delete.setTooltipText("Löschen");
return new HorizontalLayout(edit, delete);
}).setHeader("").setAutoWidth(true).setFlexGrow(0);
HorizontalLayout actions = new HorizontalLayout(edit, delete);
actions.setSpacing(false);
actions.setPadding(false);
return actions;
}).setHeader("").setWidth("110px").setFlexGrow(0);
itemsGrid.addItemDoubleClickListener(event -> openItemDialog(event.getItem()));
itemsGrid.setAllRowsVisible(true);
itemsGrid.setWidthFull();
@@ -561,7 +628,6 @@ public class MainView extends VerticalLayout {
}
private void generate() {
downloadLink.setVisible(false);
List<String> errors = validateForm();
if (!errors.isEmpty()) {
showErrors(errors);
@@ -642,13 +708,12 @@ public class MainView extends VerticalLayout {
() -> new ByteArrayInputStream(result.zip()));
resource.setContentType("application/zip");
downloadLink.setHref(resource);
downloadLink.setText("ZIP herunterladen (" + result.zipFileName() + ")");
downloadLink.setVisible(true);
if (result.valid()) {
Notification.show("E-Rechnung erzeugt und erfolgreich validiert.", 4000,
Notification.Position.BOTTOM_START)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
downloadLink.getElement().callJsFunction("click");
} else {
Notification.show("E-Rechnung erzeugt, aber die Validierung meldet Probleme. "
+ "Details stehen im Prüfbericht (validation-report.xml) im ZIP.", 8000,