Files
tenderpilot/public/dashboard.html.bak
Peter Foster c6b0169f3e feat: three major improvements - stable sources, archival, email alerts
1. Focus on Stable International/Regional Sources
   - Improved TED EU scraper (5 search strategies, 5 pages each)
   - All stable sources now hourly (TED EU, Sell2Wales, PCS Scotland, eTendersNI)
   - De-prioritize unreliable UK gov sites (100% removal rate)

2. Archival Feature
   - New DB columns: archived, archived_at, archived_snapshot, last_validated, validation_failures
   - Cleanup script now preserves full tender snapshots before archiving
   - Gradual failure handling (3 retries before archiving)
   - No data loss - historical record preserved

3. Email Alerts
   - Daily digest (8am) - all new tenders from last 24h
   - High-value alerts (every 4h) - tenders >£100k
   - Professional HTML emails with all tender details
   - Configurable via environment variables

Expected outcomes:
- 50-100 stable tenders (vs 26 currently)
- Zero 404 errors (archived data preserved)
- Proactive notifications (no missed opportunities)
- Historical archive for trend analysis

Files:
- scrapers/ted-eu.js (improved)
- cleanup-with-archival.mjs (new)
- send-tender-alerts.mjs (new)
- migrations/add-archival-fields.sql (new)
- THREE_IMPROVEMENTS_SUMMARY.md (documentation)

All cron jobs updated for hourly scraping + daily cleanup + alerts
2026-02-15 14:42:17 +00:00

1320 lines
46 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>Dashboard | TenderRadar</title>
<meta name="title" content="Dashboard | TenderRadar">
<meta name="description" content="Your TenderRadar dashboard - browse and search UK public sector tenders.">
<meta name="keywords" content="tender dashboard">
<meta name="robots" content="noindex, nofollow">
<!-- Canonical URL -->
<link rel="canonical" href="https://tenderradar.co.uk/dashboard.html">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://tenderradar.co.uk/dashboard.html">
<meta property="og:title" content="TenderRadar Dashboard">
<meta property="og:description" content="Your tender intelligence dashboard.">
<meta property="og:image" content="https://tenderradar.co.uk/og-image.png">
<meta property="og:locale" content="en_GB">
<meta property="og:site_name" content="TenderRadar">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://tenderradar.co.uk/dashboard.html">
<meta name="twitter:title" content="TenderRadar Dashboard">
<meta name="twitter:description" content="Your tender intelligence dashboard.">
<meta name="twitter:image" content="https://tenderradar.co.uk/twitter-card.png">
<!-- Preconnect for Performance -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Favicon -->
<link rel="icon">
<link rel="apple-touch-icon">
<!-- Stylesheet -->
<link rel="stylesheet" href="styles.css">
<style>
/* Dashboard Specific Styles */
.dashboard-header {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
border-bottom: 1px solid var(--border);
padding: 2rem 0 2rem;
}
.dashboard-container {
display: flex;
gap: 2rem;
margin-top: 2rem;
}
.sidebar {
width: 280px;
flex-shrink: 0;
}
.main-content {
flex: 1;
min-width: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 0.75rem;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.stat-card h3 {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
.stat-card .value {
font-size: 2rem;
font-weight: 700;
color: var(--primary);
line-height: 1;
}
.stat-card .subtitle {
font-size: 0.8125rem;
color: var(--text-light);
margin-top: 0.5rem;
}
.search-section {
background: white;
padding: 2rem;
border-radius: 0.75rem;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
margin-bottom: 2rem;
}
.search-box {
position: relative;
margin-bottom: 1.5rem;
}
.search-box input {
width: 100%;
padding: 0.875rem 1rem 0.875rem 2.75rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
font-size: 0.9375rem;
font-family: 'Inter', sans-serif;
transition: all 0.2s;
}
.search-box input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
}
.search-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-light);
width: 20px;
height: 20px;
}
.filters-section {
background: white;
padding: 1.5rem;
border-radius: 0.75rem;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.filter-group {
margin-bottom: 1.5rem;
}
.filter-group:last-child {
margin-bottom: 0;
}
.filter-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-option {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-option input[type="checkbox"],
.filter-option input[type="radio"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--primary);
}
.filter-option label {
flex: 1;
font-size: 0.875rem;
color: var(--text-secondary);
cursor: pointer;
margin: 0;
}
.filter-range {
display: flex;
gap: 0.5rem;
align-items: center;
}
.filter-range input {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.8125rem;
font-family: 'Inter', sans-serif;
}
.filter-range input:focus {
outline: none;
border-color: var(--primary);
}
.tenders-section {
background: white;
border-radius: 0.75rem;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
padding: 0;
overflow: hidden;
}
.tenders-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.tenders-header h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.results-count {
font-size: 0.875rem;
color: var(--text-secondary);
}
.tender-list {
list-style: none;
}
.tender-item {
padding: 1.5rem;
border-bottom: 1px solid var(--border);
transition: all 0.2s;
cursor: pointer;
}
.tender-item:last-child {
border-bottom: none;
}
.tender-item:hover {
background: var(--bg-secondary);
}
.tender-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.75rem;
}
.tender-title {
font-size: 1.0625rem;
font-weight: 600;
color: var(--primary);
margin: 0;
flex: 1;
}
.tender-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.badge-source {
display: inline-block;
padding: 0.375rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-relevance {
display: inline-block;
padding: 0.375rem 0.75rem;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: #92400e;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tender-meta {
display: flex;
gap: 1.5rem;
margin-bottom: 0.75rem;
font-size: 0.9375rem;
color: var(--text-secondary);
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.meta-icon {
width: 18px;
height: 18px;
color: var(--text-light);
}
.tender-description {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
.tender-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.tender-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--primary);
}
.tender-actions {
display: flex;
gap: 0.75rem;
}
.tender-actions button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: none;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-expand {
background: var(--primary);
color: white;
}
.btn-expand:hover {
background: var(--primary-dark);
}
.btn-save {
background: var(--bg-secondary);
color: var(--primary);
border: 1px solid var(--border);
}
.btn-save:hover {
background: white;
border-color: var(--primary);
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
padding: 2rem;
border-top: 1px solid var(--border);
}
.pagination button,
.pagination a {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: white;
color: var(--text-primary);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
text-decoration: none;
}
.pagination button:hover,
.pagination a:hover {
border-color: var(--primary);
color: var(--primary);
}
.pagination .active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.pagination .disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-state {
text-align: center;
padding: 3rem 1.5rem;
color: var(--text-secondary);
}
.empty-state-icon {
width: 64px;
height: 64px;
color: var(--text-light);
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.empty-state p {
margin: 0;
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(30, 64, 175, 0.1);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 0.75rem;
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-xl);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1.5rem;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: white;
}
.modal-header h2 {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
flex: 1;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-light);
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.modal-close:hover {
color: var(--text-primary);
}
.modal-body {
padding: 2rem;
}
.detail-section {
margin-bottom: 2rem;
}
.detail-section:last-child {
margin-bottom: 0;
}
.detail-section h3 {
font-size: 1.0625rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--bg-secondary);
}
.detail-row {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
}
.detail-row:last-child {
margin-bottom: 0;
}
.detail-col {
flex: 1;
}
.detail-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.detail-value {
font-size: 0.9375rem;
color: var(--text-primary);
line-height: 1.6;
word-break: break-word;
}
.detail-value a {
color: var(--primary);
text-decoration: none;
}
.detail-value a:hover {
text-decoration: underline;
}
.modal-footer {
display: flex;
gap: 1rem;
padding: 1.5rem;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
}
.modal-footer button {
flex: 1;
padding: 0.75rem;
border-radius: 0.5rem;
border: none;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.user-menu {
display: flex;
align-items: center;
gap: 1rem;
}
.user-info {
text-align: right;
}
.user-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.user-email {
font-size: 0.75rem;
color: var(--text-secondary);
}
.logout-btn {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.logout-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
/* Responsive */
@media (max-width: 1024px) {
.dashboard-container {
gap: 1.5rem;
}
.sidebar {
width: 240px;
}
}
@media (max-width: 768px) {
.dashboard-container {
flex-direction: column;
}
.sidebar {
width: 100%;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.tender-header {
flex-direction: column;
}
.tender-badges {
justify-content: flex-start;
}
.tender-footer {
flex-direction: column;
align-items: flex-start;
}
.tender-actions {
width: 100%;
}
.tender-actions button {
flex: 1;
}
.detail-row {
flex-direction: column;
gap: 0;
}
.modal-content {
max-width: calc(100vw - 3rem);
}
.filter-range {
flex-direction: column;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.search-section,
.filters-section,
.tenders-section {
padding: 1rem;
}
.stat-card {
padding: 1rem;
}
.tender-meta {
flex-direction: column;
gap: 0.5rem;
}
.modal-body {
padding: 1rem;
}
.pagination {
flex-wrap: wrap;
}
}
</style>
</head>
<body>
<!-- Header/Navigation -->
<header class="header" role="banner">
<nav class="nav container" role="navigation" aria-label="Main navigation">
<a href="/" class="nav-brand">
<img src="/logo.png" alt="TenderRadar" class="logo-icon">
</a>
<ul class="nav-menu">
<li><a href="/">Home</a></li>
<li><a href="/#features">Features</a></li>
<li><a href="/#pricing">Pricing</a></li>
</ul>
<div class="user-menu" style="display: none;" id="userMenuContainer">
<div class="user-info">
<div class="user-name" id="userName"></div>
<div class="user-email" id="userEmail"></div>
</div>
<button class="logout-btn" id="logoutBtn">Sign Out</button>
</div>
<button class="mobile-toggle" aria-label="Toggle navigation menu" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
</nav>
</header>
<!-- Dashboard Header -->
<section class="dashboard-header">
<div class="container">
<h1 style="font-size: 1.875rem; font-weight: 700; color: var(--text-primary); margin: 0 0 1rem 0;">Dashboard</h1>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<h3>Total Tenders</h3>
<div class="value" id="statTotal">-</div>
<div class="subtitle">Available to browse</div>
</div>
<div class="stat-card">
<h3>New This Week</h3>
<div class="value" id="statNew">-</div>
<div class="subtitle">Recently published</div>
</div>
<div class="stat-card">
<h3>Closing Soon</h3>
<div class="value" id="statClosing">-</div>
<div class="subtitle">Next 7 days</div>
</div>
<div class="stat-card">
<h3>Your Matches</h3>
<div class="value" id="statMatches">-</div>
<div class="subtitle">Profile matched</div>
</div>
</div>
</div>
</section>
<!-- Main Dashboard -->
<section style="padding: 2rem 0;">
<div class="container">
<div class="dashboard-container">
<!-- Sidebar -->
<aside class="sidebar">
<!-- Search -->
<div class="search-section">
<div class="search-box">
<svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" id="searchInput" placeholder="Search tenders..." autocomplete="off">
</div>
</div>
<!-- Filters -->
<div class="filters-section">
<!-- Source Filter -->
<div class="filter-group">
<label class="filter-label">Source</label>
<div class="filter-options">
<div class="filter-option">
<input type="checkbox" id="filter-source-cf" value="Contracts Finder" class="source-filter">
<label for="filter-source-cf">Contracts Finder</label>
</div>
<div class="filter-option">
<input type="checkbox" id="filter-source-fat" value="Find a Tender" class="source-filter">
<label for="filter-source-fat">Find a Tender</label>
</div>
<div class="filter-option">
<input type="checkbox" id="filter-source-pcs" value="Public Contracts Scotland" class="source-filter">
<label for="filter-source-pcs">PCS</label>
</div>
<div class="filter-option">
<input type="checkbox" id="filter-source-s2w" value="Sell2Wales" class="source-filter">
<label for="filter-source-s2w">Sell2Wales</label>
</div>
</div>
</div>
<!-- Value Range Filter -->
<div class="filter-group">
<label class="filter-label">Contract Value</label>
<div class="filter-range">
<input type="number" id="filter-min-value" placeholder="Min £" min="0">
<span style="color: var(--text-light); font-size: 0.75rem;"></span>
<input type="number" id="filter-max-value" placeholder="Max £" min="0">
</div>
</div>
<!-- Deadline Filter -->
<div class="filter-group">
<label class="filter-label">Deadline</label>
<div class="filter-options">
<div class="filter-option">
<input type="radio" id="filter-deadline-all" name="deadline" value="all" class="deadline-filter" checked>
<label for="filter-deadline-all">Any time</label>
</div>
<div class="filter-option">
<input type="radio" id="filter-deadline-7" name="deadline" value="7" class="deadline-filter">
<label for="filter-deadline-7">Next 7 days</label>
</div>
<div class="filter-option">
<input type="radio" id="filter-deadline-30" name="deadline" value="30" class="deadline-filter">
<label for="filter-deadline-30">Next 30 days</label>
</div>
<div class="filter-option">
<input type="radio" id="filter-deadline-90" name="deadline" value="90" class="deadline-filter">
<label for="filter-deadline-90">Next 90 days</label>
</div>
</div>
</div>
<!-- Sector Filter -->
<div class="filter-group">
<label class="filter-label">Sector</label>
<div class="filter-options">
<div class="filter-option">
<input type="checkbox" id="filter-sector-health" value="Health" class="sector-filter">
<label for="filter-sector-health">Health</label>
</div>
<div class="filter-option">
<input type="checkbox" id="filter-sector-education" value="Education" class="sector-filter">
<label for="filter-sector-education">Education</label>
</div>
<div class="filter-option">
<input type="checkbox" id="filter-sector-construction" value="Construction" class="sector-filter">
<label for="filter-sector-construction">Construction</label>
</div>
<div class="filter-option">
<input type="checkbox" id="filter-sector-it" value="IT" class="sector-filter">
<label for="filter-sector-it">IT & Technology</label>
</div>
<div class="filter-option">
<input type="checkbox" id="filter-sector-other" value="Other" class="sector-filter">
<label for="filter-sector-other">Other</label>
</div>
</div>
</div>
</div>
</aside>
<!-- Main Content -->
<div class="main-content">
<!-- Tenders Section -->
<div class="tenders-section">
<div class="tenders-header">
<h2>Available Tenders</h2>
<div class="results-count">
<span id="resultsCount">0</span> results
</div>
</div>
<div id="loadingSpinner" style="display: none; padding: 2rem; text-align: center;">
<div class="loading-spinner"></div>
<p style="margin-top: 1rem; color: var(--text-secondary);">Loading tenders...</p>
</div>
<div id="emptyState" style="display: none;">
<div class="empty-state">
<svg class="empty-state-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<h3>No tenders found</h3>
<p>Try adjusting your filters or search terms</p>
</div>
</div>
<ul class="tender-list" id="tenderList"></ul>
<!-- Pagination -->
<div class="pagination" id="paginationContainer"></div>
</div>
</div>
</div>
</div>
</section>
<!-- Tender Detail Modal -->
<div class="modal" id="tenderModal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle"></h2>
<button class="modal-close" id="modalCloseBtn">&times;</button>
</div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-footer">
<button class="btn btn-primary" id="modalApplyBtn">Apply Now</button>
<button class="btn btn-secondary" id="modalSaveBtn">Save Tender</button>
</div>
</div>
</div>
<script>
// Auth check and initialization
document.addEventListener('DOMContentLoaded', async () => {
const token = localStorage.getItem('token');
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (!token) {
window.location.href = '/login.html';
return;
}
// Display user info
if (user.email) {
document.getElementById('userName').textContent = user.company_name || user.email.split('@')[0];
document.getElementById('userEmail').textContent = user.email;
document.getElementById('userMenuContainer').style.display = 'flex';
}
// Logout
document.getElementById('logoutBtn').addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login.html';
});
// Load initial data
await loadDashboardData();
await loadTenders();
// Event listeners
document.getElementById('searchInput').addEventListener('input', debounce(loadTenders, 500));
document.querySelectorAll('.source-filter, .sector-filter').forEach(el => {
el.addEventListener('change', () => {
currentPage = 1;
loadTenders();
});
});
document.querySelectorAll('.deadline-filter').forEach(el => {
el.addEventListener('change', () => {
currentPage = 1;
loadTenders();
});
});
document.getElementById('filter-min-value').addEventListener('change', () => {
currentPage = 1;
loadTenders();
});
document.getElementById('filter-max-value').addEventListener('change', () => {
currentPage = 1;
loadTenders();
});
// Modal close
document.getElementById('modalCloseBtn').addEventListener('click', closeModal);
document.getElementById('tenderModal').addEventListener('click', (e) => {
if (e.target.id === 'tenderModal') closeModal();
});
// Mobile menu
document.querySelector('.mobile-toggle').addEventListener('click', function() {
document.querySelector('.nav-menu').classList.toggle('active');
});
});
let currentPage = 1;
const pageSize = 20;
async function loadDashboardData() {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/tenders/stats', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
document.getElementById('statTotal').textContent = data.total || '—';
document.getElementById('statNew').textContent = data.new_this_week || '—';
document.getElementById('statClosing').textContent = data.closing_soon || '—';
document.getElementById('statMatches').textContent = data.matched_to_profile || '—';
} else if (response.status === 401) {
window.location.href = '/login.html';
}
} catch (error) {
console.error('Error loading dashboard data:', error);
// Set fallback values
document.getElementById('statTotal').textContent = '212+';
document.getElementById('statNew').textContent = '8';
document.getElementById('statClosing').textContent = '15';
document.getElementById('statMatches').textContent = '0';
}
}
async function loadTenders() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login.html';
return;
}
const search = document.getElementById('searchInput').value.trim();
const sources = Array.from(document.querySelectorAll('.source-filter:checked'))
.map(el => el.value);
const minValue = document.getElementById('filter-min-value').value;
const maxValue = document.getElementById('filter-max-value').value;
const deadline = document.querySelector('.deadline-filter:checked').value;
const sectors = Array.from(document.querySelectorAll('.sector-filter:checked'))
.map(el => el.value);
const params = new URLSearchParams({
limit: pageSize,
offset: (currentPage - 1) * pageSize,
...(search && { search }),
...(minValue && { min_value: minValue }),
...(maxValue && { max_value: maxValue }),
...(deadline !== 'all' && { deadline_days: deadline }),
...(sources.length > 0 && { sources: sources.join(',') }),
...(sectors.length > 0 && { sectors: sectors.join(',') })
});
document.getElementById('loadingSpinner').style.display = 'block';
document.getElementById('tenderList').innerHTML = '';
document.getElementById('emptyState').style.display = 'none';
try {
const response = await fetch(`/api/tenders?${params}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.status === 401) {
window.location.href = '/login.html';
return;
}
if (!response.ok) {
throw new Error('Failed to load tenders');
}
const data = await response.json();
const tenders = data.tenders || [];
const total = data.total || 0;
document.getElementById('loadingSpinner').style.display = 'none';
if (tenders.length === 0) {
document.getElementById('emptyState').style.display = 'block';
document.getElementById('resultsCount').textContent = '0';
document.getElementById('paginationContainer').innerHTML = '';
return;
}
document.getElementById('resultsCount').textContent = total;
renderTenders(tenders);
renderPagination(total);
} catch (error) {
console.error('Error loading tenders:', error);
document.getElementById('loadingSpinner').style.display = 'none';
document.getElementById('emptyState').style.display = 'block';
document.getElementById('emptyState').innerHTML = `
<div class="empty-state">
<svg class="empty-state-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4v.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3>Error loading tenders</h3>
<p>${error.message}</p>
</div>
`;
}
}
function renderTenders(tenders) {
const list = document.getElementById('tenderList');
list.innerHTML = tenders.map(tender => `
<li class="tender-item" onclick="viewTenderDetail(${tender.id})">
<div class="tender-header">
<h3 class="tender-title">${escapeHtml(tender.title)}</h3>
<div class="tender-badges">
<span class="badge-source">${escapeHtml(tender.source || 'Unknown')}</span>
${tender.relevance_score ? `<span class="badge-relevance">Match: ${Math.round(tender.relevance_score)}%</span>` : ''}
</div>
</div>
<div class="tender-meta">
<div class="meta-item">
<svg class="meta-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<span>${escapeHtml(tender.authority_name || tender.buyer || 'Unknown Buyer')}</span>
</div>
<div class="meta-item">
<svg class="meta-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span>Closes: ${formatDate(tender.deadline)}</span>
</div>
</div>
<p class="tender-description">${escapeHtml((tender.description || '').substring(0, 150))}${(tender.description || '').length > 150 ? '...' : ''}</p>
<div class="tender-footer">
<div class="tender-value">${tender.value_high ? '£' + formatNumber(tender.value_high) : 'TBA'}</div>
<div class="tender-actions">
<button class="btn-expand" onclick="event.stopPropagation(); viewTenderDetail(${tender.id})">View Details</button>
<button class="btn-save" onclick="event.stopPropagation(); saveTender(${tender.id})">Save</button>
</div>
</div>
</li>
`).join('');
}
function renderPagination(total) {
const totalPages = Math.ceil(total / pageSize);
const container = document.getElementById('paginationContainer');
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
let html = '';
// Previous button
html += `<button ${currentPage === 1 ? 'disabled class="disabled"' : ''} onclick="goToPage(${currentPage - 1})">← Previous</button>`;
// Page numbers
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
if (startPage > 1) {
html += `<button onclick="goToPage(1)">1</button>`;
if (startPage > 2) html += `<span style="padding: 0 0.5rem;">...</span>`;
}
for (let i = startPage; i <= endPage; i++) {
html += `<button ${i === currentPage ? 'class="active"' : ''} onclick="goToPage(${i})">${i}</button>`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) html += `<span style="padding: 0 0.5rem;">...</span>`;
html += `<button onclick="goToPage(${totalPages})">${totalPages}</button>`;
}
// Next button
html += `<button ${currentPage === totalPages ? 'disabled class="disabled"' : ''} onclick="goToPage(${currentPage + 1})">Next →</button>`;
container.innerHTML = html;
}
function goToPage(page) {
currentPage = page;
loadTenders();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function viewTenderDetail(tenderId) {
const token = localStorage.getItem('token');
try {
const response = await fetch(`/api/tenders/${tenderId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Failed to load tender details');
const tender = await response.json();
displayTenderModal(tender);
} catch (error) {
console.error('Error loading tender detail:', error);
alert('Error loading tender details');
}
}
function displayTenderModal(tender) {
document.getElementById('modalTitle').textContent = escapeHtml(tender.title);
document.getElementById('modalBody').innerHTML = `
<div class="detail-section">
<h3>Overview</h3>
<div class="detail-row">
<div class="detail-col">
<div class="detail-label">Source</div>
<div class="detail-value">${escapeHtml(tender.source || 'Unknown')}</div>
</div>
<div class="detail-col">
<div class="detail-label">Buyer</div>
<div class="detail-value">${escapeHtml(tender.buyer || 'Unknown')}</div>
</div>
</div>
<div class="detail-row">
<div class="detail-col">
<div class="detail-label">Deadline</div>
<div class="detail-value">${formatDate(tender.deadline)}</div>
</div>
<div class="detail-col">
<div class="detail-label">Contract Value</div>
<div class="detail-value">${tender.value_high ? '£' + formatNumber(tender.value_high) : 'TBA'}</div>
</div>
</div>
</div>
<div class="detail-section">
<h3>Description</h3>
<div class="detail-value">${escapeHtml(tender.description || 'No description available')}</div>
</div>
${tender.notice_url ? `
<div class="detail-section">
<h3>Links</h3>
<div class="detail-value"><a href="${escapeHtml(tender.notice_url)}" target="_blank" rel="noopener">View on official portal →</a></div>
</div>
` : ''}
`;
document.getElementById('modalApplyBtn').onclick = () => {
if (tender.notice_url) {
window.open(tender.notice_url, '_blank');
} else {
alert('External link not available for this tender');
}
};
document.getElementById('modalSaveBtn').onclick = () => {
saveTender(tender.id);
};
document.getElementById('tenderModal').classList.add('active');
}
function closeModal() {
document.getElementById('tenderModal').classList.remove('active');
}
function saveTender(tenderId) {
alert('Tender saved! (Feature coming soon)');
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
function formatDate(dateString) {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
return date.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
function formatNumber(num) {
return new Intl.NumberFormat('en-GB').format(Math.round(num));
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>