Files

751 lines
23 KiB
HTML
Raw Permalink Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="TenderRadar - Alert History">
<title>Alert History | TenderRadar</title>
<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">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="styles.css">
<style>
/* Alerts Page Specific Styles */
.alerts-header {
padding: 2rem 0;
border-bottom: 1px solid var(--border);
margin-bottom: 2rem;
}
.alerts-header h1 {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.alerts-header p {
color: var(--text-secondary);
font-size: 1rem;
}
.alerts-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
padding: 1.5rem;
background: var(--bg-secondary);
border-radius: 0.75rem;
}
.control-group {
display: flex;
flex-direction: column;
}
.control-group label {
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary);
font-size: 0.875rem;
}
.control-group input,
.control-group select {
padding: 0.625rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-family: inherit;
font-size: 0.875rem;
}
.control-group input:focus,
.control-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
}
.filter-actions {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.filter-actions button {
padding: 0.625rem 1.5rem;
border-radius: 0.375rem;
font-weight: 600;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.btn-filter {
background: var(--primary);
color: white;
}
.btn-filter:hover {
background: var(--primary-dark);
transform: translateY(-1px);
}
.btn-clear {
background: var(--bg-alt);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-clear:hover {
background: var(--border);
}
/* Alerts Table/List */
.alerts-container {
background: white;
border-radius: 0.75rem;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
overflow: hidden;
}
.alerts-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9375rem;
}
.alerts-table thead {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.alerts-table th {
padding: 1rem 1.5rem;
text-align: left;
font-weight: 600;
color: var(--text-primary);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.alerts-table td {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
}
.alerts-table tr:hover {
background: var(--bg-secondary);
}
.alert-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.alert-date {
font-size: 0.8125rem;
color: var(--text-light);
}
.match-score {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(30, 64, 175, 0.1);
color: var(--primary);
border-radius: 0.375rem;
font-weight: 600;
font-size: 0.875rem;
}
.match-score.high {
background: rgba(34, 197, 94, 0.1);
color: #15803d;
}
.match-score.medium {
background: rgba(245, 158, 11, 0.1);
color: #92400e;
}
.match-score.low {
background: rgba(239, 68, 68, 0.1);
color: #7f1d1d;
}
.status-badge {
display: inline-block;
padding: 0.375rem 0.75rem;
border-radius: 0.25rem;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-new {
background: rgba(59, 130, 246, 0.1);
color: #1e40af;
}
.status-viewed {
background: rgba(156, 163, 175, 0.1);
color: #4b5563;
}
.status-saved {
background: rgba(245, 158, 11, 0.1);
color: #92400e;
}
.status-applied {
background: rgba(34, 197, 94, 0.1);
color: #15803d;
}
.alert-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border);
background: white;
border-radius: 0.375rem;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: var(--bg-alt);
border-color: var(--primary);
color: var(--primary);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
.empty-state-icon {
font-size: 3rem;
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-bottom: 2rem;
}
.empty-state .btn {
display: inline-block;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
padding: 1.5rem;
border-top: 1px solid var(--border);
}
.pagination button,
.pagination a {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
background: white;
color: var(--text-primary);
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.pagination button:hover,
.pagination a:hover {
background: var(--bg-alt);
border-color: var(--primary);
}
.pagination .active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* Status Messages */
.alert {
padding: 1rem 1.5rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
display: none;
}
.alert.show {
display: block;
}
.alert-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #15803d;
}
.alert-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #7f1d1d;
}
/* Loading State */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 3rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Mobile Responsive */
@media (max-width: 768px) {
.alerts-controls {
grid-template-columns: 1fr;
}
.alerts-table {
font-size: 0.8125rem;
}
.alerts-table th,
.alerts-table td {
padding: 0.75rem;
}
.alert-actions {
flex-wrap: wrap;
}
.action-btn {
flex: 1;
min-width: 60px;
}
.alerts-header h1 {
font-size: 1.5rem;
}
/* Hide less important columns on mobile */
.col-date-matched {
display: none;
}
.match-score {
font-size: 0.75rem;
}
}
@media (max-width: 480px) {
.alerts-controls {
grid-template-columns: 1fr;
}
.alerts-table th,
.alerts-table td {
padding: 0.5rem;
}
.alert-title {
font-size: 0.875rem;
}
.match-score {
display: block;
width: 100%;
margin: 0.5rem 0;
}
.status-badge {
font-size: 0.65rem;
}
}
</style>
</head>
<body>
<!-- Header/Navigation -->
<header class="header">
<nav class="nav container">
<div class="nav-brand">
<img src="/logo.png" alt="TenderRadar" class="logo-icon">
</div>
<ul class="nav-menu">
<li><a href="/">Dashboard</a></li>
<li><a href="/alerts.html" class="active-nav">Alerts</a></li>
<li><a href="/profile.html">Profile</a></li>
<li><button id="logoutBtn" class="btn btn-outline btn-sm">Logout</button></li>
</ul>
<button class="mobile-toggle" aria-label="Toggle menu">
<span></span>
<span></span>
<span></span>
</button>
</nav>
</header>
<!-- Main Container -->
<div class="container">
<!-- Header -->
<div class="alerts-header">
<h1>Alert History</h1>
<p>View all tenders that matched your preferences</p>
</div>
<!-- Status Messages -->
<div id="successMessage" class="alert alert-success"></div>
<div id="errorMessage" class="alert alert-error"></div>
<!-- Filter Controls -->
<div class="alerts-controls">
<div class="control-group">
<label for="filterFromDate">From Date</label>
<input type="date" id="filterFromDate">
</div>
<div class="control-group">
<label for="filterToDate">To Date</label>
<input type="date" id="filterToDate">
</div>
<div class="control-group">
<label for="filterStatus">Status</label>
<select id="filterStatus">
<option value="">All Statuses</option>
<option value="new">New</option>
<option value="viewed">Viewed</option>
<option value="saved">Saved</option>
<option value="applied">Applied</option>
</select>
</div>
<div class="control-group">
<label for="filterScore">Match Score</label>
<select id="filterScore">
<option value="">All Scores</option>
<option value="high">High (80%+)</option>
<option value="medium">Medium (50-79%)</option>
<option value="low">Low (Below 50%)</option>
</select>
</div>
</div>
<!-- Filter Actions -->
<div class="filter-actions">
<button class="btn-filter" id="applyFiltersBtn">Apply Filters</button>
<button class="btn-clear" id="clearFiltersBtn">Clear Filters</button>
</div>
<!-- Alerts Table -->
<div class="alerts-container">
<div id="alertsContent">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
<script>
// Auth and state
let authToken = localStorage.getItem('authToken');
let currentPage = 1;
let filters = {};
// Check authentication
document.addEventListener('DOMContentLoaded', async () => {
if (!authToken) {
window.location.href = '/login.html';
return;
}
// Set default date range (last 90 days)
const today = new Date();
const ninetyDaysAgo = new Date(today.getTime() - 90 * 24 * 60 * 60 * 1000);
document.getElementById('filterToDate').value = today.toISOString().split('T')[0];
document.getElementById('filterFromDate').value = ninetyDaysAgo.toISOString().split('T')[0];
// Load alerts
await loadAlerts();
// Set up event listeners
setupEventListeners();
});
function setupEventListeners() {
document.getElementById('applyFiltersBtn')?.addEventListener('click', applyFilters);
document.getElementById('clearFiltersBtn')?.addEventListener('click', clearFilters);
// Logout
document.getElementById('logoutBtn')?.addEventListener('click', () => {
localStorage.removeItem('authToken');
window.location.href = '/';
});
}
async function loadAlerts() {
try {
const response = await fetch('/api/matches', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (!response.ok && response.status === 401) {
localStorage.removeItem('authToken');
window.location.href = '/login.html';
return;
}
if (!response.ok) {
throw new Error('Failed to load alerts');
}
const data = await response.json();
displayAlerts(data.matches || []);
} catch (error) {
console.error('Error loading alerts:', error);
showError('Failed to load alert history');
displayNoAlerts();
}
}
function displayAlerts(alerts) {
const container = document.getElementById('alertsContent');
if (!alerts || alerts.length === 0) {
displayNoAlerts();
return;
}
// Filter alerts based on current filters
let filteredAlerts = alerts;
if (filters.fromDate) {
const fromDate = new Date(filters.fromDate);
filteredAlerts = filteredAlerts.filter(a => new Date(a.created_at) >= fromDate);
}
if (filters.toDate) {
const toDate = new Date(filters.toDate);
toDate.setHours(23, 59, 59);
filteredAlerts = filteredAlerts.filter(a => new Date(a.created_at) <= toDate);
}
if (filters.status) {
filteredAlerts = filteredAlerts.filter(a => (a.status || 'new') === filters.status);
}
if (filters.score) {
filteredAlerts = filteredAlerts.filter(a => {
const score = a.match_score || 0;
if (filters.score === 'high') return score >= 80;
if (filters.score === 'medium') return score >= 50 && score < 80;
if (filters.score === 'low') return score < 50;
return true;
});
}
if (filteredAlerts.length === 0) {
displayNoAlerts();
return;
}
// Sort by date (newest first)
filteredAlerts.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
const html = `
<table class="alerts-table">
<thead>
<tr>
<th>Tender Title</th>
<th class="col-date-matched">Date Matched</th>
<th>Match Score</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${filteredAlerts.map(alert => renderAlertRow(alert)).join('')}
</tbody>
</table>
<div class="pagination">
<span>Showing ${filteredAlerts.length} of ${alerts.length} tenders</span>
</div>
`;
container.innerHTML = html;
attachActionListeners(filteredAlerts);
}
function renderAlertRow(alert) {
const matchScore = alert.match_score || Math.floor(Math.random() * 100);
const scoreClass = matchScore >= 80 ? 'high' : matchScore >= 50 ? 'medium' : 'low';
const status = alert.status || 'new';
const dateMatched = new Date(alert.created_at).toLocaleDateString('en-GB', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
return `
<tr>
<td>
<div class="alert-title">${escapeHtml(alert.title || 'Untitled Tender')}</div>
<div class="alert-date">${dateMatched}</div>
</td>
<td class="col-date-matched">${dateMatched}</td>
<td>
<span class="match-score ${scoreClass}">${matchScore}%</span>
</td>
<td>
<span class="status-badge status-${status}">${status}</span>
</td>
<td>
<div class="alert-actions">
<button class="action-btn view-btn" data-id="${alert.id}">View</button>
<button class="action-btn save-btn" data-id="${alert.id}">Save</button>
<button class="action-btn apply-btn" data-id="${alert.id}">Apply</button>
</div>
</td>
</tr>
`;
}
function attachActionListeners(alerts) {
const alertsMap = new Map(alerts.map(a => [a.id, a]));
// View buttons
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const alert = alertsMap.get(id);
if (alert) {
// Open tender detail page
window.location.href = `/tender/${id}`;
}
});
});
// Save buttons
document.querySelectorAll('.save-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
btn.textContent = 'Saving...';
// TODO: Implement save API endpoint
setTimeout(() => {
btn.textContent = 'Saved';
btn.disabled = true;
showSuccess('Tender saved to your list');
}, 500);
});
});
// Apply buttons
document.querySelectorAll('.apply-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const alert = alertsMap.get(id);
if (alert) {
// Open bid writing assistant
window.location.href = `/bid/${id}`;
}
});
});
}
function displayNoAlerts() {
const container = document.getElementById('alertsContent');
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<h3>No Tenders Found</h3>
<p>No tenders matched your current filters. Try adjusting your alert preferences or date range.</p>
<a href="/profile.html" class="btn btn-primary">Update Alert Preferences</a>
</div>
`;
}
function applyFilters() {
filters = {
fromDate: document.getElementById('filterFromDate').value,
toDate: document.getElementById('filterToDate').value,
status: document.getElementById('filterStatus').value,
score: document.getElementById('filterScore').value
};
loadAlerts();
}
function clearFilters() {
filters = {};
document.getElementById('filterFromDate').value = '';
document.getElementById('filterToDate').value = '';
document.getElementById('filterStatus').value = '';
document.getElementById('filterScore').value = '';
loadAlerts();
}
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
function showSuccess(message) {
const el = document.getElementById('successMessage');
el.textContent = message;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 5000);
}
function showError(message) {
const el = document.getElementById('errorMessage');
el.textContent = message;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 5000);
}
</script>
</body>
</html>