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

View File

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

View File

@ -49,6 +49,10 @@ public class Dokumentposition {
/** Steuerbetrag der Position: {@code positionssummeNetto * steuersatz}, Scale 2 (F-23, TC-01). */
public BigDecimal getSteuerbetrag() {
// Altdaten ohne Preis-Snapshot (IF-01) liefern 0 statt eines Fehlers
if (positionssummeNetto == null || steuersatz == null) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
return positionssummeNetto.multiply(steuersatz).setScale(2, RoundingMode.HALF_UP);
}

View File

@ -13,19 +13,48 @@ import java.io.UncheckedIOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.NumberFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/**
* PDF-Export der Belege mit Apache PDFBox (A-F-04, F-07, F-10, F-15).
* Rechnungen enthalten die Pflichtangaben gemäß § 14 UStG (F-13):
* Belegnummer, Rechnungs- und Leistungsdatum, Kundendaten, Positionen mit
* Einzel- und Gesamtbeträgen, Steuersatz/-betrag sowie die Summen.
* Standardisierter PDF-Export der Belege mit Apache PDFBox
* (A-F-04, F-07, F-10, F-15), angelehnt an den deutschen Geschäftsbrief:
* Absender- und Empfängerblock, Belegkopf mit Datum und Referenzen,
* Positionstabelle mit festen Spalten und rechtsbündigen Beträgen sowie
* Summenblock.
*
* <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 {
/** Aussteller-Stammdaten (§ 14 UStG); bei Produktivbetrieb anzupassen. */
private static final String AUSSTELLER_NAME = "Team 1 Fakturierung";
private static final String AUSSTELLER_STRASSE = "Musterstraße 1";
private static final String AUSSTELLER_ORT = "68163 Mannheim";
private static final String AUSSTELLER_UST_ID = "USt-IdNr. DE000000000";
private static final DateTimeFormatter DATUM = DateTimeFormatter.ofPattern("dd.MM.yyyy");
/** Seitenränder und Zeilenraster (A4 hoch, Angaben in PDF-Punkten). */
private static final float RAND = 50;
private static final float ZEILENHOEHE = 14;
private static final float SEITENBREITE = PDRectangle.A4.getWidth();
private static final float RECHTS = SEITENBREITE - RAND;
/** Spaltenraster der Positionstabelle: Textspalten linksbündig ... */
private static final float SPALTE_POS = RAND;
private static final float SPALTE_PRODUKT = 80;
private static final float SPALTE_BEZEICHNUNG = 155;
/** ... Zahlenspalten rechtsbündig an ihrer rechten Kante. */
private static final float SPALTE_MENGE = 385;
private static final float SPALTE_EINZELPREIS = 460;
private static final float SPALTE_UST = 497;
private static final float SPALTE_SUMME = RECHTS;
private final PDFont normal = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
private final PDFont fett = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
@ -35,48 +64,11 @@ public class PdfBoxPdfExporter implements PdfExporter {
try (PDDocument pdf = new PDDocument()) {
Schreiber schreiber = new Schreiber(pdf);
schreiber.zeile(fett, 16, dokument.belegtyp().anzeigename() + " " + dokument.getBelegnummer());
if (dokument.getStatus() == DokumentStatus.STORNIERT) {
schreiber.zeile(fett, 12, "*** STORNIERT ***");
}
schreiber.leer();
schreiber.zeile(normal, 10, "Datum: " + format(dokument.getDatum()));
if (dokument instanceof Angebot angebot) {
schreiber.zeile(normal, 10, "Gültig bis: " + format(angebot.getGueltigBis()));
}
if (dokument instanceof Lieferschein lieferschein) {
schreiber.zeile(normal, 10, "Lieferdatum: " + format(lieferschein.getLieferdatum()));
}
if (dokument instanceof Rechnung rechnung) {
schreiber.zeile(normal, 10, "Leistungsdatum: " + format(rechnung.getLeistungsdatum()));
schreiber.zeile(normal, 10, "Zahlbar bis: " + format(rechnung.getZahlungsziel()));
}
if (dokument.getVorgaengerNr() != null) {
schreiber.zeile(normal, 10, "Referenz: " + dokument.getVorgaengerNr());
}
schreiber.leer();
schreiber.zeile(fett, 10, "Kunde");
schreiber.zeile(normal, 10, dokument.getKundeName() + " (" + dokument.getKundenReferenz() + ")");
schreiber.zeile(normal, 10, dokument.getKundeAnschrift());
schreiber.leer();
schreiber.zeile(fett, 10, String.format("%-4s %-10s %-34s %6s %12s %6s %12s",
"Pos", "Produkt", "Bezeichnung", "Menge", "Einzelpreis", "USt", "Summe"));
int pos = 1;
for (Dokumentposition position : dokument.getPositionen()) {
schreiber.zeile(normal, 10, String.format("%-4d %-10s %-34s %6d %12s %5s%% %12s",
pos++,
position.getProduktReferenz(),
kuerze(position.getBezeichnung(), 34),
position.getMenge(),
betrag(position.getEinzelpreisNetto()),
prozent(position.getSteuersatz()),
betrag(position.getPositionssummeNetto())));
}
schreiber.leer();
schreiber.zeile(normal, 10, "Summe netto: " + betrag(dokument.getSummeNetto()) + " EUR");
schreiber.zeile(normal, 10, "Umsatzsteuer: " + betrag(dokument.getSummeSteuer()) + " EUR");
schreiber.zeile(fett, 11, "Summe brutto: " + betrag(dokument.getSummeBrutto()) + " EUR");
schreibeBriefkopf(schreiber, dokument);
schreibeBelegkopf(schreiber, dokument);
schreibePositionstabelle(schreiber, dokument);
schreibeSummenblock(schreiber, dokument);
schreibeSchlusstext(schreiber, dokument);
schreiber.schliesse();
if (zielDatei.getParent() != null) {
@ -88,15 +80,140 @@ public class PdfBoxPdfExporter implements PdfExporter {
}
}
private static String format(java.time.LocalDate datum) {
/** Absenderblock rechts oben, Rücksendezeile und Empfängerblock links. */
private void schreibeBriefkopf(Schreiber schreiber, Dokument dokument) throws IOException {
schreiber.rechtsbuendig(fett, 11, AUSSTELLER_NAME, RECHTS);
schreiber.rechtsbuendig(normal, 9, AUSSTELLER_STRASSE, RECHTS);
schreiber.rechtsbuendig(normal, 9, AUSSTELLER_ORT, RECHTS);
schreiber.rechtsbuendig(normal, 9, AUSSTELLER_UST_ID, RECHTS);
schreiber.leer();
schreiber.zeile(normal, 7, AUSSTELLER_NAME + " · " + AUSSTELLER_STRASSE
+ " · " + AUSSTELLER_ORT);
schreiber.linie();
schreiber.zeile(normal, 10, dokument.getKundeName()
+ " (Kundennr. " + dokument.getKundenReferenz() + ")");
// anschrift() liefert "Straße, PLZ Ort" einzeilig; für den
// Empfängerblock wird sie an der ersten Trennstelle umbrochen
String anschrift = dokument.getKundeAnschrift();
int trenner = anschrift == null ? -1 : anschrift.indexOf(", ");
if (trenner >= 0) {
schreiber.zeile(normal, 10, anschrift.substring(0, trenner));
schreiber.zeile(normal, 10, anschrift.substring(trenner + 2));
} else if (anschrift != null) {
schreiber.zeile(normal, 10, anschrift);
}
schreiber.leer();
schreiber.leer();
}
/** Belegtitel, Stornokennzeichen und Datums-/Referenzangaben. */
private void schreibeBelegkopf(Schreiber schreiber, Dokument dokument) throws IOException {
schreiber.zeile(fett, 16, dokument.belegtyp().anzeigename() + " " + dokument.getBelegnummer());
if (dokument.getStatus() == DokumentStatus.STORNIERT) {
schreiber.zeile(fett, 12, "*** STORNIERT ***");
}
schreiber.leer();
schreiber.zeile(normal, 10, "Datum: " + format(dokument.getDatum()));
if (dokument instanceof Angebot angebot) {
schreiber.zeile(normal, 10, "Gültig bis: " + format(angebot.getGueltigBis()));
}
if (dokument instanceof Lieferschein lieferschein) {
schreiber.zeile(normal, 10, "Lieferdatum: " + format(lieferschein.getLieferdatum()));
}
if (dokument instanceof Rechnung rechnung) {
schreiber.zeile(normal, 10, "Leistungsdatum: " + format(rechnung.getLeistungsdatum()));
schreiber.zeile(normal, 10, "Zahlbar bis: " + format(rechnung.getZahlungsziel()));
}
if (dokument.getVorgaengerNr() != null) {
schreiber.zeile(normal, 10, "Referenzbeleg: " + dokument.getVorgaengerNr());
}
schreiber.leer();
}
/** Positionstabelle im festen Spaltenraster; Kopf wird je Seite wiederholt. */
private void schreibePositionstabelle(Schreiber schreiber, Dokument dokument) throws IOException {
schreibeTabellenkopf(schreiber);
int pos = 1;
for (Dokumentposition position : dokument.getPositionen()) {
if (!schreiber.passtNochZeile()) {
schreiber.neueSeite();
schreibeTabellenkopf(schreiber);
}
schreiber.beginneZeile();
schreiber.text(normal, 10, String.valueOf(pos++), SPALTE_POS);
schreiber.text(normal, 10, position.getProduktReferenz(), SPALTE_PRODUKT);
schreiber.text(normal, 10, kuerze(position.getBezeichnung(), 40), SPALTE_BEZEICHNUNG);
schreiber.textRechts(normal, 10, String.valueOf(position.getMenge()), SPALTE_MENGE);
schreiber.textRechts(normal, 10, betrag(position.getEinzelpreisNetto()), SPALTE_EINZELPREIS);
schreiber.textRechts(normal, 10, prozent(position.getSteuersatz()) + " %", SPALTE_UST);
schreiber.textRechts(normal, 10, betrag(position.getPositionssummeNetto()), SPALTE_SUMME);
schreiber.beendeZeile();
}
schreiber.linie();
}
private void schreibeTabellenkopf(Schreiber schreiber) throws IOException {
schreiber.beginneZeile();
schreiber.text(fett, 10, "Pos", SPALTE_POS);
schreiber.text(fett, 10, "Produkt", SPALTE_PRODUKT);
schreiber.text(fett, 10, "Bezeichnung", SPALTE_BEZEICHNUNG);
schreiber.textRechts(fett, 10, "Menge", SPALTE_MENGE);
schreiber.textRechts(fett, 10, "Einzelpreis", SPALTE_EINZELPREIS);
schreiber.textRechts(fett, 10, "USt", SPALTE_UST);
schreiber.textRechts(fett, 10, "Summe", SPALTE_SUMME);
schreiber.beendeZeile();
schreiber.linie();
}
/** Summen rechtsbündig unter der Tabelle; Bruttosumme hervorgehoben (F-03). */
private void schreibeSummenblock(Schreiber schreiber, Dokument dokument) throws IOException {
summenzeile(schreiber, normal, 10, "Summe netto:", dokument.getSummeNetto());
summenzeile(schreiber, normal, 10, "Umsatzsteuer:", dokument.getSummeSteuer());
summenzeile(schreiber, fett, 11, "Summe brutto:", dokument.getSummeBrutto());
schreiber.leer();
}
private void summenzeile(Schreiber schreiber, PDFont font, float groesse,
String beschriftung, BigDecimal wert) throws IOException {
schreiber.beginneZeile();
schreiber.textRechts(font, groesse, beschriftung, SPALTE_EINZELPREIS);
schreiber.textRechts(font, groesse, betrag(wert) + " EUR", SPALTE_SUMME);
schreiber.beendeZeile();
}
/** Belegtyp-spezifischer Hinweistext am Ende des Dokuments. */
private void schreibeSchlusstext(Schreiber schreiber, Dokument dokument) throws IOException {
if (dokument instanceof Rechnung rechnung && rechnung.getZahlungsziel() != null
&& dokument.getStatus() != DokumentStatus.STORNIERT) {
schreiber.zeile(normal, 10, "Bitte überweisen Sie den Rechnungsbetrag bis zum "
+ format(rechnung.getZahlungsziel()) + ".");
}
if (dokument instanceof Angebot angebot && angebot.getGueltigBis() != null) {
schreiber.zeile(normal, 10, "Dieses Angebot ist gültig bis zum "
+ format(angebot.getGueltigBis()) + ".");
}
}
private static String format(LocalDate datum) {
return datum == null ? "—" : DATUM.format(datum);
}
/** Deutsches Betragsformat mit Tausenderpunkt, z. B. "1.234,56". */
private static String betrag(BigDecimal wert) {
return wert.toPlainString().replace('.', ',');
if (wert == null) {
return "—";
}
NumberFormat format = NumberFormat.getNumberInstance(Locale.GERMANY);
format.setMinimumFractionDigits(2);
format.setMaximumFractionDigits(2);
return format.format(wert);
}
private static String prozent(BigDecimal steuersatz) {
if (steuersatz == null) {
return "—";
}
return steuersatz.multiply(new BigDecimal("100")).stripTrailingZeros().toPlainString();
}
@ -107,8 +224,13 @@ public class PdfBoxPdfExporter implements PdfExporter {
return text.length() <= maxLaenge ? text : text.substring(0, maxLaenge - 1) + "…";
}
/** Zeilenweiser Schreiber mit automatischem Seitenumbruch. */
private final class Schreiber {
/**
* Zeilenweiser Schreiber mit automatischem Seitenumbruch. Einfache
* Zeilen entstehen über {@link #zeile}; Tabellenzeilen mit mehreren
* Spalten über {@link #beginneZeile}, {@link #text}/{@link #textRechts}
* und {@link #beendeZeile}.
*/
private static final class Schreiber {
private final PDDocument pdf;
private PDPageContentStream inhalt;
@ -119,7 +241,7 @@ public class PdfBoxPdfExporter implements PdfExporter {
neueSeite();
}
private void neueSeite() throws IOException {
void neueSeite() throws IOException {
if (inhalt != null) {
inhalt.close();
}
@ -129,16 +251,59 @@ public class PdfBoxPdfExporter implements PdfExporter {
y = PDRectangle.A4.getHeight() - RAND;
}
void zeile(PDFont font, float groesse, String text) throws IOException {
if (y < RAND + ZEILENHOEHE) {
boolean passtNochZeile() {
return y >= RAND + ZEILENHOEHE;
}
/** Beginnt eine Tabellenzeile; bricht bei Bedarf auf eine neue Seite um. */
void beginneZeile() throws IOException {
if (!passtNochZeile()) {
neueSeite();
}
}
void beendeZeile() {
y -= ZEILENHOEHE;
}
/** Linksbündiger Text an Spaltenposition {@code x} der aktuellen Zeile. */
void text(PDFont font, float groesse, String text, float x) throws IOException {
inhalt.beginText();
inhalt.setFont(font, groesse);
inhalt.newLineAtOffset(RAND, y);
inhalt.showText(text);
inhalt.newLineAtOffset(x, y);
inhalt.showText(text == null ? "" : text);
inhalt.endText();
y -= ZEILENHOEHE;
}
/** Rechtsbündiger Text: {@code xRechts} ist die rechte Kante der Spalte. */
void textRechts(PDFont font, float groesse, String text, float xRechts) throws IOException {
String sicher = text == null ? "" : text;
float breite = font.getStringWidth(sicher) / 1000 * groesse;
text(font, groesse, sicher, xRechts - breite);
}
/** Einzelne linksbündige Zeile am linken Seitenrand. */
void zeile(PDFont font, float groesse, String text) throws IOException {
beginneZeile();
text(font, groesse, text, RAND);
beendeZeile();
}
/** Einzelne rechtsbündige Zeile (z. B. Absenderblock). */
void rechtsbuendig(PDFont font, float groesse, String text, float xRechts) throws IOException {
beginneZeile();
textRechts(font, groesse, text, xRechts);
beendeZeile();
}
/** Horizontale Trennlinie über die volle Satzspiegelbreite. */
void linie() throws IOException {
beginneZeile();
inhalt.moveTo(RAND, y + ZEILENHOEHE - 4);
inhalt.lineTo(RECHTS, y + ZEILENHOEHE - 4);
inhalt.setLineWidth(0.5f);
inhalt.stroke();
y -= ZEILENHOEHE / 2;
}
void leer() {

View File

@ -10,13 +10,16 @@ import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService;
import javax.swing.BorderFactory;
import javax.swing.DefaultComboBoxModel;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
@ -74,10 +77,11 @@ public class BelegDialog extends JDialog {
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
((JComponent) getContentPane()).setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
JPanel kopf = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 3, 3, 3);
c.insets = new Insets(4, 4, 4, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
@ -98,7 +102,13 @@ public class BelegDialog extends JDialog {
c.gridy = 2;
kopf.add(datumBeschriftung, c);
c.gridx = 1;
datumFeld.setToolTipText("Optional — Format: TT.MM.JJJJ");
kopf.add(datumFeld, c);
c.gridx = 0;
c.gridy = 3;
c.gridwidth = 2;
kopf.add(UiHilfen.pflichtfeldLegende(), c);
add(kopf, BorderLayout.NORTH);
JPanel mitte = new JPanel(new BorderLayout(5, 5));
@ -108,9 +118,11 @@ public class BelegDialog extends JDialog {
eingabe.add(new JLabel("Menge:"));
eingabe.add(mengeWahl);
JButton hinzufuegen = new JButton("Hinzufügen");
hinzufuegen.setMnemonic('H');
hinzufuegen.addActionListener(e -> fuegePositionHinzu());
eingabe.add(hinzufuegen);
JButton entfernen = new JButton("Entfernen");
entfernen.setMnemonic('N');
entfernen.addActionListener(e -> entfernePosition());
eingabe.add(entfernen);
mitte.add(eingabe, BorderLayout.NORTH);
@ -119,12 +131,32 @@ public class BelegDialog extends JDialog {
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.RIGHT));
JButton abbrechen = new JButton("Abbrechen");
abbrechen.addActionListener(e -> dispose());
abbrechen.setMnemonic('A');
abbrechen.addActionListener(e -> abbrechenMitNachfrage());
JButton erstellen = new JButton("Erstellen");
erstellen.setMnemonic('E');
erstellen.addActionListener(e -> erstelle());
knoepfe.add(abbrechen);
knoepfe.add(erstellen);
add(knoepfe, BorderLayout.SOUTH);
getRootPane().setDefaultButton(erstellen);
UiHilfen.escSchliesst(this, this::abbrechenMitNachfrage);
UiHilfen.fensterSchliessenAbfangen(this, this::abbrechenMitNachfrage);
}
/** Schutz vor Datenverlust: Nachfrage, wenn bereits Positionen erfasst wurden. */
private void abbrechenMitNachfrage() {
if (!positionen.isEmpty()) {
int antwort = JOptionPane.showConfirmDialog(this,
"Die erfassten Positionen gehen verloren. Dialog wirklich schließen?",
"Eingaben verwerfen", JOptionPane.YES_NO_OPTION,
JOptionPane.WARNING_MESSAGE);
if (antwort != JOptionPane.YES_OPTION) {
return;
}
}
dispose();
}
private void aktualisiereDatumsfeld() {

View File

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

View File

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

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.KundenVerwaltungsService;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTable;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.File;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Modulansicht Kundenverwaltung (D-F-03 bis F-05): sortierte Liste mit
* Suchfeld, Formular mit gekennzeichneten Pflichtfeldern, CSV-Export.
* Modulansicht Kundenverwaltung (D-F-03 bis F-05): sortierte Liste in voller
* Breite mit Suchfeld; Anlegen und Bearbeiten erfolgen über die modale
* Formular-Maske {@link KundenFormularDialog} (Aufruf per Knopf oder
* Doppelklick), die Aktionen liegen unter der Tabelle.
*/
public class KundenPanel extends JPanel implements ModulPanel {
private final KundenVerwaltungsService service;
private final StammdatenController controller;
private final KundenCsvExport csvExport;
private final JTextField suchfeld = new JTextField(20);
private final KundenTabellenModel tabellenModel = new KundenTabellenModel();
private final JTable tabelle = new JTable(tabellenModel);
private final JLabel trefferAnzeige = UiHilfen.trefferLabel();
private final JTextField nameFeld = new JTextField(20);
private final JTextField strasseFeld = new JTextField(20);
private final JTextField plzFeld = new JTextField(8);
private final JTextField ortFeld = new JTextField(20);
private final JTextField eMailFeld = new JTextField(20);
private final JTextField telefonFeld = new JTextField(20);
private final JTextField ustIdNrFeld = new JTextField(20);
private final JLabel nummerAnzeige = new JLabel("— neuer Kunde —");
private final Map<String, JComponent> felder = new LinkedHashMap<>();
private final JButton bearbeitenKnopf = new JButton("Bearbeiten…");
private final JButton loeschenKnopf = new JButton("Löschen");
private String gewaehlteNummer;
private boolean ungespeichert;
public KundenPanel(KundenVerwaltungsService service, KundenCsvExport csvExport,
EreignisBus ereignisBus) {
/**
* @param service Fachkomponente der Gruppe C für Anlegen/Ändern/Löschen
* @param controller GUI-freier Controller für Suche und Listeninhalt (D-F-03)
* @param csvExport CSV-Export der Kundenstammdaten (C-F-13)
* @param ereignisBus Observer-Verteiler für Aktualisierungen nach Datenänderungen
*/
public KundenPanel(KundenVerwaltungsService service, StammdatenController controller,
KundenCsvExport csvExport, EreignisBus ereignisBus) {
this.service = service;
this.controller = controller;
this.csvExport = csvExport;
felder.put("Name", nameFeld);
felder.put("Straße", strasseFeld);
felder.put("PLZ", plzFeld);
felder.put("Ort", ortFeld);
felder.put("E-Mail", eMailFeld);
baueOberflaeche();
aktualisiere();
ereignisBus.abonniere(DatenBereich.KUNDEN, this::aktualisiere);
@ -72,180 +66,124 @@ public class KundenPanel extends JPanel implements ModulPanel {
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT));
suchleiste.add(new JLabel("Suche (Name oder Kundennummer):"));
JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
suchleiste.add(new JLabel("Suche:"));
UiHilfen.platzhalter(suchfeld, "Name oder Kundennummer…");
suchfeld.setToolTipText("Suche nach Name oder Kundennummer (Strg+F)");
suchleiste.add(suchfeld);
suchfeld.getDocument().addDocumentListener(neuerSuchListener());
add(suchleiste, BorderLayout.NORTH);
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
TabellenFormat.konfiguriere(tabelle);
tabelle.getSelectionModel().addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) {
ladeAuswahl();
tabelle.getSelectionModel().addListSelectionListener(e -> aktualisiereAktionen());
tabelle.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 2) {
bearbeite();
}
}
});
JSplitPane teiler = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
new JScrollPane(tabelle), baueFormular());
teiler.setResizeWeight(0.55);
add(teiler, BorderLayout.CENTER);
JPanel listenSeite = new JPanel(new BorderLayout(0, 4));
listenSeite.add(new JScrollPane(tabelle), BorderLayout.CENTER);
listenSeite.add(trefferAnzeige, BorderLayout.SOUTH);
add(listenSeite, BorderLayout.CENTER);
JPanel aktionen = new JPanel(new FlowLayout(FlowLayout.LEFT));
JButton neuKnopf = new JButton("Neuen Kunden anlegen…");
neuKnopf.setMnemonic('N');
neuKnopf.setToolTipText("Öffnet die Maske für einen neuen Kunden (Alt+N)");
neuKnopf.addActionListener(e -> legeNeuAn());
bearbeitenKnopf.setMnemonic('B');
bearbeitenKnopf.addActionListener(e -> bearbeite());
loeschenKnopf.setMnemonic('L');
loeschenKnopf.addActionListener(e -> loesche());
JButton exportKnopf = new JButton("CSV exportieren…");
exportKnopf.setMnemonic('C');
exportKnopf.addActionListener(e -> exportiere());
aktionen.add(neuKnopf);
aktionen.add(bearbeitenKnopf);
aktionen.add(loeschenKnopf);
aktionen.add(exportKnopf);
add(aktionen, BorderLayout.SOUTH);
UiHilfen.strgF(this, suchfeld);
aktualisiereAktionen();
}
private JPanel baueFormular() {
JPanel formular = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 3, 3, 3);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
private Kunde auswahl() {
int zeile = tabelle.getSelectedRow();
return zeile < 0 ? null
: tabellenModel.gibZeile(tabelle.convertRowIndexToModel(zeile));
}
int zeile = 0;
zeile = formularZeile(formular, c, zeile, "Kundennummer:", nummerAnzeige);
zeile = formularZeile(formular, c, zeile, "Name: *", nameFeld);
zeile = formularZeile(formular, c, zeile, "Straße: *", strasseFeld);
zeile = formularZeile(formular, c, zeile, "PLZ: *", plzFeld);
zeile = formularZeile(formular, c, zeile, "Ort: *", ortFeld);
zeile = formularZeile(formular, c, zeile, "E-Mail:", eMailFeld);
zeile = formularZeile(formular, c, zeile, "Telefon:", telefonFeld);
zeile = formularZeile(formular, c, zeile, "USt-IdNr.:", ustIdNrFeld);
private void aktualisiereAktionen() {
boolean ausgewaehlt = auswahl() != null;
String hinweis = "Bitte zuerst einen Kunden in der Liste auswählen.";
UiHilfen.schalteAktion(bearbeitenKnopf, ausgewaehlt, hinweis);
UiHilfen.schalteAktion(loeschenKnopf, ausgewaehlt, hinweis);
}
DocumentListener aenderungsListener = neuerAenderungsListener();
for (JComponent feld : List.of(nameFeld, strasseFeld, plzFeld, ortFeld,
eMailFeld, telefonFeld, ustIdNrFeld)) {
((JTextField) feld).getDocument().addDocumentListener(aenderungsListener);
private void legeNeuAn() {
new KundenFormularDialog(SwingUtilities.getWindowAncestor(this), service, null)
.setVisible(true);
}
private void bearbeite() {
Kunde kunde = auswahl();
if (kunde == null) {
return;
}
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.LEFT));
JButton neu = new JButton("Neu");
neu.addActionListener(e -> leereFormular());
JButton speichern = new JButton("Speichern");
speichern.addActionListener(e -> speichere());
JButton loeschen = new JButton("Löschen");
loeschen.addActionListener(e -> loesche());
JButton export = new JButton("CSV-Export");
export.addActionListener(e -> exportiere());
knoepfe.add(neu);
knoepfe.add(speichern);
knoepfe.add(loeschen);
knoepfe.add(export);
c.gridx = 0;
c.gridy = zeile;
c.gridwidth = 2;
formular.add(knoepfe, c);
return formular;
}
private int formularZeile(JPanel formular, GridBagConstraints c, int zeile,
String beschriftung, JComponent feld) {
c.gridx = 0;
c.gridy = zeile;
c.gridwidth = 1;
c.weightx = 0;
formular.add(new JLabel(beschriftung), c);
c.gridx = 1;
c.weightx = 1;
formular.add(feld, c);
return zeile + 1;
}
private void speichere() {
MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
Kunde kunde = gewaehlteNummer == null
? new Kunde()
: service.findeKunde(gewaehlteNummer);
kunde.setName(nameFeld.getText().trim());
kunde.setStrasse(strasseFeld.getText().trim());
kunde.setPlz(plzFeld.getText().trim());
kunde.setOrt(ortFeld.getText().trim());
kunde.setEMail(leerZuNull(eMailFeld.getText()));
kunde.setTelefon(leerZuNull(telefonFeld.getText()));
kunde.setUstIdNr(leerZuNull(ustIdNrFeld.getText()));
Kunde gespeichert = gewaehlteNummer == null
? service.legeAn(kunde)
: service.aendere(kunde);
ungespeichert = false;
gewaehlteNummer = gespeichert.getKundennummer();
nummerAnzeige.setText(gespeichert.getKundennummer());
MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gespeichert. Kundennummer: "
+ gespeichert.getKundennummer()), felder);
});
new KundenFormularDialog(SwingUtilities.getWindowAncestor(this), service, kunde)
.setVisible(true);
}
private void loesche() {
if (gewaehlteNummer == null) {
Kunde kunde = auswahl();
if (kunde == null) {
return;
}
int antwort = JOptionPane.showConfirmDialog(this,
"Kunde " + gewaehlteNummer + " wirklich dauerhaft löschen?",
"Kunde " + kunde.getKundennummer() + " wirklich dauerhaft löschen?",
"Kunde löschen", JOptionPane.YES_NO_OPTION);
if (antwort != JOptionPane.YES_OPTION) {
return;
}
MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
service.loescheKunde(gewaehlteNummer);
leereFormular();
MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gelöscht."), felder);
MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
service.loescheKunde(kunde.getKundennummer());
MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gelöscht."), null);
});
}
private void exportiere() {
JFileChooser auswahl = new JFileChooser();
auswahl.setSelectedFile(new java.io.File("kunden.csv"));
if (auswahl.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
csvExport.exportiereCsv(auswahl.getSelectedFile().toPath());
MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Kundenstammdaten wurden exportiert nach "
+ auswahl.getSelectedFile()), felder);
JFileChooser auswahlDialog = new JFileChooser();
auswahlDialog.setSelectedFile(new File("kunden.csv"));
if (auswahlDialog.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
csvExport.exportiereCsv(auswahlDialog.getSelectedFile().toPath());
MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Kundenstammdaten wurden exportiert nach "
+ auswahlDialog.getSelectedFile()), null);
});
}
}
private void ladeAuswahl() {
int zeile = tabelle.getSelectedRow();
if (zeile < 0) {
return;
}
Kunde kunde = tabellenModel.kunden.get(tabelle.convertRowIndexToModel(zeile));
gewaehlteNummer = kunde.getKundennummer();
nummerAnzeige.setText(kunde.getKundennummer());
nameFeld.setText(kunde.getName());
strasseFeld.setText(kunde.getStrasse());
plzFeld.setText(kunde.getPlz());
ortFeld.setText(kunde.getOrt());
eMailFeld.setText(kunde.getEMail() == null ? "" : kunde.getEMail());
telefonFeld.setText(kunde.getTelefon() == null ? "" : kunde.getTelefon());
ustIdNrFeld.setText(kunde.getUstIdNr() == null ? "" : kunde.getUstIdNr());
ungespeichert = false;
}
private void leereFormular() {
gewaehlteNummer = null;
nummerAnzeige.setText("— neuer Kunde —");
for (JComponent feld : List.of(nameFeld, strasseFeld, plzFeld, ortFeld,
eMailFeld, telefonFeld, ustIdNrFeld)) {
((JTextField) feld).setText("");
}
tabelle.clearSelection();
ungespeichert = false;
}
private static String leerZuNull(String text) {
String wert = text.trim();
return wert.isEmpty() ? null : wert;
}
@Override
public boolean hatUngespeicherteAenderungen() {
return ungespeichert;
// Eingaben erfolgen ausschließlich in der modalen Formular-Maske;
// dort schützt die eigene Nachfrage vor Datenverlust (D-F-02).
return false;
}
@Override
public void aktualisiere() {
String begriff = suchfeld.getText().trim();
tabellenModel.setze(begriff.isEmpty()
? service.alleSortiertNachName()
: service.suche(begriff));
List<Kunde> liste = controller.kundenListe(suchfeld.getText());
tabellenModel.setze(liste);
trefferAnzeige.setText(UiHilfen.trefferText(liste.size(),
controller.kundenListe("").size(), "Kunden", suchfeld.getText()));
}
private DocumentListener neuerSuchListener() {
@ -267,25 +205,6 @@ public class KundenPanel extends JPanel implements ModulPanel {
};
}
private DocumentListener neuerAenderungsListener() {
return new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
ungespeichert = true;
}
@Override
public void removeUpdate(DocumentEvent e) {
ungespeichert = true;
}
@Override
public void changedUpdate(DocumentEvent e) {
ungespeichert = true;
}
};
}
private static final class KundenTabellenModel extends AbstractTableModel {
private static final String[] SPALTEN = {"Kundennummer", "Name", "Straße", "PLZ", "Ort"};
@ -297,6 +216,10 @@ public class KundenPanel extends JPanel implements ModulPanel {
fireTableDataChanged();
}
Kunde gibZeile(int zeile) {
return kunden.get(zeile);
}
@Override
public int getRowCount() {
return kunden.size();

View File

@ -9,10 +9,12 @@ package de.team1.faktura.gui;
*/
public record Meldung(MeldungsTyp typ, String feldname, String text) {
/** Erfolgsmeldung ohne Feldbezug (D-F-17). */
public static Meldung erfolg(String text) {
return new Meldung(MeldungsTyp.ERFOLG, null, text);
}
/** Fehlermeldung; {@code feldname} benennt das betroffene Eingabefeld (Q-09). */
public static Meldung fehler(String feldname, String text) {
return new Meldung(MeldungsTyp.FEHLER, feldname, text);
}

View File

@ -70,6 +70,9 @@ public final class MeldungsAnzeige {
} catch (UncheckedIOException e) {
zeige(parent, Meldung.fehler(null,
"Die Daten konnten nicht gespeichert werden: " + e.getMessage()), felder);
} catch (RuntimeException e) {
// Letztes Netz: kein stilles Scheitern auf dem Event-Dispatch-Thread
zeige(parent, Meldung.fehler(null, "Unerwarteter Fehler: " + e), felder);
}
}
}

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.EreignisBus;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktCsvExport;
import de.team1.faktura.produkte.ProduktVerwaltungsService;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTable;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.File;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Modulansicht Produktverwaltung (D-F-03 bis F-05): sortierte Liste mit
* Suchfeld, Formular mit gekennzeichneten Pflichtfeldern, CSV-Export.
* Modulansicht Produktverwaltung (D-F-03 bis F-05): sortierte Liste in voller
* Breite mit Suchfeld; Anlegen und Bearbeiten erfolgen über die modale
* Formular-Maske {@link ProduktFormularDialog} (Aufruf per Knopf oder
* Doppelklick), die Aktionen liegen unter der Tabelle.
*/
public class ProduktPanel extends JPanel implements ModulPanel {
private static final String[] STEUERSAETZE = {"19 %", "7 %", "0 %"};
private final ProduktVerwaltungsService service;
private final StammdatenController controller;
private final ProduktCsvExport csvExport;
private final JTextField suchfeld = new JTextField(20);
private final ProduktTabellenModel tabellenModel = new ProduktTabellenModel();
private final JTable tabelle = new JTable(tabellenModel);
private final JLabel trefferAnzeige = UiHilfen.trefferLabel();
private final JTextField bezeichnungFeld = new JTextField(20);
private final JTextField beschreibungFeld = new JTextField(20);
private final JTextField preisFeld = new JTextField(10);
private final JComboBox<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 final JButton bearbeitenKnopf = new JButton("Bearbeiten…");
private final JButton loeschenKnopf = new JButton("Löschen");
private String gewaehlteNummer;
private boolean ungespeichert;
public ProduktPanel(ProduktVerwaltungsService service, ProduktCsvExport csvExport,
EreignisBus ereignisBus) {
/**
* @param service Fachkomponente der Gruppe B für Anlegen/Ändern/Löschen
* @param controller GUI-freier Controller für Suche und Listeninhalt (D-F-03)
* @param csvExport CSV-Export der Produktstammdaten (B-F-13)
* @param ereignisBus Observer-Verteiler für Aktualisierungen nach Datenänderungen
*/
public ProduktPanel(ProduktVerwaltungsService service, StammdatenController controller,
ProduktCsvExport csvExport, EreignisBus ereignisBus) {
this.service = service;
this.controller = controller;
this.csvExport = csvExport;
felder.put("Bezeichnung", bezeichnungFeld);
felder.put("Einzelpreis", preisFeld);
felder.put("Steuersatz", steuersatzWahl);
baueOberflaeche();
aktualisiere();
ereignisBus.abonniere(DatenBereich.PRODUKTE, this::aktualisiere);
@ -73,9 +67,12 @@ public class ProduktPanel extends JPanel implements ModulPanel {
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT));
suchleiste.add(new JLabel("Suche (Bezeichnung oder Produktnummer):"));
JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
suchleiste.add(new JLabel("Suche:"));
UiHilfen.platzhalter(suchfeld, "Bezeichnung oder Produktnummer…");
suchfeld.setToolTipText("Suche nach Bezeichnung oder Produktnummer (Strg+F)");
suchleiste.add(suchfeld);
suchfeld.getDocument().addDocumentListener(neuerSuchListener());
add(suchleiste, BorderLayout.NORTH);
@ -83,191 +80,112 @@ public class ProduktPanel extends JPanel implements ModulPanel {
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
TabellenFormat.konfiguriere(tabelle);
tabelle.getColumnModel().getColumn(2).setCellRenderer(TabellenFormat.waehrungsRenderer());
tabelle.getSelectionModel().addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) {
ladeAuswahl();
tabelle.getSelectionModel().addListSelectionListener(e -> aktualisiereAktionen());
tabelle.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 2) {
bearbeite();
}
}
});
JSplitPane teiler = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
new JScrollPane(tabelle), baueFormular());
teiler.setResizeWeight(0.55);
add(teiler, BorderLayout.CENTER);
JPanel listenSeite = new JPanel(new BorderLayout(0, 4));
listenSeite.add(new JScrollPane(tabelle), BorderLayout.CENTER);
listenSeite.add(trefferAnzeige, BorderLayout.SOUTH);
add(listenSeite, BorderLayout.CENTER);
JPanel aktionen = new JPanel(new FlowLayout(FlowLayout.LEFT));
JButton neuKnopf = new JButton("Neues Produkt anlegen…");
neuKnopf.setMnemonic('N');
neuKnopf.setToolTipText("Öffnet die Maske für ein neues Produkt (Alt+N)");
neuKnopf.addActionListener(e -> legeNeuAn());
bearbeitenKnopf.setMnemonic('B');
bearbeitenKnopf.addActionListener(e -> bearbeite());
loeschenKnopf.setMnemonic('L');
loeschenKnopf.addActionListener(e -> loesche());
JButton exportKnopf = new JButton("CSV exportieren…");
exportKnopf.setMnemonic('C');
exportKnopf.addActionListener(e -> exportiere());
aktionen.add(neuKnopf);
aktionen.add(bearbeitenKnopf);
aktionen.add(loeschenKnopf);
aktionen.add(exportKnopf);
add(aktionen, BorderLayout.SOUTH);
UiHilfen.strgF(this, suchfeld);
aktualisiereAktionen();
}
private JPanel baueFormular() {
JPanel formular = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 3, 3, 3);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
private Produkt auswahl() {
int zeile = tabelle.getSelectedRow();
return zeile < 0 ? null
: tabellenModel.gibZeile(tabelle.convertRowIndexToModel(zeile));
}
int zeile = 0;
zeile = formularZeile(formular, c, zeile, "Produktnummer:", nummerAnzeige);
zeile = formularZeile(formular, c, zeile, "Bezeichnung: *", bezeichnungFeld);
zeile = formularZeile(formular, c, zeile, "Beschreibung:", beschreibungFeld);
zeile = formularZeile(formular, c, zeile, "Einzelpreis (netto): *", preisFeld);
zeile = formularZeile(formular, c, zeile, "Steuersatz: *", steuersatzWahl);
zeile = formularZeile(formular, c, zeile, "Einheit:", einheitFeld);
private void aktualisiereAktionen() {
boolean ausgewaehlt = auswahl() != null;
String hinweis = "Bitte zuerst ein Produkt in der Liste auswählen.";
UiHilfen.schalteAktion(bearbeitenKnopf, ausgewaehlt, hinweis);
UiHilfen.schalteAktion(loeschenKnopf, ausgewaehlt, hinweis);
}
DocumentListener aenderungsListener = neuerAenderungsListener();
for (JTextField feld : List.of(bezeichnungFeld, beschreibungFeld, preisFeld, einheitFeld)) {
feld.getDocument().addDocumentListener(aenderungsListener);
private void legeNeuAn() {
new ProduktFormularDialog(SwingUtilities.getWindowAncestor(this), service, null)
.setVisible(true);
}
private void bearbeite() {
Produkt produkt = auswahl();
if (produkt == null) {
return;
}
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.LEFT));
JButton neu = new JButton("Neu");
neu.addActionListener(e -> leereFormular());
JButton speichern = new JButton("Speichern");
speichern.addActionListener(e -> speichere());
JButton loeschen = new JButton("Löschen");
loeschen.addActionListener(e -> loesche());
JButton export = new JButton("CSV-Export");
export.addActionListener(e -> exportiere());
knoepfe.add(neu);
knoepfe.add(speichern);
knoepfe.add(loeschen);
knoepfe.add(export);
c.gridx = 0;
c.gridy = zeile;
c.gridwidth = 2;
formular.add(knoepfe, c);
return formular;
}
private int formularZeile(JPanel formular, GridBagConstraints c, int zeile,
String beschriftung, JComponent feld) {
c.gridx = 0;
c.gridy = zeile;
c.gridwidth = 1;
c.weightx = 0;
formular.add(new JLabel(beschriftung), c);
c.gridx = 1;
c.weightx = 1;
formular.add(feld, c);
return zeile + 1;
}
private void speichere() {
MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
Produkt produkt = gewaehlteNummer == null
? new Produkt()
: service.findeProdukt(gewaehlteNummer);
produkt.setBezeichnung(bezeichnungFeld.getText().trim());
produkt.setBeschreibung(leerZuNull(beschreibungFeld.getText()));
produkt.setEinzelpreisNetto(parsePreis(preisFeld.getText()));
produkt.setSteuersatz(gewaehlterSteuersatz());
produkt.setEinheit(leerZuNull(einheitFeld.getText()));
Produkt gespeichert = gewaehlteNummer == null
? service.legeAn(produkt)
: service.aendere(produkt);
ungespeichert = false;
gewaehlteNummer = gespeichert.getProduktnummer();
nummerAnzeige.setText(gespeichert.getProduktnummer());
MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gespeichert. Produktnummer: "
+ gespeichert.getProduktnummer()), felder);
});
}
/** Akzeptiert deutsches und englisches Dezimaltrennzeichen. */
private BigDecimal parsePreis(String text) {
String wert = text.trim().replace(',', '.');
if (wert.isEmpty()) {
throw new ValidierungsException("Einzelpreis",
"Das Pflichtfeld 'Einzelpreis (netto)' fehlt.");
}
try {
return new BigDecimal(wert).setScale(2, java.math.RoundingMode.HALF_UP);
} catch (NumberFormatException e) {
throw new ValidierungsException("Einzelpreis",
"Der 'Einzelpreis (netto)' ist keine gültige Zahl: " + text);
}
}
private BigDecimal gewaehlterSteuersatz() {
return switch (steuersatzWahl.getSelectedIndex()) {
case 0 -> new BigDecimal("0.19");
case 1 -> new BigDecimal("0.07");
default -> new BigDecimal("0.00");
};
new ProduktFormularDialog(SwingUtilities.getWindowAncestor(this), service, produkt)
.setVisible(true);
}
private void loesche() {
if (gewaehlteNummer == null) {
Produkt produkt = auswahl();
if (produkt == null) {
return;
}
int antwort = JOptionPane.showConfirmDialog(this,
"Produkt " + gewaehlteNummer + " wirklich dauerhaft löschen?",
"Produkt " + produkt.getProduktnummer() + " wirklich dauerhaft löschen?",
"Produkt löschen", JOptionPane.YES_NO_OPTION);
if (antwort != JOptionPane.YES_OPTION) {
return;
}
MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
service.loescheProdukt(gewaehlteNummer);
leereFormular();
MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gelöscht."), felder);
MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
service.loescheProdukt(produkt.getProduktnummer());
MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gelöscht."), null);
});
}
private void exportiere() {
JFileChooser auswahl = new JFileChooser();
auswahl.setSelectedFile(new java.io.File("produkte.csv"));
if (auswahl.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
csvExport.exportiereCsv(auswahl.getSelectedFile().toPath());
MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Produktstammdaten wurden exportiert nach "
+ auswahl.getSelectedFile()), felder);
JFileChooser auswahlDialog = new JFileChooser();
auswahlDialog.setSelectedFile(new File("produkte.csv"));
if (auswahlDialog.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
csvExport.exportiereCsv(auswahlDialog.getSelectedFile().toPath());
MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Produktstammdaten wurden exportiert nach "
+ auswahlDialog.getSelectedFile()), null);
});
}
}
private void ladeAuswahl() {
int zeile = tabelle.getSelectedRow();
if (zeile < 0) {
return;
}
Produkt produkt = tabellenModel.produkte.get(tabelle.convertRowIndexToModel(zeile));
gewaehlteNummer = produkt.getProduktnummer();
nummerAnzeige.setText(produkt.getProduktnummer());
bezeichnungFeld.setText(produkt.getBezeichnung());
beschreibungFeld.setText(produkt.getBeschreibung() == null ? "" : produkt.getBeschreibung());
preisFeld.setText(produkt.getEinzelpreisNetto().toPlainString());
steuersatzWahl.setSelectedIndex(switch (produkt.getSteuersatz().stripTrailingZeros().toPlainString()) {
case "0.19" -> 0;
case "0.07" -> 1;
default -> 2;
});
einheitFeld.setText(produkt.getEinheit() == null ? "" : produkt.getEinheit());
ungespeichert = false;
}
private void leereFormular() {
gewaehlteNummer = null;
nummerAnzeige.setText("— neues Produkt —");
for (JTextField feld : List.of(bezeichnungFeld, beschreibungFeld, preisFeld, einheitFeld)) {
feld.setText("");
}
steuersatzWahl.setSelectedIndex(0);
tabelle.clearSelection();
ungespeichert = false;
}
private static String leerZuNull(String text) {
String wert = text.trim();
return wert.isEmpty() ? null : wert;
}
@Override
public boolean hatUngespeicherteAenderungen() {
return ungespeichert;
// Eingaben erfolgen ausschließlich in der modalen Formular-Maske;
// dort schützt die eigene Nachfrage vor Datenverlust (D-F-02).
return false;
}
@Override
public void aktualisiere() {
String begriff = suchfeld.getText().trim();
tabellenModel.setze(begriff.isEmpty()
? service.alleSortiertNachBezeichnung()
: service.suche(begriff));
List<Produkt> liste = controller.produkteListe(suchfeld.getText());
tabellenModel.setze(liste);
trefferAnzeige.setText(UiHilfen.trefferText(liste.size(),
controller.produkteListe("").size(), "Produkte", suchfeld.getText()));
}
private DocumentListener neuerSuchListener() {
@ -289,25 +207,6 @@ public class ProduktPanel extends JPanel implements ModulPanel {
};
}
private DocumentListener neuerAenderungsListener() {
return new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
ungespeichert = true;
}
@Override
public void removeUpdate(DocumentEvent e) {
ungespeichert = true;
}
@Override
public void changedUpdate(DocumentEvent e) {
ungespeichert = true;
}
};
}
private static final class ProduktTabellenModel extends AbstractTableModel {
private static final String[] SPALTEN = {"Produktnummer", "Bezeichnung", "Einzelpreis (netto)", "Steuersatz", "Einheit"};
@ -319,6 +218,10 @@ public class ProduktPanel extends JPanel implements ModulPanel {
fireTableDataChanged();
}
Produkt gibZeile(int zeile) {
return produkte.get(zeile);
}
@Override
public int getRowCount() {
return produkte.size();

View File

@ -5,13 +5,16 @@ import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService;
import javax.swing.BorderFactory;
import javax.swing.DefaultComboBoxModel;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
@ -19,11 +22,13 @@ import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.SpinnerNumberModel;
import javax.swing.UIManager;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.Window;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@ -63,6 +68,7 @@ public class RechnungsWizardDialog extends JDialog {
private final JButton weiterKnopf = new JButton("Weiter >");
private final JButton speichernKnopf = new JButton("Speichern");
private final JLabel schrittAnzeige = new JLabel();
private final JLabel[] schrittMarkierungen = new JLabel[WizardSchritt.values().length];
public RechnungsWizardDialog(Window besitzer, RechnungsWizardController controller,
KundenService kundenService, ProduktService produktService) {
@ -80,7 +86,22 @@ public class RechnungsWizardDialog extends JDialog {
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
add(schrittAnzeige, BorderLayout.NORTH);
((JComponent) getContentPane()).setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
// Schrittindikator: alle fünf Schritte sichtbar, aktueller Schritt hervorgehoben (Q-05)
JPanel kopf = new JPanel(new BorderLayout(0, 2));
JPanel schritte = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
WizardSchritt[] alleSchritte = WizardSchritt.values();
for (int i = 0; i < alleSchritte.length; i++) {
if (i > 0) {
schritte.add(new JLabel(""));
}
schrittMarkierungen[i] = new JLabel((i + 1) + ". " + schrittName(alleSchritte[i]));
schritte.add(schrittMarkierungen[i]);
}
kopf.add(schritte, BorderLayout.NORTH);
kopf.add(schrittAnzeige, BorderLayout.SOUTH);
add(kopf, BorderLayout.NORTH);
kartenPanel.add(baueSchrittKunde(), WizardSchritt.KUNDE_WAEHLEN.name());
kartenPanel.add(baueSchrittPositionen(), WizardSchritt.POSITIONEN_ERFASSEN.name());
@ -91,18 +112,41 @@ public class RechnungsWizardDialog extends JDialog {
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.RIGHT));
JButton abbrechen = new JButton("Abbrechen");
abbrechen.addActionListener(e -> dispose());
abbrechen.setMnemonic('A');
abbrechen.addActionListener(e -> abbrechenMitNachfrage());
zurueckKnopf.setMnemonic('Z');
zurueckKnopf.addActionListener(e -> {
controller.zurueck();
zeigeSchritt();
});
weiterKnopf.setMnemonic('W');
weiterKnopf.addActionListener(e -> weiter());
speichernKnopf.setMnemonic('S');
speichernKnopf.addActionListener(e -> speichere());
knoepfe.add(abbrechen);
knoepfe.add(zurueckKnopf);
knoepfe.add(weiterKnopf);
knoepfe.add(speichernKnopf);
add(knoepfe, BorderLayout.SOUTH);
UiHilfen.escSchliesst(this, this::abbrechenMitNachfrage);
UiHilfen.fensterSchliessenAbfangen(this, this::abbrechenMitNachfrage);
}
/** Schutz vor Datenverlust: Nachfrage, wenn bereits Eingaben erfasst wurden. */
private void abbrechenMitNachfrage() {
boolean datenErfasst = positionsListenModel.getSize() > 0
|| kundenListe.getSelectedValue() != null;
if (datenErfasst) {
int antwort = JOptionPane.showConfirmDialog(this,
"Die erfassten Eingaben gehen verloren. Assistent wirklich schließen?",
"Eingaben verwerfen", JOptionPane.YES_NO_OPTION,
JOptionPane.WARNING_MESSAGE);
if (antwort != JOptionPane.YES_OPTION) {
return;
}
}
dispose();
}
/** Schritt 1: Kunde auswählen (F-09). */
@ -147,6 +191,7 @@ public class RechnungsWizardDialog extends JDialog {
eingabe.add(new JLabel("Menge:"));
eingabe.add(mengeWahl);
JButton hinzufuegen = new JButton("Hinzufügen");
hinzufuegen.setMnemonic('H');
hinzufuegen.addActionListener(e -> {
Produkt produkt = (Produkt) produktWahl.getSelectedItem();
if (produkt == null) {
@ -160,6 +205,7 @@ public class RechnungsWizardDialog extends JDialog {
});
eingabe.add(hinzufuegen);
JButton entfernen = new JButton("Entfernen");
entfernen.setMnemonic('E');
entfernen.addActionListener(e -> {
int index = positionsListe.getSelectedIndex();
if (index >= 0) {
@ -178,9 +224,12 @@ public class RechnungsWizardDialog extends JDialog {
JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT));
panel.add(new JLabel("Rechnungsdatum (TT.MM.JJJJ): *"));
rechnungsdatumFeld.setText(DATUM.format(LocalDate.now()));
rechnungsdatumFeld.setToolTipText("Pflichtfeld — Format: TT.MM.JJJJ");
panel.add(rechnungsdatumFeld);
panel.add(new JLabel("Zahlungsziel (leer = 14 Tage):"));
zahlungszielFeld.setToolTipText("Optional — Format: TT.MM.JJJJ, leer = 14 Tage nach Rechnungsdatum");
panel.add(zahlungszielFeld);
panel.add(UiHilfen.pflichtfeldLegende());
return panel;
}
@ -251,10 +300,20 @@ public class RechnungsWizardDialog extends JDialog {
zusammenfassung.setText(controller.erzeugeZusammenfassung());
}
karten.show(kartenPanel, schritt.name());
schrittAnzeige.setText(" Schritt " + (schritt.ordinal() + 1) + " von 5: " + schrittName(schritt));
schrittAnzeige.setText("Schritt " + (schritt.ordinal() + 1) + " von 5: " + schrittName(schritt));
for (int i = 0; i < schrittMarkierungen.length; i++) {
boolean aktuell = i == schritt.ordinal();
schrittMarkierungen[i].setFont(schrittMarkierungen[i].getFont()
.deriveFont(aktuell ? Font.BOLD : Font.PLAIN));
schrittMarkierungen[i].setForeground(UIManager.getColor(
aktuell ? "Label.foreground" : "Label.disabledForeground"));
}
zurueckKnopf.setEnabled(schritt.ordinal() > 0);
weiterKnopf.setEnabled(schritt != WizardSchritt.SPEICHERN);
speichernKnopf.setEnabled(schritt == WizardSchritt.SPEICHERN);
// Enter führt immer die sinnvollste Aktion des Schritts aus
getRootPane().setDefaultButton(
schritt == WizardSchritt.SPEICHERN ? speichernKnopf : weiterKnopf);
}
private static String schrittName(WizardSchritt schritt) {

View File

@ -10,6 +10,7 @@ import java.util.List;
/**
* Controller der Stammdaten-Ansichten (D-F-03): delegiert Suchanfragen an
* die Dienste der Gruppen B und C; die GUI rechnet und filtert selbst nicht.
* GUI-frei und damit im Modultest ohne Oberfläche prüfbar (TC-14, TC-15).
*/
public class StammdatenController {
@ -21,11 +22,31 @@ public class StammdatenController {
this.produktService = produktService;
}
/** Volltextsuche über Name oder Kundennummer (C-F-12). */
public List<Kunde> sucheKunden(String suchbegriff) {
return kundenService.suche(suchbegriff);
}
/** Volltextsuche über Bezeichnung oder Produktnummer (B-F-12). */
public List<Produkt> sucheProdukte(String suchbegriff) {
return produktService.suche(suchbegriff);
}
/**
* Listeninhalt der Kunden-Modulansicht (D-F-03): ein leerer oder fehlender
* Suchbegriff zeigt den gesamten Bestand; die Sortierung nach Name kommt
* aus der Fachkomponente (C-F-11).
*/
public List<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. */
public static void konfiguriere(JTable tabelle) {
tabelle.setRowHeight(24);
tabelle.setRowHeight(26);
tabelle.setAutoCreateRowSorter(true);
tabelle.setFillsViewportHeight(true);
}
@ -66,7 +66,8 @@ public final class TabellenFormat {
private static Color farbeFuer(DokumentStatus status) {
return switch (status) {
case ENTWURF -> new Color(110, 110, 110);
// dunkleres Grau: Kontrast >= 4,5:1 auch auf der alternierenden Zeilenfarbe
case ENTWURF -> new Color(90, 90, 90);
case OFFEN -> new Color(0, 90, 180);
case VERSENDET -> new Color(0, 130, 60);
case STORNIERT -> new Color(180, 40, 40);

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());
}
@Test
@DisplayName("TC-15: kundenListe zeigt bei leerem Suchbegriff den Bestand und filtert sonst (D-F-03)")
void tc15KundenListe() {
StammdatenController controller = new StammdatenController(kundenService, produktService);
// leerer oder fehlender Suchbegriff: gesamter Bestand
assertEquals(1, controller.kundenListe("").size());
assertEquals(1, controller.kundenListe(" ").size());
assertEquals(1, controller.kundenListe(null).size());
// Suchbegriff filtert (Teilstring über den Namen)
assertEquals(1, controller.kundenListe("Muster").size());
assertEquals(0, controller.kundenListe("unbekannt").size());
}
/** Zähl-Stub des DokumentService (Gruppe A) für die Controller-Tests. */
private static final class DokumentServiceStub implements DokumentService {