diff --git a/pom.xml b/pom.xml index 5b20cc5..ceefd1a 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,13 @@ ${pdfbox.version} + + + com.formdev + flatlaf + 3.6 + + org.junit.jupiter diff --git a/src/main/java/de/team1/faktura/Main.java b/src/main/java/de/team1/faktura/Main.java index 59edd9f..28254ee 100644 --- a/src/main/java/de/team1/faktura/Main.java +++ b/src/main/java/de/team1/faktura/Main.java @@ -7,6 +7,7 @@ import de.team1.faktura.dokumente.EinfacherBelegnummernGenerator; import de.team1.faktura.dokumente.JsonDokumentRepository; import de.team1.faktura.dokumente.PdfBoxPdfExporter; import de.team1.faktura.dokumente.StandardDokumentService; +import de.team1.faktura.gemeinsam.EreignisBus; import de.team1.faktura.gui.DokumentListenPanel; import de.team1.faktura.gui.HauptFenster; import de.team1.faktura.gui.KundenPanel; @@ -20,6 +21,8 @@ import de.team1.faktura.produkte.JsonProduktRepository; import de.team1.faktura.produkte.ProduktCsvExport; import de.team1.faktura.produkte.ProduktVerwaltungsService; +import com.formdev.flatlaf.FlatLightLaf; + import javax.swing.SwingUtilities; import javax.swing.UIManager; import java.nio.file.Path; @@ -49,17 +52,22 @@ public final class Main { // Gruppe A stellt die Referenzprüfungen für die Löschsperren bereit DokumentReferenzPruefung referenzPruefung = new DokumentReferenzPruefung(dokumentRepository); + // Observer-Verteiler: Services melden Datenänderungen, Panels abonnieren + EreignisBus ereignisBus = new EreignisBus(); + // Gruppe C — Kundenverwaltung KundenVerwaltungsService kundenService = new KundenVerwaltungsService( kundenRepository, EinfacherKundennummernGenerator.ausRepository(kundenRepository), - referenzPruefung); + referenzPruefung, + ereignisBus); // Gruppe B — Produktverwaltung ProduktVerwaltungsService produktService = new ProduktVerwaltungsService( produktRepository, EinfacherProduktnummernGenerator.ausRepository(produktRepository), - referenzPruefung); + referenzPruefung, + ereignisBus); // Gruppe A — Dokumentenzyklus DokumentService dokumentService = new StandardDokumentService( @@ -67,20 +75,25 @@ public final class Main { EinfacherBelegnummernGenerator.ausRepository(dokumentRepository), kundenService, produktService, - new PdfBoxPdfExporter()); + new PdfBoxPdfExporter(), + ereignisBus); // Gruppe D — Programmoberfläche SwingUtilities.invokeLater(() -> { try { - UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + FlatLightLaf.setup(); + UIManager.put("Table.alternateRowColor", new java.awt.Color(245, 246, 248)); + UIManager.put("Table.showHorizontalLines", false); } catch (Exception e) { // Standard-Look-and-Feel verwenden } HauptFenster fenster = new HauptFenster( - new KundenPanel(kundenService, new KundenCsvExport(kundenRepository)), - new ProduktPanel(produktService, new ProduktCsvExport(produktRepository)), + new KundenPanel(kundenService, new KundenCsvExport(kundenRepository), + ereignisBus), + new ProduktPanel(produktService, new ProduktCsvExport(produktRepository), + ereignisBus), new DokumentListenPanel(dokumentService, kundenService, produktService, - new DokumentCsvExport(dokumentRepository))); + new DokumentCsvExport(dokumentRepository), ereignisBus)); fenster.setVisible(true); }); } diff --git a/src/main/java/de/team1/faktura/dokumente/JsonDokumentRepository.java b/src/main/java/de/team1/faktura/dokumente/JsonDokumentRepository.java index 6ee3e83..26b3798 100644 --- a/src/main/java/de/team1/faktura/dokumente/JsonDokumentRepository.java +++ b/src/main/java/de/team1/faktura/dokumente/JsonDokumentRepository.java @@ -40,12 +40,10 @@ public class JsonDokumentRepository implements DokumentRepository { private void schreibe() { try { - if (datei.getParent() != null) { - Files.createDirectories(datei.getParent()); - } // Über den Basistyp schreiben, damit die polymorphe Typ-ID ('typ') // erhalten bleibt und Belege beim Neustart wieder geladen werden (IF-01). - mapper.writerFor(new TypeReference>() { }).writeValue(datei.toFile(), dokumente); + JsonPersistenz.schreibeAtomar(datei, + mapper.writerFor(new TypeReference>() { }), dokumente); } catch (IOException e) { throw new UncheckedIOException("Belegbestand konnte nicht gespeichert werden: " + datei, e); } diff --git a/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java b/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java index b083110..a4bc7cd 100644 --- a/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java +++ b/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java @@ -1,5 +1,7 @@ package de.team1.faktura.dokumente; +import de.team1.faktura.gemeinsam.DatenBereich; +import de.team1.faktura.gemeinsam.EreignisBus; import de.team1.faktura.gemeinsam.ValidierungsException; import de.team1.faktura.kunden.Kunde; import de.team1.faktura.kunden.KundenService; @@ -32,17 +34,29 @@ public class StandardDokumentService implements DokumentService { private final KundenService kundenService; private final ProduktService produktService; private final PdfExporter pdfExporter; + private final EreignisBus ereignisBus; public StandardDokumentService(DokumentRepository repository, BelegnummernGenerator nummernGenerator, KundenService kundenService, ProduktService produktService, PdfExporter pdfExporter) { + this(repository, nummernGenerator, kundenService, produktService, pdfExporter, + new EreignisBus()); + } + + public StandardDokumentService(DokumentRepository repository, + BelegnummernGenerator nummernGenerator, + KundenService kundenService, + ProduktService produktService, + PdfExporter pdfExporter, + EreignisBus ereignisBus) { this.repository = repository; this.nummernGenerator = nummernGenerator; this.kundenService = kundenService; this.produktService = produktService; this.pdfExporter = pdfExporter; + this.ereignisBus = ereignisBus; } @Override @@ -58,6 +72,7 @@ public class StandardDokumentService implements DokumentService { angebot.setGueltigBis(gueltigBis != null ? gueltigBis : datum.plusDays(STANDARD_GUELTIGKEIT_TAGE)); angebot.setzePositionen(dokumentpositionen); repository.speichere(angebot); + ereignisBus.melde(DatenBereich.DOKUMENTE); return angebot; } @@ -73,6 +88,7 @@ public class StandardDokumentService implements DokumentService { ab.setzeKunde(kunde.getKundennummer(), kunde.getName(), kunde.anschrift()); ab.setzePositionen(dokumentpositionen); repository.speichere(ab); + ereignisBus.melde(DatenBereich.DOKUMENTE); return ab; } @@ -89,6 +105,7 @@ public class StandardDokumentService implements DokumentService { lieferschein.setLieferdatum(lieferdatum != null ? lieferdatum : datum); lieferschein.setzePositionen(dokumentpositionen); repository.speichere(lieferschein); + ereignisBus.melde(DatenBereich.DOKUMENTE); return lieferschein; } @@ -113,6 +130,7 @@ public class StandardDokumentService implements DokumentService { rechnung.setzePositionen(dokumentpositionen); rechnung.setzeStatus(DokumentStatus.OFFEN); repository.speichere(rechnung); + ereignisBus.melde(DatenBereich.DOKUMENTE); return rechnung; } @@ -150,6 +168,7 @@ public class StandardDokumentService implements DokumentService { rechnung.setzeStatus(DokumentStatus.OFFEN); } repository.speichere(folgebeleg); + ereignisBus.melde(DatenBereich.DOKUMENTE); return folgebeleg; } @@ -158,6 +177,7 @@ public class StandardDokumentService implements DokumentService { Dokument dokument = pruefeBeleg(belegnummer); dokument.versende(); repository.speichere(dokument); + ereignisBus.melde(DatenBereich.DOKUMENTE); } @Override @@ -169,6 +189,7 @@ public class StandardDokumentService implements DokumentService { } rechnung.storniere(LocalDate.now(), SYSTEM_BENUTZER); repository.speichere(rechnung); + ereignisBus.melde(DatenBereich.DOKUMENTE); } @Override diff --git a/src/main/java/de/team1/faktura/gemeinsam/DatenBereich.java b/src/main/java/de/team1/faktura/gemeinsam/DatenBereich.java new file mode 100644 index 0000000..7bc60d9 --- /dev/null +++ b/src/main/java/de/team1/faktura/gemeinsam/DatenBereich.java @@ -0,0 +1,11 @@ +package de.team1.faktura.gemeinsam; + +/** + * Fachliche Datenbereiche der Anwendung, über die der {@link EreignisBus} + * Änderungen meldet. + */ +public enum DatenBereich { + KUNDEN, + PRODUKTE, + DOKUMENTE +} diff --git a/src/main/java/de/team1/faktura/gemeinsam/EreignisBus.java b/src/main/java/de/team1/faktura/gemeinsam/EreignisBus.java new file mode 100644 index 0000000..10bdb3c --- /dev/null +++ b/src/main/java/de/team1/faktura/gemeinsam/EreignisBus.java @@ -0,0 +1,33 @@ +package de.team1.faktura.gemeinsam; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * Einfacher synchroner Ereignis-Verteiler (Observer-Muster): Die Services + * melden nach jeder schreibenden Operation den geänderten {@link DatenBereich}, + * die Modulansichten abonnieren die für sie relevanten Bereiche und + * aktualisieren sich selbst. Dadurch entfallen manuelle Refresh-Aufrufe + * zwischen den Modulen. + * + *

Alle Aufrufe laufen auf dem Event-Dispatch-Thread der Swing-Oberfläche; + * eine Synchronisierung ist daher nicht erforderlich (Einzelplatzbetrieb). + */ +public class EreignisBus { + + private final Map> abonnenten = new EnumMap<>(DatenBereich.class); + + /** Registriert einen Beobachter für Änderungen im angegebenen Bereich. */ + public void abonniere(DatenBereich bereich, Runnable beobachter) { + abonnenten.computeIfAbsent(bereich, b -> new ArrayList<>()).add(beobachter); + } + + /** Meldet eine Änderung im angegebenen Bereich an alle Beobachter. */ + public void melde(DatenBereich bereich) { + for (Runnable beobachter : abonnenten.getOrDefault(bereich, List.of())) { + beobachter.run(); + } + } +} diff --git a/src/main/java/de/team1/faktura/gemeinsam/JsonPersistenz.java b/src/main/java/de/team1/faktura/gemeinsam/JsonPersistenz.java index 4467521..dfc40c2 100644 --- a/src/main/java/de/team1/faktura/gemeinsam/JsonPersistenz.java +++ b/src/main/java/de/team1/faktura/gemeinsam/JsonPersistenz.java @@ -2,9 +2,16 @@ package de.team1.faktura.gemeinsam; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + /** * Zentral konfigurierter Jackson-ObjectMapper für die lokale * JSON-Persistenz (IF-01). Datumswerte werden als ISO-Strings @@ -23,4 +30,25 @@ public final class JsonPersistenz { mapper.enable(SerializationFeature.INDENT_OUTPUT); return mapper; } + + /** + * Schreibt den Bestand atomar: erst vollständig in eine temporäre Datei, + * dann per Move ersetzen. Ein Absturz während des Schreibens kann so den + * vorhandenen Bestand nicht korrumpieren (GR-01/GR-02: der Belegbestand + * ist Grundlage der lückenlosen Nummernvergabe und Unveränderlichkeit). + */ + public static void schreibeAtomar(Path datei, ObjectWriter writer, Object daten) + throws IOException { + if (datei.getParent() != null) { + Files.createDirectories(datei.getParent()); + } + Path temp = datei.resolveSibling(datei.getFileName() + ".tmp"); + writer.writeValue(temp.toFile(), daten); + try { + Files.move(temp, datei, StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temp, datei, StandardCopyOption.REPLACE_EXISTING); + } + } } diff --git a/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java b/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java index 698af1d..945ad00 100644 --- a/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java +++ b/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java @@ -4,14 +4,17 @@ import de.team1.faktura.dokumente.Dokument; import de.team1.faktura.dokumente.DokumentCsvExport; import de.team1.faktura.dokumente.DokumentService; import de.team1.faktura.dokumente.DokumentStatus; -import de.team1.faktura.gemeinsam.ValidierungsException; +import de.team1.faktura.gemeinsam.DatenBereich; +import de.team1.faktura.gemeinsam.EreignisBus; import de.team1.faktura.kunden.KundenService; import de.team1.faktura.produkte.ProduktService; +import javax.swing.DefaultListCellRenderer; import javax.swing.JButton; import javax.swing.JComboBox; import javax.swing.JFileChooser; import javax.swing.JLabel; +import javax.swing.JList; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; @@ -20,6 +23,7 @@ import javax.swing.ListSelectionModel; import javax.swing.SwingUtilities; import javax.swing.table.AbstractTableModel; import java.awt.BorderLayout; +import java.awt.Component; import java.awt.Desktop; import java.awt.FlowLayout; import java.awt.Window; @@ -48,8 +52,9 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { private final DokumentCsvExport datenExport; private final DokumentListenController controller; - private final JComboBox statusFilter = new JComboBox<>( - new String[]{"Alle", "ENTWURF", "OFFEN", "VERSENDET", "STORNIERT"}); + /** Statusfilter; {@code null} steht für "Alle" (F-06). */ + private final JComboBox statusFilter = new JComboBox<>( + statusFilterWerte()); private final DokumentTabellenModel tabellenModel = new DokumentTabellenModel(); private final JTable tabelle = new JTable(tabellenModel); @@ -63,7 +68,8 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { public DokumentListenPanel(DokumentService dokumentService, KundenService kundenService, ProduktService produktService, - DokumentCsvExport datenExport) { + DokumentCsvExport datenExport, + EreignisBus ereignisBus) { this.dokumentService = dokumentService; this.kundenService = kundenService; this.produktService = produktService; @@ -71,6 +77,9 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { this.controller = new DokumentListenController(dokumentService); baueOberflaeche(); aktualisiere(); + ereignisBus.abonniere(DatenBereich.DOKUMENTE, this::aktualisiere); + // Kundenname und -nummer werden in der Belegliste angezeigt + ereignisBus.abonniere(DatenBereich.KUNDEN, this::aktualisiere); } private void baueOberflaeche() { @@ -79,6 +88,14 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { JPanel kopf = new JPanel(new FlowLayout(FlowLayout.LEFT)); kopf.add(new JLabel("Statusfilter:")); kopf.add(statusFilter); + statusFilter.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList liste, Object wert, + int index, boolean selektiert, boolean fokus) { + return super.getListCellRendererComponent(liste, + wert == null ? "Alle" : wert, index, selektiert, fokus); + } + }); statusFilter.addActionListener(e -> aktualisiere()); JButton neueRechnung = new JButton("Neue Rechnung (Assistent)…"); @@ -94,6 +111,9 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); tabelle.getSelectionModel().addListSelectionListener(e -> aktualisiereAktionen()); + TabellenFormat.konfiguriere(tabelle); + tabelle.getColumnModel().getColumn(4).setCellRenderer(TabellenFormat.waehrungsRenderer()); + tabelle.getColumnModel().getColumn(5).setCellRenderer(TabellenFormat.statusRenderer()); add(new JScrollPane(tabelle), BorderLayout.CENTER); JPanel aktionen = new JPanel(new FlowLayout(FlowLayout.LEFT)); @@ -115,7 +135,8 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { private Dokument auswahl() { int zeile = tabelle.getSelectedRow(); - return zeile < 0 ? null : tabellenModel.dokumente.get(zeile); + return zeile < 0 ? null + : tabellenModel.dokumente.get(tabelle.convertRowIndexToModel(zeile)); } /** Aktiviert/deaktiviert die Belegaktionen gemäß Status (F-08, F-14). */ @@ -144,14 +165,12 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { RechnungsWizardDialog dialog = new RechnungsWizardDialog(fenster, wizardController, kundenService, produktService); dialog.setVisible(true); - aktualisiere(); } private void oeffneBelegDialog() { Window fenster = SwingUtilities.getWindowAncestor(this); BelegDialog dialog = new BelegDialog(fenster, dokumentService, kundenService, produktService); dialog.setVisible(true); - aktualisiere(); } private void erzeugeFolgebeleg() { @@ -159,15 +178,12 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { if (dokument == null) { return; } - try { + MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> { Dokument folgebeleg = dokumentService.erzeugeFolgebeleg(dokument.getBelegnummer()); - aktualisiere(); MeldungsAnzeige.zeige(this, Meldung.erfolg(folgebeleg.belegtyp().anzeigename() + " " + folgebeleg.getBelegnummer() + " wurde aus " + dokument.getBelegnummer() + " erzeugt."), null); - } catch (ValidierungsException e) { - MeldungsAnzeige.zeige(this, Meldung.fehler(e.getFeldname(), e.getMessage()), null); - } + }); } private void versende() { @@ -182,14 +198,11 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { if (antwort != JOptionPane.YES_OPTION) { return; } - try { + MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> { dokumentService.versende(dokument.getBelegnummer()); - aktualisiere(); MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Beleg " + dokument.getBelegnummer() + " ist jetzt im Status VERSENDET."), null); - } catch (IllegalStateException e) { - MeldungsAnzeige.zeige(this, Meldung.fehler(null, e.getMessage()), null); - } + }); } /** Stornierung mit Bestätigungsdialog: Rechnungsnummer und Bruttosumme (F-15). */ @@ -204,7 +217,6 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { "Rechnung stornieren", JOptionPane.YES_NO_OPTION); Meldung meldung = controller.storniere(dokument.getBelegnummer(), antwort == JOptionPane.YES_OPTION); - aktualisiere(); MeldungsAnzeige.zeige(this, meldung, null); } @@ -277,17 +289,18 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { @Override public void aktualisiere() { - DokumentStatus filter = switch ((String) statusFilter.getSelectedItem()) { - case "ENTWURF" -> DokumentStatus.ENTWURF; - case "OFFEN" -> DokumentStatus.OFFEN; - case "VERSENDET" -> DokumentStatus.VERSENDET; - case "STORNIERT" -> DokumentStatus.STORNIERT; - default -> null; - }; - tabellenModel.setze(controller.gefiltert(filter)); + tabellenModel.setze(controller.gefiltert((DokumentStatus) statusFilter.getSelectedItem())); aktualisiereAktionen(); } + /** "Alle" (= {@code null}) gefolgt von allen Belegstatus. */ + private static DokumentStatus[] statusFilterWerte() { + DokumentStatus[] status = DokumentStatus.values(); + DokumentStatus[] werte = new DokumentStatus[status.length + 1]; + System.arraycopy(status, 0, werte, 1, status.length); + return werte; + } + private static final class DokumentTabellenModel extends AbstractTableModel { private static final String[] SPALTEN = @@ -315,6 +328,15 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { return SPALTEN[spalte]; } + @Override + public Class getColumnClass(int spalte) { + return switch (spalte) { + case 4 -> java.math.BigDecimal.class; + case 5 -> DokumentStatus.class; + default -> String.class; + }; + } + @Override public Object getValueAt(int zeile, int spalte) { Dokument dokument = dokumente.get(zeile); @@ -323,8 +345,8 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { case 1 -> dokument.belegtyp().anzeigename(); case 2 -> dokument.getDatum() == null ? "" : DATUM.format(dokument.getDatum()); case 3 -> dokument.getKundeName() + " (" + dokument.getKundenReferenz() + ")"; - case 4 -> dokument.getSummeBrutto().toPlainString() + " EUR"; - default -> dokument.getStatus().name(); + case 4 -> dokument.getSummeBrutto(); + default -> dokument.getStatus(); }; } } diff --git a/src/main/java/de/team1/faktura/gui/KundenPanel.java b/src/main/java/de/team1/faktura/gui/KundenPanel.java index f948bc0..cd470a3 100644 --- a/src/main/java/de/team1/faktura/gui/KundenPanel.java +++ b/src/main/java/de/team1/faktura/gui/KundenPanel.java @@ -1,7 +1,7 @@ package de.team1.faktura.gui; -import de.team1.faktura.gemeinsam.LoeschAbgelehntException; -import de.team1.faktura.gemeinsam.ValidierungsException; +import de.team1.faktura.gemeinsam.DatenBereich; +import de.team1.faktura.gemeinsam.EreignisBus; import de.team1.faktura.kunden.Kunde; import de.team1.faktura.kunden.KundenCsvExport; import de.team1.faktura.kunden.KundenVerwaltungsService; @@ -56,7 +56,8 @@ public class KundenPanel extends JPanel implements ModulPanel { private String gewaehlteNummer; private boolean ungespeichert; - public KundenPanel(KundenVerwaltungsService service, KundenCsvExport csvExport) { + public KundenPanel(KundenVerwaltungsService service, KundenCsvExport csvExport, + EreignisBus ereignisBus) { this.service = service; this.csvExport = csvExport; felder.put("Name", nameFeld); @@ -66,6 +67,7 @@ public class KundenPanel extends JPanel implements ModulPanel { felder.put("E-Mail", eMailFeld); baueOberflaeche(); aktualisiere(); + ereignisBus.abonniere(DatenBereich.KUNDEN, this::aktualisiere); } private void baueOberflaeche() { @@ -78,6 +80,7 @@ public class KundenPanel extends JPanel implements ModulPanel { add(suchleiste, BorderLayout.NORTH); tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + TabellenFormat.konfiguriere(tabelle); tabelle.getSelectionModel().addListSelectionListener(e -> { if (!e.getValueIsAdjusting()) { ladeAuswahl(); @@ -148,7 +151,7 @@ public class KundenPanel extends JPanel implements ModulPanel { } private void speichere() { - try { + MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> { Kunde kunde = gewaehlteNummer == null ? new Kunde() : service.findeKunde(gewaehlteNummer); @@ -166,12 +169,9 @@ public class KundenPanel extends JPanel implements ModulPanel { ungespeichert = false; gewaehlteNummer = gespeichert.getKundennummer(); nummerAnzeige.setText(gespeichert.getKundennummer()); - aktualisiere(); MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gespeichert. Kundennummer: " + gespeichert.getKundennummer()), felder); - } catch (ValidierungsException e) { - MeldungsAnzeige.zeige(this, Meldung.fehler(e.getFeldname(), e.getMessage()), felder); - } + }); } private void loesche() { @@ -184,14 +184,11 @@ public class KundenPanel extends JPanel implements ModulPanel { if (antwort != JOptionPane.YES_OPTION) { return; } - try { + MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> { service.loescheKunde(gewaehlteNummer); leereFormular(); - aktualisiere(); MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gelöscht."), felder); - } catch (LoeschAbgelehntException e) { - MeldungsAnzeige.zeige(this, Meldung.fehler(null, e.getMessage()), felder); - } + }); } private void exportiere() { @@ -209,7 +206,7 @@ public class KundenPanel extends JPanel implements ModulPanel { if (zeile < 0) { return; } - Kunde kunde = tabellenModel.kunden.get(zeile); + Kunde kunde = tabellenModel.kunden.get(tabelle.convertRowIndexToModel(zeile)); gewaehlteNummer = kunde.getKundennummer(); nummerAnzeige.setText(kunde.getKundennummer()); nameFeld.setText(kunde.getName()); diff --git a/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java b/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java index 89a402b..3131c0b 100644 --- a/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java +++ b/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java @@ -1,5 +1,8 @@ package de.team1.faktura.gui; +import de.team1.faktura.gemeinsam.LoeschAbgelehntException; +import de.team1.faktura.gemeinsam.ValidierungsException; + import javax.swing.BorderFactory; import javax.swing.JComponent; import javax.swing.JOptionPane; @@ -7,6 +10,7 @@ import javax.swing.UIManager; import javax.swing.border.Border; import java.awt.Color; import java.awt.Component; +import java.io.UncheckedIOException; import java.util.Map; /** @@ -48,4 +52,24 @@ public final class MeldungsAnzeige { JOptionPane.INFORMATION_MESSAGE); } } + + /** + * Zentrale Fehlerbehandlung der Modulansichten: führt die Aktion aus und + * zeigt fachliche Fehler einheitlich an — Validierungsfehler mit + * Feldmarkierung (Q-09), abgelehnte Löschvorgänge (GR-04), unzulässige + * Statuswechsel (GR-02) und Persistenzfehler beim Speichern (IF-01). + */ + public static void mitFehlerbehandlung(Component parent, Map felder, + Runnable aktion) { + try { + aktion.run(); + } catch (ValidierungsException e) { + zeige(parent, Meldung.fehler(e.getFeldname(), e.getMessage()), felder); + } catch (LoeschAbgelehntException | IllegalStateException e) { + zeige(parent, Meldung.fehler(null, e.getMessage()), felder); + } catch (UncheckedIOException e) { + zeige(parent, Meldung.fehler(null, + "Die Daten konnten nicht gespeichert werden: " + e.getMessage()), felder); + } + } } diff --git a/src/main/java/de/team1/faktura/gui/ProduktPanel.java b/src/main/java/de/team1/faktura/gui/ProduktPanel.java index 3d4bc8c..cc93aed 100644 --- a/src/main/java/de/team1/faktura/gui/ProduktPanel.java +++ b/src/main/java/de/team1/faktura/gui/ProduktPanel.java @@ -1,6 +1,7 @@ package de.team1.faktura.gui; -import de.team1.faktura.gemeinsam.LoeschAbgelehntException; +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; @@ -58,7 +59,8 @@ public class ProduktPanel extends JPanel implements ModulPanel { private String gewaehlteNummer; private boolean ungespeichert; - public ProduktPanel(ProduktVerwaltungsService service, ProduktCsvExport csvExport) { + public ProduktPanel(ProduktVerwaltungsService service, ProduktCsvExport csvExport, + EreignisBus ereignisBus) { this.service = service; this.csvExport = csvExport; felder.put("Bezeichnung", bezeichnungFeld); @@ -66,6 +68,7 @@ public class ProduktPanel extends JPanel implements ModulPanel { felder.put("Steuersatz", steuersatzWahl); baueOberflaeche(); aktualisiere(); + ereignisBus.abonniere(DatenBereich.PRODUKTE, this::aktualisiere); } private void baueOberflaeche() { @@ -78,6 +81,8 @@ public class ProduktPanel extends JPanel implements ModulPanel { add(suchleiste, BorderLayout.NORTH); tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + TabellenFormat.konfiguriere(tabelle); + tabelle.getColumnModel().getColumn(2).setCellRenderer(TabellenFormat.waehrungsRenderer()); tabelle.getSelectionModel().addListSelectionListener(e -> { if (!e.getValueIsAdjusting()) { ladeAuswahl(); @@ -145,7 +150,7 @@ public class ProduktPanel extends JPanel implements ModulPanel { } private void speichere() { - try { + MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> { Produkt produkt = gewaehlteNummer == null ? new Produkt() : service.findeProdukt(gewaehlteNummer); @@ -161,12 +166,9 @@ public class ProduktPanel extends JPanel implements ModulPanel { ungespeichert = false; gewaehlteNummer = gespeichert.getProduktnummer(); nummerAnzeige.setText(gespeichert.getProduktnummer()); - aktualisiere(); MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gespeichert. Produktnummer: " + gespeichert.getProduktnummer()), felder); - } catch (ValidierungsException e) { - MeldungsAnzeige.zeige(this, Meldung.fehler(e.getFeldname(), e.getMessage()), felder); - } + }); } /** Akzeptiert deutsches und englisches Dezimaltrennzeichen. */ @@ -202,14 +204,11 @@ public class ProduktPanel extends JPanel implements ModulPanel { if (antwort != JOptionPane.YES_OPTION) { return; } - try { + MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> { service.loescheProdukt(gewaehlteNummer); leereFormular(); - aktualisiere(); MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gelöscht."), felder); - } catch (LoeschAbgelehntException e) { - MeldungsAnzeige.zeige(this, Meldung.fehler(null, e.getMessage()), felder); - } + }); } private void exportiere() { @@ -227,7 +226,7 @@ public class ProduktPanel extends JPanel implements ModulPanel { if (zeile < 0) { return; } - Produkt produkt = tabellenModel.produkte.get(zeile); + Produkt produkt = tabellenModel.produkte.get(tabelle.convertRowIndexToModel(zeile)); gewaehlteNummer = produkt.getProduktnummer(); nummerAnzeige.setText(produkt.getProduktnummer()); bezeichnungFeld.setText(produkt.getBezeichnung()); @@ -335,13 +334,18 @@ public class ProduktPanel extends JPanel implements ModulPanel { return SPALTEN[spalte]; } + @Override + public Class getColumnClass(int spalte) { + return spalte == 2 ? BigDecimal.class : String.class; + } + @Override public Object getValueAt(int zeile, int spalte) { Produkt produkt = produkte.get(zeile); return switch (spalte) { case 0 -> produkt.getProduktnummer(); case 1 -> produkt.getBezeichnung(); - case 2 -> produkt.getEinzelpreisNetto().toPlainString() + " EUR"; + case 2 -> produkt.getEinzelpreisNetto(); case 3 -> produkt.getSteuersatz().multiply(new BigDecimal("100")) .stripTrailingZeros().toPlainString() + " %"; default -> produkt.getEinheit() == null ? "" : produkt.getEinheit(); diff --git a/src/main/java/de/team1/faktura/gui/TabellenFormat.java b/src/main/java/de/team1/faktura/gui/TabellenFormat.java new file mode 100644 index 0000000..6b8917f --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/TabellenFormat.java @@ -0,0 +1,75 @@ +package de.team1.faktura.gui; + +import de.team1.faktura.dokumente.DokumentStatus; + +import javax.swing.JLabel; +import javax.swing.JTable; +import javax.swing.table.DefaultTableCellRenderer; +import java.awt.Color; +import java.awt.Component; +import java.math.BigDecimal; +import java.text.NumberFormat; +import java.util.Locale; + +/** + * Gemeinsame Tabellendarstellung der Modulansichten (Q-05): Währungsbeträge + * rechtsbündig im deutschen Format, Belegstatus farblich hervorgehoben, + * einheitliche Zeilenhöhe und Sortierung per Spaltenkopf. + */ +public final class TabellenFormat { + + private TabellenFormat() { + } + + /** Formatiert einen Betrag im deutschen Währungsformat, z. B. "1.234,50 €". */ + public static String formatiereBetrag(BigDecimal betrag) { + return NumberFormat.getCurrencyInstance(Locale.GERMANY).format(betrag); + } + + /** Einheitliche Grundeinstellungen: Zeilenhöhe und Sortierung per Spaltenkopf. */ + public static void konfiguriere(JTable tabelle) { + tabelle.setRowHeight(24); + tabelle.setAutoCreateRowSorter(true); + tabelle.setFillsViewportHeight(true); + } + + /** Rechtsbündiger Renderer für {@link BigDecimal}-Beträge im Währungsformat. */ + public static DefaultTableCellRenderer waehrungsRenderer() { + DefaultTableCellRenderer renderer = new DefaultTableCellRenderer() { + @Override + protected void setValue(Object wert) { + setText(wert instanceof BigDecimal betrag ? formatiereBetrag(betrag) : ""); + } + }; + renderer.setHorizontalAlignment(JLabel.RIGHT); + return renderer; + } + + /** Renderer für {@link DokumentStatus}: dezente Statusfarbe, Selektion bleibt lesbar. */ + public static DefaultTableCellRenderer statusRenderer() { + return new DefaultTableCellRenderer() { + @Override + public Component getTableCellRendererComponent(JTable tabelle, Object wert, + boolean selektiert, boolean fokus, int zeile, int spalte) { + super.getTableCellRendererComponent(tabelle, wert, selektiert, fokus, zeile, spalte); + if (wert instanceof DokumentStatus status) { + setText(status.name()); + setForeground(selektiert ? tabelle.getSelectionForeground() : farbeFuer(status)); + } else { + setForeground(selektiert ? tabelle.getSelectionForeground() + : tabelle.getForeground()); + } + return this; + } + }; + } + + private static Color farbeFuer(DokumentStatus status) { + return switch (status) { + case ENTWURF -> new Color(110, 110, 110); + 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/kunden/JsonKundenRepository.java b/src/main/java/de/team1/faktura/kunden/JsonKundenRepository.java index ae346f6..bd481c2 100644 --- a/src/main/java/de/team1/faktura/kunden/JsonKundenRepository.java +++ b/src/main/java/de/team1/faktura/kunden/JsonKundenRepository.java @@ -42,10 +42,7 @@ public class JsonKundenRepository implements KundenRepository { private void schreibe() { try { - if (datei.getParent() != null) { - Files.createDirectories(datei.getParent()); - } - mapper.writeValue(datei.toFile(), kunden); + JsonPersistenz.schreibeAtomar(datei, mapper.writer(), kunden); } catch (IOException e) { throw new UncheckedIOException("Kundenbestand konnte nicht gespeichert werden: " + datei, e); } diff --git a/src/main/java/de/team1/faktura/kunden/KundenVerwaltungsService.java b/src/main/java/de/team1/faktura/kunden/KundenVerwaltungsService.java index c8a012b..b9d062e 100644 --- a/src/main/java/de/team1/faktura/kunden/KundenVerwaltungsService.java +++ b/src/main/java/de/team1/faktura/kunden/KundenVerwaltungsService.java @@ -1,5 +1,7 @@ package de.team1.faktura.kunden; +import de.team1.faktura.gemeinsam.DatenBereich; +import de.team1.faktura.gemeinsam.EreignisBus; import de.team1.faktura.gemeinsam.LoeschAbgelehntException; import de.team1.faktura.gemeinsam.ValidierungsException; @@ -15,20 +17,31 @@ public class KundenVerwaltungsService implements KundenService { private final KundenRepository repository; private final KundennummernGenerator nummernGenerator; private final KundenReferenzPruefung referenzPruefung; + private final EreignisBus ereignisBus; public KundenVerwaltungsService(KundenRepository repository, KundennummernGenerator nummernGenerator, KundenReferenzPruefung referenzPruefung) { + this(repository, nummernGenerator, referenzPruefung, new EreignisBus()); + } + + public KundenVerwaltungsService(KundenRepository repository, + KundennummernGenerator nummernGenerator, + KundenReferenzPruefung referenzPruefung, + EreignisBus ereignisBus) { this.repository = repository; this.nummernGenerator = nummernGenerator; this.referenzPruefung = referenzPruefung; + this.ereignisBus = ereignisBus; } /** Legt einen neuen Kunden an und vergibt die Kundennummer (F-01, F-02). */ public Kunde legeAn(Kunde kunde) { validiere(kunde); kunde.setKundennummer(nummernGenerator.naechsteNummer()); - return repository.speichere(kunde); + Kunde gespeichert = repository.speichere(kunde); + ereignisBus.melde(DatenBereich.KUNDEN); + return gespeichert; } /** Ändert einen bestehenden Kunden; die Pflichtfeldprüfung gilt unverändert (F-05). */ @@ -37,7 +50,9 @@ public class KundenVerwaltungsService implements KundenService { throw new ValidierungsException("Kundennummer", "Der Kunde wurde noch nicht angelegt."); } validiere(kunde); - return repository.speichere(kunde); + Kunde gespeichert = repository.speichere(kunde); + ereignisBus.melde(DatenBereich.KUNDEN); + return gespeichert; } /** @@ -52,6 +67,7 @@ public class KundenVerwaltungsService implements KundenService { + anzahl + " verknüpfte Dokumente vorhanden (GR-04)."); } repository.loesche(kundennummer); + ereignisBus.melde(DatenBereich.KUNDEN); } public List alleSortiertNachName() { diff --git a/src/main/java/de/team1/faktura/produkte/JsonProduktRepository.java b/src/main/java/de/team1/faktura/produkte/JsonProduktRepository.java index b8f7b3f..7106cb9 100644 --- a/src/main/java/de/team1/faktura/produkte/JsonProduktRepository.java +++ b/src/main/java/de/team1/faktura/produkte/JsonProduktRepository.java @@ -42,10 +42,7 @@ public class JsonProduktRepository implements ProduktRepository { private void schreibe() { try { - if (datei.getParent() != null) { - Files.createDirectories(datei.getParent()); - } - mapper.writeValue(datei.toFile(), produkte); + JsonPersistenz.schreibeAtomar(datei, mapper.writer(), produkte); } catch (IOException e) { throw new UncheckedIOException("Produktbestand konnte nicht gespeichert werden: " + datei, e); } diff --git a/src/main/java/de/team1/faktura/produkte/ProduktVerwaltungsService.java b/src/main/java/de/team1/faktura/produkte/ProduktVerwaltungsService.java index 9eeb4de..68f8acc 100644 --- a/src/main/java/de/team1/faktura/produkte/ProduktVerwaltungsService.java +++ b/src/main/java/de/team1/faktura/produkte/ProduktVerwaltungsService.java @@ -1,5 +1,7 @@ package de.team1.faktura.produkte; +import de.team1.faktura.gemeinsam.DatenBereich; +import de.team1.faktura.gemeinsam.EreignisBus; import de.team1.faktura.gemeinsam.LoeschAbgelehntException; import de.team1.faktura.gemeinsam.ValidierungsException; @@ -20,20 +22,31 @@ public class ProduktVerwaltungsService implements ProduktService { private final ProduktRepository repository; private final ProduktnummernGenerator nummernGenerator; private final ProduktReferenzPruefung referenzPruefung; + private final EreignisBus ereignisBus; public ProduktVerwaltungsService(ProduktRepository repository, ProduktnummernGenerator nummernGenerator, ProduktReferenzPruefung referenzPruefung) { + this(repository, nummernGenerator, referenzPruefung, new EreignisBus()); + } + + public ProduktVerwaltungsService(ProduktRepository repository, + ProduktnummernGenerator nummernGenerator, + ProduktReferenzPruefung referenzPruefung, + EreignisBus ereignisBus) { this.repository = repository; this.nummernGenerator = nummernGenerator; this.referenzPruefung = referenzPruefung; + this.ereignisBus = ereignisBus; } /** Legt ein neues Produkt an und vergibt die Produktnummer (F-01, F-02). */ public Produkt legeAn(Produkt produkt) { validiere(produkt); produkt.setProduktnummer(nummernGenerator.naechsteNummer()); - return repository.speichere(produkt); + Produkt gespeichert = repository.speichere(produkt); + ereignisBus.melde(DatenBereich.PRODUKTE); + return gespeichert; } /** @@ -45,7 +58,9 @@ public class ProduktVerwaltungsService implements ProduktService { throw new ValidierungsException("Produktnummer", "Das Produkt wurde noch nicht angelegt."); } validiere(produkt); - return repository.speichere(produkt); + Produkt gespeichert = repository.speichere(produkt); + ereignisBus.melde(DatenBereich.PRODUKTE); + return gespeichert; } /** @@ -59,6 +74,7 @@ public class ProduktVerwaltungsService implements ProduktService { + "es wird in Dokumenten verwendet."); } repository.loesche(produktnummer); + ereignisBus.melde(DatenBereich.PRODUKTE); } public List alleSortiertNachBezeichnung() { diff --git a/src/test/java/de/team1/faktura/gemeinsam/EreignisBusTest.java b/src/test/java/de/team1/faktura/gemeinsam/EreignisBusTest.java new file mode 100644 index 0000000..4eb774f --- /dev/null +++ b/src/test/java/de/team1/faktura/gemeinsam/EreignisBusTest.java @@ -0,0 +1,48 @@ +package de.team1.faktura.gemeinsam; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class EreignisBusTest { + + @Test + @DisplayName("melde benachrichtigt alle Beobachter des Bereichs") + void benachrichtigtAlleBeobachter() { + EreignisBus bus = new EreignisBus(); + AtomicInteger ersterBeobachter = new AtomicInteger(); + AtomicInteger zweiterBeobachter = new AtomicInteger(); + bus.abonniere(DatenBereich.KUNDEN, ersterBeobachter::incrementAndGet); + bus.abonniere(DatenBereich.KUNDEN, zweiterBeobachter::incrementAndGet); + + bus.melde(DatenBereich.KUNDEN); + bus.melde(DatenBereich.KUNDEN); + + assertEquals(2, ersterBeobachter.get()); + assertEquals(2, zweiterBeobachter.get()); + } + + @Test + @DisplayName("melde benachrichtigt nur Beobachter des betroffenen Bereichs") + void benachrichtigtNurBetroffenenBereich() { + EreignisBus bus = new EreignisBus(); + AtomicInteger kundenBeobachter = new AtomicInteger(); + AtomicInteger dokumentBeobachter = new AtomicInteger(); + bus.abonniere(DatenBereich.KUNDEN, kundenBeobachter::incrementAndGet); + bus.abonniere(DatenBereich.DOKUMENTE, dokumentBeobachter::incrementAndGet); + + bus.melde(DatenBereich.DOKUMENTE); + + assertEquals(0, kundenBeobachter.get()); + assertEquals(1, dokumentBeobachter.get()); + } + + @Test + @DisplayName("melde ohne Beobachter ist wirkungslos und wirft nicht") + void meldenOhneBeobachterIstWirkungslos() { + new EreignisBus().melde(DatenBereich.PRODUKTE); + } +} diff --git a/src/test/java/de/team1/faktura/gemeinsam/JsonPersistenzTest.java b/src/test/java/de/team1/faktura/gemeinsam/JsonPersistenzTest.java new file mode 100644 index 0000000..fb30385 --- /dev/null +++ b/src/test/java/de/team1/faktura/gemeinsam/JsonPersistenzTest.java @@ -0,0 +1,56 @@ +package de.team1.faktura.gemeinsam; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JsonPersistenzTest { + + private final ObjectMapper mapper = JsonPersistenz.mapper(); + + @Test + @DisplayName("schreibeAtomar legt die Zieldatei an und lässt keine Temp-Datei zurück") + void schreibtOhneTempDateiRueckstand(@TempDir Path verzeichnis) throws IOException { + Path datei = verzeichnis.resolve("bestand.json"); + + JsonPersistenz.schreibeAtomar(datei, mapper.writer(), List.of("a", "b")); + + assertTrue(Files.exists(datei)); + assertFalse(Files.exists(verzeichnis.resolve("bestand.json.tmp")), + "Temp-Datei darf nach dem Move nicht zurückbleiben"); + List gelesen = mapper.readValue(datei.toFile(), List.class); + assertEquals(List.of("a", "b"), gelesen); + } + + @Test + @DisplayName("schreibeAtomar ersetzt einen vorhandenen Bestand vollständig") + void ersetztVorhandenenBestand(@TempDir Path verzeichnis) throws IOException { + Path datei = verzeichnis.resolve("bestand.json"); + JsonPersistenz.schreibeAtomar(datei, mapper.writer(), List.of("alt")); + + JsonPersistenz.schreibeAtomar(datei, mapper.writer(), List.of("neu1", "neu2")); + + List gelesen = mapper.readValue(datei.toFile(), List.class); + assertEquals(List.of("neu1", "neu2"), gelesen); + } + + @Test + @DisplayName("schreibeAtomar legt fehlende Elternverzeichnisse an") + void legtElternverzeichnisseAn(@TempDir Path verzeichnis) throws IOException { + Path datei = verzeichnis.resolve("unterordner").resolve("bestand.json"); + + JsonPersistenz.schreibeAtomar(datei, mapper.writer(), List.of("a")); + + assertTrue(Files.exists(datei)); + } +}