# app/features/base_dashboard.py """ Base Dashboard Class ==================== Classe base per tutti i dashboard della MyICR Suite. Elimina duplicazione di codice fornendo funzionalità comuni. Pattern comuni estratti: - Gestione filter panel - Creazione action buttons (reset, fullscreen, export) - Export to Excel - Thread management per data loading - Loading overlay - Signal connections base - Lifecycle methods (page_activated/deactivated) """ import logging from pathlib import Path from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QFrame, QPushButton, QFileDialog, QMessageBox ) from PyQt6.QtCore import Qt, pyqtSignal, QSize from PyQt6.QtGui import QIcon, QKeySequence, QShortcut from app.widgets.loading_overlay import LoadingOverlay from app.utils.paths import resource_path from app.utils.excel_export import export_qtablewidget_to_excel logger = logging.getLogger(__name__) class BaseDashboard(QWidget): """ Classe base per dashboard con funzionalità comuni. Le sottoclassi devono implementare: - get_dashboard_title() -> str - get_export_filename() -> str - create_main_content() -> tuple (summary_widget, detail_widget) Opzionalmente possono override: - setup_custom_signals() - on_filters_applied(filters: dict) - cleanup_threads() """ # Segnali comuni a tutti i dashboard status_updated = pyqtSignal(str, bool, int) open_new_tab_requested = pyqtSignal(str, str, bool, dict) def __init__(self, main_window, filter_data: dict = None): super().__init__() self.main_window = main_window self.theme = main_window.theme self.filter_data = filter_data or {} # State management self.current_filters = {} self.data_thread = None self.detail_thread = None # Widgets comuni (inizializzati dalle sottoclassi) self.filter_panel = None self.loading_overlay = None self.detail_table = None # Action buttons self.reset_button = None self.fullscreen_button = None self.export_button = None # Keyboard shortcuts self._shortcuts = [] logger.info(f"📊 {self.__class__.__name__} inizializzato.") # ========== METODI DA IMPLEMENTARE NELLE SOTTOCLASSI ========== def get_dashboard_title(self) -> str: """Ritorna il titolo del dashboard (es. 'Dashboard Tipologie')""" raise NotImplementedError("Le sottoclassi devono implementare get_dashboard_title()") def get_export_filename(self) -> str: """Ritorna il nome file di default per export Excel (es. 'dettaglio_tipologie')""" raise NotImplementedError("Le sottoclassi devono implementare get_export_filename()") def create_main_content(self) -> tuple: """ Crea il contenuto principale del dashboard. Ritorna: (summary_widget, detail_widget) o solo detail_widget se non c'è summary. """ raise NotImplementedError("Le sottoclassi devono implementare create_main_content()") # ========== METODI OPZIONALI DA OVERRIDE ========== def setup_custom_signals(self): """Override per collegare segnali custom della sottoclasse""" pass def on_filters_applied(self, filters: dict): """Override per gestire applicazione filtri""" self.current_filters = filters logger.info(f"Filtri applicati: {filters}") def cleanup_threads(self): """Override per cleanup thread custom""" pass # ========== KEYBOARD SHORTCUTS ========== def setup_keyboard_shortcuts(self): """ Configura keyboard shortcuts comuni per tutti i dashboard. Shortcuts implementati: - Ctrl+E: Export to Excel - Esc: Reset selection - F11: Toggle fullscreen - Ctrl+R: Refresh data (se implementato) Le sottoclassi possono chiamare questo metodo in init_ui() e poi aggiungere shortcuts custom. """ # Ctrl+E: Export export_shortcut = QShortcut(QKeySequence("Ctrl+E"), self) export_shortcut.activated.connect(self._shortcut_export) self._shortcuts.append(export_shortcut) # Esc: Reset selection reset_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self) reset_shortcut.activated.connect(self._shortcut_reset) self._shortcuts.append(reset_shortcut) # F11: Fullscreen fullscreen_shortcut = QShortcut(QKeySequence(Qt.Key.Key_F11), self) fullscreen_shortcut.activated.connect(self._shortcut_fullscreen) self._shortcuts.append(fullscreen_shortcut) logger.debug(f"{self.__class__.__name__}: Keyboard shortcuts configurati") def _shortcut_export(self): """Handler per Ctrl+E""" if self.export_button and self.export_button.isVisible(): logger.info("Keyboard shortcut: Ctrl+E (Export)") self.export_detail_to_excel() def _shortcut_reset(self): """Handler per Esc""" if self.reset_button and self.reset_button.isVisible(): logger.info("Keyboard shortcut: Esc (Reset)") self.reset_selection() def _shortcut_fullscreen(self): """Handler per F11""" if self.fullscreen_button and self.fullscreen_button.isVisible(): logger.info("Keyboard shortcut: F11 (Fullscreen)") self.open_detail_fullscreen() # ========== UTILITY METHODS - COMUNI A TUTTI ========== def _create_action_button(self, icon_name: str, tooltip: str) -> QPushButton: """ Crea un action button stilizzato in stile Microsoft Office. Args: icon_name: Nome del file icona (senza path) tooltip: Tooltip del bottone Returns: QPushButton configurato """ btn = QPushButton() btn.setToolTip(tooltip) btn.setFixedSize(32, 32) # Icona icon_path = resource_path(f"assets/icons/{icon_name}") if Path(icon_path).exists(): btn.setIcon(QIcon(str(icon_path))) btn.setIconSize(QSize(16, 16)) else: # Fallback: usa emoji se icona non trovata emoji_map = { 'reset.png': '🔄', 'fullscreen.png': '⛶', 'export.png': '📊' } btn.setText(emoji_map.get(icon_name, '?')) # Stile Microsoft Office btn.setStyleSheet(f""" QPushButton {{ background-color: {self.theme.get('button_bg', '#FAFAFA')}; border: 1px solid {self.theme.get('border_light', '#E1DFDD')}; border-radius: 2px; padding: 4px; }} QPushButton:hover {{ background-color: {self.theme.get('button_hover_bg', '#F3F2F1')}; border: 1px solid {self.theme.get('accent_blue', '#0078D4')}; }} QPushButton:pressed {{ background-color: {self.theme.get('button_pressed_bg', '#EDEBE9')}; }} QPushButton:disabled {{ background-color: {self.theme.get('background_disabled', '#F3F2F1')}; color: {self.theme.get('text_disabled', '#A19F9D')}; border: 1px solid {self.theme.get('border_light', '#E1DFDD')}; }} """) return btn def _create_styled_panel(self, title: str = None, margins: tuple = (8, 8, 8, 8)) -> QFrame: """ Crea un pannello stilizzato con bordo Microsoft Office. Args: title: Titolo opzionale del pannello margins: (left, top, right, bottom) Returns: QFrame configurato """ panel = QFrame() panel.setFrameShape(QFrame.Shape.StyledPanel) panel.setStyleSheet(f""" QFrame {{ background-color: white; border: 1px solid {self.theme.get('border_light', '#E1DFDD')}; border-radius: 2px; }} """) layout = QVBoxLayout(panel) layout.setContentsMargins(*margins) layout.setSpacing(8) if title: from PyQt6.QtWidgets import QLabel from PyQt6.QtGui import QFont title_label = QLabel(title) title_label.setFont(QFont(self.theme.get('font_family', 'Segoe UI'), 11, QFont.Weight.Bold)) title_label.setStyleSheet(f"color: {self.theme.get('text_primary', '#323130')}; background: transparent; border: none;") layout.addWidget(title_label) return panel def export_detail_to_excel(self): """ Esporta la tabella di dettaglio in formato Excel. Metodo comune a tutti i dashboard. """ if not self.detail_table: QMessageBox.warning(self, "Attenzione", "Nessuna tabella di dettaglio disponibile per l'export.") return if self.detail_table.rowCount() == 0: QMessageBox.information(self, "Info", "La tabella di dettaglio è vuota. Nessun dato da esportare.") return default_filename = f"{self.get_export_filename()}.xlsx" file_path, _ = QFileDialog.getSaveFileName( self, "Salva File Excel", default_filename, "Excel Files (*.xlsx)" ) if not file_path: return try: export_qtablewidget_to_excel(self.detail_table, file_path) QMessageBox.information( self, "Successo", f"Dati esportati con successo in:\n{file_path}" ) self.status_updated.emit(f"✅ Export completato: {Path(file_path).name}", True, 4000) except Exception as e: logger.error(f"Errore durante l'export Excel: {e}", exc_info=True) QMessageBox.critical( self, "Errore", f"Impossibile esportare i dati.\n\nDettagli: {str(e)}" ) self.status_updated.emit("❌ Export fallito", True, 4000) def reset_selection(self): """ Resetta la selezione corrente. Override se serve logica custom. """ logger.info("Reset selezione") if self.detail_table: self.detail_table.clearContents() self.detail_table.setRowCount(0) self.status_updated.emit("✅ Selezione resettata", True, 2000) def open_detail_fullscreen(self): """ Apre la vista fullscreen del dettaglio. Override nelle sottoclassi per specificare il page_id corretto. """ logger.warning("open_detail_fullscreen() non implementato nella sottoclasse") QMessageBox.information( self, "Info", "Funzionalità fullscreen non ancora implementata per questo dashboard." ) # ========== LIFECYCLE METHODS ========== def page_activated(self): """Chiamato quando la pagina diventa visibile""" logger.info(f"{self.__class__.__name__} attivato.") pass def page_deactivated(self): """Chiamato quando la pagina viene nascosta""" logger.info(f"{self.__class__.__name__} disattivato.") self._cleanup_all_threads() def _cleanup_all_threads(self): """ Cleanup sicuro di tutti i thread attivi. Usa cancellazione graceful con fallback a terminate() solo se necessario. """ from app.utils.cancelable_thread import safe_terminate_thread # Cleanup thread generici if self.data_thread: safe_terminate_thread(self.data_thread, wait_ms=2000, force=True) if self.detail_thread: safe_terminate_thread(self.detail_thread, wait_ms=2000, force=True) # Cleanup custom self.cleanup_threads() def refresh_data_with_cache_clear(self): """ Ricarica i dati invalidando la cache. Questo metodo forza il ricaricamento dei dati dal database ignorando eventuali risultati cached. Utile quando si sa che i dati sono stati aggiornati esternamente. """ from app.utils.cache_manager import get_cache_manager cache = get_cache_manager() # Determina i namespace da invalidare in base al tipo di dashboard # Le sottoclassi possono override questo metodo per customizzare namespaces = self.get_cache_namespaces_to_invalidate() for namespace in namespaces: cache.invalidate_namespace(namespace) logger.info(f"Cache invalidata per namespace: {namespace}") # Ricarica i dati (le sottoclassi dovrebbero implementare questo) self.on_refresh_requested() def get_cache_namespaces_to_invalidate(self) -> list: """ Restituisce la lista dei namespace della cache da invalidare. Le sottoclassi dovrebbero override questo metodo per specificare quali namespace devono essere invalidati durante il refresh. Returns: Lista di namespace strings """ return [] def on_refresh_requested(self): """ Callback chiamato quando viene richiesto un refresh dei dati. Le sottoclassi dovrebbero override questo metodo per implementare la logica di ricaricamento specifica (es. chiamare load_data()). """ logger.warning(f"{self.__class__.__name__}: on_refresh_requested non implementato") def closeEvent(self, event): """Override del close event per cleanup""" self._cleanup_all_threads() super().closeEvent(event)