05/24 Fix UI/UX
This commit is contained in:
@@ -1272,26 +1272,27 @@ def get_ai_analysis_detail(analysis_id: int):
|
|||||||
BID_STATUSES = ("open", "monitoring", "awarded", "no_bid", "cancelled")
|
BID_STATUSES = ("open", "monitoring", "awarded", "no_bid", "cancelled")
|
||||||
|
|
||||||
|
|
||||||
def get_all_bids(status_filter: str = "") -> list:
|
def get_all_bids(status_filter: str = "", limit: int = 50, offset: int = 0) -> list:
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cur = conn.cursor(dictionary=True)
|
cur = conn.cursor(dictionary=True)
|
||||||
where = "WHERE b.status=%s" if status_filter else ""
|
parts = [
|
||||||
params = (status_filter,) if status_filter else ()
|
"SELECT b.*, u.username AS added_by_username, COUNT(bu.id) AS update_count"
|
||||||
cur.execute(
|
" FROM bid_tracker b"
|
||||||
f"""
|
" LEFT JOIN users u ON u.id=b.added_by"
|
||||||
SELECT b.*, u.username AS added_by_username,
|
" LEFT JOIN bid_updates bu ON bu.bid_id=b.id"
|
||||||
COUNT(bu.id) AS update_count
|
]
|
||||||
FROM bid_tracker b
|
params = []
|
||||||
LEFT JOIN users u ON u.id=b.added_by
|
if status_filter:
|
||||||
LEFT JOIN bid_updates bu ON bu.bid_id=b.id
|
parts.append("WHERE b.status=%s")
|
||||||
{where}
|
params.append(status_filter)
|
||||||
GROUP BY b.id
|
parts.append(
|
||||||
ORDER BY b.due_date IS NULL, b.due_date ASC, b.created_at DESC
|
"GROUP BY b.id ORDER BY b.due_date IS NULL, b.due_date ASC, b.created_at DESC"
|
||||||
""",
|
" LIMIT %s OFFSET %s"
|
||||||
params,
|
|
||||||
)
|
)
|
||||||
|
params.extend([limit, offset])
|
||||||
|
cur.execute(" ".join(parts), params)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
cur.close()
|
cur.close()
|
||||||
return rows
|
return rows
|
||||||
|
|||||||
@@ -190,10 +190,14 @@ def bid_json(bid_id):
|
|||||||
@bid_tracker_bp.route("/list/json")
|
@bid_tracker_bp.route("/list/json")
|
||||||
@login_required
|
@login_required
|
||||||
def list_json():
|
def list_json():
|
||||||
"""Return all bids as JSON for the AJAX list panel."""
|
"""Return paginated bids as JSON for the AJAX list panel."""
|
||||||
status_filter = request.args.get("status", "")
|
status_filter = request.args.get("status", "")
|
||||||
try:
|
try:
|
||||||
bids = get_all_bids(status_filter=status_filter)
|
offset = int(request.args.get("offset", 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
offset = 0
|
||||||
|
try:
|
||||||
|
bids = get_all_bids(status_filter=status_filter, limit=50, offset=offset)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|||||||
@@ -694,6 +694,14 @@ textarea{resize:vertical;min-height:88px}
|
|||||||
.sidebar-footer{align-items:center}
|
.sidebar-footer{align-items:center}
|
||||||
.main-content{padding:1.1rem .9rem 2rem}
|
.main-content{padding:1.1rem .9rem 2rem}
|
||||||
}
|
}
|
||||||
|
/* Hamburger button — hidden on desktop */
|
||||||
|
.sidebar-toggle{display:none}
|
||||||
|
/* Backdrop — always hidden until .open is added */
|
||||||
|
.sidebar-backdrop{
|
||||||
|
display:none;position:fixed;inset:0;
|
||||||
|
background:rgba(0,0,0,.45);z-index:199;
|
||||||
|
}
|
||||||
|
|
||||||
@media(max-width:700px){
|
@media(max-width:700px){
|
||||||
.sc-actions{flex-direction:column;align-items:flex-start}
|
.sc-actions{flex-direction:column;align-items:flex-start}
|
||||||
.sc-url{display:none}
|
.sc-url{display:none}
|
||||||
@@ -702,6 +710,33 @@ textarea{resize:vertical;min-height:88px}
|
|||||||
.filter-bar{flex-direction:column}
|
.filter-bar{flex-direction:column}
|
||||||
.credential-entry{grid-template-columns:1fr 1fr}
|
.credential-entry{grid-template-columns:1fr 1fr}
|
||||||
.page-header{flex-direction:column;align-items:flex-start}
|
.page-header{flex-direction:column;align-items:flex-start}
|
||||||
|
|
||||||
|
/* ── Mobile sidebar ──────────────────────────────────── */
|
||||||
|
.sidebar{
|
||||||
|
width:240px;
|
||||||
|
transform:translateX(-240px);
|
||||||
|
transition:transform .25s ease;
|
||||||
|
}
|
||||||
|
.sidebar.open{transform:translateX(0)}
|
||||||
|
/* Restore full labels when sidebar slides in */
|
||||||
|
.sidebar.open .brand-name,
|
||||||
|
.sidebar.open .nav-group-label,
|
||||||
|
.sidebar.open .user-info,
|
||||||
|
.sidebar.open .sidebar-btn{display:block}
|
||||||
|
.sidebar.open .sidebar-brand{justify-content:flex-start;padding:1.1rem 1rem .9rem}
|
||||||
|
.sidebar.open .brand-icon{margin:0}
|
||||||
|
.sidebar.open .nav-item{justify-content:flex-start;padding:.65rem 1rem;gap:.6rem}
|
||||||
|
.sidebar.open .sidebar-footer{align-items:stretch}
|
||||||
|
|
||||||
|
.sidebar-backdrop.open{display:block}
|
||||||
|
.sidebar-toggle{
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
position:fixed;top:.75rem;left:.75rem;z-index:201;
|
||||||
|
background:var(--bg-white);border:1px solid var(--border-strong);
|
||||||
|
border-radius:var(--r-sm);width:2.25rem;height:2.25rem;
|
||||||
|
font-size:1.1rem;cursor:pointer;box-shadow:var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.main-content{margin-left:0;padding-top:3.5rem}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -214,6 +214,52 @@ function getCsrfToken() {
|
|||||||
});
|
});
|
||||||
}());
|
}());
|
||||||
|
|
||||||
|
/* ── Mobile sidebar toggle ─────────────────────────────────── */
|
||||||
|
(function() {
|
||||||
|
var toggle = document.getElementById('sidebar-toggle');
|
||||||
|
var backdrop = document.getElementById('sidebar-backdrop');
|
||||||
|
var sidebar = document.getElementById('sidebar');
|
||||||
|
if (!toggle || !sidebar) return;
|
||||||
|
|
||||||
|
function openSidebar() {
|
||||||
|
sidebar.classList.add('open');
|
||||||
|
backdrop.classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
function closeSidebar() {
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
backdrop.classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener('click', openSidebar);
|
||||||
|
backdrop.addEventListener('click', closeSidebar);
|
||||||
|
sidebar.querySelectorAll('.nav-item').forEach(function(link) {
|
||||||
|
link.addEventListener('click', closeSidebar);
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
|
||||||
|
/* ── Relative timestamps ───────────────────────────────────── */
|
||||||
|
function timeAgo(dateStr) {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
var d = new Date(dateStr.replace(' ', 'T'));
|
||||||
|
var diff = Math.round((Date.now() - d.getTime()) / 1000);
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return Math.floor(diff / 60) + ' min ago';
|
||||||
|
if (diff < 86400) return Math.floor(diff / 3600) + ' hr ago';
|
||||||
|
if (diff < 604800) return Math.floor(diff / 86400) + ' days ago';
|
||||||
|
return d.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.querySelectorAll('[data-ts]').forEach(function(el) {
|
||||||
|
var iso = el.dataset.ts;
|
||||||
|
if (!iso) return;
|
||||||
|
el.title = el.textContent.trim();
|
||||||
|
el.textContent = timeAgo(iso);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/* ── Generic fetch-based form submit (JSON response) ───────── */
|
/* ── Generic fetch-based form submit (JSON response) ───────── */
|
||||||
async function submitJson(url, data, method = 'POST') {
|
async function submitJson(url, data, method = 'POST') {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for row in activity_rows %}
|
{% for row in activity_rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-muted nowrap">{{ row.created_at.strftime('%Y-%m-%d %H:%M:%S') if row.created_at else '—' }}</td>
|
<td class="text-muted nowrap"><span data-ts="{{ row.created_at.isoformat() if row.created_at else '' }}" title="{{ row.created_at.strftime('%Y-%m-%d %H:%M:%S') if row.created_at else '' }}">{{ row.created_at.strftime('%Y-%m-%d %H:%M:%S') if row.created_at else '—' }}</span></td>
|
||||||
<td>{{ row.username or '—' }}</td>
|
<td>{{ row.username or '—' }}</td>
|
||||||
<td><code class="action-badge">{{ row.action }}</code></td>
|
<td><code class="action-badge">{{ row.action }}</code></td>
|
||||||
<td class="text-muted">{{ row.entity or '' }} {% if row.entity_id %}#{{ row.entity_id }}{% endif %}</td>
|
<td class="text-muted">{{ row.entity or '' }} {% if row.entity_id %}#{{ row.entity_id }}{% endif %}</td>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for row in app_rows %}
|
{% for row in app_rows %}
|
||||||
<tr class="log-{{ row.level | lower }}">
|
<tr class="log-{{ row.level | lower }}">
|
||||||
<td class="text-muted nowrap">{{ row.logged_at.strftime('%Y-%m-%d %H:%M:%S') if row.logged_at else '—' }}</td>
|
<td class="text-muted nowrap"><span data-ts="{{ row.logged_at.isoformat() if row.logged_at else '' }}" title="{{ row.logged_at.strftime('%Y-%m-%d %H:%M:%S') if row.logged_at else '' }}">{{ row.logged_at.strftime('%Y-%m-%d %H:%M:%S') if row.logged_at else '—' }}</span></td>
|
||||||
<td><span class="badge badge-log-{{ row.level | lower }}">{{ row.level }}</span></td>
|
<td><span class="badge badge-log-{{ row.level | lower }}">{{ row.level }}</span></td>
|
||||||
<td class="text-muted">{{ row.logger_name }}</td>
|
<td class="text-muted">{{ row.logger_name }}</td>
|
||||||
<td class="log-message">{{ row.message }}</td>
|
<td class="log-message">{{ row.message }}</td>
|
||||||
|
|||||||
@@ -139,7 +139,11 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="4" class="text-center text-muted" style="padding:1rem">No history yet.</td></tr>
|
<tr><td colspan="{{ 5 if is_admin else 4 }}" style="padding:2.5rem 1rem;text-align:center">
|
||||||
|
<div style="font-size:2rem;margin-bottom:.5rem">🤖</div>
|
||||||
|
<p class="text-muted" style="margin:0 0 .75rem">No analyses yet. Upload a document above to get started.</p>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="document.getElementById('ai-files').scrollIntoView({behavior:'smooth'});document.getElementById('ai-files').focus()">Start Analyzing</button>
|
||||||
|
</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="layout">
|
<body class="layout">
|
||||||
|
|
||||||
|
<!-- Mobile sidebar toggle (hidden on desktop via CSS) -->
|
||||||
|
<button class="sidebar-toggle" id="sidebar-toggle" aria-label="Open navigation">☰</button>
|
||||||
|
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="sidebar" id="sidebar">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
|
|||||||
@@ -164,6 +164,9 @@
|
|||||||
var _activeBidId = null;
|
var _activeBidId = null;
|
||||||
var _activeStatus = '';
|
var _activeStatus = '';
|
||||||
var _allBids = []; // cached from last load
|
var _allBids = []; // cached from last load
|
||||||
|
var _bidOffset = 0;
|
||||||
|
var _hasMore = false;
|
||||||
|
var _PAGE_SIZE = 50;
|
||||||
var _currentUserId = {{ session['user']['id'] }};
|
var _currentUserId = {{ session['user']['id'] }};
|
||||||
var _isAdmin = {{ 'true' if session['user']['role'] == 'admin' else 'false' }};
|
var _isAdmin = {{ 'true' if session['user']['role'] == 'admin' else 'false' }};
|
||||||
|
|
||||||
@@ -189,12 +192,21 @@ var STATUS_BADGE = {
|
|||||||
Load / render bid list
|
Load / render bid list
|
||||||
══════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════ */
|
||||||
function loadBids() {
|
function loadBids() {
|
||||||
fetch('/bids/list/json?status=' + encodeURIComponent(_activeStatus))
|
_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(r) { return r.json(); })
|
||||||
.then(function(bids) {
|
.then(function(bids) {
|
||||||
_allBids = bids;
|
if (!Array.isArray(bids)) return;
|
||||||
renderBidList(bids);
|
_allBids = append ? _allBids.concat(bids) : bids;
|
||||||
// Restore selection
|
_bidOffset += bids.length;
|
||||||
|
_hasMore = bids.length >= _PAGE_SIZE;
|
||||||
|
renderBidList(_allBids);
|
||||||
if (_activeBidId) {
|
if (_activeBidId) {
|
||||||
var row = document.querySelector('.bt-row[data-id="' + _activeBidId + '"]');
|
var row = document.querySelector('.bt-row[data-id="' + _activeBidId + '"]');
|
||||||
if (row) row.classList.add('active');
|
if (row) row.classList.add('active');
|
||||||
@@ -220,21 +232,42 @@ function renderBidList(bids) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var today = new Date(); today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
list.innerHTML = filtered.map(function(b) {
|
list.innerHTML = filtered.map(function(b) {
|
||||||
var due = b.due_date ? b.due_date.slice(0, 10) : '—';
|
var due = b.due_date ? b.due_date.slice(0, 10) : '—';
|
||||||
var badge = STATUS_BADGE[b.status] || 'badge-muted';
|
var badge = STATUS_BADGE[b.status] || 'badge-muted';
|
||||||
var label = STATUS_LABELS[b.status] || b.status;
|
var label = STATUS_LABELS[b.status] || b.status;
|
||||||
var active = b.id === _activeBidId ? ' active' : '';
|
var active = b.id === _activeBidId ? ' active' : '';
|
||||||
var upds = b.update_count || 0;
|
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) {
|
||||||
|
urgencyBadge = '<span class="badge badge-danger badge-sm">Overdue</span>';
|
||||||
|
} else if (diff <= 7) {
|
||||||
|
urgencyBadge = '<span class="badge badge-warning badge-sm">Due Soon</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return '<div class="bt-row' + active + '" data-id="' + b.id + '" onclick="selectBid(' + b.id + ')">'
|
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-title">' + esc(b.title) + '</div>'
|
||||||
+ '<div class="bt-row-meta">'
|
+ '<div class="bt-row-meta">'
|
||||||
+ '<span class="badge ' + badge + ' badge-sm">' + label + '</span>'
|
+ '<span class="badge ' + badge + ' badge-sm">' + label + '</span>'
|
||||||
+ '<span class="text-muted text-xs">Due: ' + due + '</span>'
|
+ '<span class="text-muted text-xs">Due: ' + due + '</span>'
|
||||||
|
+ urgencyBadge
|
||||||
+ (upds ? '<span class="text-muted text-xs">💬 ' + upds + '</span>' : '')
|
+ (upds ? '<span class="text-muted text-xs">💬 ' + upds + '</span>' : '')
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
}).join('');
|
}).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>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════════════
|
||||||
@@ -533,6 +566,9 @@ loadBids();
|
|||||||
.bt-upd-body{font-size:.875rem;color:var(--text-secondary);line-height:1.6;
|
.bt-upd-body{font-size:.875rem;color:var(--text-secondary);line-height:1.6;
|
||||||
white-space:pre-wrap}
|
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){
|
@media(max-width:900px){
|
||||||
.bt-layout{grid-template-columns:1fr;grid-template-rows:auto auto}
|
.bt-layout{grid-template-columns:1fr;grid-template-rows:auto auto}
|
||||||
.bt-right{min-height:300px}
|
.bt-right{min-height:300px}
|
||||||
|
|||||||
Reference in New Issue
Block a user