feat: Enhance export functionality with DataFrame support and UI improvements
This commit is contained in:
+15
-6
@@ -54,10 +54,12 @@ class ExportManager:
|
||||
self.pathology_manager = pathology_manager
|
||||
self.logger = logger
|
||||
|
||||
def export_data_to_json(self, export_path: str) -> bool:
|
||||
def export_data_to_json(
|
||||
self, export_path: str, df: pd.DataFrame | None = None
|
||||
) -> bool:
|
||||
"""Export CSV data to JSON format."""
|
||||
try:
|
||||
df = self.data_manager.load_data()
|
||||
df = df if df is not None else self.data_manager.load_data()
|
||||
if df.empty:
|
||||
self.logger.warning("No data to export")
|
||||
return False
|
||||
@@ -87,10 +89,12 @@ class ExportManager:
|
||||
self.logger.error(f"Error exporting to JSON: {str(e)}")
|
||||
return False
|
||||
|
||||
def export_data_to_xml(self, export_path: str) -> bool:
|
||||
def export_data_to_xml(
|
||||
self, export_path: str, df: pd.DataFrame | None = None
|
||||
) -> bool:
|
||||
"""Export CSV data to XML format."""
|
||||
try:
|
||||
df = self.data_manager.load_data()
|
||||
df = df if df is not None else self.data_manager.load_data()
|
||||
if df.empty:
|
||||
self.logger.warning("No data to export")
|
||||
return False
|
||||
@@ -203,10 +207,15 @@ class ExportManager:
|
||||
self.logger.error(f"Error saving graph image: {str(e)}")
|
||||
return None
|
||||
|
||||
def export_to_pdf(self, export_path: str, include_graph: bool = True) -> bool:
|
||||
def export_to_pdf(
|
||||
self,
|
||||
export_path: str,
|
||||
include_graph: bool = True,
|
||||
df: pd.DataFrame | None = None,
|
||||
) -> bool:
|
||||
"""Export data and optionally graph to PDF format."""
|
||||
try:
|
||||
df = self.data_manager.load_data()
|
||||
df = df if df is not None else self.data_manager.load_data()
|
||||
|
||||
# Create PDF document in landscape format for better table/graph display
|
||||
doc = SimpleDocTemplate(
|
||||
|
||||
+36
-4
@@ -5,6 +5,7 @@ Provides a GUI interface for exporting data and graphs to various formats.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
@@ -14,9 +15,15 @@ from export_manager import ExportManager
|
||||
class ExportWindow:
|
||||
"""Export window for data and graph export functionality."""
|
||||
|
||||
def __init__(self, parent: tk.Tk, export_manager: ExportManager) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Tk,
|
||||
export_manager: ExportManager,
|
||||
get_current_filtered_df: Callable[[], object] | None = None,
|
||||
) -> None:
|
||||
self.parent = parent
|
||||
self.export_manager = export_manager
|
||||
self._get_current_filtered_df = get_current_filtered_df
|
||||
|
||||
# Create the export window
|
||||
self.window = tk.Toplevel(parent)
|
||||
@@ -113,6 +120,21 @@ Medicines: {", ".join(export_info["medicines"])}"""
|
||||
)
|
||||
graph_check.pack(anchor=tk.W, pady=(0, 10))
|
||||
|
||||
# Export scope option
|
||||
self.scope_var = tk.StringVar(value="all")
|
||||
scope_frame = ttk.Frame(options_frame)
|
||||
scope_frame.pack(fill=tk.X, pady=(0, 10))
|
||||
ttk.Label(scope_frame, text="Scope:").pack(side=tk.LEFT)
|
||||
ttk.Radiobutton(
|
||||
scope_frame, text="All data", variable=self.scope_var, value="all"
|
||||
).pack(side=tk.LEFT, padx=10)
|
||||
ttk.Radiobutton(
|
||||
scope_frame,
|
||||
text="Current (filtered) view",
|
||||
variable=self.scope_var,
|
||||
value="filtered",
|
||||
).pack(side=tk.LEFT)
|
||||
|
||||
# Format selection
|
||||
format_label = ttk.Label(options_frame, text="Export Format:")
|
||||
format_label.pack(anchor=tk.W)
|
||||
@@ -182,17 +204,27 @@ Medicines: {", ".join(export_info["medicines"])}"""
|
||||
if not filename:
|
||||
return
|
||||
|
||||
# Determine scope DataFrame (if requested and available)
|
||||
scoped_df = None
|
||||
if self.scope_var.get() == "filtered" and self._get_current_filtered_df:
|
||||
try:
|
||||
scoped_df = self._get_current_filtered_df()
|
||||
except Exception:
|
||||
scoped_df = None
|
||||
|
||||
# Perform export based on selected format
|
||||
success = False
|
||||
try:
|
||||
if selected_format == "JSON":
|
||||
success = self.export_manager.export_data_to_json(filename)
|
||||
success = self.export_manager.export_data_to_json(
|
||||
filename, df=scoped_df
|
||||
)
|
||||
elif selected_format == "XML":
|
||||
success = self.export_manager.export_data_to_xml(filename)
|
||||
success = self.export_manager.export_data_to_xml(filename, df=scoped_df)
|
||||
elif selected_format == "PDF":
|
||||
include_graph = self.include_graph_var.get()
|
||||
success = self.export_manager.export_to_pdf(
|
||||
filename, include_graph=include_graph
|
||||
filename, include_graph=include_graph, df=scoped_df
|
||||
)
|
||||
|
||||
if success:
|
||||
|
||||
+14
-1
@@ -681,7 +681,20 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
def _open_export_window(self) -> None:
|
||||
"""Open the export window."""
|
||||
self.ui_manager.update_status("Opening export window", "info")
|
||||
ExportWindow(self.root, self.export_manager)
|
||||
|
||||
def _get_current_filtered_df():
|
||||
try:
|
||||
if self.current_filtered_data is not None:
|
||||
return self.current_filtered_data
|
||||
# If no live filtered DF, but filters are active, compute one-off
|
||||
if self.data_filter.get_filter_summary().get("has_filters"):
|
||||
df = self.data_manager.load_data()
|
||||
return self.data_filter.apply_filters(df)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
ExportWindow(self.root, self.export_manager, _get_current_filtered_df)
|
||||
if hasattr(self.ui_manager, "show_toast"):
|
||||
self.ui_manager.show_toast("Export window opened", 1200)
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ _DEFAULTS: dict[str, Any] = {
|
||||
"last_window_geometry": "",
|
||||
# Keep window always on top
|
||||
"always_on_top": False,
|
||||
# Search/filter UI state
|
||||
"search_panel_visible": False,
|
||||
"last_filter_state": None,
|
||||
# Table column UX
|
||||
"column_widths": {},
|
||||
"last_sort": {"column": None, "ascending": True},
|
||||
}
|
||||
|
||||
_PREFERENCES: dict[str, Any] = dict(_DEFAULTS)
|
||||
|
||||
+120
-6
@@ -2,9 +2,10 @@
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from tkinter import ttk
|
||||
from tkinter import messagebox, simpledialog, ttk
|
||||
|
||||
from init import logger
|
||||
from preferences import get_pref, save_preferences, set_pref
|
||||
from search_filter import DataFilter, QuickFilters, SearchHistory
|
||||
|
||||
|
||||
@@ -41,10 +42,10 @@ class SearchFilterWidget:
|
||||
# Visibility and UI init state
|
||||
self.is_visible = False
|
||||
self._ui_initialized = False
|
||||
self.frame: ttk.LabelFrame | None = None
|
||||
self.frame = None
|
||||
|
||||
# Debouncing mechanism to reduce filter update frequency
|
||||
self._update_timer: str | None = None
|
||||
self._update_timer = None
|
||||
self._debounce_delay = 450 # milliseconds
|
||||
|
||||
# History and UI state variables
|
||||
@@ -53,10 +54,13 @@ class SearchFilterWidget:
|
||||
self.start_date_var = tk.StringVar()
|
||||
self.end_date_var = tk.StringVar()
|
||||
|
||||
# Presets state
|
||||
self.preset_var = tk.StringVar()
|
||||
|
||||
# Medicine and pathology filter variables
|
||||
self.medicine_vars: dict[str, tk.StringVar] = {}
|
||||
self.pathology_min_vars: dict[str, tk.StringVar] = {}
|
||||
self.pathology_max_vars: dict[str, tk.StringVar] = {}
|
||||
self.medicine_vars = {}
|
||||
self.pathology_min_vars = {}
|
||||
self.pathology_max_vars = {}
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
"""Set up the search and filter UI."""
|
||||
@@ -68,9 +72,29 @@ class SearchFilterWidget:
|
||||
content_frame.pack(fill="both", expand=True)
|
||||
|
||||
# Top row: Search and Quick filters
|
||||
# Top row: Presets, Search and Quick filters
|
||||
top_row = ttk.Frame(content_frame)
|
||||
top_row.pack(fill="x", pady=(0, 5))
|
||||
|
||||
# Presets section (leftmost)
|
||||
presets_frame = ttk.Frame(top_row)
|
||||
presets_frame.pack(side="left", padx=(0, 10))
|
||||
ttk.Label(presets_frame, text="Preset:").pack(side="left")
|
||||
self.preset_combo = ttk.Combobox(
|
||||
presets_frame, textvariable=self.preset_var, state="readonly", width=18
|
||||
)
|
||||
self._refresh_presets_combo()
|
||||
self.preset_combo.pack(side="left", padx=(5, 5))
|
||||
ttk.Button(presets_frame, text="Load", command=self._load_preset).pack(
|
||||
side="left", padx=(0, 2)
|
||||
)
|
||||
ttk.Button(presets_frame, text="Save", command=self._save_preset).pack(
|
||||
side="left", padx=(0, 2)
|
||||
)
|
||||
ttk.Button(presets_frame, text="Delete", command=self._delete_preset).pack(
|
||||
side="left"
|
||||
)
|
||||
|
||||
# Search section (left side of top row)
|
||||
search_frame = ttk.Frame(top_row)
|
||||
search_frame.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
@@ -438,6 +462,96 @@ class SearchFilterWidget:
|
||||
|
||||
self.status_label.config(text=status_text)
|
||||
|
||||
# ---------------------
|
||||
# Presets management
|
||||
# ---------------------
|
||||
def _refresh_presets_combo(self) -> None:
|
||||
presets = get_pref("filter_presets", {}) or {}
|
||||
names = sorted(presets.keys())
|
||||
if hasattr(self, "preset_combo") and self.preset_combo:
|
||||
self.preset_combo["values"] = names
|
||||
if names and not self.preset_var.get():
|
||||
self.preset_var.set(names[0])
|
||||
|
||||
def _apply_filter_summary(self, summary: dict) -> None:
|
||||
"""Apply a saved summary dict into the DataFilter and UI, then update."""
|
||||
import contextlib
|
||||
|
||||
if not isinstance(summary, dict):
|
||||
return
|
||||
# Clear then set pieces
|
||||
self.data_filter.clear_all_filters()
|
||||
self.data_filter.set_search_term(summary.get("search_term", ""))
|
||||
filt = summary.get("filters", {}) or {}
|
||||
# Date
|
||||
date_rng = filt.get("date_range") or {}
|
||||
self.data_filter.set_date_range_filter(
|
||||
date_rng.get("start") or None, date_rng.get("end") or None
|
||||
)
|
||||
# Medicines
|
||||
meds = filt.get("medicines") or {}
|
||||
for key in meds.get("taken", []) or []:
|
||||
self.data_filter.set_medicine_filter(key, True)
|
||||
for key in meds.get("not_taken", []) or []:
|
||||
self.data_filter.set_medicine_filter(key, False)
|
||||
# Pathologies
|
||||
paths = filt.get("pathologies") or {}
|
||||
for key, range_text in paths.items():
|
||||
with contextlib.suppress(Exception):
|
||||
s = str(range_text)
|
||||
parts = s.split("-")
|
||||
mn = parts[0].strip() if parts else ""
|
||||
mx = parts[1].strip() if len(parts) > 1 else ""
|
||||
mn_i = int(mn) if mn and mn.lower() != "any" else None
|
||||
mx_i = int(mx) if mx and mx.lower() != "any" else None
|
||||
self.data_filter.set_pathology_range_filter(key, mn_i, mx_i)
|
||||
# Sync UI and notify
|
||||
self.sync_ui_from_filter()
|
||||
self.update_callback()
|
||||
|
||||
def _load_preset(self) -> None:
|
||||
name = self.preset_var.get().strip()
|
||||
if not name:
|
||||
return
|
||||
presets = get_pref("filter_presets", {}) or {}
|
||||
summary = presets.get(name)
|
||||
if not summary:
|
||||
messagebox.showwarning("Preset", f"Preset '{name}' not found.")
|
||||
return
|
||||
self._apply_filter_summary(summary)
|
||||
|
||||
def _save_preset(self) -> None:
|
||||
# Ask for a name
|
||||
name = simpledialog.askstring("Save Preset", "Preset name:", parent=self.parent)
|
||||
if not name:
|
||||
return
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return
|
||||
presets = get_pref("filter_presets", {}) or {}
|
||||
presets[name] = self.data_filter.get_filter_summary()
|
||||
set_pref("filter_presets", presets)
|
||||
save_preferences()
|
||||
self._refresh_presets_combo()
|
||||
self.preset_var.set(name)
|
||||
self._update_status()
|
||||
|
||||
def _delete_preset(self) -> None:
|
||||
name = self.preset_var.get().strip()
|
||||
if not name:
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Delete Preset", f"Delete preset '{name}'?", parent=self.parent
|
||||
):
|
||||
return
|
||||
presets = get_pref("filter_presets", {}) or {}
|
||||
if name in presets:
|
||||
del presets[name]
|
||||
set_pref("filter_presets", presets)
|
||||
save_preferences()
|
||||
self.preset_var.set("")
|
||||
self._refresh_presets_combo()
|
||||
|
||||
def get_widget(self) -> ttk.LabelFrame | None:
|
||||
"""Get the main widget for embedding in UI (may be None until shown)."""
|
||||
return self.frame
|
||||
|
||||
@@ -12,6 +12,7 @@ from PIL import Image, ImageTk
|
||||
|
||||
from medicine_manager import MedicineManager
|
||||
from pathology_manager import PathologyManager
|
||||
from preferences import get_pref, save_preferences, set_pref
|
||||
from tooltip_system import TooltipManager
|
||||
|
||||
|
||||
@@ -410,6 +411,28 @@ class UIManager:
|
||||
for col, width, anchor in col_settings:
|
||||
tree.column(col, width=width, anchor=anchor)
|
||||
|
||||
# Apply saved column widths if available
|
||||
try:
|
||||
saved_widths = get_pref("column_widths", {}) or {}
|
||||
if isinstance(saved_widths, dict):
|
||||
for col in tree["columns"]:
|
||||
w = saved_widths.get(col)
|
||||
if isinstance(w, int) and w > 0:
|
||||
tree.column(col, width=w)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Initialize last sort from preferences
|
||||
try:
|
||||
last_sort = get_pref("last_sort", {}) or {}
|
||||
col = last_sort.get("column")
|
||||
asc = last_sort.get("ascending", True)
|
||||
if col in tree["columns"]:
|
||||
self._last_sorted_column = col
|
||||
self._last_sorted_ascending = bool(asc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tree.pack(side="left", fill="both", expand=True)
|
||||
|
||||
# Add scrollbars with optimized scroll handling
|
||||
@@ -422,6 +445,9 @@ class UIManager:
|
||||
# Optimize tree scrolling performance
|
||||
self._optimize_tree_scrolling(tree)
|
||||
|
||||
# Install debounced save of column widths
|
||||
self._install_column_width_persistence(tree)
|
||||
|
||||
return {"frame": table_frame, "tree": tree}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -462,6 +488,13 @@ class UIManager:
|
||||
# Re-apply alternating row tags after sort
|
||||
self.normalize_tree_stripes(tree)
|
||||
|
||||
# Persist last sort
|
||||
try:
|
||||
set_pref("last_sort", {"column": column, "ascending": ascending})
|
||||
save_preferences()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sort_tree_column_direction(
|
||||
self, tree: ttk.Treeview, column: str, ascending: bool
|
||||
) -> None:
|
||||
@@ -572,6 +605,35 @@ class UIManager:
|
||||
# Ensure alternating stripes are normalized after updates
|
||||
self.normalize_tree_stripes(tree)
|
||||
|
||||
# --- Column width persistence helpers ---
|
||||
def _install_column_width_persistence(self, tree: ttk.Treeview) -> None:
|
||||
import contextlib
|
||||
|
||||
self._col_width_save_after_id = None
|
||||
|
||||
def _debounced_save(*_args):
|
||||
if getattr(self, "_col_width_save_after_id", None):
|
||||
with contextlib.suppress(Exception):
|
||||
self.root.after_cancel(self._col_width_save_after_id)
|
||||
self._col_width_save_after_id = self.root.after(600, _save_now)
|
||||
|
||||
def _save_now():
|
||||
widths = {}
|
||||
for col in tree["columns"]:
|
||||
try:
|
||||
widths[col] = int(tree.column(col, option="width"))
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
set_pref("column_widths", widths)
|
||||
save_preferences()
|
||||
except Exception:
|
||||
pass
|
||||
self._col_width_save_after_id = None
|
||||
|
||||
tree.bind("<ButtonRelease-1>", _debounced_save, add="+")
|
||||
tree.bind("<Configure>", _debounced_save, add="+")
|
||||
|
||||
def normalize_tree_stripes(self, tree: ttk.Treeview) -> None:
|
||||
"""Normalize alternating row tags based on current visual order.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user