- Server: Express.js with 13 API route files (auth, regulations, contacts, calendar, truck stops, bridges, weigh stations, alerts, load board, escort locator, orders, documents, contributions) - Database: PostgreSQL with Prisma ORM, 15 models covering all modules - Auth: JWT + bcrypt with role-based access control (driver/carrier/escort/admin) - Geospatial: Haversine distance filtering on truck stops, bridges, escorts - Seed script: Imports all existing mock data (51 states, contacts, equipment, truck stops, bridges, weigh stations, alerts, seasonal restrictions) - Frontend: All 10 data-driven pages now fetch from /api instead of mock-data.js - API client (api.js): Compatibility layer that transforms API responses to match existing frontend rendering code, minimizing page-level changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
295 lines
13 KiB
HTML
295 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Seasonal Restriction Calendar | PilotEdge</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<style>
|
||
.gantt-bar { transition: opacity 0.2s; }
|
||
.gantt-bar:hover { opacity: 0.85; }
|
||
</style>
|
||
</head>
|
||
<body class="bg-slate-50 min-h-screen flex flex-col">
|
||
|
||
<div id="main-nav"></div>
|
||
<div id="poc-banner"></div>
|
||
|
||
<!-- Page Header -->
|
||
<section class="bg-slate-900 text-white pt-24 pb-12 px-4">
|
||
<div class="max-w-7xl mx-auto">
|
||
<h1 class="text-3xl md:text-4xl font-bold mb-3">Seasonal Restriction Calendar</h1>
|
||
<p class="text-lg text-gray-400 max-w-3xl">Plan around seasonal closures, weight restrictions, and travel blackouts that affect oversize load movement across the country.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Filters -->
|
||
<section class="max-w-7xl mx-auto px-4 pt-8 w-full">
|
||
<div class="bg-white rounded-2xl shadow-lg p-6">
|
||
<div class="flex flex-wrap items-end gap-4">
|
||
<div class="flex-1 min-w-[180px]">
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1">Restriction Type</label>
|
||
<select id="filter-type" onchange="applyFilters()" class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-amber-400 focus:border-amber-400 outline-none">
|
||
<option value="">All Types</option>
|
||
<option value="spring_weight">🔵 Spring Weight</option>
|
||
<option value="winter_closure">🟣 Winter Closure</option>
|
||
<option value="wind_season">🟡 Wind Season</option>
|
||
<option value="holiday_blackout">🔴 Holiday Blackout</option>
|
||
<option value="harvest_season">🟢 Harvest Season</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex-1 min-w-[180px]">
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1">State</label>
|
||
<select id="filter-state" onchange="applyFilters()" class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-amber-400 focus:border-amber-400 outline-none">
|
||
<option value="">All States</option>
|
||
</select>
|
||
</div>
|
||
<button onclick="resetFilters()" class="bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold px-4 py-2 rounded-lg transition-colors text-sm">
|
||
Reset Filters
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Calendar Gantt Chart -->
|
||
<section class="max-w-7xl mx-auto px-4 py-8 w-full">
|
||
<div class="bg-white rounded-2xl shadow-lg p-6 overflow-x-auto">
|
||
<h2 class="text-xl font-bold text-slate-900 mb-1">12-Month Timeline</h2>
|
||
<p class="text-sm text-slate-500 mb-6">Colored bars show when each restriction is active. Hover for details.</p>
|
||
<div id="gantt-chart"></div>
|
||
<div id="gantt-empty" class="hidden text-center py-12 text-slate-400">
|
||
<p class="text-lg font-medium">No restrictions match the current filters.</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Legend -->
|
||
<section class="max-w-7xl mx-auto px-4 pb-4 w-full">
|
||
<div class="flex flex-wrap gap-4 justify-center text-sm">
|
||
<span class="flex items-center gap-1.5"><span class="w-4 h-3 rounded" style="background:#3b82f6"></span> Spring Weight</span>
|
||
<span class="flex items-center gap-1.5"><span class="w-4 h-3 rounded" style="background:#6366f1"></span> Winter Closure</span>
|
||
<span class="flex items-center gap-1.5"><span class="w-4 h-3 rounded" style="background:#f59e0b"></span> Wind Season</span>
|
||
<span class="flex items-center gap-1.5"><span class="w-4 h-3 rounded" style="background:#ef4444"></span> Holiday Blackout</span>
|
||
<span class="flex items-center gap-1.5"><span class="w-4 h-3 rounded" style="background:#22c55e"></span> Harvest Season</span>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Restriction Detail Cards -->
|
||
<section class="max-w-7xl mx-auto px-4 pb-8 w-full">
|
||
<h2 class="text-xl font-bold text-slate-900 mb-4">Restriction Details</h2>
|
||
<div id="restriction-cards" class="grid md:grid-cols-2 gap-6"></div>
|
||
<div id="cards-empty" class="hidden text-center py-12 text-slate-400">
|
||
<p class="text-lg font-medium">No restrictions match the current filters.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<div id="main-footer"></div>
|
||
|
||
<script src="api.js"></script>
|
||
<script src="nav.js"></script>
|
||
<script>
|
||
renderNav('calendar');
|
||
renderBanner();
|
||
renderFooter();
|
||
|
||
(async () => {
|
||
const MOCK_SEASONAL_RESTRICTIONS = await PilotEdge.getSeasonalRestrictions();
|
||
|
||
// ---- Constants ----
|
||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||
|
||
const TYPE_BADGES = {
|
||
spring_weight: { label:'Spring Weight', bg:'bg-blue-100', text:'text-blue-800' },
|
||
winter_closure: { label:'Winter Closure', bg:'bg-indigo-100', text:'text-indigo-800' },
|
||
wind_season: { label:'Wind Season', bg:'bg-amber-100', text:'text-amber-800' },
|
||
holiday_blackout:{ label:'Holiday Blackout', bg:'bg-red-100', text:'text-red-800' },
|
||
harvest_season: { label:'Harvest Season', bg:'bg-green-100', text:'text-green-800' }
|
||
};
|
||
|
||
// ---- Populate state filter ----
|
||
const states = [...new Set(MOCK_SEASONAL_RESTRICTIONS.map(r => r.stateName))].sort();
|
||
const stateSelect = document.getElementById('filter-state');
|
||
states.forEach(s => {
|
||
const r = MOCK_SEASONAL_RESTRICTIONS.find(x => x.stateName === s);
|
||
stateSelect.add(new Option(`${s} (${r.state})`, r.state));
|
||
});
|
||
|
||
// ---- Filtering ----
|
||
function getFiltered() {
|
||
const type = document.getElementById('filter-type').value;
|
||
const state = document.getElementById('filter-state').value;
|
||
return MOCK_SEASONAL_RESTRICTIONS.filter(r =>
|
||
(!type || r.type === type) && (!state || r.state === state)
|
||
);
|
||
}
|
||
|
||
function applyFilters() {
|
||
renderGantt(getFiltered());
|
||
renderCards(getFiltered());
|
||
}
|
||
|
||
function resetFilters() {
|
||
document.getElementById('filter-type').value = '';
|
||
document.getElementById('filter-state').value = '';
|
||
applyFilters();
|
||
}
|
||
|
||
// ---- Month span helper (handles wrap-around) ----
|
||
function getActiveMonths(r) {
|
||
const months = [];
|
||
if (r.startMonth <= r.endMonth) {
|
||
for (let m = r.startMonth; m <= r.endMonth; m++) months.push(m);
|
||
} else {
|
||
for (let m = r.startMonth; m <= 12; m++) months.push(m);
|
||
for (let m = 1; m <= r.endMonth; m++) months.push(m);
|
||
}
|
||
return months;
|
||
}
|
||
|
||
// ---- Gantt Chart ----
|
||
function renderGantt(restrictions) {
|
||
const chart = document.getElementById('gantt-chart');
|
||
const empty = document.getElementById('gantt-empty');
|
||
|
||
if (restrictions.length === 0) {
|
||
chart.classList.add('hidden');
|
||
empty.classList.remove('hidden');
|
||
return;
|
||
}
|
||
chart.classList.remove('hidden');
|
||
empty.classList.add('hidden');
|
||
|
||
// Build the grid: header row + one row per restriction
|
||
let html = '<div style="min-width:800px;">';
|
||
|
||
// Month header row
|
||
html += '<div class="grid" style="grid-template-columns: 220px repeat(12, 1fr); gap: 0;">';
|
||
html += '<div class="text-xs font-semibold text-slate-500 py-2 pr-3 text-right">Restriction</div>';
|
||
MONTHS.forEach((m, i) => {
|
||
const now = new Date();
|
||
const isCurrentMonth = (now.getMonth() === i);
|
||
html += `<div class="text-xs font-semibold text-center py-2 ${isCurrentMonth ? 'text-amber-600 bg-amber-50 rounded-t' : 'text-slate-500'}">${m}</div>`;
|
||
});
|
||
html += '</div>';
|
||
|
||
// Restriction rows
|
||
restrictions.forEach(r => {
|
||
const active = getActiveMonths(r);
|
||
const badge = TYPE_BADGES[r.type] || { label: r.type, bg:'bg-slate-100', text:'text-slate-700' };
|
||
|
||
html += '<div class="grid border-t border-slate-100" style="grid-template-columns: 220px repeat(12, 1fr); gap: 0;">';
|
||
|
||
// Label cell
|
||
html += `<div class="flex items-center py-2 pr-3 gap-2 justify-end">
|
||
<span class="text-xs font-medium text-slate-700 text-right truncate" title="${r.title} — ${r.stateName}">${r.stateName}</span>
|
||
<span class="flex-shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded ${badge.bg} ${badge.text}">${r.state}</span>
|
||
</div>`;
|
||
|
||
// Month cells
|
||
for (let m = 1; m <= 12; m++) {
|
||
const isActive = active.includes(m);
|
||
const now = new Date();
|
||
const isCurrentMonth = (now.getMonth() + 1 === m);
|
||
|
||
if (isActive) {
|
||
const isStart = m === r.startMonth;
|
||
const isEnd = m === r.endMonth;
|
||
const roundL = isStart ? 'rounded-l-full' : '';
|
||
const roundR = isEnd ? 'rounded-r-full' : '';
|
||
|
||
html += `<div class="flex items-center py-2 px-0.5 ${isCurrentMonth ? 'bg-amber-50' : ''}">
|
||
<div class="gantt-bar w-full h-6 ${roundL} ${roundR} flex items-center justify-center cursor-default"
|
||
style="background:${r.color}; opacity:0.8;"
|
||
title="${r.title}\n${MONTHS[r.startMonth-1]} ${r.startDay} – ${MONTHS[r.endMonth-1]} ${r.endDay}">
|
||
${isStart ? `<span class="text-[9px] text-white font-bold drop-shadow pl-1">${r.startDay}</span>` : ''}
|
||
${isEnd ? `<span class="text-[9px] text-white font-bold drop-shadow pr-1">${r.endDay}</span>` : ''}
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
html += `<div class="flex items-center py-2 px-0.5 ${isCurrentMonth ? 'bg-amber-50' : ''}">
|
||
<div class="w-full h-6 bg-slate-50 ${m === 1 ? 'rounded-l' : ''} ${m === 12 ? 'rounded-r' : ''}"></div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
html += '</div>';
|
||
});
|
||
|
||
html += '</div>';
|
||
chart.innerHTML = html;
|
||
}
|
||
|
||
// ---- Detail Cards ----
|
||
function renderCards(restrictions) {
|
||
const container = document.getElementById('restriction-cards');
|
||
const empty = document.getElementById('cards-empty');
|
||
|
||
if (restrictions.length === 0) {
|
||
container.classList.add('hidden');
|
||
empty.classList.remove('hidden');
|
||
return;
|
||
}
|
||
container.classList.remove('hidden');
|
||
empty.classList.add('hidden');
|
||
|
||
container.innerHTML = restrictions.map(r => {
|
||
const badge = TYPE_BADGES[r.type] || { label: r.type, bg:'bg-slate-100', text:'text-slate-700' };
|
||
const active = getActiveMonths(r);
|
||
const monthRange = `${MONTHS[r.startMonth-1]} ${r.startDay} – ${MONTHS[r.endMonth-1]} ${r.endDay}`;
|
||
|
||
// Month pills
|
||
const monthPills = MONTHS.map((m, i) => {
|
||
const isActive = active.includes(i + 1);
|
||
return `<span class="inline-block w-7 text-center text-[10px] font-semibold rounded py-0.5 ${isActive ? 'text-white' : 'text-slate-400 bg-slate-100'}" ${isActive ? `style="background:${r.color}"` : ''}>${m.charAt(0)}${m.charAt(1)}</span>`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="bg-white rounded-2xl shadow-lg border border-slate-100 overflow-hidden">
|
||
<div class="px-6 pt-5 pb-4">
|
||
<div class="flex items-start justify-between gap-3 mb-3">
|
||
<div>
|
||
<h3 class="font-bold text-slate-900 text-lg leading-tight">${r.title}</h3>
|
||
<p class="text-sm text-slate-500 mt-0.5">${r.stateName} (${r.state})</p>
|
||
</div>
|
||
<span class="flex-shrink-0 text-xs font-bold px-2.5 py-1 rounded-full ${badge.bg} ${badge.text}">${badge.label}</span>
|
||
</div>
|
||
|
||
<!-- Active period -->
|
||
<div class="mb-4">
|
||
<div class="flex items-center gap-2 text-sm text-slate-700 font-medium mb-2">
|
||
<svg class="w-4 h-4 text-amber-500" 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>
|
||
${monthRange}
|
||
</div>
|
||
<div class="flex gap-1 flex-wrap">${monthPills}</div>
|
||
</div>
|
||
|
||
<!-- Routes -->
|
||
<div class="mb-3">
|
||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Affected Routes</p>
|
||
<p class="text-sm text-slate-700">${r.routes}</p>
|
||
</div>
|
||
|
||
<!-- Description -->
|
||
<div class="mb-3">
|
||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Description</p>
|
||
<p class="text-sm text-slate-600">${r.description}</p>
|
||
</div>
|
||
|
||
<!-- Impact -->
|
||
<div class="bg-amber-50 border border-amber-200 rounded-xl px-4 py-3">
|
||
<p class="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-1">⚠️ Impact on Oversize Loads</p>
|
||
<p class="text-sm text-amber-900">${r.impact}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// ---- Initial render ----
|
||
applyFilters();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|