"""Shipping history window. Left sidebar: search + filters over the local label history. Main toolbar: opens authenticated Stamps.com hosted pages (refund, SCAN form, pickup, full history) — the API returns a single-use URL we open in the browser so the user gets the real Stamps UI without re-logging in. Export: writes the current filtered view to CSV from our local store. """ from __future__ import annotations import csv import logging import os import tempfile import threading import webbrowser from tkinter import ttk import customtkinter as ctk from . import theme as T from .label_history import LabelEntry, LabelHistory log = logging.getLogger(__name__) # ── Table columns ────────────────────────────────────────────────────────── _COLUMNS = [ ("date", "Date Printed", 105), ("name", "Recipient", 155), ("tracking", "Tracking #", 165), ("service", "Service", 140), ("amount", "Amount", 80), ("status", "Status", 110), ("notes", "Notes", 165), ] _DATE_FILTERS = ["All time", "Today", "Last 7 days", "Last 30 days"] _STATUS_FILTERS = ["All", "Delivered", "In Transit", "Accepted", "Exception", "Voided"] def _resolve(color) -> str: """Resolve a (light, dark) tuple to the current mode's hex string.""" if isinstance(color, (list, tuple)) and len(color) == 2: mode = ctk.get_appearance_mode().lower() return color[1] if mode == "dark" else color[0] return color class HistoryWindow(ctk.CTkToplevel): def __init__(self, parent, cfg=None, carrier: str = ""): super().__init__(parent) self._cfg = cfg self._carrier = carrier self._history = LabelHistory() self._entries: list[LabelEntry] = [] _titles = {"usps": "USPS Shipping History", "fedex": "FedEx Shipping History"} self.title(_titles.get(carrier, "Shipping History")) self.geometry("1100x600") self.minsize(800, 400) self.columnconfigure(1, weight=1) self.rowconfigure(0, weight=1) self._build_sidebar() self._build_main() self._apply_treeview_style() self._refresh_table() # ── Sidebar ──────────────────────────────────────────────────────────── def _build_sidebar(self) -> None: sidebar = ctk.CTkFrame(self, fg_color=T.TOOLBAR_BG, corner_radius=0, width=180) sidebar.grid(row=0, column=0, sticky="nsew") sidebar.columnconfigure(0, weight=1) sidebar.grid_propagate(False) ctk.CTkLabel(sidebar, text="Search Prints", font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid( row=0, column=0, sticky="w", padx=14, pady=(16, 6)) self._search_var = ctk.StringVar() self._search_var.trace_add("write", lambda *_: self._refresh_table()) search = ctk.CTkEntry(sidebar, textvariable=self._search_var, placeholder_text="Name, notes, tracking…", height=30, font=T.FONT_BODY, fg_color=T.CARD_BG, border_color=T.BORDER, border_width=1, corner_radius=6, text_color=T.TEXT_PRIMARY, placeholder_text_color=T.TEXT_SUBTLE) search.grid(row=1, column=0, sticky="ew", padx=14, pady=(0, 16)) ctk.CTkFrame(sidebar, fg_color=T.DIVIDER, height=1).grid( row=2, column=0, sticky="ew", padx=14) ctk.CTkLabel(sidebar, text="Date Printed", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid( row=3, column=0, sticky="w", padx=14, pady=(12, 4)) self._date_var = ctk.StringVar(value=_DATE_FILTERS[0]) ctk.CTkOptionMenu(sidebar, variable=self._date_var, values=_DATE_FILTERS, height=28, font=T.FONT_BODY, fg_color=T.CARD_BG, button_color=T.ACCENT, button_hover_color=T.ACCENT_HOVER, text_color=T.TEXT_PRIMARY, dropdown_fg_color=T.CARD_BG, dropdown_text_color=T.TEXT_PRIMARY, dropdown_hover_color=T.CARD_BG_HOVER, command=lambda _: self._refresh_table()).grid( row=4, column=0, sticky="ew", padx=14, pady=(0, 10)) ctk.CTkLabel(sidebar, text="Status", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid( row=5, column=0, sticky="w", padx=14, pady=(6, 4)) self._status_var = ctk.StringVar(value=_STATUS_FILTERS[0]) ctk.CTkOptionMenu(sidebar, variable=self._status_var, values=_STATUS_FILTERS, height=28, font=T.FONT_BODY, fg_color=T.CARD_BG, button_color=T.ACCENT, button_hover_color=T.ACCENT_HOVER, text_color=T.TEXT_PRIMARY, dropdown_fg_color=T.CARD_BG, dropdown_text_color=T.TEXT_PRIMARY, dropdown_hover_color=T.CARD_BG_HOVER, command=lambda _: self._refresh_table()).grid( row=6, column=0, sticky="ew", padx=14, pady=(0, 10)) ctk.CTkLabel(sidebar, text="", text_color=T.TEXT_MUTED, font=T.FONT_LABEL).grid(row=7, column=0, sticky="w", padx=14, pady=(6, 4)) # ── Main area ────────────────────────────────────────────────────────── def _build_main(self) -> None: main = ctk.CTkFrame(self, fg_color=T.WINDOW_BG, corner_radius=0) main.grid(row=0, column=1, sticky="nsew") main.columnconfigure(0, weight=1) main.rowconfigure(1, weight=1) # Action toolbar toolbar = ctk.CTkFrame(main, fg_color=T.PANEL_BG, corner_radius=0, height=48, border_color=T.BORDER, border_width=1) toolbar.grid(row=0, column=0, sticky="ew") toolbar.grid_propagate(False) btn_kw = dict( height=32, font=T.FONT_LABEL, corner_radius=6, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, ) # USPS-only Stamps.com actions if self._carrier == "usps": ctk.CTkButton(toolbar, text="Refund", width=90, command=lambda: self._open_stamps_page("online_reporting_refund"), **btn_kw).pack(side="left", padx=(10, 4), pady=8) ctk.CTkButton(toolbar, text="Schedule Pickup", width=130, command=lambda: self._open_stamps_page("online_reporting_pickup"), **btn_kw).pack(side="left", padx=4, pady=8) ctk.CTkButton(toolbar, text="Create SCAN Form", width=140, command=lambda: self._open_stamps_page("online_reporting_scan"), **btn_kw).pack(side="left", padx=4, pady=8) # Right side if self._carrier == "usps": ctk.CTkButton(toolbar, text="View on Stamps.com", width=155, command=lambda: self._open_stamps_page("online_reporting_history"), fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, text_color=T.ACCENT_TEXT, height=32, font=T.FONT_LABEL, corner_radius=6, border_width=1, border_color=T.ACCENT_BORDER).pack( side="right", padx=(4, 10), pady=8) ctk.CTkButton(toolbar, text="↑ Export CSV", width=100, command=self._export_csv, **btn_kw).pack( side="right", padx=4, pady=8) ctk.CTkButton(toolbar, text="Backup…", width=90, command=self._backup_history, **btn_kw).pack( side="right", padx=(4, 0), pady=8) # Count label self._count_lbl = ctk.CTkLabel(toolbar, text="", font=T.FONT_LABEL, text_color=T.TEXT_MUTED) self._count_lbl.pack(side="right", padx=8) # Table container table_frame = ctk.CTkFrame(main, fg_color=T.PANEL_BG, corner_radius=0) table_frame.grid(row=1, column=0, sticky="nsew") table_frame.columnconfigure(0, weight=1) table_frame.rowconfigure(0, weight=1) cols = [c[0] for c in _COLUMNS] self._tree = ttk.Treeview( table_frame, columns=cols, show="headings", selectmode="extended", ) for col_id, col_title, col_w in _COLUMNS: self._tree.heading(col_id, text=col_title, command=lambda c=col_id: self._sort(c)) self._tree.column(col_id, width=col_w, minwidth=60, stretch=(col_id == "name")) vsb = ttk.Scrollbar(table_frame, orient="vertical", command=self._tree.yview) hsb = ttk.Scrollbar(table_frame, orient="horizontal", command=self._tree.xview) self._tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) self._tree.grid(row=0, column=0, sticky="nsew") vsb.grid(row=0, column=1, sticky="ns") hsb.grid(row=1, column=0, sticky="ew") self._tree.tag_configure("voided", foreground=_resolve(T.TEXT_MUTED)) self._tree.tag_configure("delivered",foreground=_resolve(T.NET_COLOR)) def _apply_treeview_style(self) -> None: bg = _resolve(T.PANEL_BG) fg = _resolve(T.TEXT_PRIMARY) muted= _resolve(T.TEXT_MUTED) sel = _resolve(T.ACCENT) hdr = _resolve(T.TOOLBAR_BG) style = ttk.Style(self) style.theme_use("clam") style.configure("Treeview", background=bg, foreground=fg, fieldbackground=bg, borderwidth=0, rowheight=30, font=("Segoe UI", 11), ) style.configure("Treeview.Heading", background=hdr, foreground=muted, relief="flat", font=("Segoe UI", 11, "bold"), padding=(6, 4), ) style.map("Treeview", background=[("selected", sel)], foreground=[("selected", "#ffffff")], ) style.configure("Vertical.TScrollbar", background=hdr, troughcolor=bg, relief="flat") style.configure("Horizontal.TScrollbar", background=hdr, troughcolor=bg, relief="flat") # ── Data ─────────────────────────────────────────────────────────────── def _refresh_table(self) -> None: search = self._search_var.get().strip() status = self._status_var.get() date_f = self._date_var.get() days = {"Today": 1, "Last 7 days": 7, "Last 30 days": 30}.get(date_f) self._entries = self._history.filter(search=search, status=status, days=days, carrier=self._carrier) for row in self._tree.get_children(): self._tree.delete(row) for e in self._entries: amt = f"${e.amount:.2f}" if e.amount is not None else "—" tag = "voided" if e.voided else ("delivered" if e.shipment_status == "Delivered" else "") self._tree.insert("", "end", iid=e.label_id, values=( e.date_printed_display, e.recipient, e.tracking_number or "—", e.service or "—", amt, "Voided" if e.voided else e.shipment_status or "—", e.notes or "—", ), tags=(tag,), ) n = len(self._entries) self._count_lbl.configure( text=f"{n} result{'s' if n != 1 else ''}") def _sort(self, col: str) -> None: items = [(self._tree.set(iid, col), iid) for iid in self._tree.get_children()] items.sort(key=lambda x: x[0]) for idx, (_, iid) in enumerate(items): self._tree.move(iid, "", idx) def _selected_entries(self) -> list[LabelEntry]: sel = self._tree.selection() by_id = {e.label_id: e for e in self._entries} return [by_id[iid] for iid in sel if iid in by_id] # ── Actions ──────────────────────────────────────────────────────────── def _open_stamps_page(self, url_type: str) -> None: """Fetch a single-use authenticated URL from SERA and open it in the browser.""" cfg = self._cfg if not cfg or not getattr(cfg, "stamps_client_id", ""): self._msg("Stamps.com", "Stamps.com credentials are not configured yet.\n\n" "Add stamps_client_id and stamps_refresh_token to config.json " "once your developer application is approved.") return def _run() -> None: try: url = _get_hosted_url(cfg, url_type) self.after(0, lambda: webbrowser.open(url)) except Exception as exc: msg = str(exc) self.after(0, lambda: self._msg("Stamps.com", msg)) threading.Thread(target=_run, daemon=True).start() def _export_csv(self) -> None: if not self._entries: self._msg("Export", "No results to export.") return path = os.path.join( tempfile.gettempdir(), f"shipping_history_{len(self._entries)}_labels.csv", ) with open(path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow([ "Date Printed", "Recipient", "Tracking #", "Service", "Amount", "Status", "Notes", "Date Delivered", ]) for e in self._entries: amt = f"${e.amount:.2f}" if e.amount is not None else "" writer.writerow([ e.date_printed_display, e.recipient, e.tracking_number, e.service, amt, "Voided" if e.voided else e.shipment_status, e.notes, e.date_delivered_display, ]) try: os.startfile(path) except Exception: pass self._msg("Export", f"Exported {len(self._entries)} records.\n\n{path}") def _backup_history(self) -> None: import shutil from tkinter import filedialog src = self._history._path if not src.exists(): self._msg("Backup", "No history file to back up yet.") return dest = filedialog.asksaveasfilename( parent=self, defaultextension=".json", filetypes=[("JSON backup", "*.json"), ("All files", "*.*")], initialfile="label_history_backup.json", title="Save History Backup", ) if dest: shutil.copy2(src, dest) self._msg("Backup", f"Backed up {len(self._entries)} records to:\n{dest}") def _msg(self, title: str, text: str) -> None: from tkinter import messagebox messagebox.showinfo(title, text, parent=self) # ══════════════════════════════════════════════════════════════════════════════ # API helpers # ══════════════════════════════════════════════════════════════════════════════ def _get_hosted_url(cfg, url_type: str) -> str: """Call GET /v1/url to get a single-use authenticated URL for a Stamps hosted page.""" import requests as _r from .ui_shipping_tab import _get_stamps_token token = _get_stamps_token(cfg) base = ("https://api.testing.stampsendicia.com/sera" if cfg.stamps_sandbox else "https://api.stampsendicia.com/sera") resp = _r.get( f"{base}/v1/url", params={"url_type": url_type}, headers={"Authorization": f"Bearer {token}"}, timeout=10, ) if not resp.ok: raise RuntimeError( f"Could not get Stamps.com URL (HTTP {resp.status_code}).") url = resp.json().get("url", "") if not url: raise RuntimeError("Stamps.com returned an empty URL.") return url