summaryrefslogtreecommitdiff
path: root/calendar_gui.py
diff options
context:
space:
mode:
authormatin <matin.kaufmann@gmail.com>2025-10-04 17:56:33 +0200
committermatin <matin.kaufmann@gmail.com>2025-10-04 17:56:33 +0200
commit1edb9c91bc1c61904b86d8f0e05527d1af6e50cc (patch)
tree10d3c138e3329903f685720122eeec339df999d4 /calendar_gui.py
parent75c1b565c47fd2f04d9c7559386ff611274fac9b (diff)
changes of symbols (cosmetic)
Diffstat (limited to 'calendar_gui.py')
-rw-r--r--calendar_gui.py1406
1 files changed, 703 insertions, 703 deletions
diff --git a/calendar_gui.py b/calendar_gui.py
index 8e05b37..5705e7a 100644
--- a/calendar_gui.py
+++ b/calendar_gui.py
@@ -1,704 +1,704 @@
-import sys
-from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
- QLabel, QLineEdit, QPushButton, QDateEdit, QTableWidget,
- QTableWidgetItem, QHeaderView, QDialog, QFormLayout, QComboBox,
- QMessageBox, QSpinBox, QAction, QFileDialog, QMenuBar, QTextEdit,
- QAbstractItemView, QToolButton)
-from PyQt5.QtWidgets import QStyle
-from PyQt5.QtCore import Qt, QDate, QLocale
-from PyQt5.QtGui import QFont, QFontDatabase
-from datetime import datetime, timedelta
-from dateutil.relativedelta import relativedelta
-
-from calendar_manager import CalendarManager
-from date_calculator import DateCalculator
-from prediction_controller import PredictionController
-from event_type_handler import EventTypeHandler
-from date_service import DateService
-from config import EventConfig
-from prediction_report_service import PredictionReportService
-
-class EventDialog(QDialog):
- def __init__(self, entry=None, parent=None):
- super().__init__(parent)
-
- self.entry = entry
- self.dateformat = EventConfig.DATE_FORMAT
-
- # Set title based on mode
- self.setWindowTitle("Neuer Eintrag" if not entry else "Eintrag editieren")
- self.init_ui()
-
- def init_ui(self):
- layout = QFormLayout()
-
- # Apply Arial font
- font = QFont("Arial", 12)
- self.setFont(font)
- self.setStyleSheet(
- "QDialog { background: #ffffff; }"
- " QLabel { color: #333; }"
- " QLineEdit, QDateEdit, QComboBox, QTextEdit, QSpinBox {"
- " background: #fafbfe; color: #333; border: 1px solid #e1e4ee;"
- " border-radius: 6px; padding: 6px; }"
- " QDateEdit::drop-down, QComboBox::drop-down { border: none; }"
- " QPushButton { background: #2f6feb; color: #fff; border: none;"
- " border-radius: 6px; padding: 8px 12px; }"
- " QPushButton:hover { background: #316de0; }"
- " QPushButton:pressed { background: #2a5fcc; }"
- )
-
- # Start date selector
- self.start_date = QDateEdit()
- self.start_date.setCalendarPopup(True)
- self.start_date.setFont(font)
- self.start_date.setDisplayFormat(self.dateformat)
-
- # Set initial date based on mode
- if self.entry:
- entry_start = QDate(self.entry.start_date.year,
- self.entry.start_date.month,
- self.entry.start_date.day)
- self.start_date.setDate(entry_start)
- else:
- self.start_date.setDate(QDate.currentDate())
-
-
- # Connect start date change to validate end date
- self.start_date.dateChanged.connect(self.validate_end_date)
-
- layout.addRow("Startdatum:", self.start_date)
-
- # End date selector
- self.end_date = QDateEdit()
- self.end_date.setCalendarPopup(True)
- self.end_date.setFont(font)
- self.end_date.setDisplayFormat(self.dateformat)
-
- # Set initial date based on mode
- if self.entry:
- entry_end = QDate(self.entry.end_date.year,
- self.entry.end_date.month,
- self.entry.end_date.day)
- self.end_date.setDate(entry_end)
- else:
- self.end_date.setDate(QDate.currentDate().addDays(1))
-
- layout.addRow("Enddatum:", self.end_date)
-
- # Keyword selector
- self.keyword = QComboBox()
- self.keyword.setFont(font)
- self.keyword.addItems(EventConfig.KEYWORDS)
-
- # Set initial keyword based on mode
- if self.entry and self.entry.keyword in EventConfig.KEYWORDS:
- current_index = EventConfig.KEYWORDS.index(self.entry.keyword)
- self.keyword.setCurrentIndex(current_index)
-
- self.keyword.currentTextChanged.connect(self.on_keyword_changed)
- layout.addRow("Art:", self.keyword)
-
- # Commentary input
- self.commentary_input = QTextEdit()
- self.commentary_input.setFont(font)
- self.commentary_input.setFixedHeight(80)
- if self.entry and hasattr(self.entry, 'commentary') and self.entry.commentary:
- self.commentary_input.setPlainText(self.entry.commentary)
- layout.addRow("Kommentar:", self.commentary_input)
-
- # Store layout for later access
- self.layout = layout
- self.end_date_row = 1 # Index of end date row in the form layout
-
- # Buttons
- button_layout = QHBoxLayout()
- self.save_button = QPushButton("Speichern")
- self.save_button.setFont(font)
- self.save_button.setIcon(self.style().standardIcon(QStyle.SP_DialogApplyButton))
- self.save_button.clicked.connect(self.accept)
-
- self.cancel_button = QPushButton("Abbrechen")
- self.cancel_button.setFont(font)
- self.cancel_button.setIcon(self.style().standardIcon(QStyle.SP_DialogCancelButton))
- self.cancel_button.clicked.connect(self.reject)
-
- button_layout.addWidget(self.save_button)
- button_layout.addWidget(self.cancel_button)
- layout.addRow("", button_layout)
-
- self.setLayout(layout)
-
- # Connect end date change to validate start date (keep range consistent both ways)
- self.end_date.dateChanged.connect(self.validate_start_date)
-
- # Handle initial keyword selection
- self.on_keyword_changed(self.keyword.currentText())
-
- def on_keyword_changed(self, keyword):
- """Handle visibility of the end_date field based on keyword"""
- # Get the widgets from the form layout
- end_date_label = self.layout.itemAt(self.end_date_row, QFormLayout.LabelRole).widget()
- end_date_field = self.layout.itemAt(self.end_date_row, QFormLayout.FieldRole).widget()
-
- if EventTypeHandler.should_hide_end_date_input(keyword):
- # Hide end date field for EZ pauschals since they have fixed duration
- end_date_label.setVisible(False)
- end_date_field.setVisible(False)
-
- # Update end date automatically if changing to EZ pauschal
- start_dt = self.start_date.date().toPyDate()
- end_dt = EventTypeHandler.get_duration_for_type(keyword, start_dt)
- self.end_date.setDate(QDate(end_dt.year, end_dt.month, end_dt.day))
- else:
- # Show end date field for other event types
- end_date_label.setVisible(True)
- end_date_field.setVisible(True)
-
- def validate_end_date(self):
- """Ensure end date is not before start date"""
- if self.end_date.date() < self.start_date.date():
- self.end_date.setDate(self.start_date.date())
-
- def validate_start_date(self):
- """Ensure start date is not after end date when end changes"""
- if self.start_date.date() > self.end_date.date():
- self.start_date.setDate(self.end_date.date())
- def get_data(self):
- start_date = DateService.format_date_for_iso(self.start_date.date())
- keyword = self.keyword.currentText()
- commentary = self.commentary_input.toPlainText().strip()
-
- if EventTypeHandler.should_hide_end_date_input(keyword):
- # For EZ pauschals, calculate end date automatically
- start_dt = DateService.parse_date_from_string(start_date)
- end_dt = EventTypeHandler.get_duration_for_type(keyword, start_dt)
- end_date = DateService.format_date_for_iso(end_dt)
- else:
- end_date = DateService.format_date_for_iso(self.end_date.date())
- return start_date, end_date, keyword, commentary
-
-
-class CalendarManagerGUI(QMainWindow):
- def __init__(self):
- super().__init__()
- self.setWindowTitle("Ausfallzeitenrechner")
- self.setMinimumSize(800, 600)
-
- # Set application font
- self.app_font = QFont("Arial", 12)
- QApplication.setFont(self.app_font)
-
- # Get system locale for consistent date formatting
- self.locale = QLocale.system()
- self.dateformat = EventConfig.DATE_FORMAT
-
- # Initialize backend components
- self.calendar_manager = CalendarManager()
- self.date_calculator = DateCalculator()
- self.prediction_controller = PredictionController(
- self.calendar_manager,
- self.date_calculator
- )
-
- self.init_ui()
- self.create_menus()
-
- def create_menus(self):
- # Create menu bar
- menubar = self.menuBar()
- menubar.setFont(self.app_font)
- menubar.setStyleSheet(
- "QMenuBar { background: #ffffff; color: #333; }"
- " QMenuBar::item:selected { background: #f0f3fa; }"
- " QMenu { background: #ffffff; color: #333; border: 1px solid #e1e4ee; }"
- " QMenu::item:selected { background: #e6f0ff; }"
- )
-
- # File menu
- file_menu = menubar.addMenu('Start')
-
- # Load action
- load_action = QAction('Laden', self)
- load_action.setShortcut('Ctrl+L')
- load_action.setIcon(self.style().standardIcon(QStyle.SP_DialogOpenButton))
- load_action.triggered.connect(self.load_file)
- file_menu.addAction(load_action)
-
- # Save action
- save_action = QAction('Speichern', self)
- save_action.setShortcut('Ctrl+S')
- save_action.setIcon(self.style().standardIcon(QStyle.SP_DialogSaveButton))
- save_action.triggered.connect(self.save_file)
- file_menu.addAction(save_action)
-
- # Export PDF action
- export_pdf_action = QAction('Als PDF exportieren', self)
- export_pdf_action.setShortcut('Ctrl+E')
- # Fallback icon; SP_DialogPrintButton may not exist in some Qt styles
- export_pdf_action.setIcon(self.style().standardIcon(QStyle.SP_FileIcon))
- export_pdf_action.triggered.connect(self.export_pdf)
- file_menu.addAction(export_pdf_action)
-
- # Note: Saving/Loading now operate on complete prediction state
-
- # Clear action
- clear_action = QAction('Einträge löschen', self)
- clear_action.setShortcut('Ctrl+N')
- clear_action.setIcon(self.style().standardIcon(QStyle.SP_DialogResetButton))
- clear_action.triggered.connect(self.clear_entries)
- file_menu.addAction(clear_action)
-
- # Exit action
- exit_action = QAction('Beenden', self)
- exit_action.setShortcut('Ctrl+Q')
- exit_action.setIcon(self.style().standardIcon(QStyle.SP_DialogCloseButton))
- exit_action.triggered.connect(self.close)
- file_menu.addAction(exit_action)
-
- def init_ui(self):
- central_widget = QWidget()
- main_layout = QVBoxLayout()
- main_layout.setContentsMargins(16, 16, 16, 16)
- main_layout.setSpacing(14)
- # Global light style for consistency
- self.setStyleSheet(
- "QMainWindow { background: #ffffff; }"
- " QLabel { color: #333; }"
- " QLineEdit, QDateEdit, QComboBox, QTextEdit, QSpinBox {"
- " background: #fafbfe; color: #333; border: 1px solid #e1e4ee;"
- " border-radius: 6px; padding: 6px; }"
- " QDateEdit::drop-down, QComboBox::drop-down { border: none; }"
- " QPushButton { background: #2f6feb; color: #fff; border: none;"
- " border-radius: 6px; padding: 8px 12px; }"
- " QPushButton:hover { background: #316de0; }"
- " QPushButton:pressed { background: #2a5fcc; }"
- " QToolButton { border: none; }"
- )
-
- top_layout = QHBoxLayout()
- top_layout.setSpacing(18)
-
- # Launch date input
- launch_date_layout = QVBoxLayout()
- launch_date_label = QLabel("Promotionsdatum:")
- launch_date_label.setFont(self.app_font)
- self.launch_date_edit = QDateEdit()
- self.launch_date_edit.setFont(self.app_font)
- self.launch_date_edit.setCalendarPopup(True) # Enable calendar popup
- self.launch_date_edit.setDate(QDate.currentDate())
- self.launch_date_edit.setDisplayFormat(self.dateformat)
- self.launch_date_edit.dateChanged.connect(self.update_prediction) # Auto-update on change
- launch_date_layout.addWidget(launch_date_label)
- launch_date_layout.addWidget(self.launch_date_edit)
- top_layout.addLayout(launch_date_layout)
-
- # Duration input
- duration_layout = QVBoxLayout()
- duration_label = QLabel("Bewerbungszeitraum (Jahre):")
- duration_label.setFont(self.app_font)
- self.duration_spin = QSpinBox()
- self.duration_spin.setFont(self.app_font)
- self.duration_spin.setRange(1, 99)
- self.duration_spin.setValue(1)
- self.duration_spin.valueChanged.connect(self.update_prediction) # Auto-update on change
- duration_layout.addWidget(duration_label)
- duration_layout.addWidget(self.duration_spin)
- top_layout.addLayout(duration_layout)
-
- # Prediction result
- prediction_result_layout = QVBoxLayout()
- prediction_result_label = QLabel("Bewerbungsfrist:")
- prediction_result_label.setFont(self.app_font)
- self.prediction_result = QDateEdit()
- self.prediction_result.setFont(self.app_font)
- self.prediction_result.setReadOnly(True)
- self.prediction_result.setButtonSymbols(QDateEdit.ButtonSymbols.NoButtons)
- self.prediction_result.setDisplayFormat(self.dateformat)
- self.prediction_result.setStyleSheet(
- "QDateEdit { background: #f5faf5; border: 1px solid #cfe8cf; color: #1e4620; }"
- )
- prediction_result_layout.addWidget(prediction_result_label)
- prediction_result_layout.addWidget(self.prediction_result)
-
- top_layout.addLayout(prediction_result_layout)
-
- main_layout.addLayout(top_layout)
-
- # Events section
- events_layout = QVBoxLayout()
- events_title = QLabel("<h3>Ausfallzeiten</h3>")
- events_title.setFont(self.app_font)
- events_layout.addWidget(events_title)
-
- # Add event button (modern with icon)
- add_event_button = QPushButton("Eintrag hinzufügen")
- add_event_button.setFont(self.app_font)
- add_event_button.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder))
- add_event_button.setStyleSheet("QPushButton { border-radius: 6px; padding: 6px 10px; }")
- add_event_button.clicked.connect(self.add_event)
- events_layout.addWidget(add_event_button)
-
- # Events table
- self.events_table = QTableWidget()
- self.events_table.setFont(self.app_font)
- self.events_table.setColumnCount(7) # ID (hidden), Start, End, Keyword, RelevantTime, Commentary, Actions
- self.events_table.setHorizontalHeaderLabels(["ID", "Anfangsdatum", "Enddatum", "Art", "Anger. Zeit", "Kommentar", "Aktionen"])
- self.events_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
- self.events_table.horizontalHeader().setFont(self.app_font)
- self.events_table.setColumnHidden(0, True) # Hide ID column
- # Table visual tweaks for a modern look
- self.events_table.setAlternatingRowColors(True)
- self.events_table.setSelectionBehavior(QAbstractItemView.SelectRows)
- self.events_table.setSelectionMode(QAbstractItemView.SingleSelection)
- self.events_table.setStyleSheet(
- "QHeaderView::section { background: #f5f7fb; color: #333; border: none;"
- " border-bottom: 1px solid #e6e8ef; padding: 8px; }"
- " QTableWidget { gridline-color: #e6e8ef; }"
- " QTableView::item { padding: 6px; color: #333; }"
- " QTableView::item:selected { background: #e6f0ff; color: #333; }"
- " QTableView::item:hover { background: #f4f7ff; color: #333; }"
- )
- # Wrap long text and auto-resize rows so comments are readable
- self.events_table.setWordWrap(True)
- self.events_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
- # Enable inline editing on double click / Enter; keep computed column read-only in population step
- self._suppress_item_changed = False
- self.events_table.setEditTriggers(
- QAbstractItemView.DoubleClicked | QAbstractItemView.SelectedClicked | QAbstractItemView.EditKeyPressed
- )
- self.events_table.itemChanged.connect(self.on_events_item_changed)
- events_layout.addWidget(self.events_table)
-
- main_layout.addLayout(events_layout)
-
- # Set main layout
- central_widget.setLayout(main_layout)
- self.setCentralWidget(central_widget)
-
- # Load initial data
- self.update_events_table()
- self.update_prediction() # Calculate initial prediction
-
- def update_prediction(self):
- """Update prediction whenever needed"""
- try:
- launch_date = self.launch_date_edit.date().toString("yyyy-MM-dd")
- duration_years = self.duration_spin.value()
-
- self.prediction_controller.make_prediction(launch_date, duration_years)
- prediction_date = self.prediction_controller.get_prediction()
-
- if prediction_date:
- self.prediction_result.setDate(prediction_date)
-
- # Update table to show corrected dates
- self.update_events_table()
-
- except Exception as e:
- self.prediction_result.setText("Error in calculation")
- print(f"Error calculating prediction: {str(e)}")
-
- def add_event(self):
- dialog = EventDialog(parent=self)
- if dialog.exec_():
- start_date, end_date, keyword, commentary = dialog.get_data()
- try:
- # Validate data before adding
- errors = self.calendar_manager.validate_entry_data(start_date, end_date, keyword)
- if errors:
- QMessageBox.warning(self, "Validation Error", "\n".join(errors))
- return
-
- self.calendar_manager.add_entry(start_date, end_date, keyword, commentary)
- self.update_events_table()
- self.update_prediction() # Auto-update prediction
- except Exception as e:
- QMessageBox.critical(self, "Error", f"Error adding event: {str(e)}")
-
- def modify_event(self, event_id):
- entry = self.calendar_manager.get_entry_by_id(event_id)
- if entry:
- dialog = EventDialog(entry=entry, parent=self)
- if dialog.exec_():
- start_date, end_date, keyword, commentary = dialog.get_data()
- try:
- # Validate data before modifying
- errors = self.calendar_manager.validate_entry_data(start_date, end_date, keyword)
- if errors:
- QMessageBox.warning(self, "Validation Error", "\n".join(errors))
- return
-
- self.calendar_manager.modify_entry(event_id, start_date, end_date, keyword, commentary)
- self.update_events_table()
- self.update_prediction() # Auto-update prediction
- except Exception as e:
- QMessageBox.critical(self, "Error", f"Error modifying event: {str(e)}")
-
- def delete_event(self, event_id):
- reply = QMessageBox.question(self, "Eintrag löschen",
- "Wollen Sie diesen Eintrag löschen?",
- QMessageBox.Yes | QMessageBox.No)
- if reply == QMessageBox.Yes:
- try:
- self.calendar_manager.delete_entry(event_id)
- self.update_events_table()
- self.update_prediction() # Auto-update prediction
- except Exception as e:
- QMessageBox.critical(self, "Error", f"Error deleting event: {str(e)}")
-
- def update_events_table(self):
- # Clear table
- self._suppress_item_changed = True
- try:
- self.events_table.setRowCount(0)
-
- # Add entries to table
- entries = self.calendar_manager.list_entries()
- for i, entry in enumerate(entries):
- self.events_table.insertRow(i)
-
- # Set item data
- self.events_table.setItem(i, 0, QTableWidgetItem(entry.id))
-
- # Format dates using unified display format
- start_date_text = DateService.format_date_for_display(entry.start_date, self.dateformat)
- end_date_text = DateService.format_date_for_display(entry.end_date, self.dateformat)
- self.events_table.setItem(i, 1, QTableWidgetItem(start_date_text))
- self.events_table.setItem(i, 2, QTableWidgetItem(end_date_text))
- # Keyword as dropdown (combobox) for safer selection
- keyword_combo = QComboBox()
- keyword_combo.setFont(self.app_font)
- keyword_combo.addItems(EventConfig.KEYWORDS)
- if entry.keyword in EventConfig.KEYWORDS:
- keyword_combo.setCurrentIndex(EventConfig.KEYWORDS.index(entry.keyword))
- keyword_combo.currentTextChanged.connect(lambda new_kw, eid=entry.id: self.on_keyword_changed_in_table(eid, new_kw))
- self.events_table.setCellWidget(i, 3, keyword_combo)
-
- # Relevant accounted time from stored time_period
- relevant_text = getattr(entry, 'time_period', "") or ""
- relevant_item = QTableWidgetItem(relevant_text)
- # Make computed column read-only
- relevant_item.setFlags(relevant_item.flags() & ~Qt.ItemIsEditable)
- self.events_table.setItem(i, 4, relevant_item)
-
- # Commentary
- commentary_text = getattr(entry, 'commentary', "") or ""
- commentary_item = QTableWidgetItem(commentary_text)
- self.events_table.setItem(i, 5, commentary_item)
-
- # Action buttons
- actions_widget = QWidget()
- actions_layout = QHBoxLayout()
- actions_layout.setContentsMargins(0, 0, 0, 0)
- # Modern icon-only tool buttons
- modify_button = QToolButton()
- modify_button.setAutoRaise(True)
- modify_button.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView))
- modify_button.setToolTip("Eintrag bearbeiten")
- modify_button.setFixedSize(28, 24)
-
- delete_button = QToolButton()
- delete_button.setAutoRaise(True)
- delete_button.setIcon(self.style().standardIcon(QStyle.SP_TrashIcon))
- delete_button.setToolTip("Eintrag löschen")
- delete_button.setFixedSize(28, 24)
-
- # Use lambda with default argument to capture the correct event_id
- modify_button.clicked.connect(lambda checked, eid=entry.id: self.modify_event(eid))
- delete_button.clicked.connect(lambda checked, eid=entry.id: self.delete_event(eid))
-
- actions_layout.addWidget(modify_button)
- actions_layout.addWidget(delete_button)
- actions_widget.setLayout(actions_layout)
-
- self.events_table.setCellWidget(i, 6, actions_widget)
- # Adjust row heights after population to show wrapped text
- self.events_table.resizeRowsToContents()
- finally:
- self._suppress_item_changed = False
-
- def on_events_item_changed(self, item):
- # Avoid reacting to programmatic changes
- if self._suppress_item_changed:
- return
- row = item.row()
- col = item.column()
- # Ignore non-editable/computed/action columns
- if col in (0, 4, 6):
- return
-
- # Retrieve entry id
- id_item = self.events_table.item(row, 0)
- if id_item is None:
- return
- event_id = id_item.text()
-
- # Collect current row values
- start_text = self.events_table.item(row, 1).text() if self.events_table.item(row, 1) else ""
- end_text = self.events_table.item(row, 2).text() if self.events_table.item(row, 2) else ""
- # Keyword comes from the combobox cell widget
- keyword_widget = self.events_table.cellWidget(row, 3)
- keyword_text = keyword_widget.currentText() if isinstance(keyword_widget, QComboBox) else (self.events_table.item(row, 3).text() if self.events_table.item(row, 3) else "")
- commentary_text = self.events_table.item(row, 5).text() if self.events_table.item(row, 5) else ""
-
- # Normalize to ISO strings for backend
- try:
- start_iso = DateService.format_date_for_iso(DateService.parse_date_from_string(start_text)) if start_text else None
- end_iso = DateService.format_date_for_iso(DateService.parse_date_from_string(end_text)) if end_text else None
- except Exception as e:
- QMessageBox.warning(self, "Ungültiges Datum", f"Bitte ein gültiges Datum eingeben.\nFehler: {str(e)}")
- # Revert table to backend values
- self.update_events_table()
- return
-
- # Validate before applying
- errors = []
- if start_iso and end_iso:
- errors = self.calendar_manager.validate_entry_data(start_iso, end_iso, keyword_text)
- if errors:
- QMessageBox.warning(self, "Validierungsfehler", "\n".join(errors))
- self.update_events_table()
- return
-
- # Apply modification
- modified = self.calendar_manager.modify_entry(
- event_id,
- start_date=start_iso if start_iso else None,
- end_date=end_iso if end_iso else None,
- keyword=keyword_text if keyword_text else None,
- commentary=commentary_text if commentary_text is not None else None,
- )
- if modified is None:
- QMessageBox.critical(self, "Fehler", "Eintrag konnte nicht aktualisiert werden.")
- self.update_events_table()
- return
-
- # Refresh to reflect normalized display and recomputed values
- self.update_events_table()
- self.update_prediction()
-
- def on_keyword_changed_in_table(self, event_id, new_keyword):
- # Validate using current start/end from the entry
- entry = self.calendar_manager.get_entry_by_id(event_id)
- if not entry:
- return
- start_iso = DateService.format_date_for_iso(entry.start_date)
- end_iso = DateService.format_date_for_iso(entry.end_date)
- errors = self.calendar_manager.validate_entry_data(start_iso, end_iso, new_keyword)
- if errors:
- QMessageBox.warning(self, "Validierungsfehler", "\n".join(errors))
- # Re-render to revert combo to valid value
- self.update_events_table()
- return
- modified = self.calendar_manager.modify_entry(event_id, keyword=new_keyword)
- if modified is None:
- QMessageBox.critical(self, "Fehler", "Eintrag konnte nicht aktualisiert werden.")
- self.update_events_table()
- return
- self.update_events_table()
- self.update_prediction()
-
- def save_file(self):
- """Save the complete prediction state to a .prediction.json file"""
- file_path, _ = QFileDialog.getSaveFileName(
- self,
- "Zustand speichern",
- "",
- "Prediction State (*.prediction.json)"
- )
- if not file_path:
- return
-
- try:
- if self.prediction_controller.save_complete_state(file_path):
- target = file_path if file_path.endswith('.prediction.json') else file_path + '.prediction.json'
- QMessageBox.information(self, "Speichern erfolgreich", f"Komplettzustand gespeichert in {target}")
- else:
- QMessageBox.critical(self, "Error", "Speichern des Komplettzustands fehlgeschlagen")
- except Exception as e:
- QMessageBox.critical(self, "Error", f"Fehler beim Speichern des Komplettzustands: {str(e)}")
-
- def load_file(self):
- """Load the complete prediction state from a .prediction.json file"""
- file_path, _ = QFileDialog.getOpenFileName(
- self,
- "Zustand laden",
- "",
- "Prediction State (*.prediction.json)"
- )
- if not file_path:
- return
-
- try:
- success = self.prediction_controller.load_complete_state(file_path)
- if success:
- # Reflect restored parameters into UI
- launch_dt = self.prediction_controller.get_launch_date()
- duration = self.prediction_controller.get_duration()
- if launch_dt:
- self.launch_date_edit.setDate(launch_dt)
- if duration is not None and hasattr(duration, 'years'):
- self.duration_spin.setValue(max(1, duration.years))
-
- # Update views
- self.update_events_table()
- prediction_date = self.prediction_controller.get_prediction()
- if prediction_date:
- self.prediction_result.setDate(prediction_date)
- QMessageBox.information(self, "Laden erfolgreich", f"Komplettzustand von {file_path} geladen")
- else:
- QMessageBox.warning(self, "Warnung", "Komplettzustand konnte nicht geladen werden")
- except Exception as e:
- QMessageBox.critical(self, "Error", f"Fehler beim Laden des Komplettzustands: {str(e)}")
-
- def export_pdf(self):
- """Export a formatted report to PDF including metadata and full table."""
- out_path, _ = QFileDialog.getSaveFileName(
- self,
- "PDF exportieren",
- "",
- "PDF (*.pdf)"
- )
- if not out_path:
- return
- success = PredictionReportService.export_pdf(
- self.calendar_manager,
- self.prediction_controller,
- out_path,
- self.dateformat,
- )
- if success:
- if not out_path.lower().endswith('.pdf'):
- out_path = out_path + '.pdf'
- QMessageBox.information(self, "Export erfolgreich", f"PDF exportiert nach {out_path}")
- else:
- QMessageBox.critical(self, "Fehler", "PDF-Export fehlgeschlagen.")
-
- def clear_entries(self):
- """Clear all calendar entries"""
- reply = QMessageBox.question(self, "Einträge löschen",
- "Alle Einträge löschen?",
- QMessageBox.Yes | QMessageBox.No)
- if reply == QMessageBox.Yes:
- try:
- self.calendar_manager.clear_entries()
- self.update_events_table()
- self.update_prediction() # Auto-update prediction
- QMessageBox.information(self, "Erfolg", "Alle Einträge gelöscht!")
- except Exception as e:
- QMessageBox.critical(self, "Error", f"Failed to clear calendar: {str(e)}")
-
-
-def main():
- app = QApplication(sys.argv)
- app.setStyle('Fusion')
- # Ensure Arial is available
- QFontDatabase.addApplicationFont("Arial")
- window = CalendarManagerGUI()
- window.show()
- sys.exit(app.exec_())
-
-
-if __name__ == "__main__":
+import sys
+from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
+ QLabel, QLineEdit, QPushButton, QDateEdit, QTableWidget,
+ QTableWidgetItem, QHeaderView, QDialog, QFormLayout, QComboBox,
+ QMessageBox, QSpinBox, QAction, QFileDialog, QMenuBar, QTextEdit,
+ QAbstractItemView, QToolButton)
+from PyQt5.QtWidgets import QStyle
+from PyQt5.QtCore import Qt, QDate, QLocale
+from PyQt5.QtGui import QFont, QFontDatabase
+from datetime import datetime, timedelta
+from dateutil.relativedelta import relativedelta
+
+from calendar_manager import CalendarManager
+from date_calculator import DateCalculator
+from prediction_controller import PredictionController
+from event_type_handler import EventTypeHandler
+from date_service import DateService
+from config import EventConfig
+from prediction_report_service import PredictionReportService
+
+class EventDialog(QDialog):
+ def __init__(self, entry=None, parent=None):
+ super().__init__(parent)
+
+ self.entry = entry
+ self.dateformat = EventConfig.DATE_FORMAT
+
+ # Set title based on mode
+ self.setWindowTitle("Neuer Eintrag" if not entry else "Eintrag editieren")
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QFormLayout()
+
+ # Apply Arial font
+ font = QFont("Arial", 12)
+ self.setFont(font)
+ self.setStyleSheet(
+ "QDialog { background: #ffffff; }"
+ " QLabel { color: #333; }"
+ " QLineEdit, QDateEdit, QComboBox, QTextEdit, QSpinBox {"
+ " background: #fafbfe; color: #333; border: 1px solid #e1e4ee;"
+ " border-radius: 6px; padding: 6px; }"
+ " QDateEdit::drop-down, QComboBox::drop-down { border: none; }"
+ " QPushButton { background: #2f6feb; color: #fff; border: none;"
+ " border-radius: 6px; padding: 8px 12px; }"
+ " QPushButton:hover { background: #316de0; }"
+ " QPushButton:pressed { background: #2a5fcc; }"
+ )
+
+ # Start date selector
+ self.start_date = QDateEdit()
+ self.start_date.setCalendarPopup(True)
+ self.start_date.setFont(font)
+ self.start_date.setDisplayFormat(self.dateformat)
+
+ # Set initial date based on mode
+ if self.entry:
+ entry_start = QDate(self.entry.start_date.year,
+ self.entry.start_date.month,
+ self.entry.start_date.day)
+ self.start_date.setDate(entry_start)
+ else:
+ self.start_date.setDate(QDate.currentDate())
+
+
+ # Connect start date change to validate end date
+ self.start_date.dateChanged.connect(self.validate_end_date)
+
+ layout.addRow("Startdatum:", self.start_date)
+
+ # End date selector
+ self.end_date = QDateEdit()
+ self.end_date.setCalendarPopup(True)
+ self.end_date.setFont(font)
+ self.end_date.setDisplayFormat(self.dateformat)
+
+ # Set initial date based on mode
+ if self.entry:
+ entry_end = QDate(self.entry.end_date.year,
+ self.entry.end_date.month,
+ self.entry.end_date.day)
+ self.end_date.setDate(entry_end)
+ else:
+ self.end_date.setDate(QDate.currentDate().addDays(1))
+
+ layout.addRow("Enddatum:", self.end_date)
+
+ # Keyword selector
+ self.keyword = QComboBox()
+ self.keyword.setFont(font)
+ self.keyword.addItems(EventConfig.KEYWORDS)
+
+ # Set initial keyword based on mode
+ if self.entry and self.entry.keyword in EventConfig.KEYWORDS:
+ current_index = EventConfig.KEYWORDS.index(self.entry.keyword)
+ self.keyword.setCurrentIndex(current_index)
+
+ self.keyword.currentTextChanged.connect(self.on_keyword_changed)
+ layout.addRow("Art:", self.keyword)
+
+ # Commentary input
+ self.commentary_input = QTextEdit()
+ self.commentary_input.setFont(font)
+ self.commentary_input.setFixedHeight(80)
+ if self.entry and hasattr(self.entry, 'commentary') and self.entry.commentary:
+ self.commentary_input.setPlainText(self.entry.commentary)
+ layout.addRow("Kommentar:", self.commentary_input)
+
+ # Store layout for later access
+ self.layout = layout
+ self.end_date_row = 1 # Index of end date row in the form layout
+
+ # Buttons
+ button_layout = QHBoxLayout()
+ self.save_button = QPushButton("Speichern")
+ self.save_button.setFont(font)
+ self.save_button.setIcon(self.style().standardIcon(QStyle.SP_DialogApplyButton))
+ self.save_button.clicked.connect(self.accept)
+
+ self.cancel_button = QPushButton("Abbrechen")
+ self.cancel_button.setFont(font)
+ self.cancel_button.setIcon(self.style().standardIcon(QStyle.SP_DialogCancelButton))
+ self.cancel_button.clicked.connect(self.reject)
+
+ button_layout.addWidget(self.save_button)
+ button_layout.addWidget(self.cancel_button)
+ layout.addRow("", button_layout)
+
+ self.setLayout(layout)
+
+ # Connect end date change to validate start date (keep range consistent both ways)
+ self.end_date.dateChanged.connect(self.validate_start_date)
+
+ # Handle initial keyword selection
+ self.on_keyword_changed(self.keyword.currentText())
+
+ def on_keyword_changed(self, keyword):
+ """Handle visibility of the end_date field based on keyword"""
+ # Get the widgets from the form layout
+ end_date_label = self.layout.itemAt(self.end_date_row, QFormLayout.LabelRole).widget()
+ end_date_field = self.layout.itemAt(self.end_date_row, QFormLayout.FieldRole).widget()
+
+ if EventTypeHandler.should_hide_end_date_input(keyword):
+ # Hide end date field for EZ pauschals since they have fixed duration
+ end_date_label.setVisible(False)
+ end_date_field.setVisible(False)
+
+ # Update end date automatically if changing to EZ pauschal
+ start_dt = self.start_date.date().toPyDate()
+ end_dt = EventTypeHandler.get_duration_for_type(keyword, start_dt)
+ self.end_date.setDate(QDate(end_dt.year, end_dt.month, end_dt.day))
+ else:
+ # Show end date field for other event types
+ end_date_label.setVisible(True)
+ end_date_field.setVisible(True)
+
+ def validate_end_date(self):
+ """Ensure end date is not before start date"""
+ if self.end_date.date() < self.start_date.date():
+ self.end_date.setDate(self.start_date.date())
+
+ def validate_start_date(self):
+ """Ensure start date is not after end date when end changes"""
+ if self.start_date.date() > self.end_date.date():
+ self.start_date.setDate(self.end_date.date())
+ def get_data(self):
+ start_date = DateService.format_date_for_iso(self.start_date.date())
+ keyword = self.keyword.currentText()
+ commentary = self.commentary_input.toPlainText().strip()
+
+ if EventTypeHandler.should_hide_end_date_input(keyword):
+ # For EZ pauschals, calculate end date automatically
+ start_dt = DateService.parse_date_from_string(start_date)
+ end_dt = EventTypeHandler.get_duration_for_type(keyword, start_dt)
+ end_date = DateService.format_date_for_iso(end_dt)
+ else:
+ end_date = DateService.format_date_for_iso(self.end_date.date())
+ return start_date, end_date, keyword, commentary
+
+
+class CalendarManagerGUI(QMainWindow):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("Ausfallzeitenrechner")
+ self.setMinimumSize(800, 600)
+
+ # Set application font
+ self.app_font = QFont("Arial", 12)
+ QApplication.setFont(self.app_font)
+
+ # Get system locale for consistent date formatting
+ self.locale = QLocale.system()
+ self.dateformat = EventConfig.DATE_FORMAT
+
+ # Initialize backend components
+ self.calendar_manager = CalendarManager()
+ self.date_calculator = DateCalculator()
+ self.prediction_controller = PredictionController(
+ self.calendar_manager,
+ self.date_calculator
+ )
+
+ self.init_ui()
+ self.create_menus()
+
+ def create_menus(self):
+ # Create menu bar
+ menubar = self.menuBar()
+ menubar.setFont(self.app_font)
+ menubar.setStyleSheet(
+ "QMenuBar { background: #ffffff; color: #333; }"
+ " QMenuBar::item:selected { background: #f0f3fa; }"
+ " QMenu { background: #ffffff; color: #333; border: 1px solid #e1e4ee; }"
+ " QMenu::item:selected { background: #e6f0ff; }"
+ )
+
+ # File menu
+ file_menu = menubar.addMenu('Start')
+
+ # Load action
+ load_action = QAction('Laden', self)
+ load_action.setShortcut('Ctrl+L')
+ load_action.setIcon(self.style().standardIcon(QStyle.SP_DialogOpenButton))
+ load_action.triggered.connect(self.load_file)
+ file_menu.addAction(load_action)
+
+ # Save action
+ save_action = QAction('Speichern', self)
+ save_action.setShortcut('Ctrl+S')
+ save_action.setIcon(self.style().standardIcon(QStyle.SP_DialogSaveButton))
+ save_action.triggered.connect(self.save_file)
+ file_menu.addAction(save_action)
+
+ # Export PDF action
+ export_pdf_action = QAction('Als PDF exportieren', self)
+ export_pdf_action.setShortcut('Ctrl+E')
+ # Fallback icon; SP_DialogPrintButton may not exist in some Qt styles
+ export_pdf_action.setIcon(self.style().standardIcon(QStyle.SP_FileIcon))
+ export_pdf_action.triggered.connect(self.export_pdf)
+ file_menu.addAction(export_pdf_action)
+
+ # Note: Saving/Loading now operate on complete prediction state
+
+ # Clear action
+ clear_action = QAction('Einträge löschen', self)
+ clear_action.setShortcut('Ctrl+N')
+ clear_action.setIcon(self.style().standardIcon(QStyle.SP_DialogDiscardButton))
+ clear_action.triggered.connect(self.clear_entries)
+ file_menu.addAction(clear_action)
+
+ # Exit action
+ exit_action = QAction('Beenden', self)
+ exit_action.setShortcut('Ctrl+Q')
+ exit_action.setIcon(self.style().standardIcon(QStyle.SP_DialogCloseButton))
+ exit_action.triggered.connect(self.close)
+ file_menu.addAction(exit_action)
+
+ def init_ui(self):
+ central_widget = QWidget()
+ main_layout = QVBoxLayout()
+ main_layout.setContentsMargins(16, 16, 16, 16)
+ main_layout.setSpacing(14)
+ # Global light style for consistency
+ self.setStyleSheet(
+ "QMainWindow { background: #ffffff; }"
+ " QLabel { color: #333; }"
+ " QLineEdit, QDateEdit, QComboBox, QTextEdit, QSpinBox {"
+ " background: #fafbfe; color: #333; border: 1px solid #e1e4ee;"
+ " border-radius: 6px; padding: 6px; }"
+ " QDateEdit::drop-down, QComboBox::drop-down { border: none; }"
+ " QPushButton { background: #2f6feb; color: #fff; border: none;"
+ " border-radius: 6px; padding: 8px 12px; }"
+ " QPushButton:hover { background: #316de0; }"
+ " QPushButton:pressed { background: #2a5fcc; }"
+ " QToolButton { border: none; }"
+ )
+
+ top_layout = QHBoxLayout()
+ top_layout.setSpacing(18)
+
+ # Launch date input
+ launch_date_layout = QVBoxLayout()
+ launch_date_label = QLabel("Promotionsdatum:")
+ launch_date_label.setFont(self.app_font)
+ self.launch_date_edit = QDateEdit()
+ self.launch_date_edit.setFont(self.app_font)
+ self.launch_date_edit.setCalendarPopup(True) # Enable calendar popup
+ self.launch_date_edit.setDate(QDate.currentDate())
+ self.launch_date_edit.setDisplayFormat(self.dateformat)
+ self.launch_date_edit.dateChanged.connect(self.update_prediction) # Auto-update on change
+ launch_date_layout.addWidget(launch_date_label)
+ launch_date_layout.addWidget(self.launch_date_edit)
+ top_layout.addLayout(launch_date_layout)
+
+ # Duration input
+ duration_layout = QVBoxLayout()
+ duration_label = QLabel("Bewerbungszeitraum (Jahre):")
+ duration_label.setFont(self.app_font)
+ self.duration_spin = QSpinBox()
+ self.duration_spin.setFont(self.app_font)
+ self.duration_spin.setRange(1, 99)
+ self.duration_spin.setValue(1)
+ self.duration_spin.valueChanged.connect(self.update_prediction) # Auto-update on change
+ duration_layout.addWidget(duration_label)
+ duration_layout.addWidget(self.duration_spin)
+ top_layout.addLayout(duration_layout)
+
+ # Prediction result
+ prediction_result_layout = QVBoxLayout()
+ prediction_result_label = QLabel("Bewerbungsfrist:")
+ prediction_result_label.setFont(self.app_font)
+ self.prediction_result = QDateEdit()
+ self.prediction_result.setFont(self.app_font)
+ self.prediction_result.setReadOnly(True)
+ self.prediction_result.setButtonSymbols(QDateEdit.ButtonSymbols.NoButtons)
+ self.prediction_result.setDisplayFormat(self.dateformat)
+ self.prediction_result.setStyleSheet(
+ "QDateEdit { background: #f5faf5; border: 1px solid #cfe8cf; color: #1e4620; }"
+ )
+ prediction_result_layout.addWidget(prediction_result_label)
+ prediction_result_layout.addWidget(self.prediction_result)
+
+ top_layout.addLayout(prediction_result_layout)
+
+ main_layout.addLayout(top_layout)
+
+ # Events section
+ events_layout = QVBoxLayout()
+ events_title = QLabel("<h3>Ausfallzeiten</h3>")
+ events_title.setFont(self.app_font)
+ events_layout.addWidget(events_title)
+
+ # Add event button (modern with icon)
+ add_event_button = QPushButton("Eintrag hinzufügen")
+ add_event_button.setFont(self.app_font)
+ add_event_button.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder))
+ add_event_button.setStyleSheet("QPushButton { border-radius: 6px; padding: 6px 10px; }")
+ add_event_button.clicked.connect(self.add_event)
+ events_layout.addWidget(add_event_button)
+
+ # Events table
+ self.events_table = QTableWidget()
+ self.events_table.setFont(self.app_font)
+ self.events_table.setColumnCount(7) # ID (hidden), Start, End, Keyword, RelevantTime, Commentary, Actions
+ self.events_table.setHorizontalHeaderLabels(["ID", "Anfangsdatum", "Enddatum", "Art", "Anger. Zeit", "Kommentar", "Aktionen"])
+ self.events_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
+ self.events_table.horizontalHeader().setFont(self.app_font)
+ self.events_table.setColumnHidden(0, True) # Hide ID column
+ # Table visual tweaks for a modern look
+ self.events_table.setAlternatingRowColors(True)
+ self.events_table.setSelectionBehavior(QAbstractItemView.SelectRows)
+ self.events_table.setSelectionMode(QAbstractItemView.SingleSelection)
+ self.events_table.setStyleSheet(
+ "QHeaderView::section { background: #f5f7fb; color: #333; border: none;"
+ " border-bottom: 1px solid #e6e8ef; padding: 8px; }"
+ " QTableWidget { gridline-color: #e6e8ef; }"
+ " QTableView::item { padding: 6px; color: #333; }"
+ " QTableView::item:selected { background: #e6f0ff; color: #333; }"
+ " QTableView::item:hover { background: #f4f7ff; color: #333; }"
+ )
+ # Wrap long text and auto-resize rows so comments are readable
+ self.events_table.setWordWrap(True)
+ self.events_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
+ # Enable inline editing on double click / Enter; keep computed column read-only in population step
+ self._suppress_item_changed = False
+ self.events_table.setEditTriggers(
+ QAbstractItemView.DoubleClicked | QAbstractItemView.SelectedClicked | QAbstractItemView.EditKeyPressed
+ )
+ self.events_table.itemChanged.connect(self.on_events_item_changed)
+ events_layout.addWidget(self.events_table)
+
+ main_layout.addLayout(events_layout)
+
+ # Set main layout
+ central_widget.setLayout(main_layout)
+ self.setCentralWidget(central_widget)
+
+ # Load initial data
+ self.update_events_table()
+ self.update_prediction() # Calculate initial prediction
+
+ def update_prediction(self):
+ """Update prediction whenever needed"""
+ try:
+ launch_date = self.launch_date_edit.date().toString("yyyy-MM-dd")
+ duration_years = self.duration_spin.value()
+
+ self.prediction_controller.make_prediction(launch_date, duration_years)
+ prediction_date = self.prediction_controller.get_prediction()
+
+ if prediction_date:
+ self.prediction_result.setDate(prediction_date)
+
+ # Update table to show corrected dates
+ self.update_events_table()
+
+ except Exception as e:
+ self.prediction_result.setText("Error in calculation")
+ print(f"Error calculating prediction: {str(e)}")
+
+ def add_event(self):
+ dialog = EventDialog(parent=self)
+ if dialog.exec_():
+ start_date, end_date, keyword, commentary = dialog.get_data()
+ try:
+ # Validate data before adding
+ errors = self.calendar_manager.validate_entry_data(start_date, end_date, keyword)
+ if errors:
+ QMessageBox.warning(self, "Validation Error", "\n".join(errors))
+ return
+
+ self.calendar_manager.add_entry(start_date, end_date, keyword, commentary)
+ self.update_events_table()
+ self.update_prediction() # Auto-update prediction
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Error adding event: {str(e)}")
+
+ def modify_event(self, event_id):
+ entry = self.calendar_manager.get_entry_by_id(event_id)
+ if entry:
+ dialog = EventDialog(entry=entry, parent=self)
+ if dialog.exec_():
+ start_date, end_date, keyword, commentary = dialog.get_data()
+ try:
+ # Validate data before modifying
+ errors = self.calendar_manager.validate_entry_data(start_date, end_date, keyword)
+ if errors:
+ QMessageBox.warning(self, "Validation Error", "\n".join(errors))
+ return
+
+ self.calendar_manager.modify_entry(event_id, start_date, end_date, keyword, commentary)
+ self.update_events_table()
+ self.update_prediction() # Auto-update prediction
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Error modifying event: {str(e)}")
+
+ def delete_event(self, event_id):
+ reply = QMessageBox.question(self, "Eintrag löschen",
+ "Wollen Sie diesen Eintrag löschen?",
+ QMessageBox.Yes | QMessageBox.No)
+ if reply == QMessageBox.Yes:
+ try:
+ self.calendar_manager.delete_entry(event_id)
+ self.update_events_table()
+ self.update_prediction() # Auto-update prediction
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Error deleting event: {str(e)}")
+
+ def update_events_table(self):
+ # Clear table
+ self._suppress_item_changed = True
+ try:
+ self.events_table.setRowCount(0)
+
+ # Add entries to table
+ entries = self.calendar_manager.list_entries()
+ for i, entry in enumerate(entries):
+ self.events_table.insertRow(i)
+
+ # Set item data
+ self.events_table.setItem(i, 0, QTableWidgetItem(entry.id))
+
+ # Format dates using unified display format
+ start_date_text = DateService.format_date_for_display(entry.start_date, self.dateformat)
+ end_date_text = DateService.format_date_for_display(entry.end_date, self.dateformat)
+ self.events_table.setItem(i, 1, QTableWidgetItem(start_date_text))
+ self.events_table.setItem(i, 2, QTableWidgetItem(end_date_text))
+ # Keyword as dropdown (combobox) for safer selection
+ keyword_combo = QComboBox()
+ keyword_combo.setFont(self.app_font)
+ keyword_combo.addItems(EventConfig.KEYWORDS)
+ if entry.keyword in EventConfig.KEYWORDS:
+ keyword_combo.setCurrentIndex(EventConfig.KEYWORDS.index(entry.keyword))
+ keyword_combo.currentTextChanged.connect(lambda new_kw, eid=entry.id: self.on_keyword_changed_in_table(eid, new_kw))
+ self.events_table.setCellWidget(i, 3, keyword_combo)
+
+ # Relevant accounted time from stored time_period
+ relevant_text = getattr(entry, 'time_period', "") or ""
+ relevant_item = QTableWidgetItem(relevant_text)
+ # Make computed column read-only
+ relevant_item.setFlags(relevant_item.flags() & ~Qt.ItemIsEditable)
+ self.events_table.setItem(i, 4, relevant_item)
+
+ # Commentary
+ commentary_text = getattr(entry, 'commentary', "") or ""
+ commentary_item = QTableWidgetItem(commentary_text)
+ self.events_table.setItem(i, 5, commentary_item)
+
+ # Action buttons
+ actions_widget = QWidget()
+ actions_layout = QHBoxLayout()
+ actions_layout.setContentsMargins(0, 0, 0, 0)
+ # Modern icon-only tool buttons
+ modify_button = QToolButton()
+ modify_button.setAutoRaise(True)
+ modify_button.setIcon(self.style().standardIcon(QStyle.SP_DialogResetButton))
+ modify_button.setToolTip("Eintrag bearbeiten")
+ modify_button.setFixedSize(28, 24)
+
+ delete_button = QToolButton()
+ delete_button.setAutoRaise(True)
+ delete_button.setIcon(self.style().standardIcon(QStyle.SP_DialogDiscardButton))
+ delete_button.setToolTip("Eintrag löschen")
+ delete_button.setFixedSize(28, 24)
+
+ # Use lambda with default argument to capture the correct event_id
+ modify_button.clicked.connect(lambda checked, eid=entry.id: self.modify_event(eid))
+ delete_button.clicked.connect(lambda checked, eid=entry.id: self.delete_event(eid))
+
+ actions_layout.addWidget(modify_button)
+ actions_layout.addWidget(delete_button)
+ actions_widget.setLayout(actions_layout)
+
+ self.events_table.setCellWidget(i, 6, actions_widget)
+ # Adjust row heights after population to show wrapped text
+ self.events_table.resizeRowsToContents()
+ finally:
+ self._suppress_item_changed = False
+
+ def on_events_item_changed(self, item):
+ # Avoid reacting to programmatic changes
+ if self._suppress_item_changed:
+ return
+ row = item.row()
+ col = item.column()
+ # Ignore non-editable/computed/action columns
+ if col in (0, 4, 6):
+ return
+
+ # Retrieve entry id
+ id_item = self.events_table.item(row, 0)
+ if id_item is None:
+ return
+ event_id = id_item.text()
+
+ # Collect current row values
+ start_text = self.events_table.item(row, 1).text() if self.events_table.item(row, 1) else ""
+ end_text = self.events_table.item(row, 2).text() if self.events_table.item(row, 2) else ""
+ # Keyword comes from the combobox cell widget
+ keyword_widget = self.events_table.cellWidget(row, 3)
+ keyword_text = keyword_widget.currentText() if isinstance(keyword_widget, QComboBox) else (self.events_table.item(row, 3).text() if self.events_table.item(row, 3) else "")
+ commentary_text = self.events_table.item(row, 5).text() if self.events_table.item(row, 5) else ""
+
+ # Normalize to ISO strings for backend
+ try:
+ start_iso = DateService.format_date_for_iso(DateService.parse_date_from_string(start_text)) if start_text else None
+ end_iso = DateService.format_date_for_iso(DateService.parse_date_from_string(end_text)) if end_text else None
+ except Exception as e:
+ QMessageBox.warning(self, "Ungültiges Datum", f"Bitte ein gültiges Datum eingeben.\nFehler: {str(e)}")
+ # Revert table to backend values
+ self.update_events_table()
+ return
+
+ # Validate before applying
+ errors = []
+ if start_iso and end_iso:
+ errors = self.calendar_manager.validate_entry_data(start_iso, end_iso, keyword_text)
+ if errors:
+ QMessageBox.warning(self, "Validierungsfehler", "\n".join(errors))
+ self.update_events_table()
+ return
+
+ # Apply modification
+ modified = self.calendar_manager.modify_entry(
+ event_id,
+ start_date=start_iso if start_iso else None,
+ end_date=end_iso if end_iso else None,
+ keyword=keyword_text if keyword_text else None,
+ commentary=commentary_text if commentary_text is not None else None,
+ )
+ if modified is None:
+ QMessageBox.critical(self, "Fehler", "Eintrag konnte nicht aktualisiert werden.")
+ self.update_events_table()
+ return
+
+ # Refresh to reflect normalized display and recomputed values
+ self.update_events_table()
+ self.update_prediction()
+
+ def on_keyword_changed_in_table(self, event_id, new_keyword):
+ # Validate using current start/end from the entry
+ entry = self.calendar_manager.get_entry_by_id(event_id)
+ if not entry:
+ return
+ start_iso = DateService.format_date_for_iso(entry.start_date)
+ end_iso = DateService.format_date_for_iso(entry.end_date)
+ errors = self.calendar_manager.validate_entry_data(start_iso, end_iso, new_keyword)
+ if errors:
+ QMessageBox.warning(self, "Validierungsfehler", "\n".join(errors))
+ # Re-render to revert combo to valid value
+ self.update_events_table()
+ return
+ modified = self.calendar_manager.modify_entry(event_id, keyword=new_keyword)
+ if modified is None:
+ QMessageBox.critical(self, "Fehler", "Eintrag konnte nicht aktualisiert werden.")
+ self.update_events_table()
+ return
+ self.update_events_table()
+ self.update_prediction()
+
+ def save_file(self):
+ """Save the complete prediction state to a .prediction.json file"""
+ file_path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Zustand speichern",
+ "",
+ "Prediction State (*.prediction.json)"
+ )
+ if not file_path:
+ return
+
+ try:
+ if self.prediction_controller.save_complete_state(file_path):
+ target = file_path if file_path.endswith('.prediction.json') else file_path + '.prediction.json'
+ QMessageBox.information(self, "Speichern erfolgreich", f"Komplettzustand gespeichert in {target}")
+ else:
+ QMessageBox.critical(self, "Error", "Speichern des Komplettzustands fehlgeschlagen")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Fehler beim Speichern des Komplettzustands: {str(e)}")
+
+ def load_file(self):
+ """Load the complete prediction state from a .prediction.json file"""
+ file_path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Zustand laden",
+ "",
+ "Prediction State (*.prediction.json)"
+ )
+ if not file_path:
+ return
+
+ try:
+ success = self.prediction_controller.load_complete_state(file_path)
+ if success:
+ # Reflect restored parameters into UI
+ launch_dt = self.prediction_controller.get_launch_date()
+ duration = self.prediction_controller.get_duration()
+ if launch_dt:
+ self.launch_date_edit.setDate(launch_dt)
+ if duration is not None and hasattr(duration, 'years'):
+ self.duration_spin.setValue(max(1, duration.years))
+
+ # Update views
+ self.update_events_table()
+ prediction_date = self.prediction_controller.get_prediction()
+ if prediction_date:
+ self.prediction_result.setDate(prediction_date)
+ QMessageBox.information(self, "Laden erfolgreich", f"Komplettzustand von {file_path} geladen")
+ else:
+ QMessageBox.warning(self, "Warnung", "Komplettzustand konnte nicht geladen werden")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Fehler beim Laden des Komplettzustands: {str(e)}")
+
+ def export_pdf(self):
+ """Export a formatted report to PDF including metadata and full table."""
+ out_path, _ = QFileDialog.getSaveFileName(
+ self,
+ "PDF exportieren",
+ "",
+ "PDF (*.pdf)"
+ )
+ if not out_path:
+ return
+ success = PredictionReportService.export_pdf(
+ self.calendar_manager,
+ self.prediction_controller,
+ out_path,
+ self.dateformat,
+ )
+ if success:
+ if not out_path.lower().endswith('.pdf'):
+ out_path = out_path + '.pdf'
+ QMessageBox.information(self, "Export erfolgreich", f"PDF exportiert nach {out_path}")
+ else:
+ QMessageBox.critical(self, "Fehler", "PDF-Export fehlgeschlagen.")
+
+ def clear_entries(self):
+ """Clear all calendar entries"""
+ reply = QMessageBox.question(self, "Einträge löschen",
+ "Alle Einträge löschen?",
+ QMessageBox.Yes | QMessageBox.No)
+ if reply == QMessageBox.Yes:
+ try:
+ self.calendar_manager.clear_entries()
+ self.update_events_table()
+ self.update_prediction() # Auto-update prediction
+ QMessageBox.information(self, "Erfolg", "Alle Einträge gelöscht!")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to clear calendar: {str(e)}")
+
+
+def main():
+ app = QApplication(sys.argv)
+ app.setStyle('Fusion')
+ # Ensure Arial is available
+ QFontDatabase.addApplicationFont("Arial")
+ window = CalendarManagerGUI()
+ window.show()
+ sys.exit(app.exec_())
+
+
+if __name__ == "__main__":
main() \ No newline at end of file