# C:\PYTHON\MyICR_Suite\app\features\dash_em_pivot\__init__.py """ Dashboard Pivot Emissioni - Refactored to use BaseDashboard """ import logging import pandas as pd import os from PyQt6.QtGui import QIcon from PyQt6.QtCore import QSize, pyqtSignal from PyQt6.QtWidgets import ( QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QMessageBox, QButtonGroup, QFileDialog, QScrollArea ) from app.features.base_dashboard import BaseDashboard from .filter_panel_widget import FilterPanelEmPivot from .pivot_table_em_widget import PivotTableEmWidget from .detail_table_widget import DetailTableWidget from app.widgets.loading_overlay import LoadingOverlay from .queries import get_pivot_emesso_data, get_dettaglio_emissioni logger = logging.getLogger(__name__) # ========== Worker Thread (mantenuto inline, refactored to use CancelableThread) ========== from app.utils.cancelable_thread import CancelableThread from app.utils.cache_manager import get_cache_manager class PivotDataLoader(CancelableThread): """ Thread per caricamento dati pivot. Refactored to use CancelableThread for safe cancellation. """ data_loaded = pyqtSignal(pd.DataFrame) def __init__(self, filters): super().__init__() self.filters = filters def do_work(self): """ Carica i dati pivot con caching. Override di CancelableThread.do_work() """ if self.is_cancelled: logger.info("PivotDataLoader: Cancellato prima dell'avvio") return None try: logger.info(f"PivotDataLoader: Caricamento dati con filtri: {self.filters}") # Usa CacheManager per cache delle query cache = get_cache_manager() df = cache.cached_query( namespace='empivot_data', params=self.filters, query_func=lambda: get_pivot_emesso_data(self.filters), ttl=600 # 10 minuti ) # Check cancellation prima di emettere if not self.is_cancelled: self.data_loaded.emit(df) logger.info(f"PivotDataLoader: Dati caricati, {len(df)} righe") return df except Exception as e: logger.error(f"Errore caricamento dati pivot: {e}", exc_info=True) self.error_occurred.emit(str(e)) return None class EmPivotDetailDataLoader(CancelableThread): """ Thread per caricamento dati di dettaglio emissioni. Gestisce il caricamento asincrono dei dettagli per una cella selezionata nella pivot table, evitando di bloccare l'interfaccia. """ detail_loaded = pyqtSignal(pd.DataFrame) def __init__(self, tipologia: str, anno: str, filters: dict): super().__init__() self.tipologia = tipologia self.anno = anno self.filters = filters def do_work(self): """ Carica i dati di dettaglio con caching. Override di CancelableThread.do_work() """ if self.is_cancelled: logger.info("EmPivotDetailDataLoader: Cancellato prima dell'avvio") return None try: logger.info(f"EmPivotDetailDataLoader: Caricamento dettaglio per {self.tipologia}, {self.anno}") # Prepara filtri per il dettaglio detail_filters = self.filters.copy() detail_filters['tipologia'] = self.tipologia detail_filters['anno'] = self.anno detail_filters['limit'] = 5000 # Usa CacheManager per cache delle query cache = get_cache_manager() detail_df = cache.cached_query( namespace='empivot_detail', params=detail_filters, query_func=lambda: get_dettaglio_emissioni(detail_filters), ttl=600 # 10 minuti ) # Check cancellation prima di emettere if not self.is_cancelled: self.detail_loaded.emit(detail_df) logger.info(f"EmPivotDetailDataLoader: Dettaglio caricato, {len(detail_df)} righe") return detail_df except Exception as e: logger.error(f"EmPivotDetailDataLoader: Errore durante il caricamento: {e}", exc_info=True) self.error_occurred.emit(str(e)) return None # ========== Dashboard Class ========== class DashboardEmPivotPage(BaseDashboard): """ Dashboard per analisi pivot emissioni. Refactored per usare BaseDashboard - elimina duplicazione codice. """ METRIC_MAP = { 'numero_emissioni': 'Numero Emissioni', 'durata_lorda_totale': 'Ore Lorde', 'durata_netta_totale': 'Ore Nette' } def __init__(self, main_window, filter_data: dict): super().__init__(main_window, filter_data) # Attributi specifici di questo dashboard self.current_metric = 'numero_emissioni' self.current_selection = {} self.full_pivot_data = pd.DataFrame() self.init_ui() logger.info("๐Ÿ“Š DashboardEmPivotPage creata e pronta.") # ========== IMPLEMENTAZIONE METODI RICHIESTI DA BaseDashboard ========== def get_dashboard_title(self) -> str: """Titolo del dashboard""" return "Dashboard Pivot Emissioni" def get_export_filename(self) -> str: """Nome file di default per export""" sel = self.current_selection safe_name = f"{sel.get('tipologia', 'dettaglio').replace(' ', '_')}_{sel.get('anno', '')}" return f"dettaglio_{safe_name}" # ========== UI INITIALIZATION ========== def init_ui(self): """Inizializza l'interfaccia utente""" page_layout = QVBoxLayout(self) page_layout.setContentsMargins(0, 0, 0, 0) page_layout.setSpacing(0) main_content_layout = QHBoxLayout() main_content_layout.setContentsMargins(0, 0, 0, 0) main_content_layout.setSpacing(0) # Filter panel (sinistra) self.filter_panel = FilterPanelEmPivot(filter_data=self.filter_data, theme=self.theme) filter_container = QFrame() filter_container.setFixedWidth(280) filter_container.setStyleSheet( f"background-color: {self.theme.get('background_panel', '#F3F2F1')}; " f"border-right: 1px solid #D2D0CE;" ) filter_layout = QVBoxLayout(filter_container) filter_layout.addWidget(self.filter_panel) # Content area (destra) content_widget = QFrame() right_layout = QVBoxLayout(content_widget) right_layout.setContentsMargins(16, 16, 16, 16) right_layout.setSpacing(12) # Header header_layout = self._create_header() # Metric selector toolbar metric_toolbar_layout = self._create_metric_selector_toolbar() # Data container con loading overlay data_container = QFrame() data_container.setFrameShape(QFrame.Shape.NoFrame) data_layout = QVBoxLayout(data_container) data_layout.setContentsMargins(0, 0, 0, 0) self.loading_overlay = LoadingOverlay(data_container, text="Aggiornamento in corso...") # Pivot table con scroll area self.pivot_table = PivotTableEmWidget(theme=self.theme) scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setFrameShape(QFrame.Shape.NoFrame) scroll_area.setWidget(self.pivot_table) # Detail panel detail_panel, self.detail_table = self._create_detail_panel() data_layout.addWidget(scroll_area, stretch=1) data_layout.addWidget(detail_panel, stretch=1) right_layout.addLayout(header_layout) right_layout.addLayout(metric_toolbar_layout) right_layout.addWidget(data_container) main_content_layout.addWidget(filter_container) main_content_layout.addWidget(content_widget, 1) page_layout.addLayout(main_content_layout) # Abilita keyboard shortcuts self.setup_keyboard_shortcuts() self._setup_connections() self._update_metric_selector_ui() def _create_header(self): """Crea header del dashboard""" header_layout = QHBoxLayout() title_label = QLabel(self.get_dashboard_title()) title_label.setStyleSheet("font-size: 20px; font-weight: 600;") header_layout.addWidget(title_label) header_layout.addStretch() # Pulsante Aggiorna con cache invalidation refresh_btn = QPushButton("๐Ÿ”„ Aggiorna") refresh_btn.setToolTip("Ricarica dati dal database (ignora cache)") refresh_btn.clicked.connect(self.refresh_data_with_cache_clear) header_layout.addWidget(refresh_btn) return header_layout def _create_metric_selector_toolbar(self): """Crea toolbar per selezione metrica""" toolbar_layout = QHBoxLayout() toolbar_layout.setContentsMargins(0, 0, 0, 0) toolbar_layout.addWidget(QLabel("Visualizza per:")) self.metric_button_group = QButtonGroup(self) self.metric_button_group.setExclusive(True) for key, text in self.METRIC_MAP.items(): button = QPushButton(text) button.setCheckable(True) button.setObjectName(key) self.metric_button_group.addButton(button) toolbar_layout.addWidget(button) toolbar_layout.addStretch() return toolbar_layout def _create_detail_panel(self): """Crea pannello di dettaglio""" panel, layout = self._create_styled_panel() # Toolbar toolbar_layout = QHBoxLayout() toolbar_layout.setContentsMargins(0, 0, 0, 0) toolbar_layout.setSpacing(4) toolbar_layout.addStretch() # Action buttons - stile minimalista custom self.reset_detail_button = self._create_action_button_custom("๐Ÿ”„", "Ripristina vista") self.fullscreen_button = self._create_action_button_custom("โ›ถ", "Vista a schermo intero") self.export_button = self._create_action_button_custom("", "Esporta in Excel") # Icona Excel personalizzata try: from app.utils.paths import resource_path icon_path = resource_path("assets/icons/excel.svg") self.export_button.setIcon(QIcon(str(icon_path))) self.export_button.setIconSize(QSize(28, 28)) self.export_button.setFixedSize(36, 36) self.export_button.setToolTip("Esporta in Excel") except Exception: self.export_button.setText("๐Ÿ“—") self.reset_detail_button.setVisible(False) self.fullscreen_button.setVisible(False) self.export_button.setVisible(False) toolbar_layout.addWidget(self.reset_detail_button) toolbar_layout.addWidget(self.fullscreen_button) toolbar_layout.addWidget(self.export_button) layout.addLayout(toolbar_layout) # Detail table detail_table = DetailTableWidget() detail_table.set_theme(self.theme) layout.addWidget(detail_table) return panel, detail_table def _create_styled_panel(self): """Crea pannello stilizzato""" frame = QFrame() frame.setFrameShape(QFrame.Shape.NoFrame) frame.setStyleSheet( "background-color: #FFFFFF; " "border: 1px solid #EDEBE9; " "border-radius: 2px;" ) layout = QVBoxLayout(frame) layout.setContentsMargins(8, 8, 8, 8) layout.setSpacing(6) return frame, layout def _create_action_button_custom(self, icon, tip): """Crea action button con stile minimalista custom""" button = QPushButton(icon) button.setToolTip(tip) button.setFixedSize(28, 28) button.setStyleSheet( "QPushButton { font-size: 16px; border: none; } " "QPushButton:hover { color: #0078D4; }" ) return button def _setup_connections(self): """Collega i segnali""" self.filter_panel.filters_applied.connect(self.on_filters_applied) self.metric_button_group.buttonClicked.connect(self._on_metric_changed) self.pivot_table.cell_selected.connect(self.on_pivot_cell_selected) # Action buttons self.reset_detail_button.clicked.connect(self.reset_detail_selection) self.fullscreen_button.clicked.connect(self.open_detail_fullscreen) self.export_button.clicked.connect(self.export_detail_to_excel) # ========== DATA LOADING ========== def on_filters_applied(self, filters_dict): """Gestisce applicazione filtri""" if not filters_dict: return self.current_filters = filters_dict # Cleanup thread sicuro if self.data_thread and self.data_thread.isRunning(): from app.utils.cancelable_thread import safe_terminate_thread safe_terminate_thread(self.data_thread, wait_ms=1000, force=True) self.loading_overlay.start_loading() self.data_thread = PivotDataLoader(filters_dict) self.data_thread.data_loaded.connect(self.on_data_loaded) self.data_thread.error_occurred.connect(self.on_data_error) self.data_thread.start() def on_data_loaded(self, data): """Callback quando dati pivot sono caricati""" self.loading_overlay.stop_loading() self.full_pivot_data = data has_data = not data.empty if not has_data: self._update_status("Nessun dato trovato per i filtri selezionati.", icon="โ„น๏ธ") else: self._update_status("Dati pivot caricati.", True, 3000, "โœ…") self.update_view_for_current_metric() def on_data_error(self, error_msg): """Callback errore caricamento dati""" self.loading_overlay.stop_loading() QMessageBox.critical( self, "Errore Dati", f"Errore caricamento dati pivot:\n{error_msg}" ) def on_pivot_cell_selected(self, tipologia: str, anno: str): """Gestisce selezione cella nella pivot in modo asincrono""" self.current_selection = {'tipologia': tipologia, 'anno': anno} # Pulisci tabella e mostra stato di caricamento self.detail_table.clear_data() self.reset_detail_button.setVisible(True) self.fullscreen_button.setVisible(False) self.export_button.setVisible(False) # Termina thread dettagli precedente se in esecuzione if self.detail_thread and self.detail_thread.isRunning(): logger.info("Terminazione thread dettagli precedente...") self.detail_thread.cancel() self.detail_thread.wait(1000) # Prepara filtri per il dettaglio detail_filters = self.current_filters.copy() detail_filters['data_inizio'] = f"{anno}-01-01" detail_filters['data_fine'] = f"{anno}-12-31" # Avvia caricamento asincrono self.detail_thread = EmPivotDetailDataLoader( tipologia, anno, detail_filters ) self.detail_thread.detail_loaded.connect(self.on_detail_loaded) self.detail_thread.error_occurred.connect(self.on_detail_error) self.detail_thread.start() def on_detail_loaded(self, detail_df): """Callback quando i dati di dettaglio sono caricati""" self.detail_table.populate(detail_df) has_data = not detail_df.empty self.fullscreen_button.setVisible(has_data) self.export_button.setVisible(has_data) def on_detail_error(self, error_msg): """Callback errore caricamento dettagli""" QMessageBox.critical( self, "Errore Caricamento Dettagli", f"Si รจ verificato un errore nel caricamento dei dettagli:\n\n{error_msg}" ) # ========== METRIC SELECTOR ========== def update_view_for_current_metric(self): """Aggiorna vista in base alla metrica selezionata""" self.pivot_table.populate(self.full_pivot_data, self.current_metric) self.reset_detail_selection() def _on_metric_changed(self, button): """Callback cambio metrica""" new_metric = button.objectName() if new_metric != self.current_metric: self.current_metric = new_metric self._update_metric_selector_ui() self.update_view_for_current_metric() def _update_metric_selector_ui(self): """Aggiorna UI del metric selector""" base_style = "QPushButton { padding: 5px 12px; border-radius: 2px; font-size: 11px; }" active_style = base_style + ( f"QPushButton {{ " f"background-color: {self.theme.get('primary_color', '#0078D4')}; " f"color: {self.theme.get('text_white', '#FFFFFF')}; " f"border: 1px solid {self.theme.get('primary_color', '#0078D4')}; " f"font-weight: 600; " f"}}" ) inactive_style = base_style + ( f"QPushButton {{ " f"background-color: {self.theme.get('background_primary', '#FFFFFF')}; " f"color: {self.theme.get('text_primary', '#323130')}; " f"border: 1px solid {self.theme.get('border_dark', '#D2D0CE')}; " f"font-weight: normal; " f"}}" ) for button in self.metric_button_group.buttons(): is_active = (button.objectName() == self.current_metric) button.setStyleSheet(active_style if is_active else inactive_style) button.setChecked(is_active) # ========== OVERRIDE METODI BaseDashboard ========== def reset_detail_selection(self): """Override reset per gestire logica specifica""" self.detail_table.clear_data() self.pivot_table.clearSelection() self.current_selection = {} self.reset_detail_button.setVisible(False) self.fullscreen_button.setVisible(False) self.export_button.setVisible(False) def export_detail_to_excel(self): """Override export per logica specifica""" data_to_export = self.detail_table.original_data if data_to_export is None or data_to_export.empty: return default_filename = f"{self.get_export_filename()}.xlsx" path, _ = QFileDialog.getSaveFileName( self, "Esporta Dettaglio", default_filename, "File Excel (*.xlsx)" ) if not path: return try: from app.utils.excel_export import export_qtablewidget_to_excel export_qtablewidget_to_excel( self.detail_table, path, sheet_name="Dettaglio", theme=self.theme, original_data=data_to_export, date_columns=['data_emissione'] # Colonna data da esportare come nativa ) self._update_status( f"Esportato con successo in {os.path.basename(path)}", True, 4000, "โœ…" ) except Exception as e: logger.error(f"Errore export: {e}", exc_info=True) QMessageBox.critical( self, "Errore Esportazione", f"Impossibile salvare il file:\n{e}" ) def open_detail_fullscreen(self): """Apre vista fullscreen del dettaglio""" if self.detail_table.original_data is None or self.detail_table.original_data.empty: return sel = self.current_selection title = f"Dettaglio: {sel.get('tipologia')} ({sel.get('anno')})" page_data = { 'dataframe': self.detail_table.original_data, 'title': title } self.open_new_tab_requested.emit( 'fullscreen_pivot', title, False, page_data ) # ========== UTILITY METHODS ========== def _update_status(self, message, is_temporary=False, duration_ms=5000, icon=""): """Helper per aggiornare status bar""" full_message = f"{icon} {message}".strip() if hasattr(self.main_window, 'show_status_message'): self.main_window.show_status_message(full_message, is_temporary, duration_ms) # ========== LIFECYCLE METHODS ========== def page_activated(self): """Override lifecycle method""" logger.info("DashboardEmPivotPage attivata.") self.filter_panel.apply_filters() def cleanup_threads(self): """Override per cleanup thread custom""" # data_thread (PivotDataLoader) รจ giร  gestito da BaseDashboard.data_thread pass def get_cache_namespaces_to_invalidate(self) -> list: """Restituisce i namespace della cache da invalidare""" return ['empivot_data', 'empivot_detail'] def on_refresh_requested(self): """Ricarica i dati applicando i filtri correnti""" logger.info("Refresh dati richiesto per DashboardEmPivotPage") self.filter_panel.apply_filters()