""" views/bid_tracker_view.py — Bid / Opportunity Follow-Up Tracker Any logged-in user can: - View all tracked bids and their latest status - Add a new bid (URL + metadata) - Post updates on any bid visible to everyone - Edit or delete their own bids (admins can edit/delete any bid) Layout: Left pane : filterable bid list with status badges Right pane : detail panel — metadata + scrollable update timeline """ import os import logging import tkinter as tk from tkinter import ttk import webbrowser from utils.ui_helpers import ( COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, show_error, show_info, confirm_delete, ) logger = logging.getLogger("bid_tracker_view") # Status display config: (label, colour_key) STATUS_META = { "open": ("🟢 Open", "success"), "monitoring": ("🔵 Monitoring", "accent"), "awarded": ("🏆 Awarded", "warning"), "no_bid": ("⛔ No Bid", "danger"), "cancelled": ("🚫 Cancelled", "text_dim"), } class BidTrackerView(ttk.Frame): def __init__(self, parent, current_user: dict): super().__init__(parent) self.current_user = current_user self._is_admin = current_user.get("role") == "admin" self._selected_bid_id = None self._build_ui() self._load_bids() # ── Layout ──────────────────────────────────────────────────────────────── def _build_ui(self): C = COLOURS # Page header hdr = ttk.Frame(self) hdr.pack(fill="x", pady=(0, 10)) ttk.Label(hdr, text="📌 Bid Tracker", style="Heading.TLabel").pack(side="left") ttk.Label(hdr, text="Track potential opportunities and follow up on updates.", style="Dim.TLabel").pack(side="left", padx=(12, 0)) # Toolbar toolbar = tk.Frame(self, bg=C["surface"], pady=8, padx=12) toolbar.pack(fill="x", pady=(0, 8)) tk.Button( toolbar, text="+ Add Bid", command=self._open_add_dialog, bg=C["accent"], fg=C["white"], activebackground=C["accent_hover"], activeforeground=C["white"], relief="flat", font=FONT_BOLD, cursor="hand2", padx=12, pady=5, ).pack(side="left") tk.Button( toolbar, text="↻ Refresh", command=self._load_bids, bg=C["surface2"], fg=C["text"], activebackground=C["accent"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=5, ).pack(side="left", padx=(6, 0)) # Status filter tk.Label(toolbar, text="Filter:", bg=C["surface"], fg=C["text_dim"], font=FONT_SMALL).pack(side="left", padx=(16, 4)) self._filter_var = tk.StringVar(value="All") filter_cb = ttk.Combobox( toolbar, textvariable=self._filter_var, values=["All"] + [v[0] for v in STATUS_META.values()], state="readonly", width=14, ) filter_cb.pack(side="left") filter_cb.bind("<>", lambda _: self._load_bids()) # Search tk.Label(toolbar, text="Search:", bg=C["surface"], fg=C["text_dim"], font=FONT_SMALL).pack(side="left", padx=(16, 4)) self._search_var = tk.StringVar() self._search_var.trace_add("write", lambda *_: self._load_bids()) tk.Entry( toolbar, textvariable=self._search_var, bg=C["surface2"], fg=C["text"], insertbackground=C["text"], relief="flat", font=FONT, width=22, ).pack(side="left", ipady=4) # Two-pane split pane = tk.PanedWindow(self, orient="horizontal", bg=C["border"], sashwidth=4, sashrelief="flat") pane.pack(fill="both", expand=True) left = tk.Frame(pane, bg=C["bg"]) pane.add(left, minsize=300, width=360) self._build_bid_list(left) right = tk.Frame(pane, bg=C["bg"]) pane.add(right, minsize=380) self._build_detail_panel(right) def _build_bid_list(self, parent): C = COLOURS # Treeview cols = ("Title", "Status", "Due Date", "Updates") self._tree = ttk.Treeview( parent, columns=cols, show="headings", selectmode="browse", ) col_widths = {"Title": 160, "Status": 90, "Due Date": 80, "Updates": 60} for col in cols: self._tree.heading(col, text=col) self._tree.column(col, width=col_widths[col], anchor="w" if col == "Title" else "center") # Colour tags per status self._tree.tag_configure("open", foreground=C["success"]) self._tree.tag_configure("monitoring", foreground=C["accent"]) self._tree.tag_configure("awarded", foreground=C["warning"]) self._tree.tag_configure("no_bid", foreground=C["danger"]) self._tree.tag_configure("cancelled", foreground=C["text_dim"]) vsb = ttk.Scrollbar(parent, orient="vertical", command=self._tree.yview) self._tree.configure(yscrollcommand=vsb.set) vsb.pack(side="right", fill="y") self._tree.pack(side="left", fill="both", expand=True) self._tree.bind("<>", self._on_bid_select) self._tree.bind("", lambda _: self._open_edit_dialog()) def _build_detail_panel(self, parent): C = COLOURS # Action toolbar (right pane) act = tk.Frame(parent, bg=C["surface"], pady=8, padx=12) act.pack(fill="x") self._edit_btn = tk.Button( act, text="✎ Edit Bid", command=self._open_edit_dialog, bg=C["surface2"], fg=C["text"], activebackground=C["accent"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=4, ) self._edit_btn.pack(side="left", padx=(0, 4)) self._del_btn = tk.Button( act, text="✕ Delete", command=self._delete_bid, bg=C["surface2"], fg=C["danger"], activebackground=C["danger"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=4, ) self._del_btn.pack(side="left") self._open_btn = tk.Button( act, text="↗ Open URL", command=self._open_url, bg=C["surface2"], fg=C["accent"], activebackground=C["accent"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=4, ) self._open_btn.pack(side="right") # Metadata card self._meta_frame = tk.Frame(parent, bg=C["surface"], padx=14, pady=10) self._meta_frame.pack(fill="x", pady=(0, 4)) self._meta_title = tk.Label( self._meta_frame, text="Select a bid to view details.", bg=C["surface"], fg=C["text"], font=FONT_BOLD, wraplength=480, justify="left", ) self._meta_title.pack(anchor="w") self._meta_sub = tk.Label( self._meta_frame, text="", bg=C["surface"], fg=C["text_dim"], font=FONT_SMALL, wraplength=480, justify="left", ) self._meta_sub.pack(anchor="w", pady=(2, 0)) self._meta_notes = tk.Label( self._meta_frame, text="", bg=C["surface"], fg=C["text"], font=FONT_SMALL, wraplength=480, justify="left", ) self._meta_notes.pack(anchor="w", pady=(4, 0)) # ── Updates section ─────────────────────────────────────────────── upd_hdr = tk.Frame(parent, bg=C["bg"], pady=4, padx=12) upd_hdr.pack(fill="x") tk.Label(upd_hdr, text="📝 Updates", bg=C["bg"], fg=C["text"], font=FONT_BOLD).pack(side="left") # Post update entry compose = tk.Frame(parent, bg=C["surface"], padx=12, pady=8) compose.pack(fill="x") self._update_txt = tk.Text( compose, height=3, wrap="word", bg=C["surface2"], fg=C["text"], insertbackground=C["text"], relief="flat", font=FONT, ) self._update_txt.pack(fill="x", pady=(0, 4)) # Placeholder behaviour self._update_txt.insert("1.0", "Write an update, e.g. 'Amendment 1 issued — due date extended to...'") self._update_txt.config(fg=C["text_dim"]) self._update_txt.bind("", self._on_update_focus_in) self._update_txt.bind("", self._on_update_focus_out) tk.Button( compose, text="📤 Post Update", command=self._post_update, bg=C["accent"], fg=C["white"], activebackground=C["accent_hover"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=4, ).pack(anchor="e") # Scrollable update timeline timeline_frame = tk.Frame(parent, bg=C["bg"]) timeline_frame.pack(fill="both", expand=True, padx=4, pady=(4, 0)) vsb = ttk.Scrollbar(timeline_frame, orient="vertical") vsb.pack(side="right", fill="y") self._update_canvas = tk.Canvas( timeline_frame, bg=C["bg"], highlightthickness=0, yscrollcommand=vsb.set, ) self._update_canvas.pack(side="left", fill="both", expand=True) vsb.config(command=self._update_canvas.yview) self._update_inner = tk.Frame(self._update_canvas, bg=C["bg"]) self._canvas_window = self._update_canvas.create_window( (0, 0), window=self._update_inner, anchor="nw") self._update_inner.bind("", self._on_inner_configure) self._update_canvas.bind("", self._on_canvas_configure) # Mousewheel scrolling self._update_canvas.bind("", lambda _: self._update_canvas.bind_all( "", self._on_mousewheel)) self._update_canvas.bind("", lambda _: self._update_canvas.unbind_all("")) self._set_detail_buttons_state("disabled") # ── Canvas / scroll helpers ─────────────────────────────────────────────── def _on_inner_configure(self, event=None): self._update_canvas.configure( scrollregion=self._update_canvas.bbox("all")) def _on_canvas_configure(self, event): self._update_canvas.itemconfig( self._canvas_window, width=event.width) def _on_mousewheel(self, event): self._update_canvas.yview_scroll( int(-1 * (event.delta / 120)), "units") # ── Placeholder helpers ─────────────────────────────────────────────────── def _on_update_focus_in(self, event=None): C = COLOURS if self._update_txt.cget("fg") == C["text_dim"]: self._update_txt.delete("1.0", "end") self._update_txt.config(fg=C["text"]) def _on_update_focus_out(self, event=None): C = COLOURS if not self._update_txt.get("1.0", "end-1c").strip(): self._update_txt.delete("1.0", "end") self._update_txt.insert("1.0", "Write an update, e.g. 'Amendment 1 issued — due date extended to...'") self._update_txt.config(fg=C["text_dim"]) def _get_update_text(self) -> str: C = COLOURS txt = self._update_txt.get("1.0", "end-1c").strip() if self._update_txt.cget("fg") == C["text_dim"]: return "" return txt # ── Data loading ───────────────────────────────────────────────────────── def _load_bids(self, *_): from models import get_all_bids # Map display label back to DB key for filter filter_label = self._filter_var.get() status_key = "" for k, (label, _) in STATUS_META.items(): if label == filter_label: status_key = k break search = self._search_var.get().lower().strip() self._tree.delete(*self._tree.get_children()) try: bids = get_all_bids(status_filter=status_key) except Exception as e: show_error(f"Failed to load bids:\n{e}") return for bid in bids: if search and search not in (bid.get("title") or "").lower() \ and search not in (bid.get("source") or "").lower() \ and search not in (bid.get("solicitation_number") or "").lower(): continue status = bid.get("status", "open") label, _ = STATUS_META.get(status, (status, "text")) due = str(bid.get("due_date") or "—") upd_count = bid.get("update_count", 0) self._tree.insert( "", "end", iid=str(bid["id"]), tags=(status,), values=(bid["title"], label, due, upd_count), ) # Restore selection if still present if self._selected_bid_id and str(self._selected_bid_id) in \ self._tree.get_children(): self._tree.selection_set(str(self._selected_bid_id)) self._tree.see(str(self._selected_bid_id)) else: self._selected_bid_id = None self._clear_detail() def _on_bid_select(self, event=None): sel = self._tree.selection() if not sel: return self._selected_bid_id = int(sel[0]) self._load_detail(self._selected_bid_id) self._set_detail_buttons_state("normal") def _load_detail(self, bid_id: int): from models import get_bid, get_bid_updates C = COLOURS bid = get_bid(bid_id) if not bid: return status = bid.get("status", "open") label, colour_key = STATUS_META.get(status, (status, "text")) due = str(bid.get("due_date") or "Not specified") sol = bid.get("solicitation_number") or "—" src = bid.get("source") or "—" added_by = bid.get("added_by_username") or "—" added_at = str(bid.get("created_at") or "")[:16] self._meta_title.config(text=bid["title"]) self._meta_sub.config( text=(f"{label} | Due: {due} | Sol #: {sol} | " f"Source: {src} | Added by: {added_by} ({added_at})") ) notes = bid.get("notes") or "" self._meta_notes.config( text=f"Notes: {notes}" if notes else "", ) # Hide edit/delete for non-owners unless admin is_owner = bid.get("added_by") == self.current_user["id"] can_edit = self._is_admin or is_owner state = "normal" if can_edit else "disabled" self._edit_btn.config(state=state) self._del_btn.config(state=state) # Render update timeline for widget in self._update_inner.winfo_children(): widget.destroy() updates = get_bid_updates(bid_id) if not updates: tk.Label( self._update_inner, text="No updates yet. Be the first to post one.", bg=C["bg"], fg=C["text_dim"], font=FONT_SMALL, ).pack(anchor="w", padx=8, pady=8) else: for upd in updates: self._render_update_card(upd) self._update_canvas.yview_moveto(0) def _render_update_card(self, upd: dict): C = COLOURS card = tk.Frame(self._update_inner, bg=C["surface"], padx=10, pady=8) card.pack(fill="x", padx=4, pady=(0, 4)) # Header row hdr = tk.Frame(card, bg=C["surface"]) hdr.pack(fill="x") poster = upd.get("posted_by_full_name") or upd.get("posted_by_username") or "Unknown" dt_str = str(upd.get("created_at") or "")[:16] tk.Label( hdr, text=f"👤 {poster}", bg=C["surface"], fg=C["text"], font=FONT_BOLD, ).pack(side="left") tk.Label( hdr, text=dt_str, bg=C["surface"], fg=C["text_dim"], font=FONT_SMALL, ).pack(side="left", padx=(8, 0)) # Delete button — own update or admin is_own = upd.get("user_id") == self.current_user["id"] if is_own or self._is_admin: tk.Button( hdr, text="✕", command=lambda uid=upd["id"]: self._delete_update(uid), bg=C["surface"], fg=C["text_dim"], activebackground=C["danger"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", ).pack(side="right") # Content tk.Label( card, text=upd.get("content", ""), bg=C["surface"], fg=C["text"], font=FONT, wraplength=460, justify="left", anchor="w", ).pack(fill="x", pady=(4, 0)) def _clear_detail(self): self._meta_title.config(text="Select a bid to view details.") self._meta_sub.config(text="") self._meta_notes.config(text="") for widget in self._update_inner.winfo_children(): widget.destroy() self._set_detail_buttons_state("disabled") def _set_detail_buttons_state(self, state: str): self._edit_btn.config(state=state) self._del_btn.config(state=state) self._open_btn.config(state=state) # ── Actions ─────────────────────────────────────────────────────────────── def _open_url(self): if not self._selected_bid_id: return from models import get_bid bid = get_bid(self._selected_bid_id) if bid and bid.get("url"): try: webbrowser.open(bid["url"]) except Exception as e: show_error(f"Could not open URL:\n{e}") def _open_add_dialog(self): BidDialog(self, self.current_user, bid_data=None, on_save=self._load_bids) def _open_edit_dialog(self): if not self._selected_bid_id: show_error("Please select a bid to edit.") return from models import get_bid bid = get_bid(self._selected_bid_id) if not bid: show_error("Bid not found.") return # Enforce ownership is_owner = bid.get("added_by") == self.current_user["id"] if not self._is_admin and not is_owner: show_error("You can only edit bids you added.") return BidDialog(self, self.current_user, bid_data=bid, on_save=self._after_edit) def _after_edit(self): """Reload list and refresh detail panel after an edit.""" self._load_bids() if self._selected_bid_id: self._load_detail(self._selected_bid_id) def _delete_bid(self): if not self._selected_bid_id: return from models import get_bid, delete_bid bid = get_bid(self._selected_bid_id) if not bid: return is_owner = bid.get("added_by") == self.current_user["id"] if not self._is_admin and not is_owner: show_error("You can only delete bids you added.") return if confirm_delete(bid["title"]): try: delete_bid(self.current_user["id"], self._selected_bid_id) self._selected_bid_id = None self._load_bids() self._clear_detail() show_info("Bid deleted.") except Exception as e: show_error(f"Delete failed:\n{e}") def _post_update(self): if not self._selected_bid_id: show_error("Please select a bid first.") return content = self._get_update_text() if not content: show_error("Please write an update before posting.") return try: from models import add_bid_update add_bid_update(self.current_user["id"], self._selected_bid_id, content) # Clear the text box and reset placeholder self._update_txt.delete("1.0", "end") self._on_update_focus_out() # Refresh the update timeline self._load_detail(self._selected_bid_id) # Refresh update count in the tree self._load_bids() if self._selected_bid_id and \ str(self._selected_bid_id) in self._tree.get_children(): self._tree.selection_set(str(self._selected_bid_id)) except Exception as e: show_error(f"Failed to post update:\n{e}") def _delete_update(self, update_id: int): from tkinter import messagebox if not messagebox.askyesno( "Delete Update", "Are you sure you want to delete this update?"): return try: from models import delete_bid_update delete_bid_update(self.current_user["id"], update_id) self._load_detail(self._selected_bid_id) self._load_bids() if self._selected_bid_id and \ str(self._selected_bid_id) in self._tree.get_children(): self._tree.selection_set(str(self._selected_bid_id)) except Exception as e: show_error(f"Failed to delete update:\n{e}") # ── Add / Edit Bid Dialog ───────────────────────────────────────────────────── class BidDialog(tk.Toplevel): """Modal dialog for adding or editing a bid/opportunity.""" def __init__(self, parent, current_user: dict, bid_data, on_save): super().__init__(parent) self.current_user = current_user self.bid_data = bid_data self.on_save = on_save self.is_edit = bid_data is not None self.title("Edit Bid" if self.is_edit else "Add Bid") self.configure(bg=COLOURS["bg"]) self.resizable(False, False) self.grab_set() self._build_ui() self._centre() def _centre(self): self.update_idletasks() w, h = 560, 520 x = (self.winfo_screenwidth() - w) // 2 y = (self.winfo_screenheight() - h) // 2 self.geometry(f"{w}x{h}+{x}+{y}") def _build_ui(self): C = COLOURS ttk.Label(self, text="Edit Opportunity" if self.is_edit else "Add Opportunity", style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4)) form = ttk.Frame(self) form.pack(fill="x", padx=24, pady=8) form.columnconfigure(1, weight=1) def lbl(text, row): ttk.Label(form, text=text).grid( row=row, column=0, sticky="w", padx=(0, 12), pady=6) def entry(row, show=None): var = tk.StringVar() e = tk.Entry(form, textvariable=var, bg=C["surface2"], fg=C["text"], insertbackground=C["text"], relief="flat", font=FONT, show=show or "") e.grid(row=row, column=1, sticky="ew", ipady=5, pady=6) return var # Row 0 — Title lbl("Title *", 0) self._title_var = entry(0) # Row 1 — URL lbl("URL *", 1) self._url_var = entry(1) # Row 2 — Source lbl("Source", 2) self._source_var = entry(2) # Row 3 — Solicitation # lbl("Solicitation #", 3) self._sol_var = entry(3) # Row 4 — Status lbl("Status", 4) from models import BID_STATUSES self._status_var = tk.StringVar(value="open") status_frame = tk.Frame(form, bg=C["bg"]) status_frame.grid(row=4, column=1, sticky="w", pady=6) for s in BID_STATUSES: label, _ = STATUS_META.get(s, (s, "text")) tk.Radiobutton( status_frame, text=label, variable=self._status_var, value=s, bg=C["bg"], fg=C["text"], activebackground=C["bg"], activeforeground=C["accent"], selectcolor=C["surface2"], font=FONT_SMALL, cursor="hand2", ).pack(side="left", padx=(0, 8)) # Row 5 — Due date lbl("Due Date", 5) self._due_var = tk.StringVar() tk.Entry(form, textvariable=self._due_var, bg=C["surface2"], fg=C["text"], insertbackground=C["text"], relief="flat", font=FONT, width=14).grid( row=5, column=1, sticky="w", ipady=5, pady=6) ttk.Label(form, text="YYYY-MM-DD", style="Dim.TLabel").grid( row=5, column=1, sticky="e", pady=6) # Row 6 — Notes lbl("Notes", 6) notes_frame = tk.Frame(form, bg=C["surface2"]) notes_frame.grid(row=6, column=1, sticky="ew", pady=6) notes_vsb = ttk.Scrollbar(notes_frame, orient="vertical") notes_vsb.pack(side="right", fill="y") self._notes_txt = tk.Text( notes_frame, height=4, wrap="word", bg=C["surface2"], fg=C["text"], insertbackground=C["text"], relief="flat", font=FONT, yscrollcommand=notes_vsb.set, ) self._notes_txt.pack(fill="both", expand=True, padx=4, pady=4) notes_vsb.config(command=self._notes_txt.yview) # Pre-populate when editing if self.is_edit: d = self.bid_data self._title_var.set(d.get("title") or "") self._url_var.set(d.get("url") or "") self._source_var.set(d.get("source") or "") self._sol_var.set(d.get("solicitation_number") or "") self._status_var.set(d.get("status") or "open") self._due_var.set(str(d.get("due_date") or "")) self._notes_txt.insert("1.0", d.get("notes") or "") ttk.Separator(self, orient="horizontal").pack(fill="x", padx=24, pady=10) btn_frame = ttk.Frame(self) btn_frame.pack(fill="x", padx=24, pady=(0, 20)) ttk.Button(btn_frame, text="Save", command=self._save).pack(side="right", padx=(6, 0)) ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton", command=self.destroy).pack(side="right") def _save(self): title = self._title_var.get().strip() url = self._url_var.get().strip() source = self._source_var.get().strip() sol = self._sol_var.get().strip() status = self._status_var.get() due_raw = self._due_var.get().strip() notes = self._notes_txt.get("1.0", "end-1c").strip() if not title: show_error("Title is required.") return if not url: show_error("URL is required.") return # Normalise URL if "://" not in url: url = "https://" + url # Validate due date if provided due_date = None if due_raw: import datetime try: due_date = datetime.date.fromisoformat(due_raw) except ValueError: show_error("Due date must be in YYYY-MM-DD format, e.g. 2025-12-31") return try: if self.is_edit: from models import update_bid update_bid( self.current_user["id"], self.bid_data["id"], title, url, source, sol, status, due_date, notes, ) show_info("Bid updated successfully.") else: from models import create_bid create_bid( self.current_user["id"], title, url, source, sol, status, due_date, notes, ) show_info("Bid added successfully.") self.on_save() self.destroy() except Exception as e: show_error(f"Save failed:\n{e}")