Add bid tracker
This commit is contained in:
@@ -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}")
|
||||
Reference in New Issue
Block a user