Add bid tracker

This commit is contained in:
2026-04-24 07:15:48 -04:00
parent 262c71e93b
commit 6ec439bafe
7 changed files with 2273 additions and 205 deletions
+761
View File
@@ -0,0 +1,761 @@
"""
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("<<ComboboxSelected>>", 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("<<TreeviewSelect>>", self._on_bid_select)
self._tree.bind("<Double-1>", 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("<FocusIn>", self._on_update_focus_in)
self._update_txt.bind("<FocusOut>", 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("<Configure>", self._on_inner_configure)
self._update_canvas.bind("<Configure>", self._on_canvas_configure)
# Mousewheel scrolling
self._update_canvas.bind("<Enter>",
lambda _: self._update_canvas.bind_all(
"<MouseWheel>", self._on_mousewheel))
self._update_canvas.bind("<Leave>",
lambda _: self._update_canvas.unbind_all("<MouseWheel>"))
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}")