762 lines
28 KiB
Python
762 lines
28 KiB
Python
"""
|
||
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}")
|