04/30 Web Checker web app ver. 1.0

This commit is contained in:
2026-04-30 17:15:56 -04:00
parent feb8e5051c
commit cd63d5a020
39 changed files with 8031 additions and 1 deletions
+540
View File
@@ -0,0 +1,540 @@
{% 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>
</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">
</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 _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() {
fetch('/bids/list/json?status=' + encodeURIComponent(_activeStatus))
.then(function(r) { return r.json(); })
.then(function(bids) {
_allBids = bids;
renderBidList(bids);
// Restore selection
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;
}
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;
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>'
+ (upds ? '<span class="text-muted text-xs">💬 ' + upds + '</span>' : '')
+ '</div>'
+ '</div>';
}).join('');
}
/* ══════════════════════════════════════════════════════════
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) + '\\\'?\')">'
+ '<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' })
.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' })
.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();
</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}
@media(max-width:900px){
.bt-layout{grid-template-columns:1fr;grid-template-rows:auto auto}
.bt-right{min-height:300px}
}
</style>
{% endblock %}