diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java
index b820ef6..e360e11 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java
@@ -429,7 +429,13 @@ public final class GuiConfigurationEditorWorkspace {
private final GuiBatchRunTab batchRunTab;
/**
- * Dritter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt
+ * Dritter Haupt-Tab: Historien-Tab „Verlauf". Wird während der Workspace-Konstruktion
+ * erstellt und in den {@link #tabPane} eingehängt.
+ */
+ private final de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab historyTab;
+
+ /**
+ * Vierter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt
* und in den {@link #tabPane} eingehängt.
*/
private final GuiPromptEditorTab promptEditorTab;
@@ -518,6 +524,14 @@ public final class GuiConfigurationEditorWorkspace {
this::editorSourceFolder,
this::editorTargetFolder);
+ this.historyTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab(
+ effectiveContext.historyOverviewPort(),
+ effectiveContext.historyDetailsPort(),
+ effectiveContext.historyResetDocumentStatusPort(),
+ effectiveContext.deleteDocumentHistoryPort(),
+ this.batchRunTab::isRunning,
+ this::loadedConfigurationPath);
+
String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile();
int maxTitleLength;
try {
@@ -1296,7 +1310,7 @@ public final class GuiConfigurationEditorWorkspace {
scrollPane.setPadding(new Insets(0));
editorTab.setContent(scrollPane);
- tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), promptEditorTab.tab());
+ tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), historyTab.tab(), promptEditorTab.tab());
root.setCenter(tabPane);
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java
index c652e47..b51976e 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java
@@ -11,6 +11,10 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
@@ -68,7 +72,11 @@ public record GuiStartupContext(
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
String applicationVersion,
- GuiPromptEditorPort promptEditorPort) {
+ GuiPromptEditorPort promptEditorPort,
+ GuiHistoryOverviewPort historyOverviewPort,
+ GuiHistoryDetailsPort historyDetailsPort,
+ GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
+ GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort) {
/**
* Creates a fully wired startup context.
@@ -134,6 +142,14 @@ public record GuiStartupContext(
// Null-Fallback für Testumgebungen ohne gepacktes JAR
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
+ historyOverviewPort = Objects.requireNonNull(historyOverviewPort,
+ "historyOverviewPort must not be null");
+ historyDetailsPort = Objects.requireNonNull(historyDetailsPort,
+ "historyDetailsPort must not be null");
+ historyResetDocumentStatusPort = Objects.requireNonNull(historyResetDocumentStatusPort,
+ "historyResetDocumentStatusPort must not be null");
+ deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
+ "deleteDocumentHistoryPort must not be null");
}
/**
@@ -175,7 +191,9 @@ public record GuiStartupContext(
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
- noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
+ noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
+ noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
+ noOpHistoryResetPort(), noOpDeleteHistoryPort());
}
/**
@@ -211,7 +229,9 @@ public record GuiStartupContext(
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
- noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
+ noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
+ noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
+ noOpHistoryResetPort(), noOpDeleteHistoryPort());
}
/**
@@ -247,7 +267,9 @@ public record GuiStartupContext(
technicalTestOrchestrator, correctionExecutionService,
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
- noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
+ noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
+ noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
+ noOpHistoryResetPort(), noOpDeleteHistoryPort());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -363,7 +385,11 @@ public record GuiStartupContext(
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(),
"dev",
- noOpPromptEditorPort());
+ noOpPromptEditorPort(),
+ noOpHistoryOverviewPort(),
+ noOpHistoryDetailsPort(),
+ noOpHistoryResetPort(),
+ noOpDeleteHistoryPort());
}
private static GuiPromptEditorPort noOpPromptEditorPort() {
@@ -391,4 +417,25 @@ public record GuiStartupContext(
}
};
}
+
+ private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort
+ noOpHistoryOverviewPort() {
+ return (configFilePath, query) -> new de.gecheckt.pdf.umbenenner.application.usecase
+ .DefaultHistoryOverviewUseCase.HistoryOverviewResult(java.util.List.of(), false);
+ }
+
+ private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort
+ noOpHistoryDetailsPort() {
+ return (configFilePath, fingerprint) -> java.util.Optional.empty();
+ }
+
+ private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort
+ noOpHistoryResetPort() {
+ return (configFilePath, fingerprint) -> { /* kein Reset in diesem Startkontext */ };
+ }
+
+ private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort
+ noOpDeleteHistoryPort() {
+ return (configFilePath, fingerprint) -> { /* kein Löschen in diesem Startkontext */ };
+ }
}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiDeleteDocumentHistoryPort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiDeleteDocumentHistoryPort.java
new file mode 100644
index 0000000..0f66050
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiDeleteDocumentHistoryPort.java
@@ -0,0 +1,39 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
+
+import java.nio.file.Path;
+
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+
+/**
+ * GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
+ * {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase}.
+ *
+ * Löscht den Dokument-Stammsatz und alle zugehörigen Verarbeitungsversuche
+ * vollständig und transaktional. Die Löschung ist destruktiv und nicht
+ * rückgängig zu machen.
+ *
+ * Die GUI muss vor dem Aufruf dieses Ports einen Bestätigungsdialog mit
+ * explizitem Warnhinweis anzeigen.
+ *
+ * Threading: Implementierungen müssen sicher von einem
+ * Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
+ * bis die Löschung abgeschlossen ist.
+ */
+@FunctionalInterface
+public interface GuiDeleteDocumentHistoryPort {
+
+ /**
+ * Löscht den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
+ *
+ * Die Löschung erfolgt in der korrekten Reihenfolge innerhalb einer Transaktion:
+ * zuerst alle {@code processing_attempt}-Einträge, dann der {@code document_record}-Stammsatz.
+ * Ist kein Datensatz vorhanden, kehrt die Methode stillschweigend zurück.
+ *
+ * @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
+ * darf nicht {@code null} sein
+ * @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
+ * @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
+ * bei technischen Datenbankfehlern
+ */
+ void deleteHistory(Path configFilePath, DocumentFingerprint fingerprint);
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryDetailsPort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryDetailsPort.java
new file mode 100644
index 0000000..c8e2491
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryDetailsPort.java
@@ -0,0 +1,38 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
+
+import java.nio.file.Path;
+import java.util.Optional;
+
+import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+
+/**
+ * GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
+ * {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase}.
+ *
+ * Dieses Interface ist kein hexagonaler Outbound-Port der Application-Schicht.
+ * Es ist eine modul-interne Brücke, über die Bootstrap die Detaildaten
+ * für einen ausgewählten Dokumenteintrag bereitstellt.
+ *
+ * Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
+ * die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann.
+ *
+ * Threading: Implementierungen müssen sicher von einem
+ * Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
+ * bis das Ergebnis vollständig vorliegt.
+ */
+@FunctionalInterface
+public interface GuiHistoryDetailsPort {
+
+ /**
+ * Lädt den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
+ *
+ * @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
+ * darf nicht {@code null} sein
+ * @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
+ * @return Optional mit den Detaildaten, oder leer wenn kein Eintrag gefunden wurde
+ * @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
+ * bei technischen Datenbankfehlern
+ */
+ Optional loadDetails(Path configFilePath, DocumentFingerprint fingerprint);
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryOverviewPort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryOverviewPort.java
new file mode 100644
index 0000000..9972945
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryOverviewPort.java
@@ -0,0 +1,43 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
+
+import java.nio.file.Path;
+
+import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
+import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
+
+/**
+ * GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
+ * {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase}.
+ *
+ * Dieses Interface ist kein hexagonaler Outbound-Port der Application-Schicht.
+ * Es ist eine modul-interne Brücke, über die Bootstrap die Dokumentenliste
+ * für den Historien-Tab bereitstellt, ohne dass der GUI-Adapter direkt auf
+ * Repository-Implementierungen zugreift.
+ *
+ * Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
+ * die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann,
+ * ohne den Pfad global zu speichern.
+ *
+ * Threading: Implementierungen müssen sicher von einem
+ * Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
+ * bis das Ergebnis vollständig vorliegt.
+ */
+@FunctionalInterface
+public interface GuiHistoryOverviewPort {
+
+ /**
+ * Lädt die gefilterte Dokumentenübersicht für den Historien-Tab.
+ *
+ * Bei mehr als 500 Treffern enthält das Ergebnis genau 500 Zeilen und
+ * {@link HistoryOverviewResult#hasMore()} liefert {@code true}.
+ *
+ * @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
+ * darf nicht {@code null} sein
+ * @param query Abfrageparameter mit Suchtext, Status-Filter und Limit;
+ * darf nicht {@code null} sein
+ * @return Ergebnisobjekt mit Trefferliste und {@code hasMore}-Flag; nie {@code null}
+ * @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
+ * bei technischen Datenbankfehlern
+ */
+ HistoryOverviewResult loadOverview(Path configFilePath, HistoryQuery query);
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryResetDocumentStatusPort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryResetDocumentStatusPort.java
new file mode 100644
index 0000000..b6e06f0
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryResetDocumentStatusPort.java
@@ -0,0 +1,47 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
+
+import java.nio.file.Path;
+
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+
+/**
+ * GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
+ * {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryResetDocumentStatusUseCase}.
+ *
+ * Führt einen feldgenauen Status-Reset durch: ausschließlich {@code overall_status},
+ * {@code content_error_count}, {@code transient_error_count} und
+ * {@code last_failure_instant} werden zurückgesetzt. Die Versuchshistorie bleibt
+ * vollständig erhalten. Nach dem Reset gilt das Dokument beim nächsten
+ * Verarbeitungslauf als verarbeitbar.
+ *
+ * Abgrenzung zu {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort}:
+ * Der bestehende Reset-Port im {@code batchrun}-Paket löscht alle Persistenzdaten
+ * (Stammsatz und Versuchshistorie) vollständig. Dieser Port hier führt ausschließlich
+ * einen feldgenauen Update durch und lässt die Versuchshistorie unangetastet.
+ *
+ * Threading: Implementierungen müssen sicher von einem
+ * Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
+ * bis die Operation abgeschlossen ist.
+ */
+@FunctionalInterface
+public interface GuiHistoryResetDocumentStatusPort {
+
+ /**
+ * Setzt den Status des Dokuments feldgenau zurück.
+ *
+ * Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer
+ * zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%),
+ * rechts ein Detailbereich mit Stammsatz, Versuchstabelle und KI-Begründung (~45%).
+ *
+ *
Alle DB-Zugriffe laufen auf einem Hintergrund-Worker-Thread.
+ * UI-Updates erfolgen ausschließlich via {@code Platform.runLater()}.
+ * Destruktive Aktionen (Reset, Löschen) sind während eines aktiven
+ * Verarbeitungslaufs deaktiviert.
+ */
+public final class GuiHistoryTab {
+
+ private static final Logger LOG = LogManager.getLogger(GuiHistoryTab.class);
+
+ private static final String TAB_TITLE = "Verlauf";
+ private static final String EMPTY_DB_TEXT = "Noch keine Verarbeitungen vorhanden.";
+ private static final String TOO_MANY_RESULTS_TEXT =
+ "Weitere Einträge vorhanden – Filter verwenden um die Trefferliste einzuschränken.";
+ private static final String DETAIL_PLACEHOLDER = "Dokument auswählen für Details";
+ private static final String NO_REASONING_TEXT = "Kein KI-Reasoning für diesen Versuch vorhanden.";
+ private static final String LOADING_TEXT = "Wird geladen …";
+ private static final String LAUF_AKTIV_HINWEIS = "Aktion während Verarbeitungslauf nicht möglich.";
+
+ private static final DateTimeFormatter TIMESTAMP_FMT =
+ DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm").withZone(ZoneId.systemDefault());
+
+ // ---- Bridge-Ports ---------------------------------------------------
+ private final GuiHistoryOverviewPort overviewPort;
+ private final GuiHistoryDetailsPort detailsPort;
+ private final GuiHistoryResetDocumentStatusPort resetPort;
+ private final GuiDeleteDocumentHistoryPort deletePort;
+ private final BooleanSupplier runningCheck;
+ /** Liefert den Pfad zur aktuell geladenen Konfigurationsdatei, oder {@code null} wenn keine geladen. */
+ private final Supplier configPathSupplier;
+
+ // ---- JavaFX-Knoten --------------------------------------------------
+ private final Tab tab = new Tab(TAB_TITLE);
+
+ private final TextField searchField = new TextField();
+ private final ComboBox statusFilterBox = new ComboBox<>();
+ private final Button refreshButton = new Button("Aktualisieren");
+
+ private final TableView overviewTable = new TableView<>();
+ private final ObservableList overviewItems = FXCollections.observableArrayList();
+
+ private final Label statusBarLabel = new Label();
+ private final Label moreThanMaxLabel = new Label();
+
+ // Detailbereich
+ private final GridPane detailGrid = new GridPane();
+ private final Label detailFingerprintLabel = new Label();
+ private final Label detailSourceFileLabel = new Label();
+ private final Label detailSourcePathLabel = new Label();
+ private final Label detailStatusLabel = new Label();
+ private final Label detailCreatedLabel = new Label();
+ private final Label detailUpdatedLabel = new Label();
+
+ private final TableView attemptsTable = new TableView<>();
+ private final ObservableList attemptsItems = FXCollections.observableArrayList();
+ private final TextArea reasoningArea = new TextArea();
+
+ private final Button resetButton = new Button("Status zurücksetzen");
+ private final Button deleteButton = new Button("Eintrag löschen");
+
+ // ---- Zustand --------------------------------------------------------
+ private final ExecutorService workerPool;
+
+ /**
+ * Erzeugt den Historien-Tab.
+ *
+ * @param overviewPort Brücke zur Dokumentenübersicht; darf nicht {@code null} sein
+ * @param detailsPort Brücke zur Detailansicht; darf nicht {@code null} sein
+ * @param resetPort Brücke zum feldgenauen Status-Reset; darf nicht {@code null} sein
+ * @param deletePort Brücke zum vollständigen Löschen; darf nicht {@code null} sein
+ * @param runningCheck Liefert {@code true} wenn gerade ein Verarbeitungslauf aktiv ist;
+ * darf nicht {@code null} sein
+ * @param configPathSupplier Liefert den Pfad zur aktuell geladenen Konfigurationsdatei,
+ * oder {@code null} wenn keine geladen ist; darf nicht {@code null} sein
+ */
+ public GuiHistoryTab(
+ GuiHistoryOverviewPort overviewPort,
+ GuiHistoryDetailsPort detailsPort,
+ GuiHistoryResetDocumentStatusPort resetPort,
+ GuiDeleteDocumentHistoryPort deletePort,
+ BooleanSupplier runningCheck,
+ Supplier configPathSupplier) {
+ this.overviewPort = Objects.requireNonNull(overviewPort, "overviewPort darf nicht null sein");
+ this.detailsPort = Objects.requireNonNull(detailsPort, "detailsPort darf nicht null sein");
+ this.resetPort = Objects.requireNonNull(resetPort, "resetPort darf nicht null sein");
+ this.deletePort = Objects.requireNonNull(deletePort, "deletePort darf nicht null sein");
+ this.runningCheck = Objects.requireNonNull(runningCheck, "runningCheck darf nicht null sein");
+ this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier darf nicht null sein");
+
+ this.workerPool = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, "HistoryTabWorker");
+ t.setDaemon(true);
+ return t;
+ });
+
+ buildUi();
+ wireEvents();
+ tab.setClosable(false);
+ }
+
+ /**
+ * Liefert den JavaFX-{@link Tab}, der in die TabPane eingefügt werden kann.
+ *
+ * @return der Tab; nie {@code null}
+ */
+ public Tab tab() {
+ return tab;
+ }
+
+ /**
+ * Lädt die Dokumentenübersicht neu – muss auf dem JavaFX Application Thread aufgerufen werden.
+ * Wird vom Tab-Wechsel-Listener ausgelöst.
+ */
+ public void refresh() {
+ loadOverview();
+ }
+
+ // =========================================================================
+ // UI-Aufbau
+ // =========================================================================
+
+ private void buildUi() {
+ // --- Toolbar ---
+ searchField.setPromptText("Suche nach Dateiname …");
+ searchField.setPrefWidth(300);
+ Tooltip.install(searchField, new Tooltip(
+ "Freitextsuche über Quell- und Zieldateiname (Groß-/Kleinschreibung egal)."));
+
+ statusFilterBox.getItems().add("Alle Status");
+ for (ProcessingStatus s : ProcessingStatus.values()) {
+ statusFilterBox.getItems().add(s.name());
+ }
+ statusFilterBox.getSelectionModel().selectFirst();
+ Tooltip.install(statusFilterBox, new Tooltip("Status-Filter: nur Einträge mit diesem Status anzeigen."));
+
+ refreshButton.setTooltip(new Tooltip("Dokumentenliste neu aus der Datenbank laden."));
+
+ Region spacer = new Region();
+ HBox.setHgrow(spacer, Priority.ALWAYS);
+
+ HBox toolbar = new HBox(8, searchField, statusFilterBox, spacer, refreshButton);
+ toolbar.setAlignment(Pos.CENTER_LEFT);
+ toolbar.setPadding(new Insets(6, 8, 6, 8));
+
+ // --- Dokumentenliste (links) ---
+ buildOverviewTable();
+
+ moreThanMaxLabel.setStyle("-fx-text-fill: #d98200; -fx-font-style: italic;");
+ moreThanMaxLabel.setVisible(false);
+ moreThanMaxLabel.setManaged(false);
+
+ VBox leftPane = new VBox(4, overviewTable, moreThanMaxLabel);
+ VBox.setVgrow(overviewTable, Priority.ALWAYS);
+ leftPane.setPadding(new Insets(0, 4, 0, 0));
+
+ // --- Detailbereich (rechts) ---
+ VBox rightPane = buildDetailPane();
+
+ // --- SplitPane ---
+ SplitPane splitPane = new SplitPane(leftPane, rightPane);
+ splitPane.setDividerPositions(0.55);
+
+ // --- Aktionsleiste unten ---
+ resetButton.setTooltip(new Tooltip(
+ "Setzt Status, Fehlerzähler und letzten Fehlerzeitpunkt zurück. "
+ + "Versuche bleiben erhalten. Das Dokument wird beim nächsten Lauf erneut verarbeitet."));
+ deleteButton.setTooltip(new Tooltip(
+ "Löscht den Eintrag und alle Versuche vollständig. "
+ + "Diese Aktion ist nicht rückgängig zu machen."));
+ resetButton.setDisable(true);
+ deleteButton.setDisable(true);
+
+ statusBarLabel.setStyle("-fx-text-fill: #555555; -fx-font-style: italic;");
+
+ HBox actionBar = new HBox(8, resetButton, deleteButton, spacerNew(), statusBarLabel);
+ actionBar.setAlignment(Pos.CENTER_LEFT);
+ actionBar.setPadding(new Insets(6, 8, 6, 8));
+
+ // --- Gesamtlayout ---
+ BorderPane content = new BorderPane();
+ content.setTop(toolbar);
+ content.setCenter(splitPane);
+ content.setBottom(actionBar);
+ BorderPane.setMargin(toolbar, Insets.EMPTY);
+
+ tab.setContent(content);
+ }
+
+ private void buildOverviewTable() {
+ overviewTable.setItems(overviewItems);
+ overviewTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
+ overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
+ overviewTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
+
+ // Status-Icon-Spalte
+ TableColumn statusCol = new TableColumn<>("Status");
+ statusCol.setCellValueFactory(cell ->
+ new SimpleStringProperty(statusIcon(cell.getValue().overallStatus())));
+ statusCol.setCellFactory(col -> new TableCell<>() {
+ @Override
+ protected void updateItem(String icon, boolean empty) {
+ super.updateItem(icon, empty);
+ if (empty || icon == null) {
+ setText(null);
+ setTooltip(null);
+ } else {
+ setText(icon);
+ DocumentHistoryRow row = getTableView().getItems().get(getIndex());
+ setStyle("-fx-text-fill: " + statusColor(row.overallStatus()) + "; -fx-font-weight: bold;");
+ setTooltip(new Tooltip(statusTooltip(row.overallStatus())));
+ }
+ }
+ });
+ statusCol.setPrefWidth(60);
+ statusCol.setMaxWidth(70);
+
+ // Quelldateiname
+ TableColumn sourceCol = new TableColumn<>("Quelldatei");
+ sourceCol.setCellValueFactory(cell ->
+ new SimpleStringProperty(cell.getValue().sourceFileName()));
+ sourceCol.setCellFactory(col -> ellipsisCell());
+
+ // Zieldateiname
+ TableColumn targetCol = new TableColumn<>("Zieldatei");
+ targetCol.setCellValueFactory(cell ->
+ new SimpleStringProperty(
+ cell.getValue().targetFileName() != null ? cell.getValue().targetFileName() : "—"));
+ targetCol.setCellFactory(col -> ellipsisCell());
+
+ // Letzter Versuch
+ TableColumn updatedCol = new TableColumn<>("Letzter Versuch");
+ updatedCol.setCellValueFactory(cell ->
+ new SimpleStringProperty(formatInstant(cell.getValue().updatedAt())));
+ updatedCol.setPrefWidth(140);
+ updatedCol.setMaxWidth(160);
+
+ // Anzahl Versuche
+ TableColumn countCol = new TableColumn<>("Versuche");
+ countCol.setCellValueFactory(cell ->
+ new SimpleStringProperty(String.valueOf(cell.getValue().attemptCount())));
+ countCol.setPrefWidth(70);
+ countCol.setMaxWidth(80);
+
+ overviewTable.getColumns().setAll(statusCol, sourceCol, targetCol, updatedCol, countCol);
+ }
+
+ private VBox buildDetailPane() {
+ // Dokument-Info
+ detailGrid.setHgap(8);
+ detailGrid.setVgap(4);
+ detailGrid.setPadding(new Insets(8));
+
+ addDetailRow(0, "Fingerprint:", detailFingerprintLabel);
+ addDetailRow(1, "Quelldatei:", detailSourceFileLabel);
+ addDetailRow(2, "Quellpfad:", detailSourcePathLabel);
+ addDetailRow(3, "Status:", detailStatusLabel);
+ addDetailRow(4, "Erstellt:", detailCreatedLabel);
+ addDetailRow(5, "Aktualisiert:", detailUpdatedLabel);
+
+ Label detailTitle = new Label("Dokument-Details");
+ detailTitle.setStyle("-fx-font-weight: bold;");
+
+ // Versuche-Tabelle
+ buildAttemptsTable();
+ Label attemptsTitle = new Label("Verarbeitungsversuche");
+ attemptsTitle.setStyle("-fx-font-weight: bold;");
+
+ // KI-Begründung
+ reasoningArea.setEditable(false);
+ reasoningArea.setWrapText(true);
+ reasoningArea.setPrefRowCount(4);
+ reasoningArea.setText(DETAIL_PLACEHOLDER);
+ Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)");
+ reasoningTitle.setStyle("-fx-font-weight: bold;");
+
+ VBox rightPane = new VBox(8,
+ detailTitle, detailGrid,
+ attemptsTitle, attemptsTable,
+ reasoningTitle, reasoningArea);
+ rightPane.setPadding(new Insets(4, 8, 4, 4));
+ VBox.setVgrow(attemptsTable, Priority.ALWAYS);
+
+ ScrollPane scroll = new ScrollPane(rightPane);
+ scroll.setFitToWidth(true);
+ scroll.setFitToHeight(true);
+
+ VBox wrapper = new VBox(scroll);
+ VBox.setVgrow(scroll, Priority.ALWAYS);
+ return wrapper;
+ }
+
+ private void buildAttemptsTable() {
+ attemptsTable.setItems(attemptsItems);
+ attemptsTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
+ attemptsTable.setPlaceholder(new Label("Keine Versuche vorhanden."));
+ attemptsTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
+ attemptsTable.setPrefHeight(150);
+
+ TableColumn numCol = new TableColumn<>("#");
+ numCol.setCellValueFactory(c ->
+ new SimpleStringProperty(String.valueOf(c.getValue().attemptNumber())));
+ numCol.setPrefWidth(40);
+ numCol.setMaxWidth(50);
+
+ TableColumn dateCol = new TableColumn<>("Datum");
+ dateCol.setCellValueFactory(c ->
+ new SimpleStringProperty(formatInstant(c.getValue().endedAt())));
+ dateCol.setPrefWidth(130);
+ dateCol.setMaxWidth(150);
+
+ TableColumn statusCol = new TableColumn<>("Status");
+ statusCol.setCellValueFactory(c ->
+ new SimpleStringProperty(
+ statusIcon(c.getValue().status()) + " " + c.getValue().status().name()));
+ statusCol.setPrefWidth(140);
+
+ TableColumn providerCol = new TableColumn<>("Provider");
+ providerCol.setCellValueFactory(c ->
+ new SimpleStringProperty(
+ c.getValue().aiProvider() != null ? c.getValue().aiProvider() : "—"));
+ providerCol.setPrefWidth(90);
+
+ TableColumn modelCol = new TableColumn<>("Modell");
+ modelCol.setCellValueFactory(c ->
+ new SimpleStringProperty(
+ c.getValue().modelName() != null ? c.getValue().modelName() : "—"));
+ modelCol.setCellFactory(col -> ellipsisCell());
+
+ TableColumn fileNameCol = new TableColumn<>("Vorgeschlagener Name");
+ fileNameCol.setCellValueFactory(c ->
+ new SimpleStringProperty(
+ c.getValue().finalTargetFileName() != null
+ ? c.getValue().finalTargetFileName() : "—"));
+ fileNameCol.setCellFactory(col -> ellipsisCell());
+
+ attemptsTable.getColumns().setAll(numCol, dateCol, statusCol, providerCol, modelCol, fileNameCol);
+ }
+
+ // =========================================================================
+ // Event-Verdrahtung
+ // =========================================================================
+
+ private void wireEvents() {
+ refreshButton.setOnAction(e -> loadOverview());
+
+ // Debounce-artige Aktualisierung bei Texteingabe: direkte Suche bei Enter,
+ // sonst über Fokus-Verlust oder expliziten Aktualisieren-Button
+ searchField.setOnAction(e -> loadOverview());
+
+ statusFilterBox.setOnAction(e -> loadOverview());
+
+ // Detailbereich bei Zeilenselektion
+ overviewTable.getSelectionModel().selectedItemProperty().addListener(
+ (obs, old, selected) -> {
+ if (selected == null) {
+ clearDetailPane();
+ resetButton.setDisable(true);
+ deleteButton.setDisable(true);
+ } else {
+ resetButton.setDisable(runningCheck.getAsBoolean());
+ deleteButton.setDisable(runningCheck.getAsBoolean());
+ loadDetails(selected.fingerprint());
+ }
+ });
+
+ resetButton.setOnAction(e -> handleResetAction());
+ deleteButton.setOnAction(e -> handleDeleteAction());
+
+ // Tab soll beim ersten Betreten automatisch laden
+ tab.selectedProperty().addListener((obs, oldVal, selected) -> {
+ if (Boolean.TRUE.equals(selected)) {
+ loadOverview();
+ }
+ });
+ }
+
+ // =========================================================================
+ // Daten laden (Worker-Thread)
+ // =========================================================================
+
+ private void loadOverview() {
+ statusBarLabel.setText(LOADING_TEXT);
+ overviewItems.clear();
+ moreThanMaxLabel.setVisible(false);
+ moreThanMaxLabel.setManaged(false);
+
+ Path configPath = configPathSupplier.get();
+ if (configPath == null) {
+ statusBarLabel.setText("Keine Konfiguration geladen – bitte zuerst eine Konfigurationsdatei öffnen.");
+ overviewTable.setPlaceholder(new Label("Keine Konfiguration geladen."));
+ return;
+ }
+
+ String searchText = searchField.getText();
+ String selectedStatus = statusFilterBox.getSelectionModel().getSelectedItem();
+ String statusFilter = (selectedStatus == null || "Alle Status".equals(selectedStatus))
+ ? null : selectedStatus;
+
+ HistoryQuery query = new HistoryQuery(searchText, statusFilter, HistoryQuery.DEFAULT_LIMIT);
+
+ workerPool.submit(() -> {
+ try {
+ HistoryOverviewResult result = overviewPort.loadOverview(configPath, query);
+ Platform.runLater(() -> {
+ overviewItems.setAll(result.rows());
+ if (result.hasMore()) {
+ moreThanMaxLabel.setText(TOO_MANY_RESULTS_TEXT);
+ moreThanMaxLabel.setVisible(true);
+ moreThanMaxLabel.setManaged(true);
+ } else {
+ moreThanMaxLabel.setVisible(false);
+ moreThanMaxLabel.setManaged(false);
+ }
+ if (result.rows().isEmpty()) {
+ overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
+ statusBarLabel.setText("Keine Einträge gefunden.");
+ } else {
+ statusBarLabel.setText(result.rows().size() + " Einträge geladen.");
+ }
+ });
+ } catch (Exception ex) {
+ LOG.error("Fehler beim Laden der Historienübersicht: {}", ex.getMessage(), ex);
+ Platform.runLater(() ->
+ statusBarLabel.setText("Fehler beim Laden: " + ex.getMessage()));
+ }
+ });
+ }
+
+ private void loadDetails(DocumentFingerprint fingerprint) {
+ reasoningArea.setText(LOADING_TEXT);
+ attemptsItems.clear();
+ clearDetailFields();
+
+ Path configPath = configPathSupplier.get();
+ if (configPath == null) {
+ reasoningArea.setText(DETAIL_PLACEHOLDER);
+ return;
+ }
+
+ workerPool.submit(() -> {
+ try {
+ Optional result = detailsPort.loadDetails(configPath, fingerprint);
+ Platform.runLater(() -> {
+ if (result.isEmpty()) {
+ clearDetailPane();
+ statusBarLabel.setText("Eintrag nicht mehr vorhanden.");
+ } else {
+ populateDetailPane(result.get());
+ }
+ });
+ } catch (Exception ex) {
+ LOG.error("Fehler beim Laden der Dokumentdetails für {}: {}",
+ fingerprint.sha256Hex(), ex.getMessage(), ex);
+ Platform.runLater(() -> {
+ reasoningArea.setText("Fehler beim Laden der Details: " + ex.getMessage());
+ statusBarLabel.setText("Fehler beim Laden der Details.");
+ });
+ }
+ });
+ }
+
+ // =========================================================================
+ // Aktionen
+ // =========================================================================
+
+ private void handleResetAction() {
+ if (runningCheck.getAsBoolean()) {
+ showInfo(LAUF_AKTIV_HINWEIS);
+ return;
+ }
+
+ DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
+ if (selected == null) return;
+
+ Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
+ confirm.setTitle("Status zurücksetzen");
+ confirm.setHeaderText("Status zurücksetzen?");
+ confirm.setContentText(
+ "Setzt den Status des Dokuments auf READY_FOR_AI zurück.\n"
+ + "Fehlerzähler und letzter Fehlerzeitpunkt werden gelöscht.\n"
+ + "Die Versuchshistorie bleibt vollständig erhalten.\n\n"
+ + "Das Dokument wird beim nächsten Verarbeitungslauf erneut verarbeitet.\n\n"
+ + "Quelldatei: " + selected.sourceFileName());
+ Optional choice = confirm.showAndWait();
+ if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
+
+ DocumentFingerprint fp = selected.fingerprint();
+ Path configPath = configPathSupplier.get();
+ if (configPath == null) {
+ showInfo("Keine Konfiguration geladen.");
+ return;
+ }
+ resetButton.setDisable(true);
+ deleteButton.setDisable(true);
+ statusBarLabel.setText("Status wird zurückgesetzt …");
+
+ workerPool.submit(() -> {
+ try {
+ resetPort.resetStatus(configPath, fp);
+ LOG.info("Status-Reset durchgeführt für Fingerprint: {}", fp.sha256Hex());
+ Platform.runLater(() -> {
+ statusBarLabel.setText("Status erfolgreich zurückgesetzt.");
+ loadOverview();
+ });
+ } catch (Exception ex) {
+ LOG.error("Status-Reset fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
+ Platform.runLater(() -> {
+ statusBarLabel.setText("Fehler beim Status-Reset: " + ex.getMessage());
+ resetButton.setDisable(false);
+ deleteButton.setDisable(false);
+ });
+ }
+ });
+ }
+
+ private void handleDeleteAction() {
+ if (runningCheck.getAsBoolean()) {
+ showInfo(LAUF_AKTIV_HINWEIS);
+ return;
+ }
+
+ DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
+ if (selected == null) return;
+
+ Alert confirm = new Alert(Alert.AlertType.WARNING);
+ confirm.setTitle("Eintrag löschen");
+ confirm.setHeaderText("Eintrag vollständig löschen?");
+ confirm.setContentText(
+ "Der Stammsatz und ALLE Verarbeitungsversuche werden unwiderruflich gelöscht.\n"
+ + "Diese Aktion kann nicht rückgängig gemacht werden.\n\n"
+ + "Quelldatei: " + selected.sourceFileName());
+ confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
+ Optional choice = confirm.showAndWait();
+ if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
+
+ DocumentFingerprint fp = selected.fingerprint();
+ Path configPath = configPathSupplier.get();
+ if (configPath == null) {
+ showInfo("Keine Konfiguration geladen.");
+ return;
+ }
+ resetButton.setDisable(true);
+ deleteButton.setDisable(true);
+ statusBarLabel.setText("Eintrag wird gelöscht …");
+
+ workerPool.submit(() -> {
+ try {
+ deletePort.deleteHistory(configPath, fp);
+ LOG.info("Dokumenteintrag gelöscht für Fingerprint: {}", fp.sha256Hex());
+ Platform.runLater(() -> {
+ statusBarLabel.setText("Eintrag erfolgreich gelöscht.");
+ clearDetailPane();
+ loadOverview();
+ });
+ } catch (Exception ex) {
+ LOG.error("Löschen fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
+ Platform.runLater(() -> {
+ statusBarLabel.setText("Fehler beim Löschen: " + ex.getMessage());
+ resetButton.setDisable(false);
+ deleteButton.setDisable(false);
+ });
+ }
+ });
+ }
+
+ // =========================================================================
+ // Detail-Bereich befüllen / leeren
+ // =========================================================================
+
+ private void populateDetailPane(HistoryDetailsResult result) {
+ DocumentRecord record = result.record();
+ String fpFull = record.fingerprint().sha256Hex();
+ detailFingerprintLabel.setText(fpFull.substring(0, Math.min(12, fpFull.length())) + " …");
+ detailFingerprintLabel.setTooltip(new Tooltip(fpFull));
+ detailSourceFileLabel.setText(record.lastKnownSourceFileName());
+ detailSourcePathLabel.setText(record.lastKnownSourceLocator().value());
+ detailSourcePathLabel.setTooltip(new Tooltip(record.lastKnownSourceLocator().value()));
+ String icon = statusIcon(record.overallStatus());
+ detailStatusLabel.setText(icon + " " + record.overallStatus().name());
+ detailStatusLabel.setStyle("-fx-text-fill: " + statusColor(record.overallStatus()) + ";");
+ detailStatusLabel.setTooltip(new Tooltip(statusTooltip(record.overallStatus())));
+ detailCreatedLabel.setText(formatInstant(record.createdAt()));
+ detailUpdatedLabel.setText(formatInstant(record.updatedAt()));
+
+ attemptsItems.setAll(result.attempts());
+
+ // Neuesten Versuch selektieren und Begründung anzeigen
+ if (!result.attempts().isEmpty()) {
+ ProcessingAttempt last = result.attempts().get(result.attempts().size() - 1);
+ attemptsTable.getSelectionModel().select(last);
+ showReasoning(last);
+ } else {
+ reasoningArea.setText(NO_REASONING_TEXT);
+ }
+
+ // KI-Begründung bei Versuchs-Selektion aktualisieren
+ attemptsTable.getSelectionModel().selectedItemProperty().addListener(
+ (obs, old, attempt) -> {
+ if (attempt != null) {
+ showReasoning(attempt);
+ }
+ });
+ }
+
+ private void showReasoning(ProcessingAttempt attempt) {
+ String reasoning = attempt.aiReasoning();
+ reasoningArea.setText(reasoning != null && !reasoning.isBlank()
+ ? reasoning : NO_REASONING_TEXT);
+ }
+
+ private void clearDetailPane() {
+ clearDetailFields();
+ attemptsItems.clear();
+ reasoningArea.setText(DETAIL_PLACEHOLDER);
+ }
+
+ private void clearDetailFields() {
+ detailFingerprintLabel.setText("");
+ detailFingerprintLabel.setTooltip(null);
+ detailSourceFileLabel.setText("");
+ detailSourcePathLabel.setText("");
+ detailSourcePathLabel.setTooltip(null);
+ detailStatusLabel.setText("");
+ detailStatusLabel.setStyle("");
+ detailStatusLabel.setTooltip(null);
+ detailCreatedLabel.setText("");
+ detailUpdatedLabel.setText("");
+ }
+
+ // =========================================================================
+ // Hilfsmethoden
+ // =========================================================================
+
+ private void addDetailRow(int row, String labelText, Label valueLabel) {
+ Label label = new Label(labelText);
+ label.setStyle("-fx-font-weight: bold;");
+ valueLabel.setMaxWidth(Double.MAX_VALUE);
+ GridPane.setHgrow(valueLabel, Priority.ALWAYS);
+ detailGrid.add(label, 0, row);
+ detailGrid.add(valueLabel, 1, row);
+ }
+
+ private String formatInstant(Instant instant) {
+ if (instant == null) return "—";
+ return TIMESTAMP_FMT.format(instant);
+ }
+
+ private static String statusIcon(ProcessingStatus status) {
+ if (status == null) return "?";
+ return switch (status) {
+ case SUCCESS -> "✓";
+ case FAILED_RETRYABLE -> "↻";
+ case FAILED_FINAL -> "×";
+ case SKIPPED_ALREADY_PROCESSED -> "≡";
+ case SKIPPED_FINAL_FAILURE -> "⊘";
+ case READY_FOR_AI -> "⟳";
+ case PROPOSAL_READY -> "◇";
+ case PROCESSING -> "▶";
+ };
+ }
+
+ private static String statusColor(ProcessingStatus status) {
+ if (status == null) return "#000000";
+ return switch (status) {
+ case SUCCESS -> "#2e7d32";
+ case FAILED_RETRYABLE -> "#d98200";
+ case FAILED_FINAL -> "#c62828";
+ case SKIPPED_ALREADY_PROCESSED -> "#757575";
+ case SKIPPED_FINAL_FAILURE -> "#424242";
+ case READY_FOR_AI -> "#1565c0";
+ case PROPOSAL_READY -> "#0288d1";
+ case PROCESSING -> "#9e9e9e";
+ };
+ }
+
+ private static String statusTooltip(ProcessingStatus status) {
+ if (status == null) return "";
+ return switch (status) {
+ case SUCCESS -> "Erfolgreich verarbeitet und umbenannt.";
+ case FAILED_RETRYABLE -> "Temporärer Fehler – wird beim nächsten Lauf automatisch erneut versucht.";
+ case FAILED_FINAL -> "Dauerhaft nicht verarbeitbar – z. B. kein Textinhalt (Foto-PDF), "
+ + "Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch.";
+ case SKIPPED_ALREADY_PROCESSED -> "Übersprungen – wurde bereits in einem früheren Lauf erfolgreich verarbeitet.";
+ case SKIPPED_FINAL_FAILURE -> "Endgültig übersprungen nach wiederholten Fehlern.";
+ case READY_FOR_AI -> "Wartet auf Verarbeitung.";
+ case PROPOSAL_READY -> "KI-Vorschlag liegt vor, wartet auf Bestätigung.";
+ case PROCESSING -> "Wird gerade verarbeitet.";
+ };
+ }
+
+ private static TableCell ellipsisCell() {
+ return new TableCell<>() {
+ @Override
+ protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null) {
+ setText(null);
+ setTooltip(null);
+ } else {
+ setText(item);
+ setTooltip(new Tooltip(item));
+ }
+ }
+ };
+ }
+
+ private static Region spacerNew() {
+ Region r = new Region();
+ HBox.setHgrow(r, Priority.ALWAYS);
+ return r;
+ }
+
+ private void showInfo(String message) {
+ Alert alert = new Alert(Alert.AlertType.INFORMATION);
+ alert.setTitle("Hinweis");
+ alert.setHeaderText(null);
+ alert.setContentText(message);
+ alert.showAndWait();
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/package-info.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/package-info.java
new file mode 100644
index 0000000..0e54cec
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/package-info.java
@@ -0,0 +1,15 @@
+/**
+ * GUI-Adapter für den Historien-Tab.
+ *
+ * Enthält die Bridge-Interfaces {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort},
+ * {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort},
+ * {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort} und
+ * {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort}
+ * sowie die JavaFX-Komponente {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab}.
+ *
+ * Die Bridge-Interfaces werden von Bootstrap implementiert und über
+ * {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext} in den GUI-Adapter injiziert.
+ * Die GUI-Komponenten kennen ausschließlich diese Interfaces –
+ * niemals direkt Repository- oder Use-Case-Implementierungen.
+ */
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
index 01d3ccf..6239f66 100644
--- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
@@ -244,14 +244,16 @@ class GuiAdapterSmokeTest {
"The 'Speichern' button must be visible");
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
"The 'Speichern unter' button must be visible");
- assertEquals(3, workspace.tabPane().getTabs().size(),
- "Configuration tab, processing-run tab and prompt editor tab must all be present");
+ assertEquals(4, workspace.tabPane().getTabs().size(),
+ "Configuration tab, processing-run tab, history tab and prompt editor tab must all be present");
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
"The first tab must use the configuration label");
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
"The second tab must host the processing-run view");
- assertEquals("Prompt", workspace.tabPane().getTabs().get(2).getText(),
- "The third tab must host the prompt editor");
+ assertEquals("Verlauf", workspace.tabPane().getTabs().get(2).getText(),
+ "The third tab must host the history view");
+ assertEquals("Prompt", workspace.tabPane().getTabs().get(3).getText(),
+ "The fourth tab must host the prompt editor");
assertEquals(
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
String.join(",", workspace.sectionTitles()),
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiHistoryTabSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiHistoryTabSmokeTest.java
new file mode 100644
index 0000000..87e669d
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiHistoryTabSmokeTest.java
@@ -0,0 +1,205 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab;
+import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
+import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
+import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+import javafx.application.Platform;
+import javafx.scene.control.Tab;
+
+/**
+ * Monocle-basierte Headless-Smoke-Tests für {@link GuiHistoryTab}.
+ *
+ * Geprüfte Szenarien:
+ *
+ *
Tab wird mit Titel „Verlauf" erstellt.
+ *
Tab ist nicht schließbar.
+ *
Ohne geladene Konfiguration bleibt die Übersicht leer (null-configPath).
+ *
Mit leerem Übersichts-Port bleibt die Tabelle leer.
+ * Kapselt alle lesenden Datenbankoperationen für den Historien-Tab.
+ * Sämtliche JDBC-Details sind strikt in dieser Klasse eingeschlossen;
+ * keine JDBC-Typen erscheinen im Port-Interface oder in Domänen-/Application-Typen.
+ *
+ * Suche: Freitextsuche ist case-insensitiv (via {@code LOWER()}).
+ * Sonderzeichen {@code %} und {@code _} in der Benutzereingabe werden vor dem
+ * SQL-LIKE-Aufruf mit {@code \} escaped.
+ *
+ * Sortierung: Standard absteigend nach {@code updated_at},
+ * Tie-Breaker aufsteigend nach {@code fingerprint} (stabil und reproduzierbar).
+ *
+ * Limit: Wird direkt als SQL-{@code LIMIT} angewendet.
+ * Ein Limit von 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr
+ * als 500 Treffer vorhanden sind.
+ */
+public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
+
+ private static final Logger logger = LogManager.getLogger(SqliteHistoryQueryAdapter.class);
+
+ private static final String PRAGMA_FOREIGN_KEYS_ON = "PRAGMA foreign_keys = ON";
+
+ private final String jdbcUrl;
+
+ /**
+ * Erzeugt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
+ *
+ * @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
+ * @throws NullPointerException wenn {@code jdbcUrl} null ist
+ * @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
+ */
+ public SqliteHistoryQueryAdapter(String jdbcUrl) {
+ Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
+ if (jdbcUrl.isBlank()) {
+ throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
+ }
+ this.jdbcUrl = jdbcUrl;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Die SQL-Abfrage aggregiert die Versuchsanzahl per {@code COUNT}-Subquery.
+ * Freitextsuche und Status-Filter werden als optionale WHERE-Klauseln ergänzt.
+ *
+ * @param query Abfrageparameter; darf nicht {@code null} sein
+ * @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
+ * @throws DocumentPersistenceException bei technischen Datenbankfehlern
+ */
+ @Override
+ public List loadOverview(HistoryQuery query) {
+ Objects.requireNonNull(query, "query darf nicht null sein");
+
+ StringBuilder sql = new StringBuilder("""
+ SELECT
+ dr.fingerprint,
+ dr.overall_status,
+ dr.last_known_source_file_name,
+ dr.last_target_file_name,
+ dr.last_known_source_locator,
+ dr.updated_at,
+ (SELECT COUNT(*) FROM processing_attempt pa WHERE pa.fingerprint = dr.fingerprint) AS attempt_count
+ FROM document_record dr
+ WHERE 1=1
+ """);
+
+ List