Files
WebChecker--Web-app-/templates/bid_tracker.html
T
2026-06-04 14:28:35 -04:00

596 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Bid Tracker — Website Checker{% endblock %}
{% block content %}
<div class="page-header">
<h1>📌 Bid Tracker</h1>
<button class="btn btn-primary" onclick="openModal('modal-add-bid')"> Add Bid</button>
</div>
<!-- ── Toolbar ─────────────────────────────────────────────── -->
<div class="bt-toolbar">
<div class="search-wrap" style="max-width:260px">
<span class="search-icon">🔍</span>
<input type="text" id="bid-search" placeholder="Search title, source, sol #…" autocomplete="off">
</div>
<div class="bt-filters">
<button class="bt-filter-btn active" data-status="">All</button>
{% for s in bid_statuses %}
<button class="bt-filter-btn" data-status="{{ s }}">{{ status_labels[s] }}</button>
{% endfor %}
</div>
<button class="btn btn-secondary btn-sm" onclick="loadBids()">↻ Refresh</button>
<button class="btn btn-secondary btn-sm" onclick="exportCsv()" title="Download current view as CSV">⬇ CSV</button>
{% if session['user']['role'] == 'admin' %}
<form method="post" action="{{ url_for('bid_tracker.send_reminders') }}" style="display:inline">
<button class="btn btn-secondary btn-sm" type="submit" title="Email a digest of bids due within 7 days to admins">📧 Remind</button>
</form>
{% endif %}
</div>
<!-- ── Split pane ─────────────────────────────────────────── -->
<div class="bt-layout">
<!-- Left — bid list -->
<div class="bt-left">
<div id="bid-list" class="bt-list">
<div class="bt-loading">Loading…</div>
</div>
</div>
<!-- Right — detail panel -->
<div class="bt-right" id="bt-detail">
<div class="bt-empty-state">
<div class="empty-icon">📌</div>
<p>Select a bid to view its details and updates.</p>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════
ADD BID MODAL
══════════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="modal-add-bid">
<div class="modal" style="max-width:600px">
<div class="modal-header">
<span class="modal-title">Add Opportunity</span>
<button class="modal-close" onclick="closeModal('modal-add-bid')"></button>
</div>
<form method="post" action="{{ url_for('bid_tracker.create') }}">
<div class="modal-body">
{% include "_bid_form_fields.html" ignore missing %}
<div class="form-group">
<label>Title *</label>
<input type="text" name="title" required>
</div>
<div class="form-group">
<label>URL *</label>
<input type="url" name="url" placeholder="https://" required>
</div>
<div class="form-row">
<div class="form-group">
<label>Source</label>
<input type="text" name="source" placeholder="e.g. SAM.gov">
</div>
<div class="form-group">
<label>Solicitation #</label>
<input type="text" name="solicitation_number">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Status</label>
<select name="status">
{% for s in bid_statuses %}
<option value="{{ s }}" {% if s == 'open' %}selected{% endif %}>{{ status_labels[s] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Due Date</label>
<input type="date" name="due_date" id="add-bid-due-date">
</div>
</div>
<div class="form-group">
<label>Notes</label>
<textarea name="notes" rows="3" placeholder="Any relevant notes…"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-add-bid')">Cancel</button>
<button type="submit" class="btn btn-primary">Add Bid</button>
</div>
</form>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════
EDIT BID MODAL
══════════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="modal-edit-bid">
<div class="modal" style="max-width:600px">
<div class="modal-header">
<span class="modal-title">Edit Opportunity</span>
<button class="modal-close" onclick="closeModal('modal-edit-bid')"></button>
</div>
<form method="post" id="edit-bid-form">
<div class="modal-body">
<div class="form-group">
<label>Title *</label>
<input type="text" name="title" id="eb-title" required>
</div>
<div class="form-group">
<label>URL *</label>
<input type="url" name="url" id="eb-url" placeholder="https://" required>
</div>
<div class="form-row">
<div class="form-group">
<label>Source</label>
<input type="text" name="source" id="eb-source">
</div>
<div class="form-group">
<label>Solicitation #</label>
<input type="text" name="solicitation_number" id="eb-sol">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Status</label>
<select name="status" id="eb-status">
{% for s in bid_statuses %}
<option value="{{ s }}">{{ status_labels[s] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Due Date</label>
<input type="date" name="due_date" id="eb-due">
</div>
</div>
<div class="form-group">
<label>Notes</label>
<textarea name="notes" id="eb-notes" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-edit-bid')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
/* ══════════════════════════════════════════════════════════
State
══════════════════════════════════════════════════════════ */
var _activeBidId = null;
var _activeStatus = '';
var _allBids = []; // cached from last load
var _bidOffset = 0;
var _hasMore = false;
var _PAGE_SIZE = 50;
function exportCsv() {
var url = '/bids/export.csv';
if (_activeStatus) url += '?status=' + encodeURIComponent(_activeStatus);
window.location.href = url;
}
var _currentUserId = {{ session['user']['id'] }};
var _isAdmin = {{ 'true' if session['user']['role'] == 'admin' else 'false' }};
/* ══════════════════════════════════════════════════════════
Status config
══════════════════════════════════════════════════════════ */
var STATUS_LABELS = {
'open': '🟢 Open',
'monitoring': '🔵 Monitoring',
'awarded': '🏆 Awarded',
'no_bid': '⛔ No Bid',
'cancelled': '🚫 Cancelled',
};
var STATUS_BADGE = {
'open': 'badge-success',
'monitoring': 'badge-info',
'awarded': 'badge-warning',
'no_bid': 'badge-danger',
'cancelled': 'badge-muted',
};
/* ══════════════════════════════════════════════════════════
Load / render bid list
══════════════════════════════════════════════════════════ */
function loadBids() {
_bidOffset = 0;
_allBids = [];
_hasMore = false;
_fetchBids(false);
}
function _fetchBids(append) {
fetch('/bids/list/json?status=' + encodeURIComponent(_activeStatus) + '&offset=' + _bidOffset)
.then(function(r) { return r.json(); })
.then(function(bids) {
if (!Array.isArray(bids)) return;
_allBids = append ? _allBids.concat(bids) : bids;
_bidOffset += bids.length;
_hasMore = bids.length >= _PAGE_SIZE;
renderBidList(_allBids);
if (_activeBidId) {
var row = document.querySelector('.bt-row[data-id="' + _activeBidId + '"]');
if (row) row.classList.add('active');
}
})
.catch(function() {
document.getElementById('bid-list').innerHTML =
'<p class="text-muted text-center" style="padding:1rem">Failed to load bids.</p>';
});
}
function renderBidList(bids) {
var q = document.getElementById('bid-search').value.trim().toLowerCase();
var filtered = q ? bids.filter(function(b) {
return (b.title || '').toLowerCase().includes(q)
|| (b.source || '').toLowerCase().includes(q)
|| (b.solicitation_number || '').toLowerCase().includes(q);
}) : bids;
var list = document.getElementById('bid-list');
if (!filtered.length) {
list.innerHTML = '<p class="text-muted text-center" style="padding:1.5rem">No bids found.</p>';
return;
}
var today = new Date(); today.setHours(0, 0, 0, 0);
list.innerHTML = filtered.map(function(b) {
var due = b.due_date ? b.due_date.slice(0, 10) : '—';
var badge = STATUS_BADGE[b.status] || 'badge-muted';
var label = STATUS_LABELS[b.status] || b.status;
var active = b.id === _activeBidId ? ' active' : '';
var upds = b.update_count || 0;
var urgencyBadge = '';
if (b.due_date) {
var dueD = new Date(b.due_date.slice(0, 10)); dueD.setHours(0, 0, 0, 0);
var diff = Math.round((dueD - today) / 86400000);
if (diff < 0) {
var od = Math.abs(diff);
urgencyBadge = '<span class="badge badge-danger badge-sm">Overdue by ' + od + (od === 1 ? ' day' : ' days') + '</span>';
} else if (diff === 0) {
urgencyBadge = '<span class="badge badge-danger badge-sm">Due today</span>';
} else if (diff <= 7) {
urgencyBadge = '<span class="badge badge-warning badge-sm">Due in ' + diff + (diff === 1 ? ' day' : ' days') + '</span>';
}
}
return '<div class="bt-row' + active + '" data-id="' + b.id + '" onclick="selectBid(' + b.id + ')">'
+ '<div class="bt-row-title">' + esc(b.title) + '</div>'
+ '<div class="bt-row-meta">'
+ '<span class="badge ' + badge + ' badge-sm">' + label + '</span>'
+ '<span class="text-muted text-xs">Due: ' + due + '</span>'
+ urgencyBadge
+ (upds ? '<span class="text-muted text-xs">💬 ' + upds + '</span>' : '')
+ '</div>'
+ '</div>';
}).join('');
if (_hasMore && !q) {
list.innerHTML += '<div class="bt-load-more">'
+ '<button class="btn btn-secondary btn-sm" onclick="_fetchBids(true)">Load more…</button>'
+ '</div>';
}
}
/* ══════════════════════════════════════════════════════════
Select + load detail
══════════════════════════════════════════════════════════ */
function selectBid(id) {
_activeBidId = id;
document.querySelectorAll('.bt-row').forEach(function(r) {
r.classList.toggle('active', r.dataset.id == id);
});
loadDetail(id);
}
function loadDetail(id) {
var panel = document.getElementById('bt-detail');
panel.innerHTML = '<div class="bt-detail-loading">Loading…</div>';
fetch('/bids/' + id + '/json')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { panel.innerHTML = '<p class="text-muted" style="padding:1rem">' + esc(data.error) + '</p>'; return; }
renderDetail(data);
})
.catch(function() {
panel.innerHTML = '<p class="text-muted" style="padding:1rem">Failed to load details.</p>';
});
}
function renderDetail(data) {
var b = data.bid;
var updates = data.updates;
var canEdit = data.can_edit;
var userId = data.user_id;
var due = b.due_date ? b.due_date.slice(0, 10) : 'Not specified';
var badge = STATUS_BADGE[b.status] || 'badge-muted';
var label = STATUS_LABELS[b.status] || b.status;
var sol = b.solicitation_number || '—';
var src = b.source || '—';
var addedBy = b.added_by_username || '—';
var addedAt = (b.created_at || '').slice(0, 16);
var notes = b.notes || '';
var updatesHtml = updates.length ? updates.map(function(u) {
var poster = u.posted_by_full_name || u.posted_by_username || 'Unknown';
var dt = (u.created_at || '').slice(0, 16);
var canDel = data.is_admin || u.user_id === userId;
var delBtn = canDel
? '<button class="bt-upd-del" onclick="deleteUpdate(' + u.id + ',' + b.id + ')" title="Delete update">✕</button>'
: '';
return '<div class="bt-update-card">'
+ '<div class="bt-upd-hdr">'
+ '<span class="bt-upd-poster">👤 ' + esc(poster) + '</span>'
+ '<span class="bt-upd-dt text-muted text-xs">' + dt + '</span>'
+ delBtn
+ '</div>'
+ '<div class="bt-upd-body">' + esc(u.content) + '</div>'
+ '</div>';
}).join('') : '<p class="text-muted text-sm" style="padding:.5rem 0">No updates yet. Be the first to post one.</p>';
var editBtn = canEdit
? '<button class="btn btn-secondary btn-sm" onclick="openEditModal(' + b.id + ')">✎ Edit</button>'
+ '<form method="post" action="/bids/' + b.id + '/delete" style="display:inline"'
+ ' onsubmit="return confirm(\'Delete \\\'' + esc(b.title) + '\\\'?\')">'
+ '<input type="hidden" name="csrf_token" value="' + getCsrfToken() + '">'
+ '<button class="btn btn-danger btn-sm" type="submit">✕ Delete</button></form>'
: '';
document.getElementById('bt-detail').innerHTML = ''
+ '<div class="bt-detail-header">'
+ '<div class="bt-detail-title-row">'
+ '<h2 class="bt-detail-title">' + esc(b.title) + '</h2>'
+ '<span class="badge ' + badge + '">' + label + '</span>'
+ '</div>'
+ '<div class="bt-detail-actions">'
+ editBtn
+ '<a href="' + esc(b.url) + '" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">↗ Open URL</a>'
+ '</div>'
+ '</div>'
+ '<div class="bt-meta-grid">'
+ metaItem('Due Date', due)
+ metaItem('Sol #', sol)
+ metaItem('Source', src)
+ metaItem('Added by', addedBy + (addedAt ? ' · ' + addedAt : ''))
+ '</div>'
+ (notes ? '<div class="bt-notes"><strong>Notes:</strong> ' + esc(notes) + '</div>' : '')
+ '<div class="bt-updates-section">'
+ '<div class="bt-updates-header">📝 Updates</div>'
+ '<div class="bt-compose">'
+ '<textarea id="upd-text-' + b.id + '" class="bt-compose-txt" rows="2"'
+ ' placeholder="Write an update, e.g. \'Amendment 1 issued — due date extended to…\'"></textarea>'
+ '<button class="btn btn-primary btn-sm" onclick="postUpdate(' + b.id + ')">📤 Post Update</button>'
+ '</div>'
+ '<div class="bt-updates-list" id="upd-list-' + b.id + '">' + updatesHtml + '</div>'
+ '</div>';
}
function metaItem(label, value) {
return '<div class="bt-meta-item"><span class="bt-meta-key">' + label + '</span>'
+ '<span class="bt-meta-val">' + esc(value) + '</span></div>';
}
/* ══════════════════════════════════════════════════════════
Post update (AJAX — no page reload)
══════════════════════════════════════════════════════════ */
function postUpdate(bidId) {
var ta = document.getElementById('upd-text-' + bidId);
var content = ta ? ta.value.trim() : '';
if (!content) { alert('Please write an update before posting.'); return; }
var fd = new FormData();
fd.append('content', content);
fetch('/bids/' + bidId + '/updates/json', { method: 'POST', body: fd, credentials: 'same-origin', headers: {'X-CSRFToken': getCsrfToken()} })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
ta.value = '';
// Reload detail to show new update
loadDetail(bidId);
// Refresh list to update count
loadBids();
})
.catch(function(e) { alert('Failed to post update: ' + e); });
}
/* ══════════════════════════════════════════════════════════
Delete update (AJAX)
══════════════════════════════════════════════════════════ */
function deleteUpdate(updateId, bidId) {
if (!confirm('Delete this update?')) return;
var fd = new FormData();
fetch('/bids/updates/' + updateId + '/delete/json', { method: 'POST', body: fd, credentials: 'same-origin', headers: {'X-CSRFToken': getCsrfToken()} })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
loadDetail(bidId);
loadBids();
})
.catch(function(e) { alert('Failed to delete update: ' + e); });
}
/* ══════════════════════════════════════════════════════════
Edit modal
══════════════════════════════════════════════════════════ */
function openEditModal(bidId) {
fetch('/bids/' + bidId + '/json')
.then(function(r) { return r.json(); })
.then(function(data) {
var b = data.bid;
document.getElementById('eb-title').value = b.title || '';
document.getElementById('eb-url').value = b.url || '';
document.getElementById('eb-source').value = b.source || '';
document.getElementById('eb-sol').value = b.solicitation_number || '';
document.getElementById('eb-status').value = b.status || 'open';
document.getElementById('eb-due').value = b.due_date ? b.due_date.slice(0, 10) : '';
document.getElementById('eb-notes').value = b.notes || '';
document.getElementById('edit-bid-form').action = '/bids/' + bidId + '/edit';
openModal('modal-edit-bid');
});
}
/* ══════════════════════════════════════════════════════════
Filter buttons
══════════════════════════════════════════════════════════ */
document.querySelectorAll('.bt-filter-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.bt-filter-btn').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
_activeStatus = btn.dataset.status;
loadBids();
});
});
/* ── Search ─────────────────────────────────────────────── */
document.getElementById('bid-search').addEventListener('input', function() {
renderBidList(_allBids);
// Restore active class after re-render
if (_activeBidId) {
var row = document.querySelector('.bt-row[data-id="' + _activeBidId + '"]');
if (row) row.classList.add('active');
}
});
/* ── Escape ─────────────────────────────────────────────── */
function esc(str) {
return String(str || '').replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
/* ── Init ───────────────────────────────────────────────── */
loadBids();
/* Set today as the minimum selectable due date for new bids */
document.getElementById('add-bid-due-date').min = new Date().toISOString().slice(0, 10);
</script>
<style>
/* ── Toolbar ───────────────────────────────────────────── */
.bt-toolbar{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;margin-bottom:1rem}
.bt-filters{display:flex;gap:.35rem;flex-wrap:wrap}
.bt-filter-btn{
padding:.3rem .75rem;border-radius:999px;font-size:.75rem;font-weight:600;
border:1px solid var(--border-strong);background:var(--bg-white);
color:var(--text-muted);cursor:pointer;transition:all var(--t);
font-family:'DM Sans',sans-serif;
}
.bt-filter-btn:hover{background:var(--bg-hover);color:var(--text)}
.bt-filter-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}
/* ── Layout ────────────────────────────────────────────── */
.bt-layout{display:grid;grid-template-columns:340px 1fr;gap:1rem;
align-items:start;min-height:calc(100vh - 200px)}
.bt-left{background:var(--bg-white);border:1px solid var(--border);
border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--shadow-sm)}
.bt-right{background:var(--bg-white);border:1px solid var(--border);
border-radius:var(--r-lg);box-shadow:var(--shadow-sm);
min-height:400px;overflow:hidden}
/* ── Bid list rows ─────────────────────────────────────── */
.bt-list{display:flex;flex-direction:column}
.bt-loading{padding:1.5rem;text-align:center;color:var(--text-muted);font-size:.875rem}
.bt-row{
padding:.8rem 1rem;border-bottom:1px solid var(--border);
cursor:pointer;transition:background var(--t);
}
.bt-row:last-child{border-bottom:none}
.bt-row:hover{background:var(--bg-subtle)}
.bt-row.active{background:var(--accent-light);border-left:3px solid var(--accent)}
.bt-row-title{font-size:.875rem;font-weight:600;color:var(--text);
margin-bottom:.3rem;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-row-meta{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
/* ── Detail panel ──────────────────────────────────────── */
.bt-empty-state{display:flex;flex-direction:column;align-items:center;
justify-content:center;padding:3rem 1rem;color:var(--text-muted);
text-align:center}
.bt-detail-loading{padding:2rem;text-align:center;color:var(--text-muted)}
.bt-detail-header{
padding:1.1rem 1.25rem .9rem;border-bottom:1px solid var(--border);
background:var(--bg-subtle);
}
.bt-detail-title-row{display:flex;align-items:flex-start;gap:.6rem;margin-bottom:.6rem}
.bt-detail-title{font-size:1.1rem;font-weight:700;color:var(--text);flex:1;line-height:1.3}
.bt-detail-actions{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}
/* ── Metadata grid ─────────────────────────────────────── */
.bt-meta-grid{
display:grid;grid-template-columns:1fr 1fr;gap:0;
padding:.75rem 1.25rem;border-bottom:1px solid var(--border);
}
.bt-meta-item{padding:.35rem 0;display:flex;flex-direction:column;gap:2px}
.bt-meta-key{font-size:.68rem;font-weight:700;letter-spacing:.07em;
text-transform:uppercase;color:var(--text-muted)}
.bt-meta-val{font-size:.85rem;color:var(--text-secondary)}
.bt-notes{
padding:.7rem 1.25rem;font-size:.85rem;color:var(--text-secondary);
border-bottom:1px solid var(--border);line-height:1.6;
}
/* ── Updates section ───────────────────────────────────── */
.bt-updates-section{padding:0 1.25rem 1.25rem}
.bt-updates-header{font-size:.85rem;font-weight:700;color:var(--text);
padding:.9rem 0 .6rem;border-bottom:1px solid var(--border);
margin-bottom:.75rem}
.bt-compose{display:flex;flex-direction:column;gap:.5rem;margin-bottom:.9rem}
.bt-compose-txt{
width:100%;padding:.55rem .75rem;border:1px solid var(--border-strong);
border-radius:var(--r-sm);font-family:'DM Sans',sans-serif;font-size:.875rem;
color:var(--text);background:var(--bg-white);resize:vertical;outline:none;
transition:border-color var(--t),box-shadow var(--t);
}
.bt-compose-txt:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
.bt-compose .btn{align-self:flex-end}
/* ── Update cards ──────────────────────────────────────── */
.bt-updates-list{display:flex;flex-direction:column;gap:.6rem;
max-height:380px;overflow-y:auto;padding-right:2px}
.bt-update-card{
background:var(--bg-subtle);border:1px solid var(--border);
border-radius:var(--r);padding:.7rem .9rem;
}
.bt-upd-hdr{display:flex;align-items:center;gap:.6rem;margin-bottom:.4rem}
.bt-upd-poster{font-size:.8rem;font-weight:600;color:var(--text);flex:1}
.bt-upd-dt{flex-shrink:0}
.bt-upd-del{
background:none;border:none;color:var(--text-muted);cursor:pointer;
font-size:.8rem;padding:.1rem .3rem;border-radius:var(--r-sm);
transition:all var(--t);
}
.bt-upd-del:hover{background:var(--danger-bg);color:var(--danger)}
.bt-upd-body{font-size:.875rem;color:var(--text-secondary);line-height:1.6;
white-space:pre-wrap}
/* ── Load more ─────────────────────────────────────────────── */
.bt-load-more{padding:.75rem 1rem;text-align:center;border-top:1px solid var(--border)}
@media(max-width:900px){
.bt-layout{grid-template-columns:1fr;grid-template-rows:auto auto}
.bt-right{min-height:300px}
}
</style>
{% endblock %}