diff --git a/src/main/java/de/team1/faktura/Main.java b/src/main/java/de/team1/faktura/Main.java index 28254ee..12fa383 100644 --- a/src/main/java/de/team1/faktura/Main.java +++ b/src/main/java/de/team1/faktura/Main.java @@ -12,6 +12,7 @@ import de.team1.faktura.gui.DokumentListenPanel; import de.team1.faktura.gui.HauptFenster; import de.team1.faktura.gui.KundenPanel; import de.team1.faktura.gui.ProduktPanel; +import de.team1.faktura.gui.StammdatenController; import de.team1.faktura.kunden.EinfacherKundennummernGenerator; import de.team1.faktura.kunden.JsonKundenRepository; import de.team1.faktura.kunden.KundenCsvExport; @@ -78,6 +79,10 @@ public final class Main { new PdfBoxPdfExporter(), ereignisBus); + // Gruppe D — GUI-freier Controller der Stammdaten-Ansichten (D-F-03) + StammdatenController stammdatenController = + new StammdatenController(kundenService, produktService); + // Gruppe D — Programmoberfläche SwingUtilities.invokeLater(() -> { try { @@ -88,10 +93,10 @@ public final class Main { // Standard-Look-and-Feel verwenden } HauptFenster fenster = new HauptFenster( - new KundenPanel(kundenService, new KundenCsvExport(kundenRepository), - ereignisBus), - new ProduktPanel(produktService, new ProduktCsvExport(produktRepository), - ereignisBus), + new KundenPanel(kundenService, stammdatenController, + new KundenCsvExport(kundenRepository), ereignisBus), + new ProduktPanel(produktService, stammdatenController, + new ProduktCsvExport(produktRepository), ereignisBus), new DokumentListenPanel(dokumentService, kundenService, produktService, new DokumentCsvExport(dokumentRepository), ereignisBus)); fenster.setVisible(true); diff --git a/src/main/java/de/team1/faktura/dokumente/Dokument.java b/src/main/java/de/team1/faktura/dokumente/Dokument.java index af17f34..633436f 100644 --- a/src/main/java/de/team1/faktura/dokumente/Dokument.java +++ b/src/main/java/de/team1/faktura/dokumente/Dokument.java @@ -69,7 +69,10 @@ public abstract class Dokument { BigDecimal netto = BigDecimal.ZERO; BigDecimal steuer = BigDecimal.ZERO; for (Dokumentposition position : positionen) { - netto = netto.add(position.getPositionssummeNetto()); + // Altdaten ohne Preis-Snapshot (IF-01) zählen als 0 statt zu scheitern + if (position.getPositionssummeNetto() != null) { + netto = netto.add(position.getPositionssummeNetto()); + } steuer = steuer.add(position.getSteuerbetrag()); } this.summeNetto = netto.setScale(2, RoundingMode.HALF_UP); diff --git a/src/main/java/de/team1/faktura/dokumente/Dokumentposition.java b/src/main/java/de/team1/faktura/dokumente/Dokumentposition.java index 2354239..6bbf4b5 100644 --- a/src/main/java/de/team1/faktura/dokumente/Dokumentposition.java +++ b/src/main/java/de/team1/faktura/dokumente/Dokumentposition.java @@ -49,6 +49,10 @@ public class Dokumentposition { /** Steuerbetrag der Position: {@code positionssummeNetto * steuersatz}, Scale 2 (F-23, TC-01). */ public BigDecimal getSteuerbetrag() { + // Altdaten ohne Preis-Snapshot (IF-01) liefern 0 statt eines Fehlers + if (positionssummeNetto == null || steuersatz == null) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } return positionssummeNetto.multiply(steuersatz).setScale(2, RoundingMode.HALF_UP); } diff --git a/src/main/java/de/team1/faktura/dokumente/PdfBoxPdfExporter.java b/src/main/java/de/team1/faktura/dokumente/PdfBoxPdfExporter.java index 463a67b..6f828d4 100644 --- a/src/main/java/de/team1/faktura/dokumente/PdfBoxPdfExporter.java +++ b/src/main/java/de/team1/faktura/dokumente/PdfBoxPdfExporter.java @@ -13,19 +13,48 @@ import java.io.UncheckedIOException; import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; +import java.text.NumberFormat; +import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.Locale; /** - * PDF-Export der Belege mit Apache PDFBox (A-F-04, F-07, F-10, F-15). - * Rechnungen enthalten die Pflichtangaben gemäß § 14 UStG (F-13): - * Belegnummer, Rechnungs- und Leistungsdatum, Kundendaten, Positionen mit - * Einzel- und Gesamtbeträgen, Steuersatz/-betrag sowie die Summen. + * Standardisierter PDF-Export der Belege mit Apache PDFBox + * (A-F-04, F-07, F-10, F-15), angelehnt an den deutschen Geschäftsbrief: + * Absender- und Empfängerblock, Belegkopf mit Datum und Referenzen, + * Positionstabelle mit festen Spalten und rechtsbündigen Beträgen sowie + * Summenblock. + * + *

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