1685 lines
69 KiB
Python
1685 lines
69 KiB
Python
"""Shipping tab — split screen: USPS/Stamps (left) and FedEx (right).
|
||
|
||
USPS panel: freeform address block + service/package/weight/notes.
|
||
FedEx panel: individual structured address fields.
|
||
|
||
When Stamps credentials arrive, the USPS panel calls the SERA address-parse
|
||
endpoint to convert the pasted block into individual fields before creating
|
||
the label, so the user never has to type each field separately.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import threading
|
||
import uuid
|
||
|
||
import customtkinter as ctk
|
||
|
||
from . import theme as T
|
||
from .fedex import FedExClient, ShipmentResult
|
||
from .ui_parts_tab import _bind_context_menu, _flash_button
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
# ── USPS shipment options ──────────────────────────────────────────────────
|
||
# (display_name, service_code, package_code, package_display)
|
||
_PKG_OPTIONS: list[tuple] = [
|
||
("Ground Advantage", "usps_ground_advantage", "package", "Package"),
|
||
("Priority Mail", "usps_priority_mail", "package", "Package"),
|
||
("First-Class (Large Env./Flat)", "usps_first_class_mail", "large_envelope", "Large Env./Flat"),
|
||
]
|
||
_ENV_SERVICE_CODE = "usps_first_class_mail"
|
||
_ENV_PACKAGE_CODE = "letter"
|
||
_ENV_SERVICE_NAME = "First-Class Mail"
|
||
_ENV_PACKAGE_NAME = "Letter"
|
||
|
||
# ── FedEx options ──────────────────────────────────────────────────────────
|
||
_FEDEX_PACKAGES = ["FedEx Envelope", "FedEx Pak"]
|
||
_FEDEX_SERVICES = ["FedEx 2Day", "FedEx 2Day AM"]
|
||
|
||
# ── Widget defaults ────────────────────────────────────────────────────────
|
||
_ENTRY_KWARGS = dict(
|
||
height=32,
|
||
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,
|
||
)
|
||
_OPTION_KWARGS = dict(
|
||
height=32,
|
||
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,
|
||
corner_radius=6,
|
||
)
|
||
|
||
|
||
def _lbl(parent, text: str, row: int, col: int = 0, colspan: int = 1) -> None:
|
||
ctk.CTkLabel(
|
||
parent, text=text.upper(),
|
||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED,
|
||
).grid(row=row, column=col, columnspan=colspan, sticky="w",
|
||
padx=(16 if col == 0 else 8, 8), pady=(0, 2))
|
||
|
||
|
||
class _PresetManager:
|
||
"""Persists package dimension presets to appdata/package_presets.json."""
|
||
|
||
_PLACEHOLDER = "— select preset —"
|
||
|
||
def __init__(self) -> None:
|
||
from .paths import appdata_dir
|
||
self._path = appdata_dir() / "package_presets.json"
|
||
self._presets: list[dict] = self._load()
|
||
|
||
def _load(self) -> list[dict]:
|
||
try:
|
||
if self._path.exists():
|
||
return json.loads(self._path.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
def _save(self) -> None:
|
||
self._path.write_text(
|
||
json.dumps(self._presets, indent=2), encoding="utf-8"
|
||
)
|
||
|
||
def dropdown_values(self) -> list[str]:
|
||
names = [p["name"] for p in self._presets]
|
||
return [self._PLACEHOLDER] + names
|
||
|
||
def get(self, name: str) -> dict | None:
|
||
return next((p for p in self._presets if p["name"] == name), None)
|
||
|
||
def save_preset(self, name: str, l: float, w: float, h: float) -> None:
|
||
self._presets = [p for p in self._presets if p["name"] != name]
|
||
self._presets.append({"name": name, "l": l, "w": w, "h": h})
|
||
self._save()
|
||
|
||
def delete(self, name: str) -> None:
|
||
self._presets = [p for p in self._presets if p["name"] != name]
|
||
self._save()
|
||
|
||
|
||
class _PresetPopup(ctk.CTkToplevel):
|
||
"""Borderless popup that lists dimension presets below the Presets button."""
|
||
|
||
def __init__(self, anchor_widget, manager: _PresetManager,
|
||
on_select, on_save, on_delete):
|
||
super().__init__(anchor_widget)
|
||
self._manager = manager
|
||
self._on_select = on_select
|
||
self._on_save = on_save
|
||
self._on_delete = on_delete
|
||
|
||
self.overrideredirect(True)
|
||
self.configure(fg_color=T.CARD_BG)
|
||
self.attributes("-topmost", True)
|
||
|
||
# Position below the anchor button
|
||
self.update_idletasks()
|
||
ax = anchor_widget.winfo_rootx()
|
||
ay = anchor_widget.winfo_rooty() + anchor_widget.winfo_height() + 2
|
||
self.geometry(f"+{ax}+{ay}")
|
||
|
||
self._root_widget = anchor_widget.winfo_toplevel()
|
||
self._bind_id: str | None = None
|
||
self._build()
|
||
# Bind after a short delay so the opening click doesn't immediately close it
|
||
self.after(120, self._bind_outside_click)
|
||
|
||
def _bind_outside_click(self) -> None:
|
||
self._bind_id = self._root_widget.bind(
|
||
"<ButtonPress>", self._on_outside_click, add="+"
|
||
)
|
||
|
||
def _on_outside_click(self, event) -> None:
|
||
try:
|
||
px, py = self.winfo_rootx(), self.winfo_rooty()
|
||
pw, ph = self.winfo_width(), self.winfo_height()
|
||
if not (px <= event.x_root <= px + pw and py <= event.y_root <= py + ph):
|
||
self._close()
|
||
except Exception:
|
||
self._close()
|
||
|
||
def _close(self) -> None:
|
||
try:
|
||
if self._bind_id:
|
||
self._root_widget.unbind("<ButtonPress>", self._bind_id)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.destroy()
|
||
except Exception:
|
||
pass
|
||
|
||
def _build(self) -> None:
|
||
outer = ctk.CTkFrame(self, fg_color=T.CARD_BG,
|
||
border_color=T.BORDER_STRONG, border_width=1,
|
||
corner_radius=6)
|
||
outer.pack(fill="both", expand=True)
|
||
outer.columnconfigure(0, weight=1)
|
||
|
||
presets = self._manager._presets
|
||
if presets:
|
||
for i, p in enumerate(presets):
|
||
dims = f"{p['l']} × {p['w']} × {p['h']}\""
|
||
row = ctk.CTkFrame(outer, fg_color="transparent")
|
||
row.grid(row=i, column=0, sticky="ew", padx=4, pady=(4 if i == 0 else 1, 1))
|
||
row.columnconfigure(0, weight=1)
|
||
|
||
name = p["name"]
|
||
ctk.CTkButton(
|
||
row, text=f"{name} — {dims}", anchor="w",
|
||
font=T.FONT_BODY, height=28,
|
||
fg_color="transparent", hover_color=T.CARD_BG_HOVER,
|
||
text_color=T.TEXT_PRIMARY, corner_radius=4,
|
||
command=lambda n=name: self._select(n),
|
||
).grid(row=0, column=0, sticky="ew")
|
||
|
||
ctk.CTkButton(
|
||
row, text="✕", width=26, height=28,
|
||
font=T.FONT_LABEL,
|
||
fg_color="transparent", hover_color=T.CARD_BG_HOVER,
|
||
text_color=T.TEXT_MUTED, corner_radius=4,
|
||
command=lambda n=name: self._delete(n),
|
||
).grid(row=0, column=1, padx=(2, 0))
|
||
|
||
ctk.CTkFrame(outer, fg_color=T.DIVIDER, height=1).grid(
|
||
row=len(presets), column=0, sticky="ew", padx=8, pady=4)
|
||
save_row = len(presets) + 1
|
||
else:
|
||
ctk.CTkLabel(outer, text="No presets saved yet.",
|
||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid(
|
||
row=0, column=0, padx=12, pady=(10, 4))
|
||
save_row = 1
|
||
|
||
ctk.CTkButton(
|
||
outer, text="Save current dims as preset…", anchor="w",
|
||
font=T.FONT_BODY, height=28,
|
||
fg_color="transparent", hover_color=T.CARD_BG_HOVER,
|
||
text_color=T.ACCENT, corner_radius=4,
|
||
command=self._save,
|
||
).grid(row=save_row, column=0, sticky="ew", padx=4, pady=(0, 4))
|
||
|
||
def _select(self, name: str) -> None:
|
||
self._on_select(name)
|
||
self._close()
|
||
|
||
def _delete(self, name: str) -> None:
|
||
self._on_delete(name)
|
||
self._close()
|
||
|
||
def _save(self) -> None:
|
||
self._close()
|
||
self._on_save()
|
||
|
||
|
||
class _AddressVerifyDialog(ctk.CTkToplevel):
|
||
"""Shows original vs. USPS-corrected address before printing."""
|
||
|
||
def __init__(self, parent, original: str, suggested: str, on_accept):
|
||
super().__init__(parent)
|
||
self._on_accept = on_accept
|
||
self.title("Address Verification")
|
||
self.resizable(False, False)
|
||
self.grab_set()
|
||
self.attributes("-topmost", True)
|
||
|
||
self.update_idletasks()
|
||
w, h = 500, 310
|
||
px = parent.winfo_rootx() + parent.winfo_width() // 2 - w // 2
|
||
py = parent.winfo_rooty() + parent.winfo_height() // 2 - h // 2
|
||
self.geometry(f"{w}x{h}+{px}+{py}")
|
||
|
||
self.columnconfigure(0, weight=1)
|
||
self.columnconfigure(1, weight=1)
|
||
|
||
ctk.CTkLabel(self, text="Address Verification",
|
||
font=T.FONT_BODY_BOLD,
|
||
text_color=T.TEXT_PRIMARY).grid(
|
||
row=0, column=0, columnspan=2, sticky="w", padx=20, pady=(16, 2))
|
||
ctk.CTkLabel(self,
|
||
text="The address was adjusted. Review before printing.",
|
||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid(
|
||
row=1, column=0, columnspan=2, sticky="w", padx=20, pady=(0, 10))
|
||
|
||
for col, label, text, border in [
|
||
(0, "AS ENTERED", original, T.BORDER),
|
||
(1, "USPS SUGGESTED", suggested, T.ACCENT),
|
||
]:
|
||
pad_l = (20, 6) if col == 0 else (6, 20)
|
||
ctk.CTkLabel(self, text=label, font=T.FONT_LABEL,
|
||
text_color=T.TEXT_MUTED if col == 0 else T.ACCENT).grid(
|
||
row=2, column=col, sticky="w",
|
||
padx=pad_l, pady=(0, 3))
|
||
box = ctk.CTkTextbox(self, height=110, font=T.FONT_BODY,
|
||
fg_color=T.CARD_BG, border_color=border,
|
||
border_width=1, text_color=T.TEXT_PRIMARY,
|
||
corner_radius=6)
|
||
box.grid(row=3, column=col, sticky="nsew", padx=pad_l, pady=(0, 12))
|
||
box.insert("1.0", text)
|
||
box.configure(state="disabled")
|
||
|
||
btn_row = ctk.CTkFrame(self, fg_color="transparent")
|
||
btn_row.grid(row=4, column=0, columnspan=2, sticky="ew", padx=20, pady=(0, 16))
|
||
|
||
ctk.CTkButton(
|
||
btn_row, text="Edit Address", width=110, height=32,
|
||
font=T.FONT_LABEL,
|
||
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,
|
||
corner_radius=6, command=self.destroy,
|
||
).pack(side="left")
|
||
|
||
ctk.CTkButton(
|
||
btn_row, text="Accept & Print", width=120, height=32,
|
||
font=T.FONT_LABEL,
|
||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||
text_color=T.ACCENT_TEXT,
|
||
border_width=1, border_color=T.ACCENT_BORDER,
|
||
corner_radius=6, command=self._accept,
|
||
).pack(side="right")
|
||
|
||
def _accept(self) -> None:
|
||
self.destroy()
|
||
self._on_accept()
|
||
|
||
|
||
class _AmbiguousAddressDialog(ctk.CTkToplevel):
|
||
"""Shown when USPS returns multiple candidate addresses.
|
||
|
||
If the user picks a candidate that contains a range like [A - D] or
|
||
[1100 - 1199], an inline input row appears so they can specify the exact
|
||
unit/number before the dialog closes.
|
||
"""
|
||
|
||
_RANGE_RE = re.compile(r'\[([^\]]+)\]')
|
||
|
||
def __init__(self, parent, original: str, candidates: list, on_select):
|
||
super().__init__(parent)
|
||
self._on_select = on_select
|
||
self._pending: dict | None = None
|
||
self._pending_field: str | None = None
|
||
self._pending_pat: str | None = None
|
||
|
||
self.title("Ambiguous Address")
|
||
self.resizable(False, False)
|
||
self.grab_set()
|
||
self.attributes("-topmost", True)
|
||
|
||
w, h = 500, 460
|
||
px = parent.winfo_rootx() + parent.winfo_width() // 2 - w // 2
|
||
py = parent.winfo_rooty() + parent.winfo_height() // 2 - h // 2
|
||
self.geometry(f"{w}x{h}+{px}+{py}")
|
||
|
||
self.columnconfigure(0, weight=1)
|
||
self.rowconfigure(4, weight=1)
|
||
|
||
ctk.CTkLabel(self, text="Ambiguous Address",
|
||
font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid(
|
||
row=0, column=0, sticky="w", padx=20, pady=(16, 2))
|
||
ctk.CTkLabel(self,
|
||
text="Multiple addresses matched. Select the most specific one.",
|
||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid(
|
||
row=1, column=0, sticky="w", padx=20, pady=(0, 8))
|
||
|
||
# Original address strip
|
||
orig = ctk.CTkFrame(self, fg_color=T.CARD_BG,
|
||
border_color=T.BORDER, border_width=1, corner_radius=6)
|
||
orig.grid(row=2, column=0, sticky="ew", padx=20, pady=(0, 10))
|
||
ctk.CTkLabel(orig, text="ORIGINAL", font=T.FONT_LABEL,
|
||
text_color=T.TEXT_MUTED).pack(side="left", padx=(10, 6), pady=7)
|
||
ctk.CTkLabel(orig, text=original.replace("\n", " · "),
|
||
font=T.FONT_BODY, text_color=T.TEXT_PRIMARY,
|
||
wraplength=340, justify="left").pack(side="left", pady=7)
|
||
|
||
ctk.CTkLabel(self, text="POSSIBLE ADDRESSES", font=T.FONT_LABEL,
|
||
text_color=T.TEXT_MUTED).grid(
|
||
row=3, column=0, sticky="w", padx=20, pady=(0, 4))
|
||
|
||
# Scrollable candidate list
|
||
scroll = ctk.CTkScrollableFrame(self, fg_color=T.CARD_BG,
|
||
border_color=T.BORDER, border_width=1,
|
||
corner_radius=6)
|
||
scroll.grid(row=4, column=0, sticky="nsew", padx=20, pady=(0, 8))
|
||
scroll.columnconfigure(0, weight=1)
|
||
|
||
for i, cand in enumerate(candidates):
|
||
parts = [
|
||
" ".join(filter(None, [
|
||
cand.get("name", ""), cand.get("company_name", "")])),
|
||
" ".join(filter(None, [
|
||
cand.get("address_line1", ""), cand.get("address_line2", "")])),
|
||
" ".join(filter(None, [
|
||
cand.get("city", ""), cand.get("state_province", ""),
|
||
cand.get("postal_code", "")])),
|
||
]
|
||
display = "\n".join(p for p in parts if p)
|
||
ctk.CTkButton(
|
||
scroll, text=display, anchor="w",
|
||
font=T.FONT_BODY, height=58,
|
||
fg_color="transparent", hover_color=T.CARD_BG_HOVER,
|
||
text_color=T.TEXT_PRIMARY, corner_radius=4,
|
||
command=lambda c=cand: self._pick(c),
|
||
).grid(row=i * 2, column=0, sticky="ew", padx=4,
|
||
pady=(6 if i == 0 else 0, 0))
|
||
if i < len(candidates) - 1:
|
||
ctk.CTkFrame(scroll, fg_color=T.DIVIDER, height=1).grid(
|
||
row=i * 2 + 1, column=0, sticky="ew", padx=8, pady=3)
|
||
|
||
# Range-input row — hidden until a range candidate is clicked
|
||
self._range_frame = ctk.CTkFrame(self, fg_color=T.CARD_BG,
|
||
border_color=T.ACCENT, border_width=1,
|
||
corner_radius=6)
|
||
self._range_frame.columnconfigure(1, weight=1)
|
||
|
||
self._range_lbl = ctk.CTkLabel(
|
||
self._range_frame, text="Enter value:",
|
||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED)
|
||
self._range_lbl.grid(row=0, column=0, padx=(12, 8), pady=9)
|
||
|
||
self._range_entry = ctk.CTkEntry(
|
||
self._range_frame, height=30,
|
||
font=T.FONT_BODY, fg_color=T.WINDOW_BG,
|
||
border_color=T.BORDER, border_width=1,
|
||
text_color=T.TEXT_PRIMARY, corner_radius=6,
|
||
)
|
||
self._range_entry.grid(row=0, column=1, sticky="ew", padx=(0, 8), pady=9)
|
||
self._range_entry.bind("<Return>", lambda _e: self._confirm_range())
|
||
|
||
ctk.CTkButton(
|
||
self._range_frame, text="Accept", width=80, height=30,
|
||
font=T.FONT_LABEL,
|
||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||
text_color=T.ACCENT_TEXT,
|
||
border_width=1, border_color=T.ACCENT_BORDER,
|
||
corner_radius=6, command=self._confirm_range,
|
||
).grid(row=0, column=2, padx=(0, 10), pady=9)
|
||
|
||
# Cancel
|
||
btns = ctk.CTkFrame(self, fg_color="transparent")
|
||
btns.grid(row=6, column=0, sticky="ew", padx=20, pady=(4, 16))
|
||
ctk.CTkButton(
|
||
btns, text="Cancel", width=90, height=32,
|
||
font=T.FONT_LABEL,
|
||
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,
|
||
corner_radius=6, command=self.destroy,
|
||
).pack(side="left")
|
||
|
||
def _pick(self, candidate: dict) -> None:
|
||
for field in ("address_line1", "address_line2"):
|
||
m = self._RANGE_RE.search(candidate.get(field, ""))
|
||
if m:
|
||
self._pending = candidate
|
||
self._pending_field = field
|
||
self._pending_pat = m.group(0)
|
||
hint = m.group(1).strip() # e.g. "A - D" or "1100 - 1199"
|
||
self._range_lbl.configure(
|
||
text=f"Enter specific value ({hint}):")
|
||
self._range_entry.delete(0, "end")
|
||
self._range_frame.grid(row=5, column=0, sticky="ew",
|
||
padx=20, pady=(0, 4))
|
||
self._range_entry.focus_set()
|
||
return
|
||
# No range — proceed immediately
|
||
self.destroy()
|
||
self._on_select(candidate)
|
||
|
||
def _confirm_range(self) -> None:
|
||
value = self._range_entry.get().strip()
|
||
if not value:
|
||
self._range_entry.focus_set()
|
||
return
|
||
candidate = dict(self._pending)
|
||
original_val = candidate.get(self._pending_field, "")
|
||
candidate[self._pending_field] = re.sub(
|
||
r'\[[^\]]+\]', value, original_val)
|
||
self.destroy()
|
||
self._on_select(candidate)
|
||
|
||
|
||
class _ShipmentConfirmDialog(ctk.CTkToplevel):
|
||
"""Review shipment details before committing to label creation and postage charge."""
|
||
|
||
def __init__(self, parent, parsed: dict, data: dict,
|
||
rate_text: str, on_confirm):
|
||
super().__init__(parent)
|
||
self._on_confirm = on_confirm
|
||
self.title("Confirm Shipment")
|
||
self.resizable(False, False)
|
||
self.grab_set()
|
||
self.attributes("-topmost", True)
|
||
self.bind("<Return>", lambda _e: self._confirm())
|
||
|
||
w = 420
|
||
self.columnconfigure(0, weight=1)
|
||
|
||
ctk.CTkLabel(self, text="Confirm Shipment", font=T.FONT_BODY_BOLD,
|
||
text_color=T.TEXT_PRIMARY).grid(
|
||
row=0, column=0, sticky="w", padx=20, pady=(16, 2))
|
||
ctk.CTkLabel(self, text="Postage will be charged once you confirm.",
|
||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid(
|
||
row=1, column=0, sticky="w", padx=20, pady=(0, 10))
|
||
|
||
detail = ctk.CTkFrame(self, fg_color=T.CARD_BG,
|
||
border_color=T.BORDER, border_width=1,
|
||
corner_radius=6)
|
||
detail.grid(row=2, column=0, sticky="ew", padx=20, pady=(0, 12))
|
||
detail.columnconfigure(1, weight=1)
|
||
|
||
addr_str = "\n".join(filter(None, [
|
||
parsed.get("name", ""),
|
||
parsed.get("company_name", ""),
|
||
parsed.get("address_line1", ""),
|
||
parsed.get("address_line2", ""),
|
||
" ".join(filter(None, [
|
||
parsed.get("city", ""),
|
||
parsed.get("state_province", ""),
|
||
parsed.get("postal_code", ""),
|
||
])),
|
||
]))
|
||
|
||
lbs = data["weight_oz"] // 16
|
||
oz_ = data["weight_oz"] % 16
|
||
wt_str = (f"{lbs} lb {oz_} oz" if lbs and oz_
|
||
else f"{lbs} lb" if lbs
|
||
else f"{oz_} oz")
|
||
|
||
rows = [
|
||
("TO", addr_str),
|
||
("SERVICE", data.get("service_name", data.get("service_code", ""))),
|
||
("PACKAGE", data.get("package_name", data.get("package_code", ""))),
|
||
("WEIGHT", wt_str),
|
||
]
|
||
if rate_text and rate_text not in ("—", "…", ""):
|
||
rows.append(("EST. COST", rate_text))
|
||
|
||
for i, (label, value) in enumerate(rows):
|
||
top = 12 if i == 0 else 6
|
||
bot = 12 if i == len(rows) - 1 else 0
|
||
ctk.CTkLabel(detail, text=label, font=T.FONT_LABEL,
|
||
text_color=T.TEXT_MUTED, anchor="nw").grid(
|
||
row=i, column=0, sticky="nw", padx=(14, 8), pady=(top, bot))
|
||
ctk.CTkLabel(detail, text=value, font=T.FONT_BODY,
|
||
text_color=T.TEXT_PRIMARY, anchor="w",
|
||
justify="left", wraplength=260).grid(
|
||
row=i, column=1, sticky="w", padx=(0, 14), pady=(top, bot))
|
||
|
||
btns = ctk.CTkFrame(self, fg_color="transparent")
|
||
btns.grid(row=3, column=0, sticky="ew", padx=20, pady=(0, 16))
|
||
|
||
ctk.CTkButton(
|
||
btns, text="Cancel", width=90, height=32,
|
||
font=T.FONT_LABEL,
|
||
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,
|
||
corner_radius=6, command=self.destroy,
|
||
).pack(side="left")
|
||
|
||
ctk.CTkButton(
|
||
btns, text="Confirm & Create", width=150, height=32,
|
||
font=T.FONT_LABEL,
|
||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||
text_color=T.ACCENT_TEXT,
|
||
border_width=1, border_color=T.ACCENT_BORDER,
|
||
corner_radius=6, command=self._confirm,
|
||
).pack(side="right")
|
||
|
||
self.update_idletasks()
|
||
h = self.winfo_reqheight()
|
||
px = parent.winfo_rootx() + parent.winfo_width() // 2 - w // 2
|
||
py = parent.winfo_rooty() + parent.winfo_height() // 2 - h // 2
|
||
self.geometry(f"{w}x{h}+{px}+{py}")
|
||
|
||
def _confirm(self) -> None:
|
||
self.destroy()
|
||
self._on_confirm()
|
||
|
||
|
||
def _card(parent, **grid_kwargs) -> ctk.CTkFrame:
|
||
f = ctk.CTkFrame(
|
||
parent, fg_color=T.PANEL_BG,
|
||
border_color=T.BORDER, border_width=1, corner_radius=8,
|
||
)
|
||
f.grid(**grid_kwargs)
|
||
return f
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# Main tab — split screen
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class ShippingTab(ctk.CTkFrame):
|
||
def __init__(self, master, cfg=None):
|
||
super().__init__(master, fg_color="transparent")
|
||
self.columnconfigure(0, weight=1)
|
||
self.columnconfigure(1, weight=0)
|
||
self.columnconfigure(2, weight=1)
|
||
self.rowconfigure(0, weight=1)
|
||
|
||
_UspsPanel(self, cfg=cfg).grid(row=0, column=0, sticky="nsew")
|
||
|
||
ctk.CTkFrame(self, fg_color=T.BORDER, width=1,
|
||
corner_radius=0).grid(row=0, column=1, sticky="ns")
|
||
|
||
_FedExPanel(self, cfg=cfg).grid(row=0, column=2, sticky="nsew")
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# USPS / Stamps panel — address block input
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class _UspsPanel(ctk.CTkScrollableFrame):
|
||
def __init__(self, master, cfg=None):
|
||
super().__init__(master, fg_color="transparent", corner_radius=0)
|
||
self._cfg = cfg
|
||
self.columnconfigure(0, weight=1)
|
||
self._build()
|
||
|
||
def _build(self) -> None:
|
||
# Header
|
||
hdr = ctk.CTkFrame(self, fg_color="transparent")
|
||
hdr.grid(row=0, column=0, sticky="ew", padx=20, pady=(18, 0))
|
||
ctk.CTkLabel(hdr, text="USPS", font=T.FONT_HEADER,
|
||
text_color=T.TEXT_PRIMARY).pack(side="left")
|
||
ctk.CTkLabel(hdr, text="via ShipEngine", font=T.FONT_BODY,
|
||
text_color=T.TEXT_MUTED).pack(side="left", padx=(8, 0), pady=(4, 0))
|
||
ctk.CTkButton(
|
||
hdr, text="History", width=80, height=28,
|
||
font=T.FONT_LABEL,
|
||
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,
|
||
corner_radius=6, command=self._open_history,
|
||
).pack(side="right")
|
||
|
||
# Shipment options — compact card
|
||
opts = _card(self, row=1, column=0, sticky="ew", padx=20, pady=(12, 0))
|
||
opts.columnconfigure(1, weight=1) # right side of each inline row expands
|
||
|
||
ctk.CTkLabel(opts, text="Shipment Options", font=T.FONT_BODY_BOLD,
|
||
text_color=T.TEXT_PRIMARY).grid(
|
||
row=0, column=0, columnspan=2, sticky="w", padx=16, pady=(12, 8))
|
||
|
||
# ── Package / Envelope mode toggle ───────────────────────────────
|
||
self._mode_var = ctk.StringVar(value="Package")
|
||
ctk.CTkSegmentedButton(
|
||
opts, values=["Package", "Envelope"],
|
||
variable=self._mode_var,
|
||
command=self._toggle_mode,
|
||
font=T.FONT_LABEL,
|
||
fg_color=T.CARD_BG,
|
||
selected_color=T.ACCENT,
|
||
selected_hover_color=T.ACCENT_HOVER,
|
||
unselected_color=T.NEUTRAL_BTN_BG,
|
||
unselected_hover_color=T.NEUTRAL_BTN_HOVER,
|
||
text_color=T.TEXT_PRIMARY,
|
||
).grid(row=1, column=0, columnspan=2, sticky="ew", padx=16, pady=(0, 8))
|
||
|
||
ctk.CTkFrame(opts, fg_color=T.DIVIDER, height=1).grid(
|
||
row=2, column=0, columnspan=2, sticky="ew", padx=16, pady=(0, 8))
|
||
|
||
# ── Package service radios (Package mode) ─────────────────────────
|
||
self._pkg_svc_var = ctk.StringVar(value="0")
|
||
self._pkg_svc_frame = ctk.CTkFrame(opts, fg_color="transparent")
|
||
self._pkg_svc_frame.grid(row=3, column=0, columnspan=2, sticky="ew",
|
||
padx=16, pady=(0, 8))
|
||
for i, (name, _, _, _) in enumerate(_PKG_OPTIONS):
|
||
ctk.CTkRadioButton(
|
||
self._pkg_svc_frame, text=name,
|
||
variable=self._pkg_svc_var, value=str(i),
|
||
font=T.FONT_BODY, text_color=T.TEXT_PRIMARY,
|
||
radiobutton_width=18, radiobutton_height=18,
|
||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||
border_color=T.BORDER_STRONG,
|
||
command=self._schedule_rate_fetch,
|
||
).grid(row=i, column=0, sticky="w", pady=3)
|
||
|
||
# ── Envelope service label (Envelope mode) ────────────────────────
|
||
self._env_svc_frame = ctk.CTkFrame(opts, fg_color="transparent")
|
||
self._env_svc_frame.grid(row=3, column=0, columnspan=2, sticky="ew",
|
||
padx=16, pady=(4, 8))
|
||
ctk.CTkLabel(self._env_svc_frame,
|
||
text="First-Class Mail · Letter",
|
||
font=T.FONT_BODY, text_color=T.TEXT_PRIMARY).pack(
|
||
side="left")
|
||
self._env_svc_frame.grid_remove() # hidden until Envelope mode selected
|
||
|
||
ctk.CTkFrame(opts, fg_color=T.DIVIDER, height=1).grid(
|
||
row=4, column=0, columnspan=2, sticky="ew", padx=16, pady=(0, 8))
|
||
|
||
# ── Weight — single inline row ────────────────────────────────────
|
||
wt_row = ctk.CTkFrame(opts, fg_color="transparent")
|
||
wt_row.grid(row=5, column=0, columnspan=2, sticky="ew", padx=16, pady=(0, 6))
|
||
|
||
ctk.CTkLabel(wt_row, text="Weight", font=T.FONT_LABEL,
|
||
text_color=T.TEXT_MUTED, width=62, anchor="w").pack(side="left")
|
||
|
||
_e_kw = {**_ENTRY_KWARGS, "height": 28}
|
||
self._lbs = ctk.CTkEntry(wt_row, width=52, **_e_kw, placeholder_text="0")
|
||
self._lbs.pack(side="left")
|
||
_bind_context_menu(self._lbs)
|
||
ctk.CTkLabel(wt_row, text="lbs", font=T.FONT_LABEL,
|
||
text_color=T.TEXT_MUTED).pack(side="left", padx=(4, 10))
|
||
|
||
self._oz = ctk.CTkEntry(wt_row, width=52, **_e_kw, placeholder_text="0")
|
||
self._oz.pack(side="left")
|
||
_bind_context_menu(self._oz)
|
||
ctk.CTkLabel(wt_row, text="oz", font=T.FONT_LABEL,
|
||
text_color=T.TEXT_MUTED).pack(side="left", padx=(4, 10))
|
||
|
||
self._weigh_btn = ctk.CTkButton(
|
||
wt_row, text="Weigh", width=60, height=28,
|
||
font=T.FONT_LABEL,
|
||
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,
|
||
corner_radius=6, command=self._weigh_once,
|
||
)
|
||
self._weigh_btn.pack(side="left")
|
||
|
||
self._auto_on = False
|
||
self._auto_btn = ctk.CTkButton(
|
||
wt_row, text="Auto", width=56, height=28,
|
||
font=T.FONT_LABEL,
|
||
fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER,
|
||
text_color=T.TEXT_MUTED,
|
||
border_width=1, border_color=T.NEUTRAL_BTN_BORDER,
|
||
corner_radius=6, command=self._toggle_auto,
|
||
)
|
||
self._auto_btn.pack(side="left", padx=(6, 0))
|
||
|
||
from .scale import ScalePoller
|
||
self._poller = ScalePoller(on_weight=self._on_scale_weight)
|
||
|
||
# ── Dimensions — single inline row with preset picker ─────────────
|
||
self._dim_row = ctk.CTkFrame(opts, fg_color="transparent")
|
||
self._dim_row.grid(row=6, column=0, columnspan=2, sticky="ew",
|
||
padx=16, pady=(0, 12))
|
||
dim_row = self._dim_row
|
||
|
||
ctk.CTkLabel(dim_row, text="Dims", font=T.FONT_LABEL,
|
||
text_color=T.TEXT_MUTED, width=62, anchor="w").pack(side="left")
|
||
|
||
self._dim_l = ctk.CTkEntry(dim_row, width=52, **_e_kw, placeholder_text="0")
|
||
self._dim_l.pack(side="left")
|
||
_bind_context_menu(self._dim_l)
|
||
ctk.CTkLabel(dim_row, text="L", font=T.FONT_LABEL,
|
||
text_color=T.TEXT_MUTED).pack(side="left", padx=(3, 8))
|
||
|
||
self._dim_w = ctk.CTkEntry(dim_row, width=52, **_e_kw, placeholder_text="0")
|
||
self._dim_w.pack(side="left")
|
||
_bind_context_menu(self._dim_w)
|
||
ctk.CTkLabel(dim_row, text="W", font=T.FONT_LABEL,
|
||
text_color=T.TEXT_MUTED).pack(side="left", padx=(3, 8))
|
||
|
||
self._dim_h = ctk.CTkEntry(dim_row, width=52, **_e_kw, placeholder_text="0")
|
||
self._dim_h.pack(side="left")
|
||
_bind_context_menu(self._dim_h)
|
||
ctk.CTkLabel(dim_row, text="H", font=T.FONT_LABEL,
|
||
text_color=T.TEXT_MUTED).pack(side="left", padx=(3, 8))
|
||
|
||
self._presets = _PresetManager()
|
||
self._preset_btn = ctk.CTkButton(
|
||
dim_row, text="Presets ▾", width=90, height=28,
|
||
font=T.FONT_LABEL,
|
||
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,
|
||
corner_radius=6, command=self._open_preset_popup,
|
||
)
|
||
self._preset_btn.pack(side="left")
|
||
|
||
# Delivery Address card
|
||
addr = _card(self, row=2, column=0, sticky="ew", padx=20, pady=(10, 0))
|
||
addr.columnconfigure(0, weight=1)
|
||
|
||
# Header row: title left, Clear button right
|
||
addr_hdr = ctk.CTkFrame(addr, fg_color="transparent")
|
||
addr_hdr.grid(row=0, column=0, sticky="ew", padx=16, pady=(12, 4))
|
||
addr_hdr.columnconfigure(0, weight=1)
|
||
|
||
ctk.CTkLabel(addr_hdr, text="Delivery Address", font=T.FONT_BODY_BOLD,
|
||
text_color=T.TEXT_PRIMARY).grid(row=0, column=0, sticky="w")
|
||
|
||
ctk.CTkButton(
|
||
addr_hdr, text="✕ Clear", width=76, height=26,
|
||
font=T.FONT_LABEL,
|
||
fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER,
|
||
text_color=T.TEXT_MUTED,
|
||
border_width=1, border_color=T.NEUTRAL_BTN_BORDER,
|
||
corner_radius=6,
|
||
command=lambda: self._address_block.delete("1.0", "end"),
|
||
).grid(row=0, column=1, sticky="e")
|
||
|
||
self._address_block = ctk.CTkTextbox(
|
||
addr, height=100,
|
||
font=T.FONT_BODY, fg_color=T.CARD_BG,
|
||
border_color=T.BORDER, border_width=1,
|
||
text_color=T.TEXT_PRIMARY, corner_radius=6,
|
||
)
|
||
self._address_block.grid(row=1, column=0, sticky="ew",
|
||
padx=16, pady=(0, 8))
|
||
_bind_context_menu(self._address_block)
|
||
|
||
# Email Recipient row
|
||
email_row = ctk.CTkFrame(addr, fg_color="transparent")
|
||
email_row.grid(row=2, column=0, sticky="ew", padx=16, pady=(0, 12))
|
||
email_row.columnconfigure(1, weight=1)
|
||
|
||
self._email_var = ctk.BooleanVar(value=True)
|
||
ctk.CTkCheckBox(
|
||
email_row, text="Email Recipient:", variable=self._email_var,
|
||
font=T.FONT_LABEL, text_color=T.TEXT_PRIMARY,
|
||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||
border_color=T.BORDER_STRONG,
|
||
checkmark_color=T.ACCENT_TEXT,
|
||
width=16, height=16,
|
||
command=self._toggle_email_entry,
|
||
).grid(row=0, column=0, sticky="w", padx=(0, 8))
|
||
|
||
self._email_entry = ctk.CTkEntry(
|
||
email_row, **{**_ENTRY_KWARGS, "height": 28},
|
||
placeholder_text="customer@example.com",
|
||
)
|
||
self._email_entry.grid(row=0, column=1, sticky="ew")
|
||
_bind_context_menu(self._email_entry)
|
||
|
||
# Notes
|
||
notes = _card(self, row=3, column=0, sticky="ew", padx=20, pady=(10, 0))
|
||
notes.columnconfigure(0, weight=1)
|
||
|
||
ctk.CTkLabel(notes, text="Notes / Invoice Numbers",
|
||
font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid(
|
||
row=0, column=0, sticky="w", padx=16, pady=(14, 6))
|
||
|
||
self._notes = ctk.CTkTextbox(
|
||
notes, height=72,
|
||
font=T.FONT_BODY, fg_color=T.CARD_BG,
|
||
border_color=T.BORDER, border_width=1,
|
||
text_color=T.TEXT_PRIMARY, corner_radius=6,
|
||
)
|
||
self._notes.grid(row=1, column=0, sticky="ew", padx=16, pady=(0, 8))
|
||
_bind_context_menu(self._notes)
|
||
|
||
ctk.CTkFrame(notes, fg_color=T.DIVIDER, height=1).grid(
|
||
row=2, column=0, sticky="ew", padx=16, pady=(0, 8))
|
||
|
||
ctk.CTkLabel(notes, text="Print Message",
|
||
font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid(
|
||
row=3, column=0, sticky="w", padx=16, pady=(0, 4))
|
||
ctk.CTkLabel(notes, text="Appears on the label (leave blank if unused)",
|
||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid(
|
||
row=4, column=0, sticky="w", padx=16, pady=(0, 4))
|
||
|
||
self._print_msg = ctk.CTkEntry(
|
||
notes, **{**_ENTRY_KWARGS, "height": 28},
|
||
placeholder_text="e.g. Invoice #12345",
|
||
)
|
||
self._print_msg.grid(row=5, column=0, sticky="ew", padx=16, pady=(0, 14))
|
||
_bind_context_menu(self._print_msg)
|
||
|
||
# Actions
|
||
actions = ctk.CTkFrame(self, fg_color="transparent")
|
||
actions.grid(row=4, column=0, sticky="ew", padx=20, pady=(12, 0))
|
||
|
||
self._clear_btn = ctk.CTkButton(
|
||
actions, text="Clear", width=80, height=34,
|
||
font=T.FONT_LABEL,
|
||
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,
|
||
corner_radius=6, command=self._clear,
|
||
)
|
||
self._clear_btn.pack(side="left")
|
||
|
||
self._ship_btn = ctk.CTkButton(
|
||
actions, text="Create Label", width=160, height=34,
|
||
font=T.FONT_LABEL,
|
||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||
text_color=T.ACCENT_TEXT,
|
||
border_width=1, border_color=T.ACCENT_BORDER,
|
||
corner_radius=6, command=self._on_create,
|
||
)
|
||
self._ship_btn.pack(side="right")
|
||
|
||
self._rate_label = ctk.CTkLabel(
|
||
actions, text="—",
|
||
font=T.FONT_BODY_BOLD, text_color=T.TEXT_MUTED,
|
||
)
|
||
self._rate_label.pack(side="right", padx=(0, 12))
|
||
|
||
# Wire rate-fetch triggers
|
||
self._rate_timer: str | None = None
|
||
self._pkg_svc_var.trace_add("write", lambda *_: self._schedule_rate_fetch())
|
||
for entry in (self._lbs, self._oz, self._dim_l, self._dim_w, self._dim_h):
|
||
entry.bind("<KeyRelease>", lambda _e: self._schedule_rate_fetch())
|
||
self._address_block.bind("<KeyRelease>", lambda _e: self._schedule_rate_fetch())
|
||
|
||
# Result
|
||
self._result = _result_card(self, "USPS Label Created")
|
||
self._result.grid(row=5, column=0, sticky="ew", padx=20, pady=(12, 20))
|
||
self._result.grid_remove()
|
||
|
||
# ── Actions ────────────────────────────────────────────────────────────
|
||
|
||
def _toggle_email_entry(self) -> None:
|
||
state = "normal" if self._email_var.get() else "disabled"
|
||
self._email_entry.configure(state=state)
|
||
|
||
def _toggle_mode(self, _value: str = "") -> None:
|
||
is_pkg = self._mode_var.get() == "Package"
|
||
if is_pkg:
|
||
self._env_svc_frame.grid_remove()
|
||
self._pkg_svc_frame.grid()
|
||
self._dim_row.grid()
|
||
else:
|
||
self._pkg_svc_frame.grid_remove()
|
||
self._env_svc_frame.grid()
|
||
self._dim_row.grid_remove()
|
||
self._schedule_rate_fetch()
|
||
|
||
def _get_shipment_type(self) -> tuple:
|
||
"""Returns (service_code, package_code, service_name, package_name)."""
|
||
if self._mode_var.get() == "Envelope":
|
||
return (_ENV_SERVICE_CODE, _ENV_PACKAGE_CODE,
|
||
_ENV_SERVICE_NAME, _ENV_PACKAGE_NAME)
|
||
display, svc, pkg, pkg_display = _PKG_OPTIONS[int(self._pkg_svc_var.get())]
|
||
return (svc, pkg, display, pkg_display)
|
||
|
||
# ── Scale ──────────────────────────────────────────────────────────────
|
||
|
||
def _on_scale_weight(self, oz: float) -> None:
|
||
"""Called from background thread — schedule UI update on main thread."""
|
||
self.after(0, lambda: self._apply_weight(oz))
|
||
|
||
def _apply_weight(self, oz: float) -> None:
|
||
lbs = int(oz // 16)
|
||
rem = round(oz % 16, 1)
|
||
self._lbs.delete(0, "end")
|
||
self._oz.delete(0, "end")
|
||
if lbs:
|
||
self._lbs.insert(0, str(lbs))
|
||
self._oz.insert(0, str(rem) if rem != int(rem) else str(int(rem)))
|
||
|
||
def _weigh_once(self) -> None:
|
||
self._weigh_btn.configure(text="…", state="disabled")
|
||
|
||
def _read():
|
||
oz = self._poller.read_once()
|
||
self.after(0, lambda: self._weigh_done(oz))
|
||
|
||
threading.Thread(target=_read, daemon=True).start()
|
||
|
||
def _weigh_done(self, oz: float | None) -> None:
|
||
self._weigh_btn.configure(text="Weigh", state="normal")
|
||
if oz is None:
|
||
_show_error(self, "Scale", "No scale found or no stable reading.\n"
|
||
"Make sure the USB scale is connected and the hid package is installed.")
|
||
return
|
||
self._apply_weight(oz)
|
||
|
||
def _toggle_auto(self) -> None:
|
||
if self._auto_on:
|
||
self._poller.stop()
|
||
self._auto_on = False
|
||
self._auto_btn.configure(
|
||
fg_color=T.NEUTRAL_BTN_BG, text_color=T.TEXT_MUTED,
|
||
border_color=T.NEUTRAL_BTN_BORDER,
|
||
)
|
||
else:
|
||
started = self._poller.start()
|
||
if not started:
|
||
_show_error(self, "Scale", "No USB scale found.\n"
|
||
"Make sure the scale is connected and the hid package is installed.")
|
||
return
|
||
self._auto_on = True
|
||
self._auto_btn.configure(
|
||
fg_color=T.ACCENT, text_color=T.ACCENT_TEXT,
|
||
border_color=T.ACCENT_BORDER,
|
||
)
|
||
|
||
# ── Rate fetch ─────────────────────────────────────────────────────────
|
||
|
||
def _schedule_rate_fetch(self) -> None:
|
||
if self._rate_timer:
|
||
self.after_cancel(self._rate_timer)
|
||
self._rate_timer = self.after(600, self._fetch_rate)
|
||
|
||
def _fetch_rate(self) -> None:
|
||
self._rate_timer = None
|
||
cfg = self._cfg
|
||
if not cfg or not getattr(cfg, "shipengine_api_key", ""):
|
||
return # credentials not set up yet — stay silent
|
||
|
||
to_zip = self._parse_zip()
|
||
if not to_zip:
|
||
return
|
||
|
||
try:
|
||
lbs = int(self._lbs.get().strip() or 0)
|
||
oz = int(self._oz.get().strip() or 0)
|
||
except ValueError:
|
||
return
|
||
weight_oz = lbs * 16 + oz
|
||
if weight_oz <= 0:
|
||
return
|
||
|
||
try:
|
||
dim_l = float(self._dim_l.get().strip() or 0)
|
||
dim_w = float(self._dim_w.get().strip() or 0)
|
||
dim_h = float(self._dim_h.get().strip() or 0)
|
||
except ValueError:
|
||
dim_l = dim_w = dim_h = 0.0
|
||
|
||
service_code, package_code, _, _ = self._get_shipment_type()
|
||
|
||
self._rate_label.configure(text="…", text_color=T.TEXT_MUTED)
|
||
|
||
def _run() -> None:
|
||
price = _fetch_usps_rate(
|
||
cfg, to_zip, weight_oz, service_code, package_code,
|
||
dim_l, dim_w, dim_h,
|
||
)
|
||
self.after(0, lambda: self._apply_rate(price))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _apply_rate(self, price: float | None) -> None:
|
||
if price is None:
|
||
self._rate_label.configure(text="—", text_color=T.TEXT_MUTED)
|
||
else:
|
||
self._rate_label.configure(
|
||
text=f"${price:.2f}", text_color=T.NET_COLOR)
|
||
|
||
def _parse_zip(self) -> str | None:
|
||
import re
|
||
text = self._address_block.get("1.0", "end")
|
||
matches = re.findall(r'\b(\d{5})(?:-\d{4})?\b', text)
|
||
return matches[-1] if matches else None
|
||
|
||
def _open_preset_popup(self) -> None:
|
||
if hasattr(self, "_popup") and self._popup.winfo_exists():
|
||
self._popup.destroy()
|
||
return
|
||
self._popup = _PresetPopup(self._preset_btn, self._presets,
|
||
on_select=self._apply_preset,
|
||
on_save=self._save_preset,
|
||
on_delete=self._presets.delete)
|
||
|
||
def _apply_preset(self, name: str) -> None:
|
||
p = self._presets.get(name)
|
||
if not p:
|
||
return
|
||
for entry, key in [(self._dim_l, "l"), (self._dim_w, "w"), (self._dim_h, "h")]:
|
||
entry.delete(0, "end")
|
||
entry.insert(0, str(p[key]))
|
||
|
||
def _save_preset(self) -> None:
|
||
l, w, h = (self._dim_l.get().strip(),
|
||
self._dim_w.get().strip(),
|
||
self._dim_h.get().strip())
|
||
if not l or not w or not h:
|
||
_show_error(self, "Save Preset", "Enter all three dimensions first.")
|
||
return
|
||
try:
|
||
l_f, w_f, h_f = float(l), float(w), float(h)
|
||
except ValueError:
|
||
_show_error(self, "Save Preset", "Dimensions must be numbers.")
|
||
return
|
||
dialog = ctk.CTkInputDialog(text="Name this preset:", title="Save Preset")
|
||
name = (dialog.get_input() or "").strip()
|
||
if name:
|
||
self._presets.save_preset(name, l_f, w_f, h_f)
|
||
|
||
def _on_create(self) -> None:
|
||
cfg = self._cfg
|
||
if not cfg or not getattr(cfg, "shipengine_api_key", ""):
|
||
_show_error(self, "USPS Label",
|
||
"ShipEngine API key is not configured.\n\n"
|
||
"Add shipengine_api_key to config.json.")
|
||
return
|
||
|
||
address = self._address_block.get("1.0", "end").strip()
|
||
if not address:
|
||
_show_error(self, "USPS Label", "Please paste a shipping address.")
|
||
return
|
||
|
||
try:
|
||
lbs = int(self._lbs.get().strip() or 0)
|
||
oz = int(self._oz.get().strip() or 0)
|
||
except ValueError:
|
||
lbs = oz = 0
|
||
if lbs * 16 + oz <= 0:
|
||
_show_error(self, "USPS Label", "Please enter a package weight.")
|
||
return
|
||
|
||
try:
|
||
dim_l = float(self._dim_l.get().strip() or 0)
|
||
dim_w = float(self._dim_w.get().strip() or 0)
|
||
dim_h = float(self._dim_h.get().strip() or 0)
|
||
except ValueError:
|
||
_show_error(self, "USPS Label", "Dimensions must be numbers.")
|
||
return
|
||
|
||
email = ""
|
||
if self._email_var.get():
|
||
email = self._email_entry.get().strip()
|
||
|
||
svc_code, pkg_code, svc_name, pkg_name = self._get_shipment_type()
|
||
data = {
|
||
"address_block": address,
|
||
"service_code": svc_code,
|
||
"service_name": svc_name,
|
||
"package_code": pkg_code,
|
||
"package_name": pkg_name,
|
||
"weight_oz": lbs * 16 + oz,
|
||
"dim_l": dim_l,
|
||
"dim_w": dim_w,
|
||
"dim_h": dim_h,
|
||
"email": email,
|
||
"notes": self._notes.get("1.0", "end").strip(),
|
||
"print_message": self._print_msg.get().strip(),
|
||
}
|
||
|
||
self._result.grid_remove()
|
||
self._set_busy(True, "Validating address…")
|
||
|
||
def _parse():
|
||
try:
|
||
parsed = _parse_usps_address(cfg, address)
|
||
self.after(0, lambda: self._after_parse(parsed, data))
|
||
except Exception as exc:
|
||
msg = str(exc)
|
||
self.after(0, lambda: self._on_error(msg))
|
||
|
||
threading.Thread(target=_parse, daemon=True).start()
|
||
|
||
def _after_parse(self, parsed: dict, data: dict) -> None:
|
||
# Multiple candidates → let user pick the specific delivery point
|
||
candidates = parsed.get("candidates") or parsed.get("addresses") or []
|
||
if isinstance(candidates, list) and len(candidates) > 1:
|
||
self._set_busy(False)
|
||
_AmbiguousAddressDialog(
|
||
self,
|
||
original=data["address_block"],
|
||
candidates=candidates,
|
||
on_select=lambda chosen: self._show_confirm(data, chosen),
|
||
)
|
||
return
|
||
|
||
# Single corrected address → confirm the change
|
||
if _address_changed(data["address_block"], parsed):
|
||
self._set_busy(False)
|
||
suggested = "\n".join(filter(None, [
|
||
parsed.get("name", ""),
|
||
parsed.get("company_name", ""),
|
||
parsed.get("address_line1", ""),
|
||
parsed.get("address_line2", ""),
|
||
" ".join(filter(None, [
|
||
parsed.get("city", ""),
|
||
parsed.get("state_province", ""),
|
||
parsed.get("postal_code", ""),
|
||
])),
|
||
]))
|
||
_AddressVerifyDialog(
|
||
self,
|
||
original=data["address_block"],
|
||
suggested=suggested,
|
||
on_accept=lambda: self._show_confirm(data, parsed),
|
||
)
|
||
return
|
||
|
||
self._show_confirm(data, parsed)
|
||
|
||
def _show_confirm(self, data: dict, parsed: dict) -> None:
|
||
self._set_busy(False)
|
||
rate_text = self._rate_label.cget("text")
|
||
_ShipmentConfirmDialog(
|
||
self,
|
||
parsed=parsed,
|
||
data=data,
|
||
rate_text=rate_text,
|
||
on_confirm=lambda: self._do_create_label(data, parsed),
|
||
)
|
||
|
||
def _do_create_label(self, data: dict, parsed: dict) -> None:
|
||
self._set_busy(True)
|
||
cfg = self._cfg
|
||
|
||
def _run():
|
||
try:
|
||
result = _create_usps_label(cfg, data, parsed_addr=parsed)
|
||
self.after(0, lambda: self._on_success(result))
|
||
except Exception as exc:
|
||
msg = str(exc)
|
||
log.exception("USPS label failed")
|
||
self.after(0, lambda: self._on_error(msg))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _open_history(self) -> None:
|
||
from .ui_history_window import HistoryWindow
|
||
win = HistoryWindow(self, cfg=self._cfg, carrier="usps")
|
||
win.grab_set()
|
||
|
||
def _on_success(self, result: dict) -> None:
|
||
self._set_busy(False)
|
||
self._result._tracking_lbl.configure(
|
||
text=result.get("tracking_number") or "(not returned)")
|
||
label_path = result.get("label_pdf_path")
|
||
self._result._label_path = label_path
|
||
self._result.grid()
|
||
self._save_to_history(result)
|
||
if label_path:
|
||
try:
|
||
os.startfile(label_path, "print")
|
||
except Exception:
|
||
try:
|
||
os.startfile(label_path)
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_error(self, msg: str) -> None:
|
||
self._set_busy(False)
|
||
_show_error(self, "USPS Label", msg)
|
||
|
||
def _save_to_history(self, result: dict) -> None:
|
||
from datetime import datetime, timezone
|
||
from .label_history import LabelEntry, LabelHistory
|
||
|
||
address = self._address_block.get("1.0", "end").strip()
|
||
recipient = address.splitlines()[0] if address else ""
|
||
rate_text = self._rate_label.cget("text")
|
||
try:
|
||
amount = float(rate_text.lstrip("$")) if rate_text.startswith("$") else None
|
||
except ValueError:
|
||
amount = None
|
||
|
||
entry = LabelEntry(
|
||
label_id = result.get("label_id", result.get("tracking_number", "")),
|
||
tracking_number = result.get("tracking_number", ""),
|
||
date_printed = datetime.now(timezone.utc).isoformat(),
|
||
recipient = recipient,
|
||
service = self._get_shipment_type()[2],
|
||
package_type = self._get_shipment_type()[3],
|
||
amount = amount,
|
||
notes = self._notes.get("1.0", "end").strip(),
|
||
address_block = address,
|
||
carrier = "usps",
|
||
)
|
||
try:
|
||
LabelHistory().add(entry)
|
||
except Exception as exc:
|
||
log.warning("Could not save label to history: %s", exc)
|
||
|
||
def _clear(self) -> None:
|
||
self._address_block.delete("1.0", "end")
|
||
self._notes.delete("1.0", "end")
|
||
self._print_msg.delete(0, "end")
|
||
self._email_entry.delete(0, "end")
|
||
for entry in (self._lbs, self._oz, self._dim_l, self._dim_w, self._dim_h):
|
||
entry.delete(0, "end")
|
||
self._mode_var.set("Package")
|
||
self._pkg_svc_var.set("0")
|
||
self._toggle_mode()
|
||
self._result.grid_remove()
|
||
# Leave Auto running if on — intentional, weight stays live
|
||
|
||
def _set_busy(self, busy: bool, label: str = "") -> None:
|
||
s = "disabled" if busy else "normal"
|
||
text = label if (busy and label) else ("Creating…" if busy else "Create Label")
|
||
self._ship_btn.configure(text=text, state=s)
|
||
self._clear_btn.configure(state=s)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# FedEx panel — individual address fields
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class _FedExPanel(ctk.CTkScrollableFrame):
|
||
def __init__(self, master, cfg=None):
|
||
super().__init__(master, fg_color="transparent", corner_radius=0)
|
||
self._cfg = cfg
|
||
self.columnconfigure(0, weight=1)
|
||
self._build()
|
||
|
||
def _build(self) -> None:
|
||
# Header
|
||
hdr = ctk.CTkFrame(self, fg_color="transparent")
|
||
hdr.grid(row=0, column=0, sticky="ew", padx=20, pady=(18, 0))
|
||
ctk.CTkLabel(hdr, text="FedEx", font=T.FONT_HEADER,
|
||
text_color=T.TEXT_PRIMARY).pack(side="left")
|
||
ctk.CTkLabel(hdr, text="One Rate · 2-Day", font=T.FONT_BODY,
|
||
text_color=T.TEXT_MUTED).pack(side="left", padx=(8, 0), pady=(4, 0))
|
||
ctk.CTkButton(
|
||
hdr, text="History", width=80, height=28,
|
||
font=T.FONT_LABEL,
|
||
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,
|
||
corner_radius=6, command=self._open_history,
|
||
).pack(side="right")
|
||
|
||
# Shipment options
|
||
opts = _card(self, row=1, column=0, sticky="ew", padx=20, pady=(12, 0))
|
||
opts.columnconfigure(0, weight=1)
|
||
|
||
ctk.CTkLabel(opts, text="Shipment Options", font=T.FONT_BODY_BOLD,
|
||
text_color=T.TEXT_PRIMARY).grid(
|
||
row=0, column=0, sticky="w", padx=16, pady=(14, 10))
|
||
|
||
_lbl(opts, "Package Type", row=1)
|
||
self._pkg_var = ctk.StringVar(value=_FEDEX_PACKAGES[0])
|
||
for i, pkg in enumerate(_FEDEX_PACKAGES):
|
||
ctk.CTkRadioButton(
|
||
opts, text=pkg, variable=self._pkg_var, value=pkg,
|
||
font=T.FONT_BODY, text_color=T.TEXT_PRIMARY,
|
||
radiobutton_width=18, radiobutton_height=18,
|
||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||
border_color=T.BORDER_STRONG,
|
||
).grid(row=2 + i, column=0, sticky="w", padx=16, pady=3)
|
||
|
||
ctk.CTkFrame(opts, fg_color=T.DIVIDER, height=1).grid(
|
||
row=4, column=0, sticky="ew", padx=16, pady=10)
|
||
|
||
_lbl(opts, "Service", row=5)
|
||
self._svc_var = ctk.StringVar(value=_FEDEX_SERVICES[0])
|
||
for i, svc in enumerate(_FEDEX_SERVICES):
|
||
ctk.CTkRadioButton(
|
||
opts, text=svc, variable=self._svc_var, value=svc,
|
||
font=T.FONT_BODY, text_color=T.TEXT_PRIMARY,
|
||
radiobutton_width=18, radiobutton_height=18,
|
||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||
border_color=T.BORDER_STRONG,
|
||
).grid(row=6 + i, column=0, sticky="w", padx=16, pady=3)
|
||
|
||
ctk.CTkFrame(opts, fg_color="transparent", height=8).grid(row=8, column=0)
|
||
|
||
# Recipient
|
||
recip = _card(self, row=2, column=0, sticky="ew", padx=20, pady=(10, 0))
|
||
recip.columnconfigure(0, weight=3)
|
||
recip.columnconfigure(1, weight=1)
|
||
recip.columnconfigure(2, weight=2)
|
||
|
||
ctk.CTkLabel(recip, text="Recipient", font=T.FONT_BODY_BOLD,
|
||
text_color=T.TEXT_PRIMARY).grid(
|
||
row=0, column=0, columnspan=3, sticky="w", padx=16, pady=(14, 10))
|
||
|
||
_lbl(recip, "Full Name", row=1, colspan=3)
|
||
self._name = ctk.CTkEntry(recip, **_ENTRY_KWARGS, placeholder_text="Jane Smith")
|
||
self._name.grid(row=2, column=0, columnspan=3, sticky="ew",
|
||
padx=16, pady=(0, 8))
|
||
_bind_context_menu(self._name)
|
||
|
||
_lbl(recip, "Company (optional)", row=3, colspan=3)
|
||
self._company = ctk.CTkEntry(recip, **_ENTRY_KWARGS)
|
||
self._company.grid(row=4, column=0, columnspan=3, sticky="ew",
|
||
padx=16, pady=(0, 8))
|
||
_bind_context_menu(self._company)
|
||
|
||
_lbl(recip, "Street Address", row=5, colspan=3)
|
||
self._address1 = ctk.CTkEntry(recip, **_ENTRY_KWARGS,
|
||
placeholder_text="123 Main St")
|
||
self._address1.grid(row=6, column=0, columnspan=3, sticky="ew",
|
||
padx=16, pady=(0, 8))
|
||
_bind_context_menu(self._address1)
|
||
|
||
_lbl(recip, "Apt / Suite (optional)", row=7, colspan=3)
|
||
self._address2 = ctk.CTkEntry(recip, **_ENTRY_KWARGS,
|
||
placeholder_text="Suite 200")
|
||
self._address2.grid(row=8, column=0, columnspan=3, sticky="ew",
|
||
padx=16, pady=(0, 8))
|
||
_bind_context_menu(self._address2)
|
||
|
||
_lbl(recip, "City", row=9, col=0)
|
||
_lbl(recip, "State", row=9, col=1)
|
||
_lbl(recip, "ZIP", row=9, col=2)
|
||
|
||
self._city = ctk.CTkEntry(recip, **_ENTRY_KWARGS, placeholder_text="Springfield")
|
||
self._city.grid(row=10, column=0, sticky="ew", padx=(16, 8), pady=(0, 8))
|
||
_bind_context_menu(self._city)
|
||
|
||
self._state = ctk.CTkEntry(recip, **_ENTRY_KWARGS, placeholder_text="IL")
|
||
self._state.grid(row=10, column=1, sticky="ew", padx=(8, 8), pady=(0, 8))
|
||
_bind_context_menu(self._state)
|
||
|
||
self._zip = ctk.CTkEntry(recip, **_ENTRY_KWARGS, placeholder_text="62701")
|
||
self._zip.grid(row=10, column=2, sticky="ew", padx=(8, 16), pady=(0, 8))
|
||
_bind_context_menu(self._zip)
|
||
|
||
_lbl(recip, "Phone", row=11, colspan=3)
|
||
self._phone = ctk.CTkEntry(recip, **_ENTRY_KWARGS,
|
||
placeholder_text="(555) 123-4567")
|
||
self._phone.grid(row=12, column=0, columnspan=3, sticky="ew",
|
||
padx=16, pady=(0, 8))
|
||
_bind_context_menu(self._phone)
|
||
|
||
_lbl(recip, "Tracking Email", row=13, colspan=3)
|
||
self._email = ctk.CTkEntry(recip, **_ENTRY_KWARGS,
|
||
placeholder_text="customer@example.com")
|
||
self._email.grid(row=14, column=0, columnspan=3, sticky="ew",
|
||
padx=16, pady=(0, 16))
|
||
_bind_context_menu(self._email)
|
||
|
||
# Actions
|
||
actions = ctk.CTkFrame(self, fg_color="transparent")
|
||
actions.grid(row=3, column=0, sticky="ew", padx=20, pady=(12, 0))
|
||
|
||
self._clear_btn = ctk.CTkButton(
|
||
actions, text="Clear", width=80, height=34,
|
||
font=T.FONT_LABEL,
|
||
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,
|
||
corner_radius=6, command=self._clear,
|
||
)
|
||
self._clear_btn.pack(side="left")
|
||
|
||
self._ship_btn = ctk.CTkButton(
|
||
actions, text="Create Shipment", width=170, height=34,
|
||
font=T.FONT_LABEL,
|
||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||
text_color=T.ACCENT_TEXT,
|
||
border_width=1, border_color=T.ACCENT_BORDER,
|
||
corner_radius=6, command=self._on_create,
|
||
)
|
||
self._ship_btn.pack(side="right")
|
||
|
||
# Result
|
||
self._result = _result_card(self, "FedEx Shipment Created")
|
||
self._result.grid(row=4, column=0, sticky="ew", padx=20, pady=(12, 20))
|
||
self._result.grid_remove()
|
||
|
||
# ── Actions ────────────────────────────────────────────────────────────
|
||
|
||
def _on_create(self) -> None:
|
||
d = {
|
||
"package_type": self._pkg_var.get(),
|
||
"service": self._svc_var.get(),
|
||
"name": self._name.get().strip(),
|
||
"company": self._company.get().strip(),
|
||
"address1": self._address1.get().strip(),
|
||
"address2": self._address2.get().strip(),
|
||
"city": self._city.get().strip(),
|
||
"state": self._state.get().strip().upper(),
|
||
"zip": self._zip.get().strip(),
|
||
"phone": self._phone.get().strip(),
|
||
"email": self._email.get().strip(),
|
||
}
|
||
missing = [label for field, label in [
|
||
("name", "Full Name"), ("address1", "Street Address"),
|
||
("city", "City"), ("state", "State"),
|
||
("zip", "ZIP"), ("phone", "Phone"),
|
||
] if not d[field]]
|
||
if missing:
|
||
_show_error(self, "FedEx Shipment",
|
||
"Please fill in: " + ", ".join(missing))
|
||
return
|
||
|
||
cfg = self._cfg
|
||
if not cfg or not cfg.fedex_api_key:
|
||
_show_error(self, "FedEx Shipment",
|
||
"FedEx credentials are not configured in config.json.")
|
||
return
|
||
|
||
client = FedExClient(
|
||
api_key=cfg.fedex_api_key, secret=cfg.fedex_secret,
|
||
account=cfg.fedex_account, sandbox=cfg.fedex_sandbox,
|
||
shipper={
|
||
"name": cfg.fedex_shipper_name,
|
||
"company": cfg.fedex_shipper_company,
|
||
"phone": cfg.fedex_shipper_phone,
|
||
"address1": cfg.fedex_shipper_address1,
|
||
"city": cfg.fedex_shipper_city,
|
||
"state": cfg.fedex_shipper_state,
|
||
"zip": cfg.fedex_shipper_zip,
|
||
},
|
||
)
|
||
self._result.grid_remove()
|
||
self._set_busy(True)
|
||
|
||
def _run():
|
||
try:
|
||
result = client.create_shipment(d)
|
||
self.after(0, lambda: self._on_success(result))
|
||
except Exception as exc:
|
||
msg = str(exc)
|
||
log.exception("FedEx shipment failed")
|
||
self.after(0, lambda: self._on_error(msg))
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _on_success(self, result: ShipmentResult) -> None:
|
||
self._set_busy(False)
|
||
self._result._tracking_lbl.configure(
|
||
text=result.tracking_number or "(not returned)")
|
||
self._result._label_path = result.label_pdf_path
|
||
self._result.grid()
|
||
self._save_to_history(result)
|
||
try:
|
||
os.startfile(result.label_pdf_path, "print")
|
||
except Exception:
|
||
try:
|
||
os.startfile(result.label_pdf_path)
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_error(self, msg: str) -> None:
|
||
self._set_busy(False)
|
||
_show_error(self, "FedEx Shipment", msg)
|
||
|
||
def _save_to_history(self, result: ShipmentResult) -> None:
|
||
from datetime import datetime, timezone
|
||
from .label_history import LabelEntry, LabelHistory
|
||
city = self._city.get().strip()
|
||
state = self._state.get().strip()
|
||
zip_ = self._zip.get().strip()
|
||
addr = "\n".join(filter(None, [
|
||
self._address1.get().strip(),
|
||
self._address2.get().strip(),
|
||
f"{city}, {state} {zip_}".strip(", "),
|
||
]))
|
||
entry = LabelEntry(
|
||
label_id = result.tracking_number or str(uuid.uuid4()),
|
||
tracking_number = result.tracking_number,
|
||
date_printed = datetime.now(timezone.utc).isoformat(),
|
||
recipient = self._name.get().strip(),
|
||
service = f"{self._svc_var.get()} – {self._pkg_var.get()}",
|
||
package_type = self._pkg_var.get(),
|
||
amount = None,
|
||
notes = "",
|
||
address_block = addr,
|
||
carrier = "fedex",
|
||
)
|
||
try:
|
||
LabelHistory().add(entry)
|
||
except Exception as exc:
|
||
log.warning("Could not save FedEx label to history: %s", exc)
|
||
|
||
def _open_history(self) -> None:
|
||
from .ui_history_window import HistoryWindow
|
||
win = HistoryWindow(self, cfg=self._cfg, carrier="fedex")
|
||
win.grab_set()
|
||
|
||
def _clear(self) -> None:
|
||
for e in (self._name, self._company, self._address1, self._address2,
|
||
self._city, self._state, self._zip, self._phone, self._email):
|
||
e.delete(0, "end")
|
||
self._pkg_var.set(_FEDEX_PACKAGES[0])
|
||
self._svc_var.set(_FEDEX_SERVICES[0])
|
||
self._result.grid_remove()
|
||
|
||
def _set_busy(self, busy: bool) -> None:
|
||
s = "disabled" if busy else "normal"
|
||
self._ship_btn.configure(
|
||
text="Creating…" if busy else "Create Shipment", state=s)
|
||
self._clear_btn.configure(state=s)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# Shared helpers
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def _result_card(parent, title: str) -> ctk.CTkFrame:
|
||
card = _card(parent, row=99, column=0) # row overridden by caller
|
||
card.columnconfigure(0, weight=1)
|
||
card._label_path: str | None = None
|
||
|
||
ctk.CTkLabel(card, text=title, font=T.FONT_BODY_BOLD,
|
||
text_color=T.NET_COLOR).grid(
|
||
row=0, column=0, columnspan=3, sticky="w", padx=16, pady=(12, 6))
|
||
|
||
_lbl(card, "Tracking Number", row=1, colspan=3)
|
||
|
||
lbl = ctk.CTkLabel(card, text="", font=T.FONT_FACT_VALUE,
|
||
text_color=T.TEXT_PRIMARY)
|
||
lbl.grid(row=2, column=0, sticky="w", padx=16, pady=(0, 10))
|
||
card._tracking_lbl = lbl
|
||
|
||
copy_btn = ctk.CTkButton(
|
||
card, text="Copy", width=70, height=26,
|
||
font=T.FONT_LABEL,
|
||
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,
|
||
corner_radius=6,
|
||
)
|
||
copy_btn.grid(row=2, column=1, sticky="e", padx=(4, 4), pady=(0, 10))
|
||
copy_btn.configure(command=lambda: _copy_tracking(lbl, copy_btn, parent))
|
||
|
||
reprint_btn = ctk.CTkButton(
|
||
card, text="Reprint", width=76, height=26,
|
||
font=T.FONT_LABEL,
|
||
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,
|
||
corner_radius=6,
|
||
command=lambda: _reprint_label(card),
|
||
)
|
||
reprint_btn.grid(row=2, column=2, sticky="e", padx=(0, 16), pady=(0, 10))
|
||
|
||
ctk.CTkLabel(card, text="Label opened for printing.",
|
||
font=T.FONT_BODY, text_color=T.TEXT_MUTED).grid(
|
||
row=3, column=0, columnspan=3, sticky="w", padx=16, pady=(0, 12))
|
||
|
||
return card
|
||
|
||
|
||
def _reprint_label(card) -> None:
|
||
path = getattr(card, "_label_path", None)
|
||
if not path:
|
||
return
|
||
try:
|
||
os.startfile(path)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _copy_tracking(lbl, btn, widget) -> None:
|
||
t = lbl.cget("text")
|
||
if t:
|
||
widget.clipboard_clear()
|
||
widget.clipboard_append(t)
|
||
_flash_button(btn, T.NEUTRAL_BTN_BG,
|
||
restore_text="Copy", flash_text="Copied!")
|
||
|
||
|
||
def _show_error(widget, title: str, msg: str) -> None:
|
||
from tkinter import messagebox
|
||
messagebox.showerror(title, msg, parent=widget)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# USPS API — ShipEngine
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def _se_client(cfg):
|
||
from .shipengine import ShipEngineClient
|
||
client = ShipEngineClient(cfg.shipengine_api_key)
|
||
client.set_shipper(cfg)
|
||
return client
|
||
|
||
|
||
def _fetch_usps_rate(
|
||
cfg,
|
||
to_zip: str,
|
||
weight_oz: int,
|
||
service_code: str,
|
||
package_code: str,
|
||
dim_l: float,
|
||
dim_w: float,
|
||
dim_h: float,
|
||
) -> float | None:
|
||
try:
|
||
return _se_client(cfg).get_rate(
|
||
from_zip=cfg.usps_shipper_zip,
|
||
to_zip=to_zip,
|
||
weight_oz=weight_oz,
|
||
service_code=service_code,
|
||
package_code=package_code,
|
||
dim_l=dim_l, dim_w=dim_w, dim_h=dim_h,
|
||
)
|
||
except Exception as exc:
|
||
log.debug("Rate fetch failed: %s", exc)
|
||
return None
|
||
|
||
|
||
def _parse_usps_address(cfg, address_block: str) -> dict:
|
||
"""Parse freeform text locally then validate/normalise via ShipEngine."""
|
||
from .shipengine import parse_address_text
|
||
parsed = parse_address_text(address_block)
|
||
if not parsed.get("address_line1"):
|
||
raise RuntimeError(
|
||
"Could not parse the address block.\n"
|
||
"Make sure it contains a street address and City, State ZIP line."
|
||
)
|
||
try:
|
||
return _se_client(cfg).validate_address(parsed)
|
||
except Exception:
|
||
return parsed
|
||
|
||
|
||
def _address_changed(original_block: str, parsed: dict) -> bool:
|
||
"""True if city, state, or ZIP differ from what was typed."""
|
||
block = original_block.upper()
|
||
city = (parsed.get("city") or "").upper().strip()
|
||
state = (parsed.get("state_province") or "").upper().strip()
|
||
zip_ = (parsed.get("postal_code") or "").split("-")[0].strip()
|
||
return any([
|
||
city and city not in block,
|
||
state and state not in block,
|
||
zip_ and zip_ not in block,
|
||
])
|
||
|
||
|
||
def _create_usps_label(cfg, data: dict, parsed_addr: dict | None = None) -> dict:
|
||
"""Create a USPS label via ShipEngine."""
|
||
from .shipengine import parse_address_text
|
||
if parsed_addr is None:
|
||
parsed_addr = parse_address_text(data["address_block"])
|
||
result = _se_client(cfg).create_label(data, parsed_addr)
|
||
return {
|
||
"label_id": result.label_id,
|
||
"tracking_number": result.tracking_number,
|
||
"label_pdf_path": result.label_pdf_path,
|
||
}
|