GeoAccess / templates /index.html
OKRN's picture
Update templates/index.html
c044db5 verified
<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GeoAccess Tool</title>
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
:root{
--ga-surface: rgba(255,255,255,.75);
--ga-shadow: 0 12px 30px rgba(0,0,0,.08);
}
body{
background:
radial-gradient(900px 600px at 10% 10%, rgba(13,110,253,.12), transparent 60%),
radial-gradient(900px 600px at 90% 20%, rgba(25,135,84,.10), transparent 60%),
radial-gradient(900px 600px at 50% 90%, rgba(111,66,193,.10), transparent 60%),
var(--bs-body-bg);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.glass{
background: var(--ga-surface);
backdrop-filter: blur(10px);
border: 1px solid rgba(0,0,0,.06);
box-shadow: var(--ga-shadow);
}
[data-bs-theme="dark"] .glass{
background: rgba(33,37,41,.65);
border-color: rgba(255,255,255,.08);
}
.brand-badge{
width: 42px; height: 42px;
display:grid; place-items:center;
border-radius: 14px;
background: linear-gradient(135deg, rgba(13,110,253,.95), rgba(111,66,193,.95));
box-shadow: 0 10px 22px rgba(13,110,253,.22);
}
.sticky-head thead th{
position: sticky;
top: 0;
z-index: 2;
background: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
}
.table td, .table th{ vertical-align: middle; }
.small-help{ font-size: .85rem; color: var(--bs-secondary-color); }
.file-hint{
display:flex; align-items:center; gap:.5rem;
padding:.55rem .75rem;
border-radius: .75rem;
background: rgba(13,110,253,.06);
border: 1px dashed rgba(13,110,253,.25);
}
[data-bs-theme="dark"] .file-hint{
background: rgba(13,110,253,.12);
border-color: rgba(13,110,253,.35);
}
.scrollbox{ overflow:auto; }
.scrollbox pre{ margin: 0; }
.toast-container{ z-index: 1080; }
.chip{
display:inline-flex; align-items:center; gap:.35rem;
padding: .2rem .55rem;
border-radius: 999px;
font-size: .78rem;
border: 1px solid var(--bs-border-color);
background: rgba(255,255,255,.55);
}
[data-bs-theme="dark"] .chip{ background: rgba(33,37,41,.55); }
main{ flex: 1 1 auto; }
</style>
</head>
<body>
<!-- Top nav -->
<nav class="navbar navbar-expand-lg border-bottom bg-body">
<div class="container-fluid py-2 px-3 px-md-4">
<div class="d-flex align-items-center gap-3">
<div class="brand-badge text-white">
<i class="bi bi-geo-alt-fill fs-5"></i>
</div>
<div>
<div class="fw-semibold lh-1">Geo Access Tool</div>
<div class="small-help">Upload inputs (optional), run analysis, and download reports.</div>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<button id="themeToggle" class="btn btn-outline-secondary btn-sm" type="button" title="Toggle theme">
<i class="bi bi-moon-stars"></i>
<span class="d-none d-sm-inline">Theme</span>
</button>
<a href="{{ url_for('run_script') }}" class="btn btn-success btn-sm">
<i class="bi bi-play-fill"></i>
Run analysis
</a>
</div>
</div>
</nav>
<!-- Toasts (flash messages) -->
<div class="toast-container position-fixed top-0 end-0 p-3">
{% for category, message in get_flashed_messages(with_categories=true) %}
{% set tone = 'success' if category == 'success' else ('danger' if category in ['error','danger'] else 'secondary') %}
<div class="toast align-items-center text-bg-{{ tone }} border-0 mb-2" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="5000">
<div class="d-flex">
<div class="toast-body">
<strong class="me-1">
{% if tone == 'success' %}<i class="bi bi-check-circle-fill"></i>{% endif %}
{% if tone == 'danger' %}<i class="bi bi-exclamation-triangle-fill"></i>{% endif %}
{% if tone == 'secondary' %}<i class="bi bi-info-circle-fill"></i>{% endif %}
</strong>
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{% endfor %}
</div>
<main class="container-fluid py-4 px-3 px-md-4">
<div class="row g-4 align-items-start">
<!-- Left: Inputs -->
<div class="col-12 col-lg-5">
<div class="card glass border-0">
<div class="card-body p-4">
<div class="d-flex align-items-start justify-content-between gap-3 mb-3">
<div>
<h5 class="mb-1"><i class="bi bi-upload"></i> Inputs</h5>
<div class="small-help">
Uploading a file replaces the server-side default of the same type.
</div>
</div>
<span class="chip">
<i class="bi bi-file-earmark-text"></i> .txt/.csv
</span>
</div>
<!--
<div class="file-hint mb-4">
<i class="bi bi-folder2-open text-primary fs-5"></i>
<div class="flex-grow-1">
<div class="fw-semibold">Defaults used if you don’t upload anything</div>
<div class="small-help">
Zip Code Data: <code>uszips.csv</code> · Customer Zip List: <code>ziplist.txt</code> · Service Centers: <code>locations.txt</code>
</div>
</div>
</div> -->
<div class="row g-3">
<!-- Zip Code Data Upload -->
<div class="col-12">
<form action="{{ url_for('upload_file') }}" method="post" enctype="multipart/form-data">
<div class="d-flex justify-content-between align-items-center">
<label class="form-label fw-semibold mb-1">
<i class="bi bi-map"></i> Zip Code Data
</label>
<span class="badge text-bg-light border">Default: 20260106_uszips.csv</span>
</div>
<input class="form-control" type="file" name="file" required>
<input type="hidden" name="file_type" value="zipcodedata">
<div class="d-flex justify-content-between align-items-center mt-2">
<!--<div class="small-help">Expected columns: <code>zip</code>, <code>lat</code>, <code>lng</code>.</div>-->
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-cloud-arrow-up"></i> Upload
</button>
</div>
</form>
</div>
<!-- Customer Zip List Upload -->
<div class="col-12">
<form action="{{ url_for('upload_file') }}" method="post" enctype="multipart/form-data">
<div class="d-flex justify-content-between align-items-center">
<label class="form-label fw-semibold mb-1">
<i class="bi bi-people"></i> Customer Zip List
</label>
<span class="badge text-bg-light border">Default: 813_customer_ziplist.txt</span>
</div>
<input class="form-control" type="file" name="file" required>
<input type="hidden" name="file_type" value="ziplist">
<div class="d-flex justify-content-between align-items-center mt-2">
<!--<div class="small-help">Format: <code>ZIP&lt;tab&gt;count</code> per line.</div>-->
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-cloud-arrow-up"></i> Upload
</button>
</div>
</form>
</div>
<!-- Service Centers Upload -->
<div class="col-12">
<form action="{{ url_for('upload_file') }}" method="post" enctype="multipart/form-data">
<div class="d-flex justify-content-between align-items-center">
<label class="form-label fw-semibold mb-1">
<i class="bi bi-buildings"></i> Service Centers
</label>
<span class="badge text-bg-light border">Default: 813_service_locations.txt</span>
</div>
<input class="form-control" type="file" name="file" required>
<input type="hidden" name="file_type" value="servicecenters">
<div class="d-flex justify-content-between align-items-center mt-2">
<!--<div class="small-help">Format: name, address, city, state, ZIP (tab-delimited).</div>-->
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-cloud-arrow-up"></i> Upload
</button>
</div>
</form>
</div>
<div class="col-12">
<div class="alert alert-info mb-0">
<div class="d-flex gap-2">
<i class="bi bi-lightning-charge-fill"></i>
<div>
<div class="fw-semibold">Ready to run</div>
<div class="small-help mb-0">
Use the <strong>Run analysis</strong> button in the top-right. Reports will appear on the right.
</div>
</div>
</div>
</div>
</div>
</div><!-- /row -->
</div><!-- /card-body -->
</div><!-- /card -->
</div>
<!-- Right: Reports -->
<div class="col-12 col-lg-7">
<div class="card glass border-0">
<div class="card-body p-0">
<div class="p-4 pb-0">
<div class="d-flex align-items-center justify-content-between gap-3">
<div>
<h5 class="mb-1"><i class="bi bi-clipboard-data"></i> Reports</h5>
<div class="small-help">Full previews are shown below. Use the download buttons for the raw files.</div>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('download_file', report_type='report1') }}"
class="btn btn-outline-primary btn-sm {% if not report1_exists %}disabled{% endif %}">
<i class="bi bi-download"></i> Report 1
</a>
<a href="{{ url_for('download_file', report_type='report2') }}"
class="btn btn-outline-primary btn-sm {% if not report2_exists %}disabled{% endif %}">
<i class="bi bi-download"></i> Report 2
</a>
<a href="{{ url_for('download_file', report_type='errors') }}"
class="btn btn-outline-warning btn-sm {% if not errors_exist %}disabled{% endif %}">
<i class="bi bi-download"></i> Errors
</a>
</div>
</div>
</div>
<ul class="nav nav-tabs px-4 mt-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-r1" type="button" role="tab">
Distance report
{% if report1_exists %}<span class="badge text-bg-success ms-2">Ready</span>{% else %}<span class="badge text-bg-secondary ms-2">Not yet</span>{% endif %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-r2" type="button" role="tab">
Customer distribution
{% if report2_exists %}<span class="badge text-bg-success ms-2">Ready</span>{% else %}<span class="badge text-bg-secondary ms-2">Not yet</span>{% endif %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-err" type="button" role="tab">
Error log
{% if errors_exist %}<span class="badge text-bg-warning ms-2">Available</span>{% else %}<span class="badge text-bg-secondary ms-2">Not yet</span>{% endif %}
</button>
</li>
</ul>
<div class="tab-content">
<!-- Report 1 -->
<div class="tab-pane fade show active p-4" id="tab-r1" role="tabpanel">
{% if report1_data %}
<div class="input-group input-group-sm mb-3">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input id="filterR1" type="text" class="form-control" placeholder="Filter by ZIP or distance...">
</div>
<div class="table-responsive scrollbox">
<table class="table table-hover table-sm sticky-head mb-0" id="tableR1">
<thead>
<tr>
<th style="width: 120px;">Zip</th>
<th style="width: 120px;">Customers</th>
<th>Distances</th>
</tr>
</thead>
<tbody>
{% for row in report1_data %}
<tr>
<td><code>{{ row.zipcode }}</code></td>
<td>{{ row.customers }}</td>
<td>{{ row.distances }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="display-6 mb-2"><i class="bi bi-inbox"></i></div>
<div class="fw-semibold">No distance report yet</div>
<div class="small-help">Run the analysis to generate <code>report1.txt</code>.</div>
</div>
{% endif %}
</div>
<!-- Report 2 -->
<div class="tab-pane fade p-4" id="tab-r2" role="tabpanel">
{% if report2_data %}
<div class="table-responsive scrollbox">
<table class="table table-hover table-sm sticky-head mb-0">
<thead>
<tr>
<th style="width: 160px;">Distance</th>
<th>Customers</th>
</tr>
</thead>
<tbody>
{% for row in report2_data %}
<tr>
<td><code>{{ row.distance }}</code></td>
<td>{{ row.customers }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="display-6 mb-2"><i class="bi bi-inbox"></i></div>
<div class="fw-semibold">No distribution report yet</div>
<div class="small-help">Run the analysis to generate <code>report2.txt</code>.</div>
</div>
{% endif %}
</div>
<!-- Errors -->
<div class="tab-pane fade p-4" id="tab-err" role="tabpanel">
{% if error_data %}
<div class="scrollbox border rounded-3 p-3 bg-body">
<pre class="text-danger small">{{ error_data|join('') }}</pre>
</div>
{% else %}
<div class="text-center py-5">
<div class="display-6 mb-2"><i class="bi bi-shield-check"></i></div>
<div class="fw-semibold">No error log yet</div>
<div class="small-help">After a run, <code>errorfile.txt</code> shows here.</div>
</div>
{% endif %}
</div>
</div><!-- /tab-content -->
</div><!-- /card-body -->
</div><!-- /card -->
</div>
</div>
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
<script>
// Theme toggle (persists in localStorage)
(function() {
const root = document.documentElement;
const saved = localStorage.getItem("ga-theme");
if (saved) root.setAttribute("data-bs-theme", saved);
document.getElementById("themeToggle")?.addEventListener("click", () => {
const next = (root.getAttribute("data-bs-theme") === "dark") ? "light" : "dark";
root.setAttribute("data-bs-theme", next);
localStorage.setItem("ga-theme", next);
});
})();
// Show toasts
document.querySelectorAll('.toast').forEach((el) => new bootstrap.Toast(el).show());
// Simple filter for Report 1 table
(function() {
const input = document.getElementById("filterR1");
const table = document.getElementById("tableR1");
if (!input || !table) return;
input.addEventListener("input", () => {
const q = input.value.toLowerCase().trim();
table.querySelectorAll("tbody tr").forEach((tr) => {
const text = tr.innerText.toLowerCase();
tr.style.display = text.includes(q) ? "" : "none";
});
});
})();
// Upload badges: swap "Default: ..." -> "Uploaded: <filename>"
(function() {
const KEY_PREFIX = "ga-uploaded-";
const PENDING_KEY = "ga-upload-pending";
const hiddenInputs = document.querySelectorAll('form input[name="file_type"]');
const forms = Array.from(hiddenInputs).map((i) => i.closest("form")).filter(Boolean);
function setBadge(badge, text, isUploaded) {
if (!badge) return;
badge.textContent = text;
// Make uploaded visually distinct (keeps existing border)
if (isUploaded) {
badge.classList.remove("text-bg-light");
badge.classList.add("text-bg-success");
} else {
badge.classList.remove("text-bg-success");
badge.classList.add("text-bg-light");
}
}
// Apply stored uploaded filenames on load + live updates on selection
// Commit pending upload only if we see the upload success toast after redirect
try {
const pending = JSON.parse(localStorage.getItem(PENDING_KEY) || "null");
if (pending?.type && pending?.name) {
const toastText = Array.from(document.querySelectorAll(".toast .toast-body"))
.map((el) => (el.innerText || "").toLowerCase())
.join(" ");
const uploadSucceeded = toastText.includes("file successfully uploaded");
const uploadFailed =
toastText.includes("no file") ||
toastText.includes("no file selected") ||
toastText.includes("not allowed") ||
toastText.includes("invalid file type") ||
toastText.includes("invalid file");
if (uploadSucceeded && !uploadFailed) {
localStorage.setItem(KEY_PREFIX + pending.type, pending.name);
}
localStorage.removeItem(PENDING_KEY);
}
} catch (_) {
localStorage.removeItem(PENDING_KEY);
}
forms.forEach((form) => {
const type = form.querySelector('input[name="file_type"]')?.value;
const fileInput = form.querySelector('input[type="file"][name="file"]');
const badge = form.querySelector("span.badge");
if (!type || !badge || !fileInput) return;
badge.dataset.defaultText = badge.dataset.defaultText || badge.textContent.trim();
const saved = localStorage.getItem(KEY_PREFIX + type);
if (saved) setBadge(badge, `Uploaded: ${saved}`, true);
fileInput.addEventListener("change", () => {
const f = fileInput.files && fileInput.files[0];
if (f) setBadge(badge, `Selected: ${f.name}`, true);
else setBadge(badge, badge.dataset.defaultText, false);
});
// Track what the user tried to upload so we can persist it after the redirect
form.addEventListener("submit", () => {
const f = fileInput.files && fileInput.files[0];
if (!f) return;
localStorage.setItem(PENDING_KEY, JSON.stringify({ type, name: f.name }));
});
});
})();
</script>
</body>
</html>