Update UI

main
Lucas Strubel 2026-06-13 01:28:12 +02:00
parent 26c3840d07
commit 3846ba0cde
19 changed files with 1303 additions and 478 deletions

View File

@ -12,6 +12,7 @@ import de.team1.faktura.gui.DokumentListenPanel;
import de.team1.faktura.gui.HauptFenster; import de.team1.faktura.gui.HauptFenster;
import de.team1.faktura.gui.KundenPanel; import de.team1.faktura.gui.KundenPanel;
import de.team1.faktura.gui.ProduktPanel; import de.team1.faktura.gui.ProduktPanel;
import de.team1.faktura.gui.StammdatenController;
import de.team1.faktura.kunden.EinfacherKundennummernGenerator; import de.team1.faktura.kunden.EinfacherKundennummernGenerator;
import de.team1.faktura.kunden.JsonKundenRepository; import de.team1.faktura.kunden.JsonKundenRepository;
import de.team1.faktura.kunden.KundenCsvExport; import de.team1.faktura.kunden.KundenCsvExport;
@ -78,6 +79,10 @@ public final class Main {
new PdfBoxPdfExporter(), new PdfBoxPdfExporter(),
ereignisBus); ereignisBus);
// Gruppe D — GUI-freier Controller der Stammdaten-Ansichten (D-F-03)
StammdatenController stammdatenController =
new StammdatenController(kundenService, produktService);
// Gruppe D — Programmoberfläche // Gruppe D — Programmoberfläche
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
try { try {
@ -88,10 +93,10 @@ public final class Main {
// Standard-Look-and-Feel verwenden // Standard-Look-and-Feel verwenden
} }
HauptFenster fenster = new HauptFenster( HauptFenster fenster = new HauptFenster(
new KundenPanel(kundenService, new KundenCsvExport(kundenRepository), new KundenPanel(kundenService, stammdatenController,
ereignisBus), new KundenCsvExport(kundenRepository), ereignisBus),
new ProduktPanel(produktService, new ProduktCsvExport(produktRepository), new ProduktPanel(produktService, stammdatenController,
ereignisBus), new ProduktCsvExport(produktRepository), ereignisBus),
new DokumentListenPanel(dokumentService, kundenService, produktService, new DokumentListenPanel(dokumentService, kundenService, produktService,
new DokumentCsvExport(dokumentRepository), ereignisBus)); new DokumentCsvExport(dokumentRepository), ereignisBus));
fenster.setVisible(true); fenster.setVisible(true);

View File

@ -69,7 +69,10 @@ public abstract class Dokument {
BigDecimal netto = BigDecimal.ZERO; BigDecimal netto = BigDecimal.ZERO;
BigDecimal steuer = BigDecimal.ZERO; BigDecimal steuer = BigDecimal.ZERO;
for (Dokumentposition position : positionen) { for (Dokumentposition position : positionen) {
// Altdaten ohne Preis-Snapshot (IF-01) zählen als 0 statt zu scheitern
if (position.getPositionssummeNetto() != null) {
netto = netto.add(position.getPositionssummeNetto()); netto = netto.add(position.getPositionssummeNetto());
}
steuer = steuer.add(position.getSteuerbetrag()); steuer = steuer.add(position.getSteuerbetrag());
} }
this.summeNetto = netto.setScale(2, RoundingMode.HALF_UP); this.summeNetto = netto.setScale(2, RoundingMode.HALF_UP);

View File

@ -49,6 +49,10 @@ public class Dokumentposition {
/** Steuerbetrag der Position: {@code positionssummeNetto * steuersatz}, Scale 2 (F-23, TC-01). */ /** Steuerbetrag der Position: {@code positionssummeNetto * steuersatz}, Scale 2 (F-23, TC-01). */
public BigDecimal getSteuerbetrag() { 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); return positionssummeNetto.multiply(steuersatz).setScale(2, RoundingMode.HALF_UP);
} }

View File

@ -13,19 +13,48 @@ import java.io.UncheckedIOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.text.NumberFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter; 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). * Standardisierter PDF-Export der Belege mit Apache PDFBox
* Rechnungen enthalten die Pflichtangaben gemäß § 14 UStG (F-13): * (A-F-04, F-07, F-10, F-15), angelehnt an den deutschen Geschäftsbrief:
* Belegnummer, Rechnungs- und Leistungsdatum, Kundendaten, Positionen mit * Absender- und Empfängerblock, Belegkopf mit Datum und Referenzen,
* Einzel- und Gesamtbeträgen, Steuersatz/-betrag sowie die Summen. * Positionstabelle mit festen Spalten und rechtsbündigen Beträgen sowie
* Summenblock.
*
* <p>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 { 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"); 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 RAND = 50;
private static final float ZEILENHOEHE = 14; 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 normal = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
private final PDFont fett = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD); private final PDFont fett = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
@ -35,6 +64,51 @@ public class PdfBoxPdfExporter implements PdfExporter {
try (PDDocument pdf = new PDDocument()) { try (PDDocument pdf = new PDDocument()) {
Schreiber schreiber = new Schreiber(pdf); Schreiber schreiber = new Schreiber(pdf);
schreibeBriefkopf(schreiber, dokument);
schreibeBelegkopf(schreiber, dokument);
schreibePositionstabelle(schreiber, dokument);
schreibeSummenblock(schreiber, dokument);
schreibeSchlusstext(schreiber, dokument);
schreiber.schliesse();
if (zielDatei.getParent() != null) {
Files.createDirectories(zielDatei.getParent());
}
pdf.save(zielDatei.toFile());
} catch (IOException e) {
throw new UncheckedIOException("PDF-Export fehlgeschlagen: " + zielDatei, e);
}
}
/** 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()); schreiber.zeile(fett, 16, dokument.belegtyp().anzeigename() + " " + dokument.getBelegnummer());
if (dokument.getStatus() == DokumentStatus.STORNIERT) { if (dokument.getStatus() == DokumentStatus.STORNIERT) {
schreiber.zeile(fett, 12, "*** STORNIERT ***"); schreiber.zeile(fett, 12, "*** STORNIERT ***");
@ -52,51 +126,94 @@ public class PdfBoxPdfExporter implements PdfExporter {
schreiber.zeile(normal, 10, "Zahlbar bis: " + format(rechnung.getZahlungsziel())); schreiber.zeile(normal, 10, "Zahlbar bis: " + format(rechnung.getZahlungsziel()));
} }
if (dokument.getVorgaengerNr() != null) { if (dokument.getVorgaengerNr() != null) {
schreiber.zeile(normal, 10, "Referenz: " + dokument.getVorgaengerNr()); schreiber.zeile(normal, 10, "Referenzbeleg: " + dokument.getVorgaengerNr());
} }
schreiber.leer(); 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", /** Positionstabelle im festen Spaltenraster; Kopf wird je Seite wiederholt. */
"Pos", "Produkt", "Bezeichnung", "Menge", "Einzelpreis", "USt", "Summe")); private void schreibePositionstabelle(Schreiber schreiber, Dokument dokument) throws IOException {
schreibeTabellenkopf(schreiber);
int pos = 1; int pos = 1;
for (Dokumentposition position : dokument.getPositionen()) { for (Dokumentposition position : dokument.getPositionen()) {
schreiber.zeile(normal, 10, String.format("%-4d %-10s %-34s %6d %12s %5s%% %12s", if (!schreiber.passtNochZeile()) {
pos++, schreiber.neueSeite();
position.getProduktReferenz(), schreibeTabellenkopf(schreiber);
kuerze(position.getBezeichnung(), 34),
position.getMenge(),
betrag(position.getEinzelpreisNetto()),
prozent(position.getSteuersatz()),
betrag(position.getPositionssummeNetto())));
} }
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(); 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");
schreiber.schliesse();
if (zielDatei.getParent() != null) {
Files.createDirectories(zielDatei.getParent());
} }
pdf.save(zielDatei.toFile());
} catch (IOException e) { private void summenzeile(Schreiber schreiber, PDFont font, float groesse,
throw new UncheckedIOException("PDF-Export fehlgeschlagen: " + zielDatei, e); 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(java.time.LocalDate datum) { private static String format(LocalDate datum) {
return datum == null ? "—" : DATUM.format(datum); return datum == null ? "—" : DATUM.format(datum);
} }
/** Deutsches Betragsformat mit Tausenderpunkt, z. B. "1.234,56". */
private static String betrag(BigDecimal wert) { 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) { private static String prozent(BigDecimal steuersatz) {
if (steuersatz == null) {
return "—";
}
return steuersatz.multiply(new BigDecimal("100")).stripTrailingZeros().toPlainString(); 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) + "…"; 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 final PDDocument pdf;
private PDPageContentStream inhalt; private PDPageContentStream inhalt;
@ -119,7 +241,7 @@ public class PdfBoxPdfExporter implements PdfExporter {
neueSeite(); neueSeite();
} }
private void neueSeite() throws IOException { void neueSeite() throws IOException {
if (inhalt != null) { if (inhalt != null) {
inhalt.close(); inhalt.close();
} }
@ -129,16 +251,59 @@ public class PdfBoxPdfExporter implements PdfExporter {
y = PDRectangle.A4.getHeight() - RAND; y = PDRectangle.A4.getHeight() - RAND;
} }
void zeile(PDFont font, float groesse, String text) throws IOException { boolean passtNochZeile() {
if (y < RAND + ZEILENHOEHE) { return y >= RAND + ZEILENHOEHE;
}
/** Beginnt eine Tabellenzeile; bricht bei Bedarf auf eine neue Seite um. */
void beginneZeile() throws IOException {
if (!passtNochZeile()) {
neueSeite(); 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.beginText();
inhalt.setFont(font, groesse); inhalt.setFont(font, groesse);
inhalt.newLineAtOffset(RAND, y); inhalt.newLineAtOffset(x, y);
inhalt.showText(text); inhalt.showText(text == null ? "" : text);
inhalt.endText(); 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() { void leer() {

View File

@ -10,13 +10,16 @@ import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt; import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService; import de.team1.faktura.produkte.ProduktService;
import javax.swing.BorderFactory;
import javax.swing.DefaultComboBoxModel; import javax.swing.DefaultComboBoxModel;
import javax.swing.DefaultListModel; import javax.swing.DefaultListModel;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JComboBox; import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog; import javax.swing.JDialog;
import javax.swing.JLabel; import javax.swing.JLabel;
import javax.swing.JList; import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.JSpinner; import javax.swing.JSpinner;
@ -74,10 +77,11 @@ public class BelegDialog extends JDialog {
private void baueOberflaeche() { private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8)); setLayout(new BorderLayout(8, 8));
((JComponent) getContentPane()).setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
JPanel kopf = new JPanel(new GridBagLayout()); JPanel kopf = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints(); 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.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL; c.fill = GridBagConstraints.HORIZONTAL;
@ -98,7 +102,13 @@ public class BelegDialog extends JDialog {
c.gridy = 2; c.gridy = 2;
kopf.add(datumBeschriftung, c); kopf.add(datumBeschriftung, c);
c.gridx = 1; c.gridx = 1;
datumFeld.setToolTipText("Optional — Format: TT.MM.JJJJ");
kopf.add(datumFeld, c); kopf.add(datumFeld, c);
c.gridx = 0;
c.gridy = 3;
c.gridwidth = 2;
kopf.add(UiHilfen.pflichtfeldLegende(), c);
add(kopf, BorderLayout.NORTH); add(kopf, BorderLayout.NORTH);
JPanel mitte = new JPanel(new BorderLayout(5, 5)); JPanel mitte = new JPanel(new BorderLayout(5, 5));
@ -108,9 +118,11 @@ public class BelegDialog extends JDialog {
eingabe.add(new JLabel("Menge:")); eingabe.add(new JLabel("Menge:"));
eingabe.add(mengeWahl); eingabe.add(mengeWahl);
JButton hinzufuegen = new JButton("Hinzufügen"); JButton hinzufuegen = new JButton("Hinzufügen");
hinzufuegen.setMnemonic('H');
hinzufuegen.addActionListener(e -> fuegePositionHinzu()); hinzufuegen.addActionListener(e -> fuegePositionHinzu());
eingabe.add(hinzufuegen); eingabe.add(hinzufuegen);
JButton entfernen = new JButton("Entfernen"); JButton entfernen = new JButton("Entfernen");
entfernen.setMnemonic('N');
entfernen.addActionListener(e -> entfernePosition()); entfernen.addActionListener(e -> entfernePosition());
eingabe.add(entfernen); eingabe.add(entfernen);
mitte.add(eingabe, BorderLayout.NORTH); mitte.add(eingabe, BorderLayout.NORTH);
@ -119,12 +131,32 @@ public class BelegDialog extends JDialog {
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.RIGHT)); JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.RIGHT));
JButton abbrechen = new JButton("Abbrechen"); JButton abbrechen = new JButton("Abbrechen");
abbrechen.addActionListener(e -> dispose()); abbrechen.setMnemonic('A');
abbrechen.addActionListener(e -> abbrechenMitNachfrage());
JButton erstellen = new JButton("Erstellen"); JButton erstellen = new JButton("Erstellen");
erstellen.setMnemonic('E');
erstellen.addActionListener(e -> erstelle()); erstellen.addActionListener(e -> erstelle());
knoepfe.add(abbrechen); knoepfe.add(abbrechen);
knoepfe.add(erstellen); knoepfe.add(erstellen);
add(knoepfe, BorderLayout.SOUTH); 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() { private void aktualisiereDatumsfeld() {

View File

@ -1,5 +1,6 @@
package de.team1.faktura.gui; package de.team1.faktura.gui;
import de.team1.faktura.dokumente.Belegtyp;
import de.team1.faktura.dokumente.Dokument; import de.team1.faktura.dokumente.Dokument;
import de.team1.faktura.dokumente.DokumentCsvExport; import de.team1.faktura.dokumente.DokumentCsvExport;
import de.team1.faktura.dokumente.DokumentService; 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.kunden.KundenService;
import de.team1.faktura.produkte.ProduktService; import de.team1.faktura.produkte.ProduktService;
import javax.swing.BorderFactory;
import javax.swing.DefaultListCellRenderer; import javax.swing.DefaultListCellRenderer;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JComboBox; import javax.swing.JComboBox;
@ -27,6 +29,7 @@ import java.awt.Component;
import java.awt.Desktop; import java.awt.Desktop;
import java.awt.FlowLayout; import java.awt.FlowLayout;
import java.awt.Window; import java.awt.Window;
import java.io.File;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; 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 folgebelegKnopf = new JButton("Folgebeleg erzeugen");
private final JButton versendenKnopf = new JButton("Versenden"); private final JButton versendenKnopf = new JButton("Versenden");
private final JButton stornierenKnopf = new JButton("Stornieren"); 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 druckenKnopf = new JButton("Drucken");
private final JButton mailKnopf = new JButton("Per E-Mail senden"); private final JButton mailKnopf = new JButton("Per E-Mail senden");
private final JLabel trefferAnzeige = UiHilfen.trefferLabel();
public DokumentListenPanel(DokumentService dokumentService, public DokumentListenPanel(DokumentService dokumentService,
KundenService kundenService, KundenService kundenService,
@ -84,8 +88,9 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
private void baueOberflaeche() { private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8)); 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(new JLabel("Statusfilter:"));
kopf.add(statusFilter); kopf.add(statusFilter);
statusFilter.setRenderer(new DefaultListCellRenderer() { statusFilter.setRenderer(new DefaultListCellRenderer() {
@ -99,10 +104,16 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
statusFilter.addActionListener(e -> aktualisiere()); statusFilter.addActionListener(e -> aktualisiere());
JButton neueRechnung = new JButton("Neue Rechnung (Assistent)…"); 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()); neueRechnung.addActionListener(e -> oeffneWizard());
JButton neuerBeleg = new JButton("Neuer Beleg…"); JButton neuerBeleg = new JButton("Neuer Beleg…");
neuerBeleg.setMnemonic('B');
neuerBeleg.setToolTipText("Angebot, Auftragsbestätigung oder Lieferschein erstellen (Alt+B)");
neuerBeleg.addActionListener(e -> oeffneBelegDialog()); 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()); datenExportKnopf.addActionListener(e -> exportiereDaten());
kopf.add(neueRechnung); kopf.add(neueRechnung);
kopf.add(neuerBeleg); kopf.add(neuerBeleg);
@ -114,9 +125,18 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
TabellenFormat.konfiguriere(tabelle); TabellenFormat.konfiguriere(tabelle);
tabelle.getColumnModel().getColumn(4).setCellRenderer(TabellenFormat.waehrungsRenderer()); tabelle.getColumnModel().getColumn(4).setCellRenderer(TabellenFormat.waehrungsRenderer());
tabelle.getColumnModel().getColumn(5).setCellRenderer(TabellenFormat.statusRenderer()); 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)); 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()); folgebelegKnopf.addActionListener(e -> erzeugeFolgebeleg());
versendenKnopf.addActionListener(e -> versende()); versendenKnopf.addActionListener(e -> versende());
stornierenKnopf.addActionListener(e -> storniere()); stornierenKnopf.addActionListener(e -> storniere());
@ -136,7 +156,7 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
private Dokument auswahl() { private Dokument auswahl() {
int zeile = tabelle.getSelectedRow(); int zeile = tabelle.getSelectedRow();
return zeile < 0 ? null 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). */ /** Aktiviert/deaktiviert die Belegaktionen gemäß Status (F-08, F-14). */
@ -145,17 +165,22 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
if (dokument == null) { if (dokument == null) {
for (JButton knopf : List.of(folgebelegKnopf, versendenKnopf, stornierenKnopf, for (JButton knopf : List.of(folgebelegKnopf, versendenKnopf, stornierenKnopf,
pdfKnopf, druckenKnopf, mailKnopf)) { pdfKnopf, druckenKnopf, mailKnopf)) {
knopf.setEnabled(false); UiHilfen.schalteAktion(knopf, false, "Bitte zuerst einen Beleg in der Liste auswählen.");
} }
return; return;
} }
BelegAktionen verfuegbar = controller.aktionenFuer(dokument); BelegAktionen verfuegbar = controller.aktionenFuer(dokument);
folgebelegKnopf.setEnabled(dokument.belegtyp() != de.team1.faktura.dokumente.Belegtyp.RECHNUNG); DokumentStatus status = dokument.getStatus();
versendenKnopf.setEnabled(verfuegbar.aenderbar()); UiHilfen.schalteAktion(folgebelegKnopf, dokument.belegtyp() != Belegtyp.RECHNUNG,
stornierenKnopf.setEnabled(verfuegbar.stornierbar()); "Für Rechnungen wird kein Folgebeleg erzeugt.");
pdfKnopf.setEnabled(verfuegbar.pdfExport()); UiHilfen.schalteAktion(versendenKnopf, verfuegbar.aenderbar(),
druckenKnopf.setEnabled(verfuegbar.pdfExport()); "Im Status " + status + " sind keine inhaltlichen Änderungen mehr möglich (GR-02).");
mailKnopf.setEnabled(verfuegbar.pdfExport()); 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() { private void oeffneWizard() {
@ -226,23 +251,27 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
return; return;
} }
JFileChooser auswahlDialog = new JFileChooser(); 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) { if (auswahlDialog.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
Path ziel = auswahlDialog.getSelectedFile().toPath(); Path ziel = auswahlDialog.getSelectedFile().toPath();
MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
dokumentService.exportierePdf(dokument.getBelegnummer(), ziel); dokumentService.exportierePdf(dokument.getBelegnummer(), ziel);
MeldungsAnzeige.zeige(this, Meldung.erfolg("Das PDF wurde exportiert nach " + ziel), null); MeldungsAnzeige.zeige(this, Meldung.erfolg("Das PDF wurde exportiert nach " + ziel), null);
});
} }
} }
/** Vollständiger Datenexport aller Belege als CSV (Q-08, IF-04). */ /** Vollständiger Datenexport aller Belege als CSV (Q-08, IF-04). */
private void exportiereDaten() { private void exportiereDaten() {
JFileChooser auswahlDialog = new JFileChooser(); 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) { if (auswahlDialog.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
Path ziel = auswahlDialog.getSelectedFile().toPath(); Path ziel = auswahlDialog.getSelectedFile().toPath();
MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
datenExport.exportiereCsv(ziel); datenExport.exportiereCsv(ziel);
MeldungsAnzeige.zeige(this, Meldung.erfolg( MeldungsAnzeige.zeige(this, Meldung.erfolg(
"Die Belegdaten wurden exportiert nach " + ziel), null); "Die Belegdaten wurden exportiert nach " + ziel), null);
});
} }
} }
@ -289,7 +318,17 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
@Override @Override
public void aktualisiere() { public void aktualisiere() {
tabellenModel.setze(controller.gefiltert((DokumentStatus) statusFilter.getSelectedItem())); DokumentStatus status = (DokumentStatus) statusFilter.getSelectedItem();
List<Dokument> 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(); aktualisiereAktionen();
} }
@ -313,6 +352,10 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
fireTableDataChanged(); fireTableDataChanged();
} }
Dokument gibZeile(int zeile) {
return dokumente.get(zeile);
}
@Override @Override
public int getRowCount() { public int getRowCount() {
return dokumente.size(); return dokumente.size();

View File

@ -1,25 +1,35 @@
package de.team1.faktura.gui; 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.JFrame;
import javax.swing.JOptionPane; import javax.swing.JOptionPane;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JToggleButton;
import javax.swing.JToolBar; import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.CardLayout; 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.LinkedHashMap;
import java.util.Map; import java.util.Map;
/** /**
* Hauptfenster mit Navigation zu den drei Modulen Kundenverwaltung, * Hauptfenster mit Navigation zu den drei Modulen Kundenverwaltung,
* Produktverwaltung und Dokumente (D-F-01). Beim Modulwechsel wird bei * Produktverwaltung und Dokumente (D-F-01). Das aktive Modul ist in der
* ungespeicherten Formulareingaben nachgefragt (D-F-02). * Navigationsleiste sichtbar markiert; Alt+1/2/3 wechseln direkt. Beim
* Modulwechsel wird bei ungespeicherten Formulareingaben nachgefragt (D-F-02).
*/ */
public class HauptFenster extends JFrame { public class HauptFenster extends JFrame {
private final CardLayout karten = new CardLayout(); private final CardLayout karten = new CardLayout();
private final JPanel kartenPanel = new JPanel(karten); private final JPanel kartenPanel = new JPanel(karten);
private final Map<String, ModulPanel> module = new LinkedHashMap<>(); private final Map<String, ModulPanel> module = new LinkedHashMap<>();
private final Map<String, JToggleButton> navigationsKnoepfe = new LinkedHashMap<>();
private String aktuellesModul; private String aktuellesModul;
@ -35,10 +45,18 @@ public class HauptFenster extends JFrame {
JToolBar navigation = new JToolBar(); JToolBar navigation = new JToolBar();
navigation.setFloatable(false); navigation.setFloatable(false);
ButtonGroup gruppe = new ButtonGroup();
int modulNummer = 1;
for (Map.Entry<String, ModulPanel> eintrag : module.entrySet()) { for (Map.Entry<String, ModulPanel> 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())); knopf.addActionListener(e -> wechsleZu(eintrag.getKey()));
gruppe.add(knopf);
navigation.add(knopf); navigation.add(knopf);
navigationsKnoepfe.put(eintrag.getKey(), knopf);
bindeModulTaste(eintrag.getKey(), modulNummer);
modulNummer++;
} }
add(navigation, BorderLayout.NORTH); add(navigation, BorderLayout.NORTH);
@ -48,15 +66,29 @@ public class HauptFenster extends JFrame {
add(kartenPanel, BorderLayout.CENTER); add(kartenPanel, BorderLayout.CENTER);
aktuellesModul = "Kunden"; aktuellesModul = "Kunden";
navigationsKnoepfe.get(aktuellesModul).setSelected(true);
karten.show(kartenPanel, aktuellesModul); karten.show(kartenPanel, aktuellesModul);
setSize(1100, 650); setSize(1100, 650);
setLocationRelativeTo(null); 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). */ /** Modulwechsel mit Nachfrage bei ungespeicherten Eingaben (D-F-02). */
private void wechsleZu(String modulName) { private void wechsleZu(String modulName) {
if (modulName.equals(aktuellesModul)) { if (modulName.equals(aktuellesModul)) {
navigationsKnoepfe.get(aktuellesModul).setSelected(true);
return; return;
} }
ModulPanel aktuell = module.get(aktuellesModul); ModulPanel aktuell = module.get(aktuellesModul);
@ -66,10 +98,13 @@ public class HauptFenster extends JFrame {
"Ungespeicherte Eingaben", JOptionPane.YES_NO_OPTION, "Ungespeicherte Eingaben", JOptionPane.YES_NO_OPTION,
JOptionPane.WARNING_MESSAGE); JOptionPane.WARNING_MESSAGE);
if (antwort != JOptionPane.YES_OPTION) { if (antwort != JOptionPane.YES_OPTION) {
// Wechsel abgebrochen: Markierung zurück auf das aktuelle Modul
navigationsKnoepfe.get(aktuellesModul).setSelected(true);
return; return;
} }
} }
aktuellesModul = modulName; aktuellesModul = modulName;
navigationsKnoepfe.get(modulName).setSelected(true);
module.get(modulName).aktualisiere(); module.get(modulName).aktualisiere();
karten.show(kartenPanel, modulName); karten.show(kartenPanel, modulName);
} }

View File

@ -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<String, JComponent> 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;
}
}

View File

@ -6,65 +6,59 @@ import de.team1.faktura.kunden.Kunde;
import de.team1.faktura.kunden.KundenCsvExport; import de.team1.faktura.kunden.KundenCsvExport;
import de.team1.faktura.kunden.KundenVerwaltungsService; import de.team1.faktura.kunden.KundenVerwaltungsService;
import javax.swing.BorderFactory;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JFileChooser; import javax.swing.JFileChooser;
import javax.swing.JLabel; import javax.swing.JLabel;
import javax.swing.JOptionPane; import javax.swing.JOptionPane;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTable; import javax.swing.JTable;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.ListSelectionModel; import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener; import javax.swing.event.DocumentListener;
import javax.swing.table.AbstractTableModel; import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.FlowLayout; import java.awt.FlowLayout;
import java.awt.GridBagConstraints; import java.awt.event.MouseAdapter;
import java.awt.GridBagLayout; import java.awt.event.MouseEvent;
import java.awt.Insets; import java.io.File;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* Modulansicht Kundenverwaltung (D-F-03 bis F-05): sortierte Liste mit * Modulansicht Kundenverwaltung (D-F-03 bis F-05): sortierte Liste in voller
* Suchfeld, Formular mit gekennzeichneten Pflichtfeldern, CSV-Export. * 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 { public class KundenPanel extends JPanel implements ModulPanel {
private final KundenVerwaltungsService service; private final KundenVerwaltungsService service;
private final StammdatenController controller;
private final KundenCsvExport csvExport; private final KundenCsvExport csvExport;
private final JTextField suchfeld = new JTextField(20); private final JTextField suchfeld = new JTextField(20);
private final KundenTabellenModel tabellenModel = new KundenTabellenModel(); private final KundenTabellenModel tabellenModel = new KundenTabellenModel();
private final JTable tabelle = new JTable(tabellenModel); private final JTable tabelle = new JTable(tabellenModel);
private final JLabel trefferAnzeige = UiHilfen.trefferLabel();
private final JTextField nameFeld = new JTextField(20); private final JButton bearbeitenKnopf = new JButton("Bearbeiten…");
private final JTextField strasseFeld = new JTextField(20); private final JButton loeschenKnopf = new JButton("Löschen");
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<String, JComponent> felder = new LinkedHashMap<>();
private String gewaehlteNummer; /**
private boolean ungespeichert; * @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)
public KundenPanel(KundenVerwaltungsService service, KundenCsvExport csvExport, * @param csvExport CSV-Export der Kundenstammdaten (C-F-13)
EreignisBus ereignisBus) { * @param ereignisBus Observer-Verteiler für Aktualisierungen nach Datenänderungen
*/
public KundenPanel(KundenVerwaltungsService service, StammdatenController controller,
KundenCsvExport csvExport, EreignisBus ereignisBus) {
this.service = service; this.service = service;
this.controller = controller;
this.csvExport = csvExport; 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(); baueOberflaeche();
aktualisiere(); aktualisiere();
ereignisBus.abonniere(DatenBereich.KUNDEN, this::aktualisiere); ereignisBus.abonniere(DatenBereich.KUNDEN, this::aktualisiere);
@ -72,180 +66,124 @@ public class KundenPanel extends JPanel implements ModulPanel {
private void baueOberflaeche() { private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8)); setLayout(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT)); JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
suchleiste.add(new JLabel("Suche (Name oder Kundennummer):")); suchleiste.add(new JLabel("Suche:"));
UiHilfen.platzhalter(suchfeld, "Name oder Kundennummer…");
suchfeld.setToolTipText("Suche nach Name oder Kundennummer (Strg+F)");
suchleiste.add(suchfeld); suchleiste.add(suchfeld);
suchfeld.getDocument().addDocumentListener(neuerSuchListener()); suchfeld.getDocument().addDocumentListener(neuerSuchListener());
add(suchleiste, BorderLayout.NORTH); add(suchleiste, BorderLayout.NORTH);
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
TabellenFormat.konfiguriere(tabelle); TabellenFormat.konfiguriere(tabelle);
tabelle.getSelectionModel().addListSelectionListener(e -> { tabelle.getSelectionModel().addListSelectionListener(e -> aktualisiereAktionen());
if (!e.getValueIsAdjusting()) { tabelle.addMouseListener(new MouseAdapter() {
ladeAuswahl(); @Override
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 2) {
bearbeite();
}
} }
}); });
JSplitPane teiler = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, JPanel listenSeite = new JPanel(new BorderLayout(0, 4));
new JScrollPane(tabelle), baueFormular()); listenSeite.add(new JScrollPane(tabelle), BorderLayout.CENTER);
teiler.setResizeWeight(0.55); listenSeite.add(trefferAnzeige, BorderLayout.SOUTH);
add(teiler, BorderLayout.CENTER); 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() { private Kunde auswahl() {
JPanel formular = new JPanel(new GridBagLayout()); int zeile = tabelle.getSelectedRow();
GridBagConstraints c = new GridBagConstraints(); return zeile < 0 ? null
c.insets = new Insets(3, 3, 3, 3); : tabellenModel.gibZeile(tabelle.convertRowIndexToModel(zeile));
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
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);
DocumentListener aenderungsListener = neuerAenderungsListener();
for (JComponent feld : List.of(nameFeld, strasseFeld, plzFeld, ortFeld,
eMailFeld, telefonFeld, ustIdNrFeld)) {
((JTextField) feld).getDocument().addDocumentListener(aenderungsListener);
} }
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.LEFT)); private void aktualisiereAktionen() {
JButton neu = new JButton("Neu"); boolean ausgewaehlt = auswahl() != null;
neu.addActionListener(e -> leereFormular()); String hinweis = "Bitte zuerst einen Kunden in der Liste auswählen.";
JButton speichern = new JButton("Speichern"); UiHilfen.schalteAktion(bearbeitenKnopf, ausgewaehlt, hinweis);
speichern.addActionListener(e -> speichere()); UiHilfen.schalteAktion(loeschenKnopf, ausgewaehlt, hinweis);
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, private void legeNeuAn() {
String beschriftung, JComponent feld) { new KundenFormularDialog(SwingUtilities.getWindowAncestor(this), service, null)
c.gridx = 0; .setVisible(true);
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() { private void bearbeite() {
MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> { Kunde kunde = auswahl();
Kunde kunde = gewaehlteNummer == null if (kunde == null) {
? new Kunde() return;
: service.findeKunde(gewaehlteNummer); }
kunde.setName(nameFeld.getText().trim()); new KundenFormularDialog(SwingUtilities.getWindowAncestor(this), service, kunde)
kunde.setStrasse(strasseFeld.getText().trim()); .setVisible(true);
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);
});
} }
private void loesche() { private void loesche() {
if (gewaehlteNummer == null) { Kunde kunde = auswahl();
if (kunde == null) {
return; return;
} }
int antwort = JOptionPane.showConfirmDialog(this, 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); "Kunde löschen", JOptionPane.YES_NO_OPTION);
if (antwort != JOptionPane.YES_OPTION) { if (antwort != JOptionPane.YES_OPTION) {
return; return;
} }
MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> { MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
service.loescheKunde(gewaehlteNummer); service.loescheKunde(kunde.getKundennummer());
leereFormular(); MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gelöscht."), null);
MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gelöscht."), felder);
}); });
} }
private void exportiere() { private void exportiere() {
JFileChooser auswahl = new JFileChooser(); JFileChooser auswahlDialog = new JFileChooser();
auswahl.setSelectedFile(new java.io.File("kunden.csv")); auswahlDialog.setSelectedFile(new File("kunden.csv"));
if (auswahl.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) { if (auswahlDialog.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
csvExport.exportiereCsv(auswahl.getSelectedFile().toPath()); MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
csvExport.exportiereCsv(auswahlDialog.getSelectedFile().toPath());
MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Kundenstammdaten wurden exportiert nach " MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Kundenstammdaten wurden exportiert nach "
+ auswahl.getSelectedFile()), felder); + 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 @Override
public boolean hatUngespeicherteAenderungen() { 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 @Override
public void aktualisiere() { public void aktualisiere() {
String begriff = suchfeld.getText().trim(); List<Kunde> liste = controller.kundenListe(suchfeld.getText());
tabellenModel.setze(begriff.isEmpty() tabellenModel.setze(liste);
? service.alleSortiertNachName() trefferAnzeige.setText(UiHilfen.trefferText(liste.size(),
: service.suche(begriff)); controller.kundenListe("").size(), "Kunden", suchfeld.getText()));
} }
private DocumentListener neuerSuchListener() { 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 class KundenTabellenModel extends AbstractTableModel {
private static final String[] SPALTEN = {"Kundennummer", "Name", "Straße", "PLZ", "Ort"}; private static final String[] SPALTEN = {"Kundennummer", "Name", "Straße", "PLZ", "Ort"};
@ -297,6 +216,10 @@ public class KundenPanel extends JPanel implements ModulPanel {
fireTableDataChanged(); fireTableDataChanged();
} }
Kunde gibZeile(int zeile) {
return kunden.get(zeile);
}
@Override @Override
public int getRowCount() { public int getRowCount() {
return kunden.size(); return kunden.size();

View File

@ -9,10 +9,12 @@ package de.team1.faktura.gui;
*/ */
public record Meldung(MeldungsTyp typ, String feldname, String text) { public record Meldung(MeldungsTyp typ, String feldname, String text) {
/** Erfolgsmeldung ohne Feldbezug (D-F-17). */
public static Meldung erfolg(String text) { public static Meldung erfolg(String text) {
return new Meldung(MeldungsTyp.ERFOLG, null, 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) { public static Meldung fehler(String feldname, String text) {
return new Meldung(MeldungsTyp.FEHLER, feldname, text); return new Meldung(MeldungsTyp.FEHLER, feldname, text);
} }

View File

@ -70,6 +70,9 @@ public final class MeldungsAnzeige {
} catch (UncheckedIOException e) { } catch (UncheckedIOException e) {
zeige(parent, Meldung.fehler(null, zeige(parent, Meldung.fehler(null,
"Die Daten konnten nicht gespeichert werden: " + e.getMessage()), felder); "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);
} }
} }
} }

View File

@ -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<String> steuersatzWahl = new JComboBox<>(STEUERSAETZE);
private final JTextField einheitFeld = new JTextField(10);
private final Map<String, JComponent> 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;
}
}

View File

@ -2,70 +2,64 @@ package de.team1.faktura.gui;
import de.team1.faktura.gemeinsam.DatenBereich; import de.team1.faktura.gemeinsam.DatenBereich;
import de.team1.faktura.gemeinsam.EreignisBus; import de.team1.faktura.gemeinsam.EreignisBus;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.produkte.Produkt; import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktCsvExport; import de.team1.faktura.produkte.ProduktCsvExport;
import de.team1.faktura.produkte.ProduktVerwaltungsService; import de.team1.faktura.produkte.ProduktVerwaltungsService;
import javax.swing.BorderFactory;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JFileChooser; import javax.swing.JFileChooser;
import javax.swing.JLabel; import javax.swing.JLabel;
import javax.swing.JOptionPane; import javax.swing.JOptionPane;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTable; import javax.swing.JTable;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.ListSelectionModel; import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener; import javax.swing.event.DocumentListener;
import javax.swing.table.AbstractTableModel; import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.FlowLayout; import java.awt.FlowLayout;
import java.awt.GridBagConstraints; import java.awt.event.MouseAdapter;
import java.awt.GridBagLayout; import java.awt.event.MouseEvent;
import java.awt.Insets; import java.io.File;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* Modulansicht Produktverwaltung (D-F-03 bis F-05): sortierte Liste mit * Modulansicht Produktverwaltung (D-F-03 bis F-05): sortierte Liste in voller
* Suchfeld, Formular mit gekennzeichneten Pflichtfeldern, CSV-Export. * 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 { public class ProduktPanel extends JPanel implements ModulPanel {
private static final String[] STEUERSAETZE = {"19 %", "7 %", "0 %"};
private final ProduktVerwaltungsService service; private final ProduktVerwaltungsService service;
private final StammdatenController controller;
private final ProduktCsvExport csvExport; private final ProduktCsvExport csvExport;
private final JTextField suchfeld = new JTextField(20); private final JTextField suchfeld = new JTextField(20);
private final ProduktTabellenModel tabellenModel = new ProduktTabellenModel(); private final ProduktTabellenModel tabellenModel = new ProduktTabellenModel();
private final JTable tabelle = new JTable(tabellenModel); private final JTable tabelle = new JTable(tabellenModel);
private final JLabel trefferAnzeige = UiHilfen.trefferLabel();
private final JTextField bezeichnungFeld = new JTextField(20); private final JButton bearbeitenKnopf = new JButton("Bearbeiten…");
private final JTextField beschreibungFeld = new JTextField(20); private final JButton loeschenKnopf = new JButton("Löschen");
private final JTextField preisFeld = new JTextField(10);
private final JComboBox<String> steuersatzWahl = new JComboBox<>(STEUERSAETZE);
private final JTextField einheitFeld = new JTextField(10);
private final JLabel nummerAnzeige = new JLabel("— neues Produkt —");
private final Map<String, JComponent> felder = new LinkedHashMap<>();
private String gewaehlteNummer; /**
private boolean ungespeichert; * @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)
public ProduktPanel(ProduktVerwaltungsService service, ProduktCsvExport csvExport, * @param csvExport CSV-Export der Produktstammdaten (B-F-13)
EreignisBus ereignisBus) { * @param ereignisBus Observer-Verteiler für Aktualisierungen nach Datenänderungen
*/
public ProduktPanel(ProduktVerwaltungsService service, StammdatenController controller,
ProduktCsvExport csvExport, EreignisBus ereignisBus) {
this.service = service; this.service = service;
this.controller = controller;
this.csvExport = csvExport; this.csvExport = csvExport;
felder.put("Bezeichnung", bezeichnungFeld);
felder.put("Einzelpreis", preisFeld);
felder.put("Steuersatz", steuersatzWahl);
baueOberflaeche(); baueOberflaeche();
aktualisiere(); aktualisiere();
ereignisBus.abonniere(DatenBereich.PRODUKTE, this::aktualisiere); ereignisBus.abonniere(DatenBereich.PRODUKTE, this::aktualisiere);
@ -73,9 +67,12 @@ public class ProduktPanel extends JPanel implements ModulPanel {
private void baueOberflaeche() { private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8)); setLayout(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT)); JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
suchleiste.add(new JLabel("Suche (Bezeichnung oder Produktnummer):")); suchleiste.add(new JLabel("Suche:"));
UiHilfen.platzhalter(suchfeld, "Bezeichnung oder Produktnummer…");
suchfeld.setToolTipText("Suche nach Bezeichnung oder Produktnummer (Strg+F)");
suchleiste.add(suchfeld); suchleiste.add(suchfeld);
suchfeld.getDocument().addDocumentListener(neuerSuchListener()); suchfeld.getDocument().addDocumentListener(neuerSuchListener());
add(suchleiste, BorderLayout.NORTH); add(suchleiste, BorderLayout.NORTH);
@ -83,191 +80,112 @@ public class ProduktPanel extends JPanel implements ModulPanel {
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
TabellenFormat.konfiguriere(tabelle); TabellenFormat.konfiguriere(tabelle);
tabelle.getColumnModel().getColumn(2).setCellRenderer(TabellenFormat.waehrungsRenderer()); tabelle.getColumnModel().getColumn(2).setCellRenderer(TabellenFormat.waehrungsRenderer());
tabelle.getSelectionModel().addListSelectionListener(e -> { tabelle.getSelectionModel().addListSelectionListener(e -> aktualisiereAktionen());
if (!e.getValueIsAdjusting()) { tabelle.addMouseListener(new MouseAdapter() {
ladeAuswahl(); @Override
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 2) {
bearbeite();
}
} }
}); });
JSplitPane teiler = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, JPanel listenSeite = new JPanel(new BorderLayout(0, 4));
new JScrollPane(tabelle), baueFormular()); listenSeite.add(new JScrollPane(tabelle), BorderLayout.CENTER);
teiler.setResizeWeight(0.55); listenSeite.add(trefferAnzeige, BorderLayout.SOUTH);
add(teiler, BorderLayout.CENTER); 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() { private Produkt auswahl() {
JPanel formular = new JPanel(new GridBagLayout()); int zeile = tabelle.getSelectedRow();
GridBagConstraints c = new GridBagConstraints(); return zeile < 0 ? null
c.insets = new Insets(3, 3, 3, 3); : tabellenModel.gibZeile(tabelle.convertRowIndexToModel(zeile));
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
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);
DocumentListener aenderungsListener = neuerAenderungsListener();
for (JTextField feld : List.of(bezeichnungFeld, beschreibungFeld, preisFeld, einheitFeld)) {
feld.getDocument().addDocumentListener(aenderungsListener);
} }
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.LEFT)); private void aktualisiereAktionen() {
JButton neu = new JButton("Neu"); boolean ausgewaehlt = auswahl() != null;
neu.addActionListener(e -> leereFormular()); String hinweis = "Bitte zuerst ein Produkt in der Liste auswählen.";
JButton speichern = new JButton("Speichern"); UiHilfen.schalteAktion(bearbeitenKnopf, ausgewaehlt, hinweis);
speichern.addActionListener(e -> speichere()); UiHilfen.schalteAktion(loeschenKnopf, ausgewaehlt, hinweis);
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, private void legeNeuAn() {
String beschriftung, JComponent feld) { new ProduktFormularDialog(SwingUtilities.getWindowAncestor(this), service, null)
c.gridx = 0; .setVisible(true);
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() { private void bearbeite() {
MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> { Produkt produkt = auswahl();
Produkt produkt = gewaehlteNummer == null if (produkt == null) {
? new Produkt() return;
: 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);
});
} }
new ProduktFormularDialog(SwingUtilities.getWindowAncestor(this), service, produkt)
/** Akzeptiert deutsches und englisches Dezimaltrennzeichen. */ .setVisible(true);
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");
};
} }
private void loesche() { private void loesche() {
if (gewaehlteNummer == null) { Produkt produkt = auswahl();
if (produkt == null) {
return; return;
} }
int antwort = JOptionPane.showConfirmDialog(this, 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); "Produkt löschen", JOptionPane.YES_NO_OPTION);
if (antwort != JOptionPane.YES_OPTION) { if (antwort != JOptionPane.YES_OPTION) {
return; return;
} }
MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> { MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
service.loescheProdukt(gewaehlteNummer); service.loescheProdukt(produkt.getProduktnummer());
leereFormular(); MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gelöscht."), null);
MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gelöscht."), felder);
}); });
} }
private void exportiere() { private void exportiere() {
JFileChooser auswahl = new JFileChooser(); JFileChooser auswahlDialog = new JFileChooser();
auswahl.setSelectedFile(new java.io.File("produkte.csv")); auswahlDialog.setSelectedFile(new File("produkte.csv"));
if (auswahl.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) { if (auswahlDialog.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
csvExport.exportiereCsv(auswahl.getSelectedFile().toPath()); MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
csvExport.exportiereCsv(auswahlDialog.getSelectedFile().toPath());
MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Produktstammdaten wurden exportiert nach " MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Produktstammdaten wurden exportiert nach "
+ auswahl.getSelectedFile()), felder); + 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 @Override
public boolean hatUngespeicherteAenderungen() { 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 @Override
public void aktualisiere() { public void aktualisiere() {
String begriff = suchfeld.getText().trim(); List<Produkt> liste = controller.produkteListe(suchfeld.getText());
tabellenModel.setze(begriff.isEmpty() tabellenModel.setze(liste);
? service.alleSortiertNachBezeichnung() trefferAnzeige.setText(UiHilfen.trefferText(liste.size(),
: service.suche(begriff)); controller.produkteListe("").size(), "Produkte", suchfeld.getText()));
} }
private DocumentListener neuerSuchListener() { 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 class ProduktTabellenModel extends AbstractTableModel {
private static final String[] SPALTEN = {"Produktnummer", "Bezeichnung", "Einzelpreis (netto)", "Steuersatz", "Einheit"}; private static final String[] SPALTEN = {"Produktnummer", "Bezeichnung", "Einzelpreis (netto)", "Steuersatz", "Einheit"};
@ -319,6 +218,10 @@ public class ProduktPanel extends JPanel implements ModulPanel {
fireTableDataChanged(); fireTableDataChanged();
} }
Produkt gibZeile(int zeile) {
return produkte.get(zeile);
}
@Override @Override
public int getRowCount() { public int getRowCount() {
return produkte.size(); return produkte.size();

View File

@ -5,13 +5,16 @@ import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt; import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService; import de.team1.faktura.produkte.ProduktService;
import javax.swing.BorderFactory;
import javax.swing.DefaultComboBoxModel; import javax.swing.DefaultComboBoxModel;
import javax.swing.DefaultListModel; import javax.swing.DefaultListModel;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JComboBox; import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog; import javax.swing.JDialog;
import javax.swing.JLabel; import javax.swing.JLabel;
import javax.swing.JList; import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.JSpinner; import javax.swing.JSpinner;
@ -19,11 +22,13 @@ import javax.swing.JTextArea;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.ListSelectionModel; import javax.swing.ListSelectionModel;
import javax.swing.SpinnerNumberModel; import javax.swing.SpinnerNumberModel;
import javax.swing.UIManager;
import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener; import javax.swing.event.DocumentListener;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.CardLayout; import java.awt.CardLayout;
import java.awt.FlowLayout; import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.Window; import java.awt.Window;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@ -63,6 +68,7 @@ public class RechnungsWizardDialog extends JDialog {
private final JButton weiterKnopf = new JButton("Weiter >"); private final JButton weiterKnopf = new JButton("Weiter >");
private final JButton speichernKnopf = new JButton("Speichern"); private final JButton speichernKnopf = new JButton("Speichern");
private final JLabel schrittAnzeige = new JLabel(); private final JLabel schrittAnzeige = new JLabel();
private final JLabel[] schrittMarkierungen = new JLabel[WizardSchritt.values().length];
public RechnungsWizardDialog(Window besitzer, RechnungsWizardController controller, public RechnungsWizardDialog(Window besitzer, RechnungsWizardController controller,
KundenService kundenService, ProduktService produktService) { KundenService kundenService, ProduktService produktService) {
@ -80,7 +86,22 @@ public class RechnungsWizardDialog extends JDialog {
private void baueOberflaeche() { private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8)); 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(baueSchrittKunde(), WizardSchritt.KUNDE_WAEHLEN.name());
kartenPanel.add(baueSchrittPositionen(), WizardSchritt.POSITIONEN_ERFASSEN.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)); JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.RIGHT));
JButton abbrechen = new JButton("Abbrechen"); JButton abbrechen = new JButton("Abbrechen");
abbrechen.addActionListener(e -> dispose()); abbrechen.setMnemonic('A');
abbrechen.addActionListener(e -> abbrechenMitNachfrage());
zurueckKnopf.setMnemonic('Z');
zurueckKnopf.addActionListener(e -> { zurueckKnopf.addActionListener(e -> {
controller.zurueck(); controller.zurueck();
zeigeSchritt(); zeigeSchritt();
}); });
weiterKnopf.setMnemonic('W');
weiterKnopf.addActionListener(e -> weiter()); weiterKnopf.addActionListener(e -> weiter());
speichernKnopf.setMnemonic('S');
speichernKnopf.addActionListener(e -> speichere()); speichernKnopf.addActionListener(e -> speichere());
knoepfe.add(abbrechen); knoepfe.add(abbrechen);
knoepfe.add(zurueckKnopf); knoepfe.add(zurueckKnopf);
knoepfe.add(weiterKnopf); knoepfe.add(weiterKnopf);
knoepfe.add(speichernKnopf); knoepfe.add(speichernKnopf);
add(knoepfe, BorderLayout.SOUTH); 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). */ /** Schritt 1: Kunde auswählen (F-09). */
@ -147,6 +191,7 @@ public class RechnungsWizardDialog extends JDialog {
eingabe.add(new JLabel("Menge:")); eingabe.add(new JLabel("Menge:"));
eingabe.add(mengeWahl); eingabe.add(mengeWahl);
JButton hinzufuegen = new JButton("Hinzufügen"); JButton hinzufuegen = new JButton("Hinzufügen");
hinzufuegen.setMnemonic('H');
hinzufuegen.addActionListener(e -> { hinzufuegen.addActionListener(e -> {
Produkt produkt = (Produkt) produktWahl.getSelectedItem(); Produkt produkt = (Produkt) produktWahl.getSelectedItem();
if (produkt == null) { if (produkt == null) {
@ -160,6 +205,7 @@ public class RechnungsWizardDialog extends JDialog {
}); });
eingabe.add(hinzufuegen); eingabe.add(hinzufuegen);
JButton entfernen = new JButton("Entfernen"); JButton entfernen = new JButton("Entfernen");
entfernen.setMnemonic('E');
entfernen.addActionListener(e -> { entfernen.addActionListener(e -> {
int index = positionsListe.getSelectedIndex(); int index = positionsListe.getSelectedIndex();
if (index >= 0) { if (index >= 0) {
@ -178,9 +224,12 @@ public class RechnungsWizardDialog extends JDialog {
JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT)); JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT));
panel.add(new JLabel("Rechnungsdatum (TT.MM.JJJJ): *")); panel.add(new JLabel("Rechnungsdatum (TT.MM.JJJJ): *"));
rechnungsdatumFeld.setText(DATUM.format(LocalDate.now())); rechnungsdatumFeld.setText(DATUM.format(LocalDate.now()));
rechnungsdatumFeld.setToolTipText("Pflichtfeld — Format: TT.MM.JJJJ");
panel.add(rechnungsdatumFeld); panel.add(rechnungsdatumFeld);
panel.add(new JLabel("Zahlungsziel (leer = 14 Tage):")); 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(zahlungszielFeld);
panel.add(UiHilfen.pflichtfeldLegende());
return panel; return panel;
} }
@ -252,9 +301,19 @@ public class RechnungsWizardDialog extends JDialog {
} }
karten.show(kartenPanel, schritt.name()); 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); zurueckKnopf.setEnabled(schritt.ordinal() > 0);
weiterKnopf.setEnabled(schritt != WizardSchritt.SPEICHERN); weiterKnopf.setEnabled(schritt != WizardSchritt.SPEICHERN);
speichernKnopf.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) { private static String schrittName(WizardSchritt schritt) {

View File

@ -10,6 +10,7 @@ import java.util.List;
/** /**
* Controller der Stammdaten-Ansichten (D-F-03): delegiert Suchanfragen an * Controller der Stammdaten-Ansichten (D-F-03): delegiert Suchanfragen an
* die Dienste der Gruppen B und C; die GUI rechnet und filtert selbst nicht. * 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 { public class StammdatenController {
@ -21,11 +22,31 @@ public class StammdatenController {
this.produktService = produktService; this.produktService = produktService;
} }
/** Volltextsuche über Name oder Kundennummer (C-F-12). */
public List<Kunde> sucheKunden(String suchbegriff) { public List<Kunde> sucheKunden(String suchbegriff) {
return kundenService.suche(suchbegriff); return kundenService.suche(suchbegriff);
} }
/** Volltextsuche über Bezeichnung oder Produktnummer (B-F-12). */
public List<Produkt> sucheProdukte(String suchbegriff) { public List<Produkt> sucheProdukte(String suchbegriff) {
return produktService.suche(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<Kunde> 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<Produkt> produkteListe(String suchbegriff) {
return sucheProdukte(suchbegriff == null ? "" : suchbegriff.trim());
}
} }

View File

@ -28,7 +28,7 @@ public final class TabellenFormat {
/** Einheitliche Grundeinstellungen: Zeilenhöhe und Sortierung per Spaltenkopf. */ /** Einheitliche Grundeinstellungen: Zeilenhöhe und Sortierung per Spaltenkopf. */
public static void konfiguriere(JTable tabelle) { public static void konfiguriere(JTable tabelle) {
tabelle.setRowHeight(24); tabelle.setRowHeight(26);
tabelle.setAutoCreateRowSorter(true); tabelle.setAutoCreateRowSorter(true);
tabelle.setFillsViewportHeight(true); tabelle.setFillsViewportHeight(true);
} }
@ -66,7 +66,8 @@ public final class TabellenFormat {
private static Color farbeFuer(DokumentStatus status) { private static Color farbeFuer(DokumentStatus status) {
return switch (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 OFFEN -> new Color(0, 90, 180);
case VERSENDET -> new Color(0, 130, 60); case VERSENDET -> new Color(0, 130, 60);
case STORNIERT -> new Color(180, 40, 40); case STORNIERT -> new Color(180, 40, 40);

View File

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

View File

@ -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());
}
}

View File

@ -257,6 +257,21 @@ class OberflaechenControllerTest {
assertEquals("K-000017", treffer.get(0).getKundennummer()); 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. */ /** Zähl-Stub des DokumentService (Gruppe A) für die Controller-Tests. */
private static final class DokumentServiceStub implements DokumentService { private static final class DokumentServiceStub implements DokumentService {