From 3846ba0cdedbee50d65a3c06fc23e0cfeaa159b5 Mon Sep 17 00:00:00 2001
From: Lucas <3023626@stud.hs-mannheim.de>
Date: Sat, 13 Jun 2026 01:28:12 +0200
Subject: [PATCH] Update UI
---
src/main/java/de/team1/faktura/Main.java | 13 +-
.../de/team1/faktura/dokumente/Dokument.java | 5 +-
.../faktura/dokumente/Dokumentposition.java | 4 +
.../faktura/dokumente/PdfBoxPdfExporter.java | 277 ++++++++++++----
.../de/team1/faktura/gui/BelegDialog.java | 36 +-
.../faktura/gui/DokumentListenPanel.java | 83 +++--
.../de/team1/faktura/gui/HauptFenster.java | 43 ++-
.../faktura/gui/KundenFormularDialog.java | 201 ++++++++++++
.../de/team1/faktura/gui/KundenPanel.java | 289 ++++++----------
.../java/de/team1/faktura/gui/Meldung.java | 2 +
.../de/team1/faktura/gui/MeldungsAnzeige.java | 3 +
.../faktura/gui/ProduktFormularDialog.java | 223 +++++++++++++
.../de/team1/faktura/gui/ProduktPanel.java | 309 ++++++------------
.../faktura/gui/RechnungsWizardDialog.java | 65 +++-
.../faktura/gui/StammdatenController.java | 21 ++
.../de/team1/faktura/gui/TabellenFormat.java | 5 +-
.../java/de/team1/faktura/gui/UiHilfen.java | 124 +++++++
.../dokumente/PdfBoxPdfExporterTest.java | 63 ++++
.../gui/OberflaechenControllerTest.java | 15 +
19 files changed, 1303 insertions(+), 478 deletions(-)
create mode 100644 src/main/java/de/team1/faktura/gui/KundenFormularDialog.java
create mode 100644 src/main/java/de/team1/faktura/gui/ProduktFormularDialog.java
create mode 100644 src/main/java/de/team1/faktura/gui/UiHilfen.java
create mode 100644 src/test/java/de/team1/faktura/dokumente/PdfBoxPdfExporterTest.java
diff --git a/src/main/java/de/team1/faktura/Main.java b/src/main/java/de/team1/faktura/Main.java
index 28254ee..12fa383 100644
--- a/src/main/java/de/team1/faktura/Main.java
+++ b/src/main/java/de/team1/faktura/Main.java
@@ -12,6 +12,7 @@ import de.team1.faktura.gui.DokumentListenPanel;
import de.team1.faktura.gui.HauptFenster;
import de.team1.faktura.gui.KundenPanel;
import de.team1.faktura.gui.ProduktPanel;
+import de.team1.faktura.gui.StammdatenController;
import de.team1.faktura.kunden.EinfacherKundennummernGenerator;
import de.team1.faktura.kunden.JsonKundenRepository;
import de.team1.faktura.kunden.KundenCsvExport;
@@ -78,6 +79,10 @@ public final class Main {
new PdfBoxPdfExporter(),
ereignisBus);
+ // Gruppe D — GUI-freier Controller der Stammdaten-Ansichten (D-F-03)
+ StammdatenController stammdatenController =
+ new StammdatenController(kundenService, produktService);
+
// Gruppe D — Programmoberfläche
SwingUtilities.invokeLater(() -> {
try {
@@ -88,10 +93,10 @@ public final class Main {
// Standard-Look-and-Feel verwenden
}
HauptFenster fenster = new HauptFenster(
- new KundenPanel(kundenService, new KundenCsvExport(kundenRepository),
- ereignisBus),
- new ProduktPanel(produktService, new ProduktCsvExport(produktRepository),
- ereignisBus),
+ new KundenPanel(kundenService, stammdatenController,
+ new KundenCsvExport(kundenRepository), ereignisBus),
+ new ProduktPanel(produktService, stammdatenController,
+ new ProduktCsvExport(produktRepository), ereignisBus),
new DokumentListenPanel(dokumentService, kundenService, produktService,
new DokumentCsvExport(dokumentRepository), ereignisBus));
fenster.setVisible(true);
diff --git a/src/main/java/de/team1/faktura/dokumente/Dokument.java b/src/main/java/de/team1/faktura/dokumente/Dokument.java
index af17f34..633436f 100644
--- a/src/main/java/de/team1/faktura/dokumente/Dokument.java
+++ b/src/main/java/de/team1/faktura/dokumente/Dokument.java
@@ -69,7 +69,10 @@ public abstract class Dokument {
BigDecimal netto = BigDecimal.ZERO;
BigDecimal steuer = BigDecimal.ZERO;
for (Dokumentposition position : positionen) {
- netto = netto.add(position.getPositionssummeNetto());
+ // Altdaten ohne Preis-Snapshot (IF-01) zählen als 0 statt zu scheitern
+ if (position.getPositionssummeNetto() != null) {
+ netto = netto.add(position.getPositionssummeNetto());
+ }
steuer = steuer.add(position.getSteuerbetrag());
}
this.summeNetto = netto.setScale(2, RoundingMode.HALF_UP);
diff --git a/src/main/java/de/team1/faktura/dokumente/Dokumentposition.java b/src/main/java/de/team1/faktura/dokumente/Dokumentposition.java
index 2354239..6bbf4b5 100644
--- a/src/main/java/de/team1/faktura/dokumente/Dokumentposition.java
+++ b/src/main/java/de/team1/faktura/dokumente/Dokumentposition.java
@@ -49,6 +49,10 @@ public class Dokumentposition {
/** Steuerbetrag der Position: {@code positionssummeNetto * steuersatz}, Scale 2 (F-23, TC-01). */
public BigDecimal getSteuerbetrag() {
+ // Altdaten ohne Preis-Snapshot (IF-01) liefern 0 statt eines Fehlers
+ if (positionssummeNetto == null || steuersatz == null) {
+ return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+ }
return positionssummeNetto.multiply(steuersatz).setScale(2, RoundingMode.HALF_UP);
}
diff --git a/src/main/java/de/team1/faktura/dokumente/PdfBoxPdfExporter.java b/src/main/java/de/team1/faktura/dokumente/PdfBoxPdfExporter.java
index 463a67b..6f828d4 100644
--- a/src/main/java/de/team1/faktura/dokumente/PdfBoxPdfExporter.java
+++ b/src/main/java/de/team1/faktura/dokumente/PdfBoxPdfExporter.java
@@ -13,19 +13,48 @@ import java.io.UncheckedIOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.text.NumberFormat;
+import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
+import java.util.Locale;
/**
- * PDF-Export der Belege mit Apache PDFBox (A-F-04, F-07, F-10, F-15).
- * Rechnungen enthalten die Pflichtangaben gemäß § 14 UStG (F-13):
- * Belegnummer, Rechnungs- und Leistungsdatum, Kundendaten, Positionen mit
- * Einzel- und Gesamtbeträgen, Steuersatz/-betrag sowie die Summen.
+ * Standardisierter PDF-Export der Belege mit Apache PDFBox
+ * (A-F-04, F-07, F-10, F-15), angelehnt an den deutschen Geschäftsbrief:
+ * Absender- und Empfängerblock, Belegkopf mit Datum und Referenzen,
+ * Positionstabelle mit festen Spalten und rechtsbündigen Beträgen sowie
+ * Summenblock.
+ *
+ *
Rechnungen enthalten die Pflichtangaben gemäß § 14 UStG (F-13):
+ * Name und Anschrift von Aussteller und Kunde, Belegnummer, Rechnungs- und
+ * Leistungsdatum, Positionen mit Mengen und Einzelbeträgen, Steuersatz und
+ * Steuerbetrag sowie Netto-/Bruttosummen.
*/
public class PdfBoxPdfExporter implements PdfExporter {
+ /** Aussteller-Stammdaten (§ 14 UStG); bei Produktivbetrieb anzupassen. */
+ private static final String AUSSTELLER_NAME = "Team 1 Fakturierung";
+ private static final String AUSSTELLER_STRASSE = "Musterstraße 1";
+ private static final String AUSSTELLER_ORT = "68163 Mannheim";
+ private static final String AUSSTELLER_UST_ID = "USt-IdNr. DE000000000";
+
private static final DateTimeFormatter DATUM = DateTimeFormatter.ofPattern("dd.MM.yyyy");
+
+ /** Seitenränder und Zeilenraster (A4 hoch, Angaben in PDF-Punkten). */
private static final float RAND = 50;
private static final float ZEILENHOEHE = 14;
+ private static final float SEITENBREITE = PDRectangle.A4.getWidth();
+ private static final float RECHTS = SEITENBREITE - RAND;
+
+ /** Spaltenraster der Positionstabelle: Textspalten linksbündig ... */
+ private static final float SPALTE_POS = RAND;
+ private static final float SPALTE_PRODUKT = 80;
+ private static final float SPALTE_BEZEICHNUNG = 155;
+ /** ... Zahlenspalten rechtsbündig an ihrer rechten Kante. */
+ private static final float SPALTE_MENGE = 385;
+ private static final float SPALTE_EINZELPREIS = 460;
+ private static final float SPALTE_UST = 497;
+ private static final float SPALTE_SUMME = RECHTS;
private final PDFont normal = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
private final PDFont fett = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
@@ -35,48 +64,11 @@ public class PdfBoxPdfExporter implements PdfExporter {
try (PDDocument pdf = new PDDocument()) {
Schreiber schreiber = new Schreiber(pdf);
- schreiber.zeile(fett, 16, dokument.belegtyp().anzeigename() + " " + dokument.getBelegnummer());
- if (dokument.getStatus() == DokumentStatus.STORNIERT) {
- schreiber.zeile(fett, 12, "*** STORNIERT ***");
- }
- schreiber.leer();
- schreiber.zeile(normal, 10, "Datum: " + format(dokument.getDatum()));
- if (dokument instanceof Angebot angebot) {
- schreiber.zeile(normal, 10, "Gültig bis: " + format(angebot.getGueltigBis()));
- }
- if (dokument instanceof Lieferschein lieferschein) {
- schreiber.zeile(normal, 10, "Lieferdatum: " + format(lieferschein.getLieferdatum()));
- }
- if (dokument instanceof Rechnung rechnung) {
- schreiber.zeile(normal, 10, "Leistungsdatum: " + format(rechnung.getLeistungsdatum()));
- schreiber.zeile(normal, 10, "Zahlbar bis: " + format(rechnung.getZahlungsziel()));
- }
- if (dokument.getVorgaengerNr() != null) {
- schreiber.zeile(normal, 10, "Referenz: " + dokument.getVorgaengerNr());
- }
- schreiber.leer();
- schreiber.zeile(fett, 10, "Kunde");
- schreiber.zeile(normal, 10, dokument.getKundeName() + " (" + dokument.getKundenReferenz() + ")");
- schreiber.zeile(normal, 10, dokument.getKundeAnschrift());
- schreiber.leer();
-
- schreiber.zeile(fett, 10, String.format("%-4s %-10s %-34s %6s %12s %6s %12s",
- "Pos", "Produkt", "Bezeichnung", "Menge", "Einzelpreis", "USt", "Summe"));
- int pos = 1;
- for (Dokumentposition position : dokument.getPositionen()) {
- schreiber.zeile(normal, 10, String.format("%-4d %-10s %-34s %6d %12s %5s%% %12s",
- pos++,
- position.getProduktReferenz(),
- kuerze(position.getBezeichnung(), 34),
- position.getMenge(),
- betrag(position.getEinzelpreisNetto()),
- prozent(position.getSteuersatz()),
- betrag(position.getPositionssummeNetto())));
- }
- schreiber.leer();
- schreiber.zeile(normal, 10, "Summe netto: " + betrag(dokument.getSummeNetto()) + " EUR");
- schreiber.zeile(normal, 10, "Umsatzsteuer: " + betrag(dokument.getSummeSteuer()) + " EUR");
- schreiber.zeile(fett, 11, "Summe brutto: " + betrag(dokument.getSummeBrutto()) + " EUR");
+ schreibeBriefkopf(schreiber, dokument);
+ schreibeBelegkopf(schreiber, dokument);
+ schreibePositionstabelle(schreiber, dokument);
+ schreibeSummenblock(schreiber, dokument);
+ schreibeSchlusstext(schreiber, dokument);
schreiber.schliesse();
if (zielDatei.getParent() != null) {
@@ -88,15 +80,140 @@ public class PdfBoxPdfExporter implements PdfExporter {
}
}
- private static String format(java.time.LocalDate datum) {
+ /** Absenderblock rechts oben, Rücksendezeile und Empfängerblock links. */
+ private void schreibeBriefkopf(Schreiber schreiber, Dokument dokument) throws IOException {
+ schreiber.rechtsbuendig(fett, 11, AUSSTELLER_NAME, RECHTS);
+ schreiber.rechtsbuendig(normal, 9, AUSSTELLER_STRASSE, RECHTS);
+ schreiber.rechtsbuendig(normal, 9, AUSSTELLER_ORT, RECHTS);
+ schreiber.rechtsbuendig(normal, 9, AUSSTELLER_UST_ID, RECHTS);
+ schreiber.leer();
+
+ schreiber.zeile(normal, 7, AUSSTELLER_NAME + " · " + AUSSTELLER_STRASSE
+ + " · " + AUSSTELLER_ORT);
+ schreiber.linie();
+ schreiber.zeile(normal, 10, dokument.getKundeName()
+ + " (Kundennr. " + dokument.getKundenReferenz() + ")");
+ // anschrift() liefert "Straße, PLZ Ort" einzeilig; für den
+ // Empfängerblock wird sie an der ersten Trennstelle umbrochen
+ String anschrift = dokument.getKundeAnschrift();
+ int trenner = anschrift == null ? -1 : anschrift.indexOf(", ");
+ if (trenner >= 0) {
+ schreiber.zeile(normal, 10, anschrift.substring(0, trenner));
+ schreiber.zeile(normal, 10, anschrift.substring(trenner + 2));
+ } else if (anschrift != null) {
+ schreiber.zeile(normal, 10, anschrift);
+ }
+ schreiber.leer();
+ schreiber.leer();
+ }
+
+ /** Belegtitel, Stornokennzeichen und Datums-/Referenzangaben. */
+ private void schreibeBelegkopf(Schreiber schreiber, Dokument dokument) throws IOException {
+ schreiber.zeile(fett, 16, dokument.belegtyp().anzeigename() + " " + dokument.getBelegnummer());
+ if (dokument.getStatus() == DokumentStatus.STORNIERT) {
+ schreiber.zeile(fett, 12, "*** STORNIERT ***");
+ }
+ schreiber.leer();
+ schreiber.zeile(normal, 10, "Datum: " + format(dokument.getDatum()));
+ if (dokument instanceof Angebot angebot) {
+ schreiber.zeile(normal, 10, "Gültig bis: " + format(angebot.getGueltigBis()));
+ }
+ if (dokument instanceof Lieferschein lieferschein) {
+ schreiber.zeile(normal, 10, "Lieferdatum: " + format(lieferschein.getLieferdatum()));
+ }
+ if (dokument instanceof Rechnung rechnung) {
+ schreiber.zeile(normal, 10, "Leistungsdatum: " + format(rechnung.getLeistungsdatum()));
+ schreiber.zeile(normal, 10, "Zahlbar bis: " + format(rechnung.getZahlungsziel()));
+ }
+ if (dokument.getVorgaengerNr() != null) {
+ schreiber.zeile(normal, 10, "Referenzbeleg: " + dokument.getVorgaengerNr());
+ }
+ schreiber.leer();
+ }
+
+ /** Positionstabelle im festen Spaltenraster; Kopf wird je Seite wiederholt. */
+ private void schreibePositionstabelle(Schreiber schreiber, Dokument dokument) throws IOException {
+ schreibeTabellenkopf(schreiber);
+ int pos = 1;
+ for (Dokumentposition position : dokument.getPositionen()) {
+ if (!schreiber.passtNochZeile()) {
+ schreiber.neueSeite();
+ schreibeTabellenkopf(schreiber);
+ }
+ schreiber.beginneZeile();
+ schreiber.text(normal, 10, String.valueOf(pos++), SPALTE_POS);
+ schreiber.text(normal, 10, position.getProduktReferenz(), SPALTE_PRODUKT);
+ schreiber.text(normal, 10, kuerze(position.getBezeichnung(), 40), SPALTE_BEZEICHNUNG);
+ schreiber.textRechts(normal, 10, String.valueOf(position.getMenge()), SPALTE_MENGE);
+ schreiber.textRechts(normal, 10, betrag(position.getEinzelpreisNetto()), SPALTE_EINZELPREIS);
+ schreiber.textRechts(normal, 10, prozent(position.getSteuersatz()) + " %", SPALTE_UST);
+ schreiber.textRechts(normal, 10, betrag(position.getPositionssummeNetto()), SPALTE_SUMME);
+ schreiber.beendeZeile();
+ }
+ schreiber.linie();
+ }
+
+ private void schreibeTabellenkopf(Schreiber schreiber) throws IOException {
+ schreiber.beginneZeile();
+ schreiber.text(fett, 10, "Pos", SPALTE_POS);
+ schreiber.text(fett, 10, "Produkt", SPALTE_PRODUKT);
+ schreiber.text(fett, 10, "Bezeichnung", SPALTE_BEZEICHNUNG);
+ schreiber.textRechts(fett, 10, "Menge", SPALTE_MENGE);
+ schreiber.textRechts(fett, 10, "Einzelpreis", SPALTE_EINZELPREIS);
+ schreiber.textRechts(fett, 10, "USt", SPALTE_UST);
+ schreiber.textRechts(fett, 10, "Summe", SPALTE_SUMME);
+ schreiber.beendeZeile();
+ schreiber.linie();
+ }
+
+ /** Summen rechtsbündig unter der Tabelle; Bruttosumme hervorgehoben (F-03). */
+ private void schreibeSummenblock(Schreiber schreiber, Dokument dokument) throws IOException {
+ summenzeile(schreiber, normal, 10, "Summe netto:", dokument.getSummeNetto());
+ summenzeile(schreiber, normal, 10, "Umsatzsteuer:", dokument.getSummeSteuer());
+ summenzeile(schreiber, fett, 11, "Summe brutto:", dokument.getSummeBrutto());
+ schreiber.leer();
+ }
+
+ private void summenzeile(Schreiber schreiber, PDFont font, float groesse,
+ String beschriftung, BigDecimal wert) throws IOException {
+ schreiber.beginneZeile();
+ schreiber.textRechts(font, groesse, beschriftung, SPALTE_EINZELPREIS);
+ schreiber.textRechts(font, groesse, betrag(wert) + " EUR", SPALTE_SUMME);
+ schreiber.beendeZeile();
+ }
+
+ /** Belegtyp-spezifischer Hinweistext am Ende des Dokuments. */
+ private void schreibeSchlusstext(Schreiber schreiber, Dokument dokument) throws IOException {
+ if (dokument instanceof Rechnung rechnung && rechnung.getZahlungsziel() != null
+ && dokument.getStatus() != DokumentStatus.STORNIERT) {
+ schreiber.zeile(normal, 10, "Bitte überweisen Sie den Rechnungsbetrag bis zum "
+ + format(rechnung.getZahlungsziel()) + ".");
+ }
+ if (dokument instanceof Angebot angebot && angebot.getGueltigBis() != null) {
+ schreiber.zeile(normal, 10, "Dieses Angebot ist gültig bis zum "
+ + format(angebot.getGueltigBis()) + ".");
+ }
+ }
+
+ private static String format(LocalDate datum) {
return datum == null ? "—" : DATUM.format(datum);
}
+ /** Deutsches Betragsformat mit Tausenderpunkt, z. B. "1.234,56". */
private static String betrag(BigDecimal wert) {
- return wert.toPlainString().replace('.', ',');
+ if (wert == null) {
+ return "—";
+ }
+ NumberFormat format = NumberFormat.getNumberInstance(Locale.GERMANY);
+ format.setMinimumFractionDigits(2);
+ format.setMaximumFractionDigits(2);
+ return format.format(wert);
}
private static String prozent(BigDecimal steuersatz) {
+ if (steuersatz == null) {
+ return "—";
+ }
return steuersatz.multiply(new BigDecimal("100")).stripTrailingZeros().toPlainString();
}
@@ -107,8 +224,13 @@ public class PdfBoxPdfExporter implements PdfExporter {
return text.length() <= maxLaenge ? text : text.substring(0, maxLaenge - 1) + "…";
}
- /** Zeilenweiser Schreiber mit automatischem Seitenumbruch. */
- private final class Schreiber {
+ /**
+ * Zeilenweiser Schreiber mit automatischem Seitenumbruch. Einfache
+ * Zeilen entstehen über {@link #zeile}; Tabellenzeilen mit mehreren
+ * Spalten über {@link #beginneZeile}, {@link #text}/{@link #textRechts}
+ * und {@link #beendeZeile}.
+ */
+ private static final class Schreiber {
private final PDDocument pdf;
private PDPageContentStream inhalt;
@@ -119,7 +241,7 @@ public class PdfBoxPdfExporter implements PdfExporter {
neueSeite();
}
- private void neueSeite() throws IOException {
+ void neueSeite() throws IOException {
if (inhalt != null) {
inhalt.close();
}
@@ -129,16 +251,59 @@ public class PdfBoxPdfExporter implements PdfExporter {
y = PDRectangle.A4.getHeight() - RAND;
}
- void zeile(PDFont font, float groesse, String text) throws IOException {
- if (y < RAND + ZEILENHOEHE) {
+ boolean passtNochZeile() {
+ return y >= RAND + ZEILENHOEHE;
+ }
+
+ /** Beginnt eine Tabellenzeile; bricht bei Bedarf auf eine neue Seite um. */
+ void beginneZeile() throws IOException {
+ if (!passtNochZeile()) {
neueSeite();
}
+ }
+
+ void beendeZeile() {
+ y -= ZEILENHOEHE;
+ }
+
+ /** Linksbündiger Text an Spaltenposition {@code x} der aktuellen Zeile. */
+ void text(PDFont font, float groesse, String text, float x) throws IOException {
inhalt.beginText();
inhalt.setFont(font, groesse);
- inhalt.newLineAtOffset(RAND, y);
- inhalt.showText(text);
+ inhalt.newLineAtOffset(x, y);
+ inhalt.showText(text == null ? "" : text);
inhalt.endText();
- y -= ZEILENHOEHE;
+ }
+
+ /** Rechtsbündiger Text: {@code xRechts} ist die rechte Kante der Spalte. */
+ void textRechts(PDFont font, float groesse, String text, float xRechts) throws IOException {
+ String sicher = text == null ? "" : text;
+ float breite = font.getStringWidth(sicher) / 1000 * groesse;
+ text(font, groesse, sicher, xRechts - breite);
+ }
+
+ /** Einzelne linksbündige Zeile am linken Seitenrand. */
+ void zeile(PDFont font, float groesse, String text) throws IOException {
+ beginneZeile();
+ text(font, groesse, text, RAND);
+ beendeZeile();
+ }
+
+ /** Einzelne rechtsbündige Zeile (z. B. Absenderblock). */
+ void rechtsbuendig(PDFont font, float groesse, String text, float xRechts) throws IOException {
+ beginneZeile();
+ textRechts(font, groesse, text, xRechts);
+ beendeZeile();
+ }
+
+ /** Horizontale Trennlinie über die volle Satzspiegelbreite. */
+ void linie() throws IOException {
+ beginneZeile();
+ inhalt.moveTo(RAND, y + ZEILENHOEHE - 4);
+ inhalt.lineTo(RECHTS, y + ZEILENHOEHE - 4);
+ inhalt.setLineWidth(0.5f);
+ inhalt.stroke();
+ y -= ZEILENHOEHE / 2;
}
void leer() {
diff --git a/src/main/java/de/team1/faktura/gui/BelegDialog.java b/src/main/java/de/team1/faktura/gui/BelegDialog.java
index 70217e5..ec2abe3 100644
--- a/src/main/java/de/team1/faktura/gui/BelegDialog.java
+++ b/src/main/java/de/team1/faktura/gui/BelegDialog.java
@@ -10,13 +10,16 @@ import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService;
+import javax.swing.BorderFactory;
import javax.swing.DefaultComboBoxModel;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JComboBox;
+import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
+import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
@@ -74,10 +77,11 @@ public class BelegDialog extends JDialog {
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
+ ((JComponent) getContentPane()).setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
JPanel kopf = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
- c.insets = new Insets(3, 3, 3, 3);
+ c.insets = new Insets(4, 4, 4, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
@@ -98,7 +102,13 @@ public class BelegDialog extends JDialog {
c.gridy = 2;
kopf.add(datumBeschriftung, c);
c.gridx = 1;
+ datumFeld.setToolTipText("Optional — Format: TT.MM.JJJJ");
kopf.add(datumFeld, c);
+
+ c.gridx = 0;
+ c.gridy = 3;
+ c.gridwidth = 2;
+ kopf.add(UiHilfen.pflichtfeldLegende(), c);
add(kopf, BorderLayout.NORTH);
JPanel mitte = new JPanel(new BorderLayout(5, 5));
@@ -108,9 +118,11 @@ public class BelegDialog extends JDialog {
eingabe.add(new JLabel("Menge:"));
eingabe.add(mengeWahl);
JButton hinzufuegen = new JButton("Hinzufügen");
+ hinzufuegen.setMnemonic('H');
hinzufuegen.addActionListener(e -> fuegePositionHinzu());
eingabe.add(hinzufuegen);
JButton entfernen = new JButton("Entfernen");
+ entfernen.setMnemonic('N');
entfernen.addActionListener(e -> entfernePosition());
eingabe.add(entfernen);
mitte.add(eingabe, BorderLayout.NORTH);
@@ -119,12 +131,32 @@ public class BelegDialog extends JDialog {
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.RIGHT));
JButton abbrechen = new JButton("Abbrechen");
- abbrechen.addActionListener(e -> dispose());
+ abbrechen.setMnemonic('A');
+ abbrechen.addActionListener(e -> abbrechenMitNachfrage());
JButton erstellen = new JButton("Erstellen");
+ erstellen.setMnemonic('E');
erstellen.addActionListener(e -> erstelle());
knoepfe.add(abbrechen);
knoepfe.add(erstellen);
add(knoepfe, BorderLayout.SOUTH);
+
+ getRootPane().setDefaultButton(erstellen);
+ UiHilfen.escSchliesst(this, this::abbrechenMitNachfrage);
+ UiHilfen.fensterSchliessenAbfangen(this, this::abbrechenMitNachfrage);
+ }
+
+ /** Schutz vor Datenverlust: Nachfrage, wenn bereits Positionen erfasst wurden. */
+ private void abbrechenMitNachfrage() {
+ if (!positionen.isEmpty()) {
+ int antwort = JOptionPane.showConfirmDialog(this,
+ "Die erfassten Positionen gehen verloren. Dialog wirklich schließen?",
+ "Eingaben verwerfen", JOptionPane.YES_NO_OPTION,
+ JOptionPane.WARNING_MESSAGE);
+ if (antwort != JOptionPane.YES_OPTION) {
+ return;
+ }
+ }
+ dispose();
}
private void aktualisiereDatumsfeld() {
diff --git a/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java b/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java
index 945ad00..da96ace 100644
--- a/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java
+++ b/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java
@@ -1,5 +1,6 @@
package de.team1.faktura.gui;
+import de.team1.faktura.dokumente.Belegtyp;
import de.team1.faktura.dokumente.Dokument;
import de.team1.faktura.dokumente.DokumentCsvExport;
import de.team1.faktura.dokumente.DokumentService;
@@ -9,6 +10,7 @@ import de.team1.faktura.gemeinsam.EreignisBus;
import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.ProduktService;
+import javax.swing.BorderFactory;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JButton;
import javax.swing.JComboBox;
@@ -27,6 +29,7 @@ import java.awt.Component;
import java.awt.Desktop;
import java.awt.FlowLayout;
import java.awt.Window;
+import java.io.File;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@@ -61,9 +64,10 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
private final JButton folgebelegKnopf = new JButton("Folgebeleg erzeugen");
private final JButton versendenKnopf = new JButton("Versenden");
private final JButton stornierenKnopf = new JButton("Stornieren");
- private final JButton pdfKnopf = new JButton("PDF exportieren");
+ private final JButton pdfKnopf = new JButton("PDF exportieren…");
private final JButton druckenKnopf = new JButton("Drucken");
private final JButton mailKnopf = new JButton("Per E-Mail senden");
+ private final JLabel trefferAnzeige = UiHilfen.trefferLabel();
public DokumentListenPanel(DokumentService dokumentService,
KundenService kundenService,
@@ -84,8 +88,9 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
+ setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
- JPanel kopf = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ JPanel kopf = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
kopf.add(new JLabel("Statusfilter:"));
kopf.add(statusFilter);
statusFilter.setRenderer(new DefaultListCellRenderer() {
@@ -99,10 +104,16 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
statusFilter.addActionListener(e -> aktualisiere());
JButton neueRechnung = new JButton("Neue Rechnung (Assistent)…");
+ neueRechnung.setMnemonic('R');
+ neueRechnung.setToolTipText("Geführte Rechnungserstellung in fünf Schritten (Alt+R)");
neueRechnung.addActionListener(e -> oeffneWizard());
JButton neuerBeleg = new JButton("Neuer Beleg…");
+ neuerBeleg.setMnemonic('B');
+ neuerBeleg.setToolTipText("Angebot, Auftragsbestätigung oder Lieferschein erstellen (Alt+B)");
neuerBeleg.addActionListener(e -> oeffneBelegDialog());
- JButton datenExportKnopf = new JButton("Daten exportieren (CSV)…");
+ JButton datenExportKnopf = new JButton("CSV exportieren…");
+ datenExportKnopf.setMnemonic('C');
+ datenExportKnopf.setToolTipText("Alle Belegdaten als CSV-Datei exportieren");
datenExportKnopf.addActionListener(e -> exportiereDaten());
kopf.add(neueRechnung);
kopf.add(neuerBeleg);
@@ -114,9 +125,18 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
TabellenFormat.konfiguriere(tabelle);
tabelle.getColumnModel().getColumn(4).setCellRenderer(TabellenFormat.waehrungsRenderer());
tabelle.getColumnModel().getColumn(5).setCellRenderer(TabellenFormat.statusRenderer());
- add(new JScrollPane(tabelle), BorderLayout.CENTER);
+ JPanel listenSeite = new JPanel(new BorderLayout(0, 4));
+ listenSeite.add(new JScrollPane(tabelle), BorderLayout.CENTER);
+ listenSeite.add(trefferAnzeige, BorderLayout.SOUTH);
+ add(listenSeite, BorderLayout.CENTER);
JPanel aktionen = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ folgebelegKnopf.setMnemonic('F');
+ versendenKnopf.setMnemonic('V');
+ stornierenKnopf.setMnemonic('O');
+ pdfKnopf.setMnemonic('P');
+ druckenKnopf.setMnemonic('D');
+ mailKnopf.setMnemonic('E');
folgebelegKnopf.addActionListener(e -> erzeugeFolgebeleg());
versendenKnopf.addActionListener(e -> versende());
stornierenKnopf.addActionListener(e -> storniere());
@@ -136,7 +156,7 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
private Dokument auswahl() {
int zeile = tabelle.getSelectedRow();
return zeile < 0 ? null
- : tabellenModel.dokumente.get(tabelle.convertRowIndexToModel(zeile));
+ : tabellenModel.gibZeile(tabelle.convertRowIndexToModel(zeile));
}
/** Aktiviert/deaktiviert die Belegaktionen gemäß Status (F-08, F-14). */
@@ -145,17 +165,22 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
if (dokument == null) {
for (JButton knopf : List.of(folgebelegKnopf, versendenKnopf, stornierenKnopf,
pdfKnopf, druckenKnopf, mailKnopf)) {
- knopf.setEnabled(false);
+ UiHilfen.schalteAktion(knopf, false, "Bitte zuerst einen Beleg in der Liste auswählen.");
}
return;
}
BelegAktionen verfuegbar = controller.aktionenFuer(dokument);
- folgebelegKnopf.setEnabled(dokument.belegtyp() != de.team1.faktura.dokumente.Belegtyp.RECHNUNG);
- versendenKnopf.setEnabled(verfuegbar.aenderbar());
- stornierenKnopf.setEnabled(verfuegbar.stornierbar());
- pdfKnopf.setEnabled(verfuegbar.pdfExport());
- druckenKnopf.setEnabled(verfuegbar.pdfExport());
- mailKnopf.setEnabled(verfuegbar.pdfExport());
+ DokumentStatus status = dokument.getStatus();
+ UiHilfen.schalteAktion(folgebelegKnopf, dokument.belegtyp() != Belegtyp.RECHNUNG,
+ "Für Rechnungen wird kein Folgebeleg erzeugt.");
+ UiHilfen.schalteAktion(versendenKnopf, verfuegbar.aenderbar(),
+ "Im Status " + status + " sind keine inhaltlichen Änderungen mehr möglich (GR-02).");
+ UiHilfen.schalteAktion(stornierenKnopf, verfuegbar.stornierbar(),
+ "Stornieren ist nur für Rechnungen im Status OFFEN möglich (aktuell: " + status + ").");
+ String keinPdf = "Für diesen Beleg ist kein PDF-Export möglich.";
+ UiHilfen.schalteAktion(pdfKnopf, verfuegbar.pdfExport(), keinPdf);
+ UiHilfen.schalteAktion(druckenKnopf, verfuegbar.pdfExport(), keinPdf);
+ UiHilfen.schalteAktion(mailKnopf, verfuegbar.pdfExport(), keinPdf);
}
private void oeffneWizard() {
@@ -226,23 +251,27 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
return;
}
JFileChooser auswahlDialog = new JFileChooser();
- auswahlDialog.setSelectedFile(new java.io.File(dokument.getBelegnummer() + ".pdf"));
+ auswahlDialog.setSelectedFile(new File(dokument.getBelegnummer() + ".pdf"));
if (auswahlDialog.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
Path ziel = auswahlDialog.getSelectedFile().toPath();
- dokumentService.exportierePdf(dokument.getBelegnummer(), ziel);
- MeldungsAnzeige.zeige(this, Meldung.erfolg("Das PDF wurde exportiert nach " + ziel), null);
+ MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
+ dokumentService.exportierePdf(dokument.getBelegnummer(), ziel);
+ MeldungsAnzeige.zeige(this, Meldung.erfolg("Das PDF wurde exportiert nach " + ziel), null);
+ });
}
}
/** Vollständiger Datenexport aller Belege als CSV (Q-08, IF-04). */
private void exportiereDaten() {
JFileChooser auswahlDialog = new JFileChooser();
- auswahlDialog.setSelectedFile(new java.io.File("dokumente.csv"));
+ auswahlDialog.setSelectedFile(new File("dokumente.csv"));
if (auswahlDialog.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
Path ziel = auswahlDialog.getSelectedFile().toPath();
- datenExport.exportiereCsv(ziel);
- MeldungsAnzeige.zeige(this, Meldung.erfolg(
- "Die Belegdaten wurden exportiert nach " + ziel), null);
+ MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
+ datenExport.exportiereCsv(ziel);
+ MeldungsAnzeige.zeige(this, Meldung.erfolg(
+ "Die Belegdaten wurden exportiert nach " + ziel), null);
+ });
}
}
@@ -289,7 +318,17 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
@Override
public void aktualisiere() {
- tabellenModel.setze(controller.gefiltert((DokumentStatus) statusFilter.getSelectedItem()));
+ DokumentStatus status = (DokumentStatus) statusFilter.getSelectedItem();
+ List liste = controller.gefiltert(status);
+ tabellenModel.setze(liste);
+ int gesamt = status == null ? liste.size() : controller.gefiltert(null).size();
+ if (gesamt == 0) {
+ trefferAnzeige.setText("Noch keine Belege vorhanden");
+ } else if (liste.isEmpty()) {
+ trefferAnzeige.setText("Keine Belege im Status " + status);
+ } else {
+ trefferAnzeige.setText(liste.size() + " von " + gesamt + " Belegen");
+ }
aktualisiereAktionen();
}
@@ -313,6 +352,10 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
fireTableDataChanged();
}
+ Dokument gibZeile(int zeile) {
+ return dokumente.get(zeile);
+ }
+
@Override
public int getRowCount() {
return dokumente.size();
diff --git a/src/main/java/de/team1/faktura/gui/HauptFenster.java b/src/main/java/de/team1/faktura/gui/HauptFenster.java
index 95f002e..b55a24c 100644
--- a/src/main/java/de/team1/faktura/gui/HauptFenster.java
+++ b/src/main/java/de/team1/faktura/gui/HauptFenster.java
@@ -1,25 +1,35 @@
package de.team1.faktura.gui;
-import javax.swing.JButton;
+import javax.swing.AbstractAction;
+import javax.swing.ButtonGroup;
+import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
+import javax.swing.JToggleButton;
import javax.swing.JToolBar;
+import javax.swing.KeyStroke;
import java.awt.BorderLayout;
import java.awt.CardLayout;
+import java.awt.Insets;
+import java.awt.event.ActionEvent;
+import java.awt.event.InputEvent;
+import java.awt.event.KeyEvent;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Hauptfenster mit Navigation zu den drei Modulen Kundenverwaltung,
- * Produktverwaltung und Dokumente (D-F-01). Beim Modulwechsel wird bei
- * ungespeicherten Formulareingaben nachgefragt (D-F-02).
+ * Produktverwaltung und Dokumente (D-F-01). Das aktive Modul ist in der
+ * Navigationsleiste sichtbar markiert; Alt+1/2/3 wechseln direkt. Beim
+ * Modulwechsel wird bei ungespeicherten Formulareingaben nachgefragt (D-F-02).
*/
public class HauptFenster extends JFrame {
private final CardLayout karten = new CardLayout();
private final JPanel kartenPanel = new JPanel(karten);
private final Map module = new LinkedHashMap<>();
+ private final Map navigationsKnoepfe = new LinkedHashMap<>();
private String aktuellesModul;
@@ -35,10 +45,18 @@ public class HauptFenster extends JFrame {
JToolBar navigation = new JToolBar();
navigation.setFloatable(false);
+ ButtonGroup gruppe = new ButtonGroup();
+ int modulNummer = 1;
for (Map.Entry eintrag : module.entrySet()) {
- JButton knopf = new JButton(eintrag.getKey());
+ JToggleButton knopf = new JToggleButton(eintrag.getKey());
+ knopf.setMargin(new Insets(6, 14, 6, 14));
+ knopf.setToolTipText(eintrag.getKey() + " anzeigen (Alt+" + modulNummer + ")");
knopf.addActionListener(e -> wechsleZu(eintrag.getKey()));
+ gruppe.add(knopf);
navigation.add(knopf);
+ navigationsKnoepfe.put(eintrag.getKey(), knopf);
+ bindeModulTaste(eintrag.getKey(), modulNummer);
+ modulNummer++;
}
add(navigation, BorderLayout.NORTH);
@@ -48,15 +66,29 @@ public class HauptFenster extends JFrame {
add(kartenPanel, BorderLayout.CENTER);
aktuellesModul = "Kunden";
+ navigationsKnoepfe.get(aktuellesModul).setSelected(true);
karten.show(kartenPanel, aktuellesModul);
setSize(1100, 650);
setLocationRelativeTo(null);
}
+ /** Alt+1/2/3 wechselt direkt zum jeweiligen Modul. */
+ private void bindeModulTaste(String modulName, int nummer) {
+ KeyStroke taste = KeyStroke.getKeyStroke(KeyEvent.VK_0 + nummer, InputEvent.ALT_DOWN_MASK);
+ getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(taste, "modul" + nummer);
+ getRootPane().getActionMap().put("modul" + nummer, new AbstractAction() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ wechsleZu(modulName);
+ }
+ });
+ }
+
/** Modulwechsel mit Nachfrage bei ungespeicherten Eingaben (D-F-02). */
private void wechsleZu(String modulName) {
if (modulName.equals(aktuellesModul)) {
+ navigationsKnoepfe.get(aktuellesModul).setSelected(true);
return;
}
ModulPanel aktuell = module.get(aktuellesModul);
@@ -66,10 +98,13 @@ public class HauptFenster extends JFrame {
"Ungespeicherte Eingaben", JOptionPane.YES_NO_OPTION,
JOptionPane.WARNING_MESSAGE);
if (antwort != JOptionPane.YES_OPTION) {
+ // Wechsel abgebrochen: Markierung zurück auf das aktuelle Modul
+ navigationsKnoepfe.get(aktuellesModul).setSelected(true);
return;
}
}
aktuellesModul = modulName;
+ navigationsKnoepfe.get(modulName).setSelected(true);
module.get(modulName).aktualisiere();
karten.show(kartenPanel, modulName);
}
diff --git a/src/main/java/de/team1/faktura/gui/KundenFormularDialog.java b/src/main/java/de/team1/faktura/gui/KundenFormularDialog.java
new file mode 100644
index 0000000..0b83865
--- /dev/null
+++ b/src/main/java/de/team1/faktura/gui/KundenFormularDialog.java
@@ -0,0 +1,201 @@
+package de.team1.faktura.gui;
+
+import de.team1.faktura.kunden.Kunde;
+import de.team1.faktura.kunden.KundenVerwaltungsService;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+import javax.swing.event.DocumentEvent;
+import javax.swing.event.DocumentListener;
+import java.awt.BorderLayout;
+import java.awt.FlowLayout;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.awt.Window;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Modale Formular-Maske zum Anlegen und Bearbeiten eines Kunden (D-F-04,
+ * F-05): alle Pflicht- und optionalen Felder mit Kennzeichnung, Validierungs-
+ * rückmeldung mit Feldmarkierung und Schutz vor unbeabsichtigtem Verwerfen.
+ */
+public class KundenFormularDialog extends JDialog {
+
+ private final KundenVerwaltungsService service;
+ /** Kundennummer des bearbeiteten Kunden; {@code null} = Neuanlage. */
+ private final String vorhandeneNummer;
+
+ private final JTextField nameFeld = new JTextField(20);
+ private final JTextField strasseFeld = new JTextField(20);
+ private final JTextField plzFeld = new JTextField(8);
+ private final JTextField ortFeld = new JTextField(20);
+ private final JTextField eMailFeld = new JTextField(20);
+ private final JTextField telefonFeld = new JTextField(20);
+ private final JTextField ustIdNrFeld = new JTextField(20);
+ private final Map felder = new LinkedHashMap<>();
+
+ private boolean ungespeichert;
+
+ public KundenFormularDialog(Window besitzer, KundenVerwaltungsService service,
+ Kunde vorhandener) {
+ super(besitzer, vorhandener == null ? "Neuen Kunden anlegen"
+ : "Kunde " + vorhandener.getKundennummer() + " bearbeiten",
+ ModalityType.APPLICATION_MODAL);
+ this.service = service;
+ this.vorhandeneNummer = vorhandener == null ? null : vorhandener.getKundennummer();
+ felder.put("Name", nameFeld);
+ felder.put("Straße", strasseFeld);
+ felder.put("PLZ", plzFeld);
+ felder.put("Ort", ortFeld);
+ felder.put("E-Mail", eMailFeld);
+ baueOberflaeche();
+ if (vorhandener != null) {
+ fuelleFelder(vorhandener);
+ }
+ beobachteAenderungen();
+ pack();
+ setLocationRelativeTo(besitzer);
+ }
+
+ private void baueOberflaeche() {
+ setLayout(new BorderLayout(8, 8));
+ ((JComponent) getContentPane()).setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+ JPanel formular = new JPanel(new GridBagLayout());
+ GridBagConstraints c = new GridBagConstraints();
+ c.insets = new Insets(4, 4, 4, 4);
+ c.anchor = GridBagConstraints.WEST;
+ c.fill = GridBagConstraints.HORIZONTAL;
+
+ plzFeld.setToolTipText("Pflichtfeld — Postleitzahl, z. B. 68163");
+ eMailFeld.setToolTipText("Optional — Format: name@domain.de");
+
+ int zeile = 0;
+ zeile = formularZeile(formular, c, zeile, "Name: *", nameFeld);
+ zeile = formularZeile(formular, c, zeile, "Straße: *", strasseFeld);
+ zeile = formularZeile(formular, c, zeile, "PLZ: *", plzFeld);
+ zeile = formularZeile(formular, c, zeile, "Ort: *", ortFeld);
+ zeile = formularZeile(formular, c, zeile, "E-Mail:", eMailFeld);
+ zeile = formularZeile(formular, c, zeile, "Telefon:", telefonFeld);
+ zeile = formularZeile(formular, c, zeile, "USt-IdNr.:", ustIdNrFeld);
+
+ c.gridx = 0;
+ c.gridy = zeile;
+ c.gridwidth = 2;
+ formular.add(UiHilfen.pflichtfeldLegende(), c);
+ add(formular, BorderLayout.CENTER);
+
+ JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.RIGHT));
+ JButton abbrechen = new JButton("Abbrechen");
+ abbrechen.setMnemonic('A');
+ abbrechen.addActionListener(e -> abbrechenMitNachfrage());
+ JButton speichern = new JButton("Speichern");
+ speichern.setMnemonic('S');
+ speichern.addActionListener(e -> speichere());
+ knoepfe.add(abbrechen);
+ knoepfe.add(speichern);
+ add(knoepfe, BorderLayout.SOUTH);
+
+ getRootPane().setDefaultButton(speichern);
+ UiHilfen.escSchliesst(this, this::abbrechenMitNachfrage);
+ UiHilfen.fensterSchliessenAbfangen(this, this::abbrechenMitNachfrage);
+ }
+
+ private int formularZeile(JPanel formular, GridBagConstraints c, int zeile,
+ String beschriftung, JComponent feld) {
+ c.gridx = 0;
+ c.gridy = zeile;
+ c.gridwidth = 1;
+ c.weightx = 0;
+ formular.add(new JLabel(beschriftung), c);
+ c.gridx = 1;
+ c.weightx = 1;
+ formular.add(feld, c);
+ return zeile + 1;
+ }
+
+ private void fuelleFelder(Kunde kunde) {
+ nameFeld.setText(kunde.getName());
+ strasseFeld.setText(kunde.getStrasse());
+ plzFeld.setText(kunde.getPlz());
+ ortFeld.setText(kunde.getOrt());
+ eMailFeld.setText(kunde.getEMail() == null ? "" : kunde.getEMail());
+ telefonFeld.setText(kunde.getTelefon() == null ? "" : kunde.getTelefon());
+ ustIdNrFeld.setText(kunde.getUstIdNr() == null ? "" : kunde.getUstIdNr());
+ }
+
+ /** Erst nach dem Vorbefüllen anmelden, damit nur Nutzereingaben zählen. */
+ private void beobachteAenderungen() {
+ DocumentListener listener = new DocumentListener() {
+ @Override
+ public void insertUpdate(DocumentEvent e) {
+ ungespeichert = true;
+ }
+
+ @Override
+ public void removeUpdate(DocumentEvent e) {
+ ungespeichert = true;
+ }
+
+ @Override
+ public void changedUpdate(DocumentEvent e) {
+ ungespeichert = true;
+ }
+ };
+ for (JTextField feld : List.of(nameFeld, strasseFeld, plzFeld, ortFeld,
+ eMailFeld, telefonFeld, ustIdNrFeld)) {
+ feld.getDocument().addDocumentListener(listener);
+ }
+ }
+
+ private void speichere() {
+ MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
+ Kunde kunde = vorhandeneNummer == null
+ ? new Kunde()
+ : service.findeKunde(vorhandeneNummer);
+ kunde.setName(nameFeld.getText().trim());
+ kunde.setStrasse(strasseFeld.getText().trim());
+ kunde.setPlz(plzFeld.getText().trim());
+ kunde.setOrt(ortFeld.getText().trim());
+ kunde.setEMail(leerZuNull(eMailFeld.getText()));
+ kunde.setTelefon(leerZuNull(telefonFeld.getText()));
+ kunde.setUstIdNr(leerZuNull(ustIdNrFeld.getText()));
+
+ Kunde gespeichert = vorhandeneNummer == null
+ ? service.legeAn(kunde)
+ : service.aendere(kunde);
+ ungespeichert = false;
+ MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gespeichert. Kundennummer: "
+ + gespeichert.getKundennummer()), felder);
+ dispose();
+ });
+ }
+
+ /** Schutz vor Datenverlust: Nachfrage, wenn bereits Eingaben geändert wurden. */
+ private void abbrechenMitNachfrage() {
+ if (ungespeichert) {
+ int antwort = JOptionPane.showConfirmDialog(this,
+ "Die Eingaben gehen verloren. Maske wirklich schließen?",
+ "Eingaben verwerfen", JOptionPane.YES_NO_OPTION,
+ JOptionPane.WARNING_MESSAGE);
+ if (antwort != JOptionPane.YES_OPTION) {
+ return;
+ }
+ }
+ dispose();
+ }
+
+ private static String leerZuNull(String text) {
+ String wert = text.trim();
+ return wert.isEmpty() ? null : wert;
+ }
+}
diff --git a/src/main/java/de/team1/faktura/gui/KundenPanel.java b/src/main/java/de/team1/faktura/gui/KundenPanel.java
index cd470a3..f49829e 100644
--- a/src/main/java/de/team1/faktura/gui/KundenPanel.java
+++ b/src/main/java/de/team1/faktura/gui/KundenPanel.java
@@ -6,65 +6,59 @@ import de.team1.faktura.kunden.Kunde;
import de.team1.faktura.kunden.KundenCsvExport;
import de.team1.faktura.kunden.KundenVerwaltungsService;
+import javax.swing.BorderFactory;
import javax.swing.JButton;
-import javax.swing.JComponent;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
-import javax.swing.JSplitPane;
import javax.swing.JTable;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
+import javax.swing.SwingUtilities;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
-import java.awt.GridBagConstraints;
-import java.awt.GridBagLayout;
-import java.awt.Insets;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.io.File;
import java.util.ArrayList;
-import java.util.LinkedHashMap;
import java.util.List;
-import java.util.Map;
/**
- * Modulansicht Kundenverwaltung (D-F-03 bis F-05): sortierte Liste mit
- * Suchfeld, Formular mit gekennzeichneten Pflichtfeldern, CSV-Export.
+ * Modulansicht Kundenverwaltung (D-F-03 bis F-05): sortierte Liste in voller
+ * Breite mit Suchfeld; Anlegen und Bearbeiten erfolgen über die modale
+ * Formular-Maske {@link KundenFormularDialog} (Aufruf per Knopf oder
+ * Doppelklick), die Aktionen liegen unter der Tabelle.
*/
public class KundenPanel extends JPanel implements ModulPanel {
private final KundenVerwaltungsService service;
+ private final StammdatenController controller;
private final KundenCsvExport csvExport;
private final JTextField suchfeld = new JTextField(20);
private final KundenTabellenModel tabellenModel = new KundenTabellenModel();
private final JTable tabelle = new JTable(tabellenModel);
+ private final JLabel trefferAnzeige = UiHilfen.trefferLabel();
- private final JTextField nameFeld = new JTextField(20);
- private final JTextField strasseFeld = new JTextField(20);
- private final JTextField plzFeld = new JTextField(8);
- private final JTextField ortFeld = new JTextField(20);
- private final JTextField eMailFeld = new JTextField(20);
- private final JTextField telefonFeld = new JTextField(20);
- private final JTextField ustIdNrFeld = new JTextField(20);
- private final JLabel nummerAnzeige = new JLabel("— neuer Kunde —");
- private final Map felder = new LinkedHashMap<>();
+ private final JButton bearbeitenKnopf = new JButton("Bearbeiten…");
+ private final JButton loeschenKnopf = new JButton("Löschen");
- private String gewaehlteNummer;
- private boolean ungespeichert;
-
- public KundenPanel(KundenVerwaltungsService service, KundenCsvExport csvExport,
- EreignisBus ereignisBus) {
+ /**
+ * @param service Fachkomponente der Gruppe C für Anlegen/Ändern/Löschen
+ * @param controller GUI-freier Controller für Suche und Listeninhalt (D-F-03)
+ * @param csvExport CSV-Export der Kundenstammdaten (C-F-13)
+ * @param ereignisBus Observer-Verteiler für Aktualisierungen nach Datenänderungen
+ */
+ public KundenPanel(KundenVerwaltungsService service, StammdatenController controller,
+ KundenCsvExport csvExport, EreignisBus ereignisBus) {
this.service = service;
+ this.controller = controller;
this.csvExport = csvExport;
- felder.put("Name", nameFeld);
- felder.put("Straße", strasseFeld);
- felder.put("PLZ", plzFeld);
- felder.put("Ort", ortFeld);
- felder.put("E-Mail", eMailFeld);
baueOberflaeche();
aktualisiere();
ereignisBus.abonniere(DatenBereich.KUNDEN, this::aktualisiere);
@@ -72,180 +66,124 @@ public class KundenPanel extends JPanel implements ModulPanel {
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
+ setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
- JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT));
- suchleiste.add(new JLabel("Suche (Name oder Kundennummer):"));
+ JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
+ suchleiste.add(new JLabel("Suche:"));
+ UiHilfen.platzhalter(suchfeld, "Name oder Kundennummer…");
+ suchfeld.setToolTipText("Suche nach Name oder Kundennummer (Strg+F)");
suchleiste.add(suchfeld);
suchfeld.getDocument().addDocumentListener(neuerSuchListener());
add(suchleiste, BorderLayout.NORTH);
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
TabellenFormat.konfiguriere(tabelle);
- tabelle.getSelectionModel().addListSelectionListener(e -> {
- if (!e.getValueIsAdjusting()) {
- ladeAuswahl();
+ tabelle.getSelectionModel().addListSelectionListener(e -> aktualisiereAktionen());
+ tabelle.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ if (e.getClickCount() == 2) {
+ bearbeite();
+ }
}
});
- JSplitPane teiler = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
- new JScrollPane(tabelle), baueFormular());
- teiler.setResizeWeight(0.55);
- add(teiler, BorderLayout.CENTER);
+ JPanel listenSeite = new JPanel(new BorderLayout(0, 4));
+ listenSeite.add(new JScrollPane(tabelle), BorderLayout.CENTER);
+ listenSeite.add(trefferAnzeige, BorderLayout.SOUTH);
+ add(listenSeite, BorderLayout.CENTER);
+
+ JPanel aktionen = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ JButton neuKnopf = new JButton("Neuen Kunden anlegen…");
+ neuKnopf.setMnemonic('N');
+ neuKnopf.setToolTipText("Öffnet die Maske für einen neuen Kunden (Alt+N)");
+ neuKnopf.addActionListener(e -> legeNeuAn());
+ bearbeitenKnopf.setMnemonic('B');
+ bearbeitenKnopf.addActionListener(e -> bearbeite());
+ loeschenKnopf.setMnemonic('L');
+ loeschenKnopf.addActionListener(e -> loesche());
+ JButton exportKnopf = new JButton("CSV exportieren…");
+ exportKnopf.setMnemonic('C');
+ exportKnopf.addActionListener(e -> exportiere());
+ aktionen.add(neuKnopf);
+ aktionen.add(bearbeitenKnopf);
+ aktionen.add(loeschenKnopf);
+ aktionen.add(exportKnopf);
+ add(aktionen, BorderLayout.SOUTH);
+
+ UiHilfen.strgF(this, suchfeld);
+ aktualisiereAktionen();
}
- private JPanel baueFormular() {
- JPanel formular = new JPanel(new GridBagLayout());
- GridBagConstraints c = new GridBagConstraints();
- c.insets = new Insets(3, 3, 3, 3);
- c.anchor = GridBagConstraints.WEST;
- c.fill = GridBagConstraints.HORIZONTAL;
+ private Kunde auswahl() {
+ int zeile = tabelle.getSelectedRow();
+ return zeile < 0 ? null
+ : tabellenModel.gibZeile(tabelle.convertRowIndexToModel(zeile));
+ }
- int zeile = 0;
- zeile = formularZeile(formular, c, zeile, "Kundennummer:", nummerAnzeige);
- zeile = formularZeile(formular, c, zeile, "Name: *", nameFeld);
- zeile = formularZeile(formular, c, zeile, "Straße: *", strasseFeld);
- zeile = formularZeile(formular, c, zeile, "PLZ: *", plzFeld);
- zeile = formularZeile(formular, c, zeile, "Ort: *", ortFeld);
- zeile = formularZeile(formular, c, zeile, "E-Mail:", eMailFeld);
- zeile = formularZeile(formular, c, zeile, "Telefon:", telefonFeld);
- zeile = formularZeile(formular, c, zeile, "USt-IdNr.:", ustIdNrFeld);
+ private void aktualisiereAktionen() {
+ boolean ausgewaehlt = auswahl() != null;
+ String hinweis = "Bitte zuerst einen Kunden in der Liste auswählen.";
+ UiHilfen.schalteAktion(bearbeitenKnopf, ausgewaehlt, hinweis);
+ UiHilfen.schalteAktion(loeschenKnopf, ausgewaehlt, hinweis);
+ }
- DocumentListener aenderungsListener = neuerAenderungsListener();
- for (JComponent feld : List.of(nameFeld, strasseFeld, plzFeld, ortFeld,
- eMailFeld, telefonFeld, ustIdNrFeld)) {
- ((JTextField) feld).getDocument().addDocumentListener(aenderungsListener);
+ private void legeNeuAn() {
+ new KundenFormularDialog(SwingUtilities.getWindowAncestor(this), service, null)
+ .setVisible(true);
+ }
+
+ private void bearbeite() {
+ Kunde kunde = auswahl();
+ if (kunde == null) {
+ return;
}
-
- JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.LEFT));
- JButton neu = new JButton("Neu");
- neu.addActionListener(e -> leereFormular());
- JButton speichern = new JButton("Speichern");
- speichern.addActionListener(e -> speichere());
- JButton loeschen = new JButton("Löschen");
- loeschen.addActionListener(e -> loesche());
- JButton export = new JButton("CSV-Export");
- export.addActionListener(e -> exportiere());
- knoepfe.add(neu);
- knoepfe.add(speichern);
- knoepfe.add(loeschen);
- knoepfe.add(export);
-
- c.gridx = 0;
- c.gridy = zeile;
- c.gridwidth = 2;
- formular.add(knoepfe, c);
- return formular;
- }
-
- private int formularZeile(JPanel formular, GridBagConstraints c, int zeile,
- String beschriftung, JComponent feld) {
- c.gridx = 0;
- c.gridy = zeile;
- c.gridwidth = 1;
- c.weightx = 0;
- formular.add(new JLabel(beschriftung), c);
- c.gridx = 1;
- c.weightx = 1;
- formular.add(feld, c);
- return zeile + 1;
- }
-
- private void speichere() {
- MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
- Kunde kunde = gewaehlteNummer == null
- ? new Kunde()
- : service.findeKunde(gewaehlteNummer);
- kunde.setName(nameFeld.getText().trim());
- kunde.setStrasse(strasseFeld.getText().trim());
- kunde.setPlz(plzFeld.getText().trim());
- kunde.setOrt(ortFeld.getText().trim());
- kunde.setEMail(leerZuNull(eMailFeld.getText()));
- kunde.setTelefon(leerZuNull(telefonFeld.getText()));
- kunde.setUstIdNr(leerZuNull(ustIdNrFeld.getText()));
-
- Kunde gespeichert = gewaehlteNummer == null
- ? service.legeAn(kunde)
- : service.aendere(kunde);
- ungespeichert = false;
- gewaehlteNummer = gespeichert.getKundennummer();
- nummerAnzeige.setText(gespeichert.getKundennummer());
- MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gespeichert. Kundennummer: "
- + gespeichert.getKundennummer()), felder);
- });
+ new KundenFormularDialog(SwingUtilities.getWindowAncestor(this), service, kunde)
+ .setVisible(true);
}
private void loesche() {
- if (gewaehlteNummer == null) {
+ Kunde kunde = auswahl();
+ if (kunde == null) {
return;
}
int antwort = JOptionPane.showConfirmDialog(this,
- "Kunde " + gewaehlteNummer + " wirklich dauerhaft löschen?",
+ "Kunde " + kunde.getKundennummer() + " wirklich dauerhaft löschen?",
"Kunde löschen", JOptionPane.YES_NO_OPTION);
if (antwort != JOptionPane.YES_OPTION) {
return;
}
- MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
- service.loescheKunde(gewaehlteNummer);
- leereFormular();
- MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gelöscht."), felder);
+ MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
+ service.loescheKunde(kunde.getKundennummer());
+ MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gelöscht."), null);
});
}
private void exportiere() {
- JFileChooser auswahl = new JFileChooser();
- auswahl.setSelectedFile(new java.io.File("kunden.csv"));
- if (auswahl.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
- csvExport.exportiereCsv(auswahl.getSelectedFile().toPath());
- MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Kundenstammdaten wurden exportiert nach "
- + auswahl.getSelectedFile()), felder);
+ JFileChooser auswahlDialog = new JFileChooser();
+ auswahlDialog.setSelectedFile(new File("kunden.csv"));
+ if (auswahlDialog.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
+ MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
+ csvExport.exportiereCsv(auswahlDialog.getSelectedFile().toPath());
+ MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Kundenstammdaten wurden exportiert nach "
+ + auswahlDialog.getSelectedFile()), null);
+ });
}
}
- private void ladeAuswahl() {
- int zeile = tabelle.getSelectedRow();
- if (zeile < 0) {
- return;
- }
- Kunde kunde = tabellenModel.kunden.get(tabelle.convertRowIndexToModel(zeile));
- gewaehlteNummer = kunde.getKundennummer();
- nummerAnzeige.setText(kunde.getKundennummer());
- nameFeld.setText(kunde.getName());
- strasseFeld.setText(kunde.getStrasse());
- plzFeld.setText(kunde.getPlz());
- ortFeld.setText(kunde.getOrt());
- eMailFeld.setText(kunde.getEMail() == null ? "" : kunde.getEMail());
- telefonFeld.setText(kunde.getTelefon() == null ? "" : kunde.getTelefon());
- ustIdNrFeld.setText(kunde.getUstIdNr() == null ? "" : kunde.getUstIdNr());
- ungespeichert = false;
- }
-
- private void leereFormular() {
- gewaehlteNummer = null;
- nummerAnzeige.setText("— neuer Kunde —");
- for (JComponent feld : List.of(nameFeld, strasseFeld, plzFeld, ortFeld,
- eMailFeld, telefonFeld, ustIdNrFeld)) {
- ((JTextField) feld).setText("");
- }
- tabelle.clearSelection();
- ungespeichert = false;
- }
-
- private static String leerZuNull(String text) {
- String wert = text.trim();
- return wert.isEmpty() ? null : wert;
- }
-
@Override
public boolean hatUngespeicherteAenderungen() {
- return ungespeichert;
+ // Eingaben erfolgen ausschließlich in der modalen Formular-Maske;
+ // dort schützt die eigene Nachfrage vor Datenverlust (D-F-02).
+ return false;
}
@Override
public void aktualisiere() {
- String begriff = suchfeld.getText().trim();
- tabellenModel.setze(begriff.isEmpty()
- ? service.alleSortiertNachName()
- : service.suche(begriff));
+ List liste = controller.kundenListe(suchfeld.getText());
+ tabellenModel.setze(liste);
+ trefferAnzeige.setText(UiHilfen.trefferText(liste.size(),
+ controller.kundenListe("").size(), "Kunden", suchfeld.getText()));
}
private DocumentListener neuerSuchListener() {
@@ -267,25 +205,6 @@ public class KundenPanel extends JPanel implements ModulPanel {
};
}
- private DocumentListener neuerAenderungsListener() {
- return new DocumentListener() {
- @Override
- public void insertUpdate(DocumentEvent e) {
- ungespeichert = true;
- }
-
- @Override
- public void removeUpdate(DocumentEvent e) {
- ungespeichert = true;
- }
-
- @Override
- public void changedUpdate(DocumentEvent e) {
- ungespeichert = true;
- }
- };
- }
-
private static final class KundenTabellenModel extends AbstractTableModel {
private static final String[] SPALTEN = {"Kundennummer", "Name", "Straße", "PLZ", "Ort"};
@@ -297,6 +216,10 @@ public class KundenPanel extends JPanel implements ModulPanel {
fireTableDataChanged();
}
+ Kunde gibZeile(int zeile) {
+ return kunden.get(zeile);
+ }
+
@Override
public int getRowCount() {
return kunden.size();
diff --git a/src/main/java/de/team1/faktura/gui/Meldung.java b/src/main/java/de/team1/faktura/gui/Meldung.java
index cc1906f..2c2e0d7 100644
--- a/src/main/java/de/team1/faktura/gui/Meldung.java
+++ b/src/main/java/de/team1/faktura/gui/Meldung.java
@@ -9,10 +9,12 @@ package de.team1.faktura.gui;
*/
public record Meldung(MeldungsTyp typ, String feldname, String text) {
+ /** Erfolgsmeldung ohne Feldbezug (D-F-17). */
public static Meldung erfolg(String text) {
return new Meldung(MeldungsTyp.ERFOLG, null, text);
}
+ /** Fehlermeldung; {@code feldname} benennt das betroffene Eingabefeld (Q-09). */
public static Meldung fehler(String feldname, String text) {
return new Meldung(MeldungsTyp.FEHLER, feldname, text);
}
diff --git a/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java b/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java
index 3131c0b..0fe6539 100644
--- a/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java
+++ b/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java
@@ -70,6 +70,9 @@ public final class MeldungsAnzeige {
} catch (UncheckedIOException e) {
zeige(parent, Meldung.fehler(null,
"Die Daten konnten nicht gespeichert werden: " + e.getMessage()), felder);
+ } catch (RuntimeException e) {
+ // Letztes Netz: kein stilles Scheitern auf dem Event-Dispatch-Thread
+ zeige(parent, Meldung.fehler(null, "Unerwarteter Fehler: " + e), felder);
}
}
}
diff --git a/src/main/java/de/team1/faktura/gui/ProduktFormularDialog.java b/src/main/java/de/team1/faktura/gui/ProduktFormularDialog.java
new file mode 100644
index 0000000..802df6d
--- /dev/null
+++ b/src/main/java/de/team1/faktura/gui/ProduktFormularDialog.java
@@ -0,0 +1,223 @@
+package de.team1.faktura.gui;
+
+import de.team1.faktura.gemeinsam.ValidierungsException;
+import de.team1.faktura.produkte.Produkt;
+import de.team1.faktura.produkte.ProduktVerwaltungsService;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JComboBox;
+import javax.swing.JComponent;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+import javax.swing.event.DocumentEvent;
+import javax.swing.event.DocumentListener;
+import java.awt.BorderLayout;
+import java.awt.FlowLayout;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.awt.Window;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Modale Formular-Maske zum Anlegen und Bearbeiten eines Produkts (D-F-04,
+ * F-05): alle Pflicht- und optionalen Felder mit Kennzeichnung, Validierungs-
+ * rückmeldung mit Feldmarkierung und Schutz vor unbeabsichtigtem Verwerfen.
+ */
+public class ProduktFormularDialog extends JDialog {
+
+ private static final String[] STEUERSAETZE = {"19 %", "7 %", "0 %"};
+
+ private final ProduktVerwaltungsService service;
+ /** Produktnummer des bearbeiteten Produkts; {@code null} = Neuanlage. */
+ private final String vorhandeneNummer;
+
+ private final JTextField bezeichnungFeld = new JTextField(20);
+ private final JTextField beschreibungFeld = new JTextField(20);
+ private final JTextField preisFeld = new JTextField(10);
+ private final JComboBox steuersatzWahl = new JComboBox<>(STEUERSAETZE);
+ private final JTextField einheitFeld = new JTextField(10);
+ private final Map felder = new LinkedHashMap<>();
+
+ private boolean ungespeichert;
+
+ public ProduktFormularDialog(Window besitzer, ProduktVerwaltungsService service,
+ Produkt vorhandenes) {
+ super(besitzer, vorhandenes == null ? "Neues Produkt anlegen"
+ : "Produkt " + vorhandenes.getProduktnummer() + " bearbeiten",
+ ModalityType.APPLICATION_MODAL);
+ this.service = service;
+ this.vorhandeneNummer = vorhandenes == null ? null : vorhandenes.getProduktnummer();
+ felder.put("Bezeichnung", bezeichnungFeld);
+ felder.put("Einzelpreis", preisFeld);
+ felder.put("Steuersatz", steuersatzWahl);
+ baueOberflaeche();
+ if (vorhandenes != null) {
+ fuelleFelder(vorhandenes);
+ }
+ beobachteAenderungen();
+ pack();
+ setLocationRelativeTo(besitzer);
+ }
+
+ private void baueOberflaeche() {
+ setLayout(new BorderLayout(8, 8));
+ ((JComponent) getContentPane()).setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+ JPanel formular = new JPanel(new GridBagLayout());
+ GridBagConstraints c = new GridBagConstraints();
+ c.insets = new Insets(4, 4, 4, 4);
+ c.anchor = GridBagConstraints.WEST;
+ c.fill = GridBagConstraints.HORIZONTAL;
+
+ preisFeld.setToolTipText("Pflichtfeld — z. B. 19,90 (Komma oder Punkt als Dezimaltrennzeichen)");
+
+ int zeile = 0;
+ zeile = formularZeile(formular, c, zeile, "Bezeichnung: *", bezeichnungFeld);
+ zeile = formularZeile(formular, c, zeile, "Beschreibung:", beschreibungFeld);
+ zeile = formularZeile(formular, c, zeile, "Einzelpreis (netto): *", preisFeld);
+ zeile = formularZeile(formular, c, zeile, "Steuersatz: *", steuersatzWahl);
+ zeile = formularZeile(formular, c, zeile, "Einheit:", einheitFeld);
+
+ c.gridx = 0;
+ c.gridy = zeile;
+ c.gridwidth = 2;
+ formular.add(UiHilfen.pflichtfeldLegende(), c);
+ add(formular, BorderLayout.CENTER);
+
+ JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.RIGHT));
+ JButton abbrechen = new JButton("Abbrechen");
+ abbrechen.setMnemonic('A');
+ abbrechen.addActionListener(e -> abbrechenMitNachfrage());
+ JButton speichern = new JButton("Speichern");
+ speichern.setMnemonic('S');
+ speichern.addActionListener(e -> speichere());
+ knoepfe.add(abbrechen);
+ knoepfe.add(speichern);
+ add(knoepfe, BorderLayout.SOUTH);
+
+ getRootPane().setDefaultButton(speichern);
+ UiHilfen.escSchliesst(this, this::abbrechenMitNachfrage);
+ UiHilfen.fensterSchliessenAbfangen(this, this::abbrechenMitNachfrage);
+ }
+
+ private int formularZeile(JPanel formular, GridBagConstraints c, int zeile,
+ String beschriftung, JComponent feld) {
+ c.gridx = 0;
+ c.gridy = zeile;
+ c.gridwidth = 1;
+ c.weightx = 0;
+ formular.add(new JLabel(beschriftung), c);
+ c.gridx = 1;
+ c.weightx = 1;
+ formular.add(feld, c);
+ return zeile + 1;
+ }
+
+ private void fuelleFelder(Produkt produkt) {
+ bezeichnungFeld.setText(produkt.getBezeichnung());
+ beschreibungFeld.setText(produkt.getBeschreibung() == null ? "" : produkt.getBeschreibung());
+ preisFeld.setText(produkt.getEinzelpreisNetto().toPlainString());
+ steuersatzWahl.setSelectedIndex(switch (produkt.getSteuersatz().stripTrailingZeros().toPlainString()) {
+ case "0.19" -> 0;
+ case "0.07" -> 1;
+ default -> 2;
+ });
+ einheitFeld.setText(produkt.getEinheit() == null ? "" : produkt.getEinheit());
+ }
+
+ /** Erst nach dem Vorbefüllen anmelden, damit nur Nutzereingaben zählen. */
+ private void beobachteAenderungen() {
+ DocumentListener listener = new DocumentListener() {
+ @Override
+ public void insertUpdate(DocumentEvent e) {
+ ungespeichert = true;
+ }
+
+ @Override
+ public void removeUpdate(DocumentEvent e) {
+ ungespeichert = true;
+ }
+
+ @Override
+ public void changedUpdate(DocumentEvent e) {
+ ungespeichert = true;
+ }
+ };
+ for (JTextField feld : List.of(bezeichnungFeld, beschreibungFeld, preisFeld, einheitFeld)) {
+ feld.getDocument().addDocumentListener(listener);
+ }
+ steuersatzWahl.addActionListener(e -> ungespeichert = true);
+ }
+
+ private void speichere() {
+ MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
+ Produkt produkt = vorhandeneNummer == null
+ ? new Produkt()
+ : service.findeProdukt(vorhandeneNummer);
+ produkt.setBezeichnung(bezeichnungFeld.getText().trim());
+ produkt.setBeschreibung(leerZuNull(beschreibungFeld.getText()));
+ produkt.setEinzelpreisNetto(parsePreis(preisFeld.getText()));
+ produkt.setSteuersatz(gewaehlterSteuersatz());
+ produkt.setEinheit(leerZuNull(einheitFeld.getText()));
+
+ Produkt gespeichert = vorhandeneNummer == null
+ ? service.legeAn(produkt)
+ : service.aendere(produkt);
+ ungespeichert = false;
+ MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gespeichert. Produktnummer: "
+ + gespeichert.getProduktnummer()), felder);
+ dispose();
+ });
+ }
+
+ /** Akzeptiert deutsches und englisches Dezimaltrennzeichen. */
+ private BigDecimal parsePreis(String text) {
+ String wert = text.trim().replace(',', '.');
+ if (wert.isEmpty()) {
+ throw new ValidierungsException("Einzelpreis",
+ "Das Pflichtfeld 'Einzelpreis (netto)' fehlt.");
+ }
+ try {
+ return new BigDecimal(wert).setScale(2, RoundingMode.HALF_UP);
+ } catch (NumberFormatException e) {
+ throw new ValidierungsException("Einzelpreis",
+ "Der 'Einzelpreis (netto)' ist keine gültige Zahl: " + text);
+ }
+ }
+
+ private BigDecimal gewaehlterSteuersatz() {
+ return switch (steuersatzWahl.getSelectedIndex()) {
+ case 0 -> new BigDecimal("0.19");
+ case 1 -> new BigDecimal("0.07");
+ default -> new BigDecimal("0.00");
+ };
+ }
+
+ /** Schutz vor Datenverlust: Nachfrage, wenn bereits Eingaben geändert wurden. */
+ private void abbrechenMitNachfrage() {
+ if (ungespeichert) {
+ int antwort = JOptionPane.showConfirmDialog(this,
+ "Die Eingaben gehen verloren. Maske wirklich schließen?",
+ "Eingaben verwerfen", JOptionPane.YES_NO_OPTION,
+ JOptionPane.WARNING_MESSAGE);
+ if (antwort != JOptionPane.YES_OPTION) {
+ return;
+ }
+ }
+ dispose();
+ }
+
+ private static String leerZuNull(String text) {
+ String wert = text.trim();
+ return wert.isEmpty() ? null : wert;
+ }
+}
diff --git a/src/main/java/de/team1/faktura/gui/ProduktPanel.java b/src/main/java/de/team1/faktura/gui/ProduktPanel.java
index cc93aed..97aaa38 100644
--- a/src/main/java/de/team1/faktura/gui/ProduktPanel.java
+++ b/src/main/java/de/team1/faktura/gui/ProduktPanel.java
@@ -2,70 +2,64 @@ package de.team1.faktura.gui;
import de.team1.faktura.gemeinsam.DatenBereich;
import de.team1.faktura.gemeinsam.EreignisBus;
-import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktCsvExport;
import de.team1.faktura.produkte.ProduktVerwaltungsService;
+import javax.swing.BorderFactory;
import javax.swing.JButton;
-import javax.swing.JComboBox;
-import javax.swing.JComponent;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
-import javax.swing.JSplitPane;
import javax.swing.JTable;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
+import javax.swing.SwingUtilities;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
-import java.awt.GridBagConstraints;
-import java.awt.GridBagLayout;
-import java.awt.Insets;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.io.File;
import java.math.BigDecimal;
import java.util.ArrayList;
-import java.util.LinkedHashMap;
import java.util.List;
-import java.util.Map;
/**
- * Modulansicht Produktverwaltung (D-F-03 bis F-05): sortierte Liste mit
- * Suchfeld, Formular mit gekennzeichneten Pflichtfeldern, CSV-Export.
+ * Modulansicht Produktverwaltung (D-F-03 bis F-05): sortierte Liste in voller
+ * Breite mit Suchfeld; Anlegen und Bearbeiten erfolgen über die modale
+ * Formular-Maske {@link ProduktFormularDialog} (Aufruf per Knopf oder
+ * Doppelklick), die Aktionen liegen unter der Tabelle.
*/
public class ProduktPanel extends JPanel implements ModulPanel {
- private static final String[] STEUERSAETZE = {"19 %", "7 %", "0 %"};
-
private final ProduktVerwaltungsService service;
+ private final StammdatenController controller;
private final ProduktCsvExport csvExport;
private final JTextField suchfeld = new JTextField(20);
private final ProduktTabellenModel tabellenModel = new ProduktTabellenModel();
private final JTable tabelle = new JTable(tabellenModel);
+ private final JLabel trefferAnzeige = UiHilfen.trefferLabel();
- private final JTextField bezeichnungFeld = new JTextField(20);
- private final JTextField beschreibungFeld = new JTextField(20);
- private final JTextField preisFeld = new JTextField(10);
- private final JComboBox steuersatzWahl = new JComboBox<>(STEUERSAETZE);
- private final JTextField einheitFeld = new JTextField(10);
- private final JLabel nummerAnzeige = new JLabel("— neues Produkt —");
- private final Map felder = new LinkedHashMap<>();
+ private final JButton bearbeitenKnopf = new JButton("Bearbeiten…");
+ private final JButton loeschenKnopf = new JButton("Löschen");
- private String gewaehlteNummer;
- private boolean ungespeichert;
-
- public ProduktPanel(ProduktVerwaltungsService service, ProduktCsvExport csvExport,
- EreignisBus ereignisBus) {
+ /**
+ * @param service Fachkomponente der Gruppe B für Anlegen/Ändern/Löschen
+ * @param controller GUI-freier Controller für Suche und Listeninhalt (D-F-03)
+ * @param csvExport CSV-Export der Produktstammdaten (B-F-13)
+ * @param ereignisBus Observer-Verteiler für Aktualisierungen nach Datenänderungen
+ */
+ public ProduktPanel(ProduktVerwaltungsService service, StammdatenController controller,
+ ProduktCsvExport csvExport, EreignisBus ereignisBus) {
this.service = service;
+ this.controller = controller;
this.csvExport = csvExport;
- felder.put("Bezeichnung", bezeichnungFeld);
- felder.put("Einzelpreis", preisFeld);
- felder.put("Steuersatz", steuersatzWahl);
baueOberflaeche();
aktualisiere();
ereignisBus.abonniere(DatenBereich.PRODUKTE, this::aktualisiere);
@@ -73,9 +67,12 @@ public class ProduktPanel extends JPanel implements ModulPanel {
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
+ setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
- JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT));
- suchleiste.add(new JLabel("Suche (Bezeichnung oder Produktnummer):"));
+ JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
+ suchleiste.add(new JLabel("Suche:"));
+ UiHilfen.platzhalter(suchfeld, "Bezeichnung oder Produktnummer…");
+ suchfeld.setToolTipText("Suche nach Bezeichnung oder Produktnummer (Strg+F)");
suchleiste.add(suchfeld);
suchfeld.getDocument().addDocumentListener(neuerSuchListener());
add(suchleiste, BorderLayout.NORTH);
@@ -83,191 +80,112 @@ public class ProduktPanel extends JPanel implements ModulPanel {
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
TabellenFormat.konfiguriere(tabelle);
tabelle.getColumnModel().getColumn(2).setCellRenderer(TabellenFormat.waehrungsRenderer());
- tabelle.getSelectionModel().addListSelectionListener(e -> {
- if (!e.getValueIsAdjusting()) {
- ladeAuswahl();
+ tabelle.getSelectionModel().addListSelectionListener(e -> aktualisiereAktionen());
+ tabelle.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ if (e.getClickCount() == 2) {
+ bearbeite();
+ }
}
});
- JSplitPane teiler = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
- new JScrollPane(tabelle), baueFormular());
- teiler.setResizeWeight(0.55);
- add(teiler, BorderLayout.CENTER);
+ JPanel listenSeite = new JPanel(new BorderLayout(0, 4));
+ listenSeite.add(new JScrollPane(tabelle), BorderLayout.CENTER);
+ listenSeite.add(trefferAnzeige, BorderLayout.SOUTH);
+ add(listenSeite, BorderLayout.CENTER);
+
+ JPanel aktionen = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ JButton neuKnopf = new JButton("Neues Produkt anlegen…");
+ neuKnopf.setMnemonic('N');
+ neuKnopf.setToolTipText("Öffnet die Maske für ein neues Produkt (Alt+N)");
+ neuKnopf.addActionListener(e -> legeNeuAn());
+ bearbeitenKnopf.setMnemonic('B');
+ bearbeitenKnopf.addActionListener(e -> bearbeite());
+ loeschenKnopf.setMnemonic('L');
+ loeschenKnopf.addActionListener(e -> loesche());
+ JButton exportKnopf = new JButton("CSV exportieren…");
+ exportKnopf.setMnemonic('C');
+ exportKnopf.addActionListener(e -> exportiere());
+ aktionen.add(neuKnopf);
+ aktionen.add(bearbeitenKnopf);
+ aktionen.add(loeschenKnopf);
+ aktionen.add(exportKnopf);
+ add(aktionen, BorderLayout.SOUTH);
+
+ UiHilfen.strgF(this, suchfeld);
+ aktualisiereAktionen();
}
- private JPanel baueFormular() {
- JPanel formular = new JPanel(new GridBagLayout());
- GridBagConstraints c = new GridBagConstraints();
- c.insets = new Insets(3, 3, 3, 3);
- c.anchor = GridBagConstraints.WEST;
- c.fill = GridBagConstraints.HORIZONTAL;
+ private Produkt auswahl() {
+ int zeile = tabelle.getSelectedRow();
+ return zeile < 0 ? null
+ : tabellenModel.gibZeile(tabelle.convertRowIndexToModel(zeile));
+ }
- int zeile = 0;
- zeile = formularZeile(formular, c, zeile, "Produktnummer:", nummerAnzeige);
- zeile = formularZeile(formular, c, zeile, "Bezeichnung: *", bezeichnungFeld);
- zeile = formularZeile(formular, c, zeile, "Beschreibung:", beschreibungFeld);
- zeile = formularZeile(formular, c, zeile, "Einzelpreis (netto): *", preisFeld);
- zeile = formularZeile(formular, c, zeile, "Steuersatz: *", steuersatzWahl);
- zeile = formularZeile(formular, c, zeile, "Einheit:", einheitFeld);
+ private void aktualisiereAktionen() {
+ boolean ausgewaehlt = auswahl() != null;
+ String hinweis = "Bitte zuerst ein Produkt in der Liste auswählen.";
+ UiHilfen.schalteAktion(bearbeitenKnopf, ausgewaehlt, hinweis);
+ UiHilfen.schalteAktion(loeschenKnopf, ausgewaehlt, hinweis);
+ }
- DocumentListener aenderungsListener = neuerAenderungsListener();
- for (JTextField feld : List.of(bezeichnungFeld, beschreibungFeld, preisFeld, einheitFeld)) {
- feld.getDocument().addDocumentListener(aenderungsListener);
+ private void legeNeuAn() {
+ new ProduktFormularDialog(SwingUtilities.getWindowAncestor(this), service, null)
+ .setVisible(true);
+ }
+
+ private void bearbeite() {
+ Produkt produkt = auswahl();
+ if (produkt == null) {
+ return;
}
-
- JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.LEFT));
- JButton neu = new JButton("Neu");
- neu.addActionListener(e -> leereFormular());
- JButton speichern = new JButton("Speichern");
- speichern.addActionListener(e -> speichere());
- JButton loeschen = new JButton("Löschen");
- loeschen.addActionListener(e -> loesche());
- JButton export = new JButton("CSV-Export");
- export.addActionListener(e -> exportiere());
- knoepfe.add(neu);
- knoepfe.add(speichern);
- knoepfe.add(loeschen);
- knoepfe.add(export);
-
- c.gridx = 0;
- c.gridy = zeile;
- c.gridwidth = 2;
- formular.add(knoepfe, c);
- return formular;
- }
-
- private int formularZeile(JPanel formular, GridBagConstraints c, int zeile,
- String beschriftung, JComponent feld) {
- c.gridx = 0;
- c.gridy = zeile;
- c.gridwidth = 1;
- c.weightx = 0;
- formular.add(new JLabel(beschriftung), c);
- c.gridx = 1;
- c.weightx = 1;
- formular.add(feld, c);
- return zeile + 1;
- }
-
- private void speichere() {
- MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
- Produkt produkt = gewaehlteNummer == null
- ? new Produkt()
- : service.findeProdukt(gewaehlteNummer);
- produkt.setBezeichnung(bezeichnungFeld.getText().trim());
- produkt.setBeschreibung(leerZuNull(beschreibungFeld.getText()));
- produkt.setEinzelpreisNetto(parsePreis(preisFeld.getText()));
- produkt.setSteuersatz(gewaehlterSteuersatz());
- produkt.setEinheit(leerZuNull(einheitFeld.getText()));
-
- Produkt gespeichert = gewaehlteNummer == null
- ? service.legeAn(produkt)
- : service.aendere(produkt);
- ungespeichert = false;
- gewaehlteNummer = gespeichert.getProduktnummer();
- nummerAnzeige.setText(gespeichert.getProduktnummer());
- MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gespeichert. Produktnummer: "
- + gespeichert.getProduktnummer()), felder);
- });
- }
-
- /** Akzeptiert deutsches und englisches Dezimaltrennzeichen. */
- private BigDecimal parsePreis(String text) {
- String wert = text.trim().replace(',', '.');
- if (wert.isEmpty()) {
- throw new ValidierungsException("Einzelpreis",
- "Das Pflichtfeld 'Einzelpreis (netto)' fehlt.");
- }
- try {
- return new BigDecimal(wert).setScale(2, java.math.RoundingMode.HALF_UP);
- } catch (NumberFormatException e) {
- throw new ValidierungsException("Einzelpreis",
- "Der 'Einzelpreis (netto)' ist keine gültige Zahl: " + text);
- }
- }
-
- private BigDecimal gewaehlterSteuersatz() {
- return switch (steuersatzWahl.getSelectedIndex()) {
- case 0 -> new BigDecimal("0.19");
- case 1 -> new BigDecimal("0.07");
- default -> new BigDecimal("0.00");
- };
+ new ProduktFormularDialog(SwingUtilities.getWindowAncestor(this), service, produkt)
+ .setVisible(true);
}
private void loesche() {
- if (gewaehlteNummer == null) {
+ Produkt produkt = auswahl();
+ if (produkt == null) {
return;
}
int antwort = JOptionPane.showConfirmDialog(this,
- "Produkt " + gewaehlteNummer + " wirklich dauerhaft löschen?",
+ "Produkt " + produkt.getProduktnummer() + " wirklich dauerhaft löschen?",
"Produkt löschen", JOptionPane.YES_NO_OPTION);
if (antwort != JOptionPane.YES_OPTION) {
return;
}
- MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
- service.loescheProdukt(gewaehlteNummer);
- leereFormular();
- MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gelöscht."), felder);
+ MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
+ service.loescheProdukt(produkt.getProduktnummer());
+ MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gelöscht."), null);
});
}
private void exportiere() {
- JFileChooser auswahl = new JFileChooser();
- auswahl.setSelectedFile(new java.io.File("produkte.csv"));
- if (auswahl.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
- csvExport.exportiereCsv(auswahl.getSelectedFile().toPath());
- MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Produktstammdaten wurden exportiert nach "
- + auswahl.getSelectedFile()), felder);
+ JFileChooser auswahlDialog = new JFileChooser();
+ auswahlDialog.setSelectedFile(new File("produkte.csv"));
+ if (auswahlDialog.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
+ MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
+ csvExport.exportiereCsv(auswahlDialog.getSelectedFile().toPath());
+ MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Produktstammdaten wurden exportiert nach "
+ + auswahlDialog.getSelectedFile()), null);
+ });
}
}
- private void ladeAuswahl() {
- int zeile = tabelle.getSelectedRow();
- if (zeile < 0) {
- return;
- }
- Produkt produkt = tabellenModel.produkte.get(tabelle.convertRowIndexToModel(zeile));
- gewaehlteNummer = produkt.getProduktnummer();
- nummerAnzeige.setText(produkt.getProduktnummer());
- bezeichnungFeld.setText(produkt.getBezeichnung());
- beschreibungFeld.setText(produkt.getBeschreibung() == null ? "" : produkt.getBeschreibung());
- preisFeld.setText(produkt.getEinzelpreisNetto().toPlainString());
- steuersatzWahl.setSelectedIndex(switch (produkt.getSteuersatz().stripTrailingZeros().toPlainString()) {
- case "0.19" -> 0;
- case "0.07" -> 1;
- default -> 2;
- });
- einheitFeld.setText(produkt.getEinheit() == null ? "" : produkt.getEinheit());
- ungespeichert = false;
- }
-
- private void leereFormular() {
- gewaehlteNummer = null;
- nummerAnzeige.setText("— neues Produkt —");
- for (JTextField feld : List.of(bezeichnungFeld, beschreibungFeld, preisFeld, einheitFeld)) {
- feld.setText("");
- }
- steuersatzWahl.setSelectedIndex(0);
- tabelle.clearSelection();
- ungespeichert = false;
- }
-
- private static String leerZuNull(String text) {
- String wert = text.trim();
- return wert.isEmpty() ? null : wert;
- }
-
@Override
public boolean hatUngespeicherteAenderungen() {
- return ungespeichert;
+ // Eingaben erfolgen ausschließlich in der modalen Formular-Maske;
+ // dort schützt die eigene Nachfrage vor Datenverlust (D-F-02).
+ return false;
}
@Override
public void aktualisiere() {
- String begriff = suchfeld.getText().trim();
- tabellenModel.setze(begriff.isEmpty()
- ? service.alleSortiertNachBezeichnung()
- : service.suche(begriff));
+ List liste = controller.produkteListe(suchfeld.getText());
+ tabellenModel.setze(liste);
+ trefferAnzeige.setText(UiHilfen.trefferText(liste.size(),
+ controller.produkteListe("").size(), "Produkte", suchfeld.getText()));
}
private DocumentListener neuerSuchListener() {
@@ -289,25 +207,6 @@ public class ProduktPanel extends JPanel implements ModulPanel {
};
}
- private DocumentListener neuerAenderungsListener() {
- return new DocumentListener() {
- @Override
- public void insertUpdate(DocumentEvent e) {
- ungespeichert = true;
- }
-
- @Override
- public void removeUpdate(DocumentEvent e) {
- ungespeichert = true;
- }
-
- @Override
- public void changedUpdate(DocumentEvent e) {
- ungespeichert = true;
- }
- };
- }
-
private static final class ProduktTabellenModel extends AbstractTableModel {
private static final String[] SPALTEN = {"Produktnummer", "Bezeichnung", "Einzelpreis (netto)", "Steuersatz", "Einheit"};
@@ -319,6 +218,10 @@ public class ProduktPanel extends JPanel implements ModulPanel {
fireTableDataChanged();
}
+ Produkt gibZeile(int zeile) {
+ return produkte.get(zeile);
+ }
+
@Override
public int getRowCount() {
return produkte.size();
diff --git a/src/main/java/de/team1/faktura/gui/RechnungsWizardDialog.java b/src/main/java/de/team1/faktura/gui/RechnungsWizardDialog.java
index c560034..378611a 100644
--- a/src/main/java/de/team1/faktura/gui/RechnungsWizardDialog.java
+++ b/src/main/java/de/team1/faktura/gui/RechnungsWizardDialog.java
@@ -5,13 +5,16 @@ import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService;
+import javax.swing.BorderFactory;
import javax.swing.DefaultComboBoxModel;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JComboBox;
+import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
+import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
@@ -19,11 +22,13 @@ import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.SpinnerNumberModel;
+import javax.swing.UIManager;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.FlowLayout;
+import java.awt.Font;
import java.awt.Window;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@@ -63,6 +68,7 @@ public class RechnungsWizardDialog extends JDialog {
private final JButton weiterKnopf = new JButton("Weiter >");
private final JButton speichernKnopf = new JButton("Speichern");
private final JLabel schrittAnzeige = new JLabel();
+ private final JLabel[] schrittMarkierungen = new JLabel[WizardSchritt.values().length];
public RechnungsWizardDialog(Window besitzer, RechnungsWizardController controller,
KundenService kundenService, ProduktService produktService) {
@@ -80,7 +86,22 @@ public class RechnungsWizardDialog extends JDialog {
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
- add(schrittAnzeige, BorderLayout.NORTH);
+ ((JComponent) getContentPane()).setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+ // Schrittindikator: alle fünf Schritte sichtbar, aktueller Schritt hervorgehoben (Q-05)
+ JPanel kopf = new JPanel(new BorderLayout(0, 2));
+ JPanel schritte = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
+ WizardSchritt[] alleSchritte = WizardSchritt.values();
+ for (int i = 0; i < alleSchritte.length; i++) {
+ if (i > 0) {
+ schritte.add(new JLabel("›"));
+ }
+ schrittMarkierungen[i] = new JLabel((i + 1) + ". " + schrittName(alleSchritte[i]));
+ schritte.add(schrittMarkierungen[i]);
+ }
+ kopf.add(schritte, BorderLayout.NORTH);
+ kopf.add(schrittAnzeige, BorderLayout.SOUTH);
+ add(kopf, BorderLayout.NORTH);
kartenPanel.add(baueSchrittKunde(), WizardSchritt.KUNDE_WAEHLEN.name());
kartenPanel.add(baueSchrittPositionen(), WizardSchritt.POSITIONEN_ERFASSEN.name());
@@ -91,18 +112,41 @@ public class RechnungsWizardDialog extends JDialog {
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.RIGHT));
JButton abbrechen = new JButton("Abbrechen");
- abbrechen.addActionListener(e -> dispose());
+ abbrechen.setMnemonic('A');
+ abbrechen.addActionListener(e -> abbrechenMitNachfrage());
+ zurueckKnopf.setMnemonic('Z');
zurueckKnopf.addActionListener(e -> {
controller.zurueck();
zeigeSchritt();
});
+ weiterKnopf.setMnemonic('W');
weiterKnopf.addActionListener(e -> weiter());
+ speichernKnopf.setMnemonic('S');
speichernKnopf.addActionListener(e -> speichere());
knoepfe.add(abbrechen);
knoepfe.add(zurueckKnopf);
knoepfe.add(weiterKnopf);
knoepfe.add(speichernKnopf);
add(knoepfe, BorderLayout.SOUTH);
+
+ UiHilfen.escSchliesst(this, this::abbrechenMitNachfrage);
+ UiHilfen.fensterSchliessenAbfangen(this, this::abbrechenMitNachfrage);
+ }
+
+ /** Schutz vor Datenverlust: Nachfrage, wenn bereits Eingaben erfasst wurden. */
+ private void abbrechenMitNachfrage() {
+ boolean datenErfasst = positionsListenModel.getSize() > 0
+ || kundenListe.getSelectedValue() != null;
+ if (datenErfasst) {
+ int antwort = JOptionPane.showConfirmDialog(this,
+ "Die erfassten Eingaben gehen verloren. Assistent wirklich schließen?",
+ "Eingaben verwerfen", JOptionPane.YES_NO_OPTION,
+ JOptionPane.WARNING_MESSAGE);
+ if (antwort != JOptionPane.YES_OPTION) {
+ return;
+ }
+ }
+ dispose();
}
/** Schritt 1: Kunde auswählen (F-09). */
@@ -147,6 +191,7 @@ public class RechnungsWizardDialog extends JDialog {
eingabe.add(new JLabel("Menge:"));
eingabe.add(mengeWahl);
JButton hinzufuegen = new JButton("Hinzufügen");
+ hinzufuegen.setMnemonic('H');
hinzufuegen.addActionListener(e -> {
Produkt produkt = (Produkt) produktWahl.getSelectedItem();
if (produkt == null) {
@@ -160,6 +205,7 @@ public class RechnungsWizardDialog extends JDialog {
});
eingabe.add(hinzufuegen);
JButton entfernen = new JButton("Entfernen");
+ entfernen.setMnemonic('E');
entfernen.addActionListener(e -> {
int index = positionsListe.getSelectedIndex();
if (index >= 0) {
@@ -178,9 +224,12 @@ public class RechnungsWizardDialog extends JDialog {
JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT));
panel.add(new JLabel("Rechnungsdatum (TT.MM.JJJJ): *"));
rechnungsdatumFeld.setText(DATUM.format(LocalDate.now()));
+ rechnungsdatumFeld.setToolTipText("Pflichtfeld — Format: TT.MM.JJJJ");
panel.add(rechnungsdatumFeld);
panel.add(new JLabel("Zahlungsziel (leer = 14 Tage):"));
+ zahlungszielFeld.setToolTipText("Optional — Format: TT.MM.JJJJ, leer = 14 Tage nach Rechnungsdatum");
panel.add(zahlungszielFeld);
+ panel.add(UiHilfen.pflichtfeldLegende());
return panel;
}
@@ -251,10 +300,20 @@ public class RechnungsWizardDialog extends JDialog {
zusammenfassung.setText(controller.erzeugeZusammenfassung());
}
karten.show(kartenPanel, schritt.name());
- schrittAnzeige.setText(" Schritt " + (schritt.ordinal() + 1) + " von 5: " + schrittName(schritt));
+ schrittAnzeige.setText("Schritt " + (schritt.ordinal() + 1) + " von 5: " + schrittName(schritt));
+ for (int i = 0; i < schrittMarkierungen.length; i++) {
+ boolean aktuell = i == schritt.ordinal();
+ schrittMarkierungen[i].setFont(schrittMarkierungen[i].getFont()
+ .deriveFont(aktuell ? Font.BOLD : Font.PLAIN));
+ schrittMarkierungen[i].setForeground(UIManager.getColor(
+ aktuell ? "Label.foreground" : "Label.disabledForeground"));
+ }
zurueckKnopf.setEnabled(schritt.ordinal() > 0);
weiterKnopf.setEnabled(schritt != WizardSchritt.SPEICHERN);
speichernKnopf.setEnabled(schritt == WizardSchritt.SPEICHERN);
+ // Enter führt immer die sinnvollste Aktion des Schritts aus
+ getRootPane().setDefaultButton(
+ schritt == WizardSchritt.SPEICHERN ? speichernKnopf : weiterKnopf);
}
private static String schrittName(WizardSchritt schritt) {
diff --git a/src/main/java/de/team1/faktura/gui/StammdatenController.java b/src/main/java/de/team1/faktura/gui/StammdatenController.java
index 4c39e5a..3421219 100644
--- a/src/main/java/de/team1/faktura/gui/StammdatenController.java
+++ b/src/main/java/de/team1/faktura/gui/StammdatenController.java
@@ -10,6 +10,7 @@ import java.util.List;
/**
* Controller der Stammdaten-Ansichten (D-F-03): delegiert Suchanfragen an
* die Dienste der Gruppen B und C; die GUI rechnet und filtert selbst nicht.
+ * GUI-frei und damit im Modultest ohne Oberfläche prüfbar (TC-14, TC-15).
*/
public class StammdatenController {
@@ -21,11 +22,31 @@ public class StammdatenController {
this.produktService = produktService;
}
+ /** Volltextsuche über Name oder Kundennummer (C-F-12). */
public List sucheKunden(String suchbegriff) {
return kundenService.suche(suchbegriff);
}
+ /** Volltextsuche über Bezeichnung oder Produktnummer (B-F-12). */
public List sucheProdukte(String suchbegriff) {
return produktService.suche(suchbegriff);
}
+
+ /**
+ * Listeninhalt der Kunden-Modulansicht (D-F-03): ein leerer oder fehlender
+ * Suchbegriff zeigt den gesamten Bestand; die Sortierung nach Name kommt
+ * aus der Fachkomponente (C-F-11).
+ */
+ public List kundenListe(String suchbegriff) {
+ return sucheKunden(suchbegriff == null ? "" : suchbegriff.trim());
+ }
+
+ /**
+ * Listeninhalt der Produkt-Modulansicht (D-F-03): ein leerer oder fehlender
+ * Suchbegriff zeigt den gesamten Bestand; die Sortierung nach Bezeichnung
+ * kommt aus der Fachkomponente (B-F-11).
+ */
+ public List produkteListe(String suchbegriff) {
+ return sucheProdukte(suchbegriff == null ? "" : suchbegriff.trim());
+ }
}
diff --git a/src/main/java/de/team1/faktura/gui/TabellenFormat.java b/src/main/java/de/team1/faktura/gui/TabellenFormat.java
index 6b8917f..1cd50a9 100644
--- a/src/main/java/de/team1/faktura/gui/TabellenFormat.java
+++ b/src/main/java/de/team1/faktura/gui/TabellenFormat.java
@@ -28,7 +28,7 @@ public final class TabellenFormat {
/** Einheitliche Grundeinstellungen: Zeilenhöhe und Sortierung per Spaltenkopf. */
public static void konfiguriere(JTable tabelle) {
- tabelle.setRowHeight(24);
+ tabelle.setRowHeight(26);
tabelle.setAutoCreateRowSorter(true);
tabelle.setFillsViewportHeight(true);
}
@@ -66,7 +66,8 @@ public final class TabellenFormat {
private static Color farbeFuer(DokumentStatus status) {
return switch (status) {
- case ENTWURF -> new Color(110, 110, 110);
+ // dunkleres Grau: Kontrast >= 4,5:1 auch auf der alternierenden Zeilenfarbe
+ case ENTWURF -> new Color(90, 90, 90);
case OFFEN -> new Color(0, 90, 180);
case VERSENDET -> new Color(0, 130, 60);
case STORNIERT -> new Color(180, 40, 40);
diff --git a/src/main/java/de/team1/faktura/gui/UiHilfen.java b/src/main/java/de/team1/faktura/gui/UiHilfen.java
new file mode 100644
index 0000000..0cfa93e
--- /dev/null
+++ b/src/main/java/de/team1/faktura/gui/UiHilfen.java
@@ -0,0 +1,124 @@
+package de.team1.faktura.gui;
+
+import javax.swing.AbstractAction;
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JTextField;
+import javax.swing.KeyStroke;
+import javax.swing.UIManager;
+import java.awt.Color;
+import java.awt.event.ActionEvent;
+import java.awt.event.KeyEvent;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+
+/**
+ * Gemeinsame Bedienhilfen der Oberfläche: Tastaturkürzel, Platzhaltertexte,
+ * Pflichtfeld-Legende und Trefferanzeige. Unterstützt die einheitliche
+ * Bedienbarkeit aller Modulansichten und Dialoge (Q-05, Q-09).
+ */
+public final class UiHilfen {
+
+ private UiHilfen() {
+ }
+
+ /** Grauer Platzhaltertext im leeren Eingabefeld (FlatLaf-Eigenschaft). */
+ public static void platzhalter(JTextField feld, String text) {
+ feld.putClientProperty("JTextField.placeholderText", text);
+ }
+
+ /** ESC führt die Schließ-Aktion des Dialogs aus (inkl. Nachfrage-Logik). */
+ public static void escSchliesst(JDialog dialog, Runnable schliessAktion) {
+ bindeTaste(dialog.getRootPane(), KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
+ "dialogSchliessen", schliessAktion);
+ }
+
+ /**
+ * Leitet auch das Schließen über die Fensterleiste (X) auf die
+ * Schließ-Aktion um, damit die Nachfrage nicht umgangen wird.
+ */
+ public static void fensterSchliessenAbfangen(JDialog dialog, Runnable schliessAktion) {
+ dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
+ dialog.addWindowListener(new WindowAdapter() {
+ @Override
+ public void windowClosing(WindowEvent e) {
+ schliessAktion.run();
+ }
+ });
+ }
+
+ /** Strg+F setzt den Fokus in das Suchfeld der sichtbaren Modulansicht. */
+ public static void strgF(JComponent panel, JTextField suchfeld) {
+ bindeTaste(panel, KeyStroke.getKeyStroke(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK),
+ "sucheFokussieren", () -> {
+ suchfeld.requestFocusInWindow();
+ suchfeld.selectAll();
+ });
+ }
+
+ /** Strg+S speichert das Formular der sichtbaren Modulansicht. */
+ public static void strgS(JComponent panel, Runnable speichern) {
+ bindeTaste(panel, KeyStroke.getKeyStroke(KeyEvent.VK_S, KeyEvent.CTRL_DOWN_MASK),
+ "formularSpeichern", speichern);
+ }
+
+ private static void bindeTaste(JComponent komponente, KeyStroke taste,
+ String aktionsName, Runnable aktion) {
+ komponente.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(taste, aktionsName);
+ komponente.getActionMap().put(aktionsName, new AbstractAction() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ aktion.run();
+ }
+ });
+ }
+
+ /** Schaltet einen Aktionsknopf und erklärt im Tooltip, warum er gesperrt ist. */
+ public static void schalteAktion(JButton knopf, boolean aktiv, String grundWennGesperrt) {
+ knopf.setEnabled(aktiv);
+ knopf.setToolTipText(aktiv ? null : grundWennGesperrt);
+ }
+
+ /** Kleines graues Hinweis-Label „* Pflichtfeld" unter Formularen (Q-09). */
+ public static JLabel pflichtfeldLegende() {
+ JLabel legende = new JLabel("* Pflichtfeld");
+ legende.setForeground(dezentesGrau());
+ legende.putClientProperty("FlatLaf.styleClass", "small");
+ return legende;
+ }
+
+ /** Graues Label für Trefferanzahl bzw. Leerzustand unter einer Tabelle. */
+ public static JLabel trefferLabel() {
+ JLabel label = new JLabel(" ");
+ label.setForeground(dezentesGrau());
+ label.setBorder(BorderFactory.createEmptyBorder(0, 4, 2, 4));
+ return label;
+ }
+
+ /**
+ * Text der Trefferanzeige: Anzahl der gezeigten von allen Einträgen,
+ * verständlicher Leerzustand bei erfolgloser Suche oder leerem Bestand.
+ *
+ * @param gezeigt Anzahl der aktuell angezeigten Einträge
+ * @param gesamt Gesamtbestand ohne Filter
+ * @param einheit Mehrzahlbegriff, z. B. "Kunden", "Produkte", "Belege"
+ * @param suchbegriff aktueller Such-/Filtertext (leer = kein Filter)
+ */
+ public static String trefferText(int gezeigt, int gesamt, String einheit, String suchbegriff) {
+ if (gesamt == 0) {
+ return "Noch keine " + einheit + " vorhanden";
+ }
+ if (gezeigt == 0) {
+ return "Keine Treffer für „" + suchbegriff.trim() + "“";
+ }
+ return gezeigt + " von " + gesamt + " " + einheit;
+ }
+
+ private static Color dezentesGrau() {
+ Color farbe = UIManager.getColor("Label.disabledForeground");
+ return farbe == null ? new Color(110, 110, 110) : farbe;
+ }
+}
diff --git a/src/test/java/de/team1/faktura/dokumente/PdfBoxPdfExporterTest.java b/src/test/java/de/team1/faktura/dokumente/PdfBoxPdfExporterTest.java
new file mode 100644
index 0000000..cdce15e
--- /dev/null
+++ b/src/test/java/de/team1/faktura/dokumente/PdfBoxPdfExporterTest.java
@@ -0,0 +1,63 @@
+package de.team1.faktura.dokumente;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.math.BigDecimal;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * PDF-Export (A-F-04): muss auch Altdaten ohne Produkt-Snapshot verkraften —
+ * Positionen aus früheren Datenbeständen können {@code null} in
+ * Produktreferenz, Einzelpreis und Positionssumme enthalten (IF-01).
+ */
+class PdfBoxPdfExporterTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ @DisplayName("Vollständiger Beleg wird als PDF-Datei exportiert")
+ void exportiertVollstaendigenBeleg() {
+ Path ziel = tempDir.resolve("rechnung.pdf");
+ Rechnung rechnung = TestBelege.rechnung("R-2026-000001", DokumentStatus.OFFEN);
+
+ new PdfBoxPdfExporter().exportiere(rechnung, ziel);
+
+ assertTrue(Files.exists(ziel));
+ }
+
+ @Test
+ @DisplayName("Altdaten mit null-Positionsfeldern werfen beim Export keinen Fehler")
+ void exportiertBelegMitNullPositionsfeldern() {
+ Path ziel = tempDir.resolve("altdaten.pdf");
+ Rechnung rechnung = new Rechnung();
+ rechnung.setBelegnummer("R-2026-000099");
+ // Default-Konstruktor wie beim Laden unvollständiger JSON-Altdaten:
+ // produktReferenz, einzelpreisNetto und positionssummeNetto sind null
+ rechnung.setzePositionen(List.of(new Dokumentposition()));
+
+ assertDoesNotThrow(() -> new PdfBoxPdfExporter().exportiere(rechnung, ziel));
+ assertTrue(Files.exists(ziel));
+ }
+
+ @Test
+ @DisplayName("Summenberechnung behandelt null-Positionssummen als 0")
+ void summenberechnungToleriertNullPositionen() {
+ Rechnung rechnung = new Rechnung();
+ rechnung.setBelegnummer("R-2026-000098");
+
+ rechnung.setzePositionen(List.of(new Dokumentposition()));
+
+ assertEquals(new BigDecimal("0.00"), rechnung.getSummeNetto());
+ assertEquals(new BigDecimal("0.00"), rechnung.getSummeSteuer());
+ assertEquals(new BigDecimal("0.00"), rechnung.getSummeBrutto());
+ }
+}
diff --git a/src/test/java/de/team1/faktura/gui/OberflaechenControllerTest.java b/src/test/java/de/team1/faktura/gui/OberflaechenControllerTest.java
index 059dfcd..b437102 100644
--- a/src/test/java/de/team1/faktura/gui/OberflaechenControllerTest.java
+++ b/src/test/java/de/team1/faktura/gui/OberflaechenControllerTest.java
@@ -257,6 +257,21 @@ class OberflaechenControllerTest {
assertEquals("K-000017", treffer.get(0).getKundennummer());
}
+ @Test
+ @DisplayName("TC-15: kundenListe zeigt bei leerem Suchbegriff den Bestand und filtert sonst (D-F-03)")
+ void tc15KundenListe() {
+ StammdatenController controller = new StammdatenController(kundenService, produktService);
+
+ // leerer oder fehlender Suchbegriff: gesamter Bestand
+ assertEquals(1, controller.kundenListe("").size());
+ assertEquals(1, controller.kundenListe(" ").size());
+ assertEquals(1, controller.kundenListe(null).size());
+
+ // Suchbegriff filtert (Teilstring über den Namen)
+ assertEquals(1, controller.kundenListe("Muster").size());
+ assertEquals(0, controller.kundenListe("unbekannt").size());
+ }
+
/** Zähl-Stub des DokumentService (Gruppe A) für die Controller-Tests. */
private static final class DokumentServiceStub implements DokumentService {