| | <!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> |
| | |
| | <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> |
| |
|
| | |
| | <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"> |
| | |
| | <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="row g-3"> |
| | |
| | <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"> |
| | |
| | <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"> |
| | <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"> |
| | |
| | <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"> |
| | <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"> |
| | |
| | <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> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <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"> |
| | |
| | <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> |
| |
|
| | |
| | <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> |
| |
|
| | |
| | <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> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | </main> |
| |
|
| | <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script> |
| | <script> |
| | |
| | (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); |
| | }); |
| | })(); |
| | |
| | |
| | document.querySelectorAll('.toast').forEach((el) => new bootstrap.Toast(el).show()); |
| | |
| | |
| | (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"; |
| | }); |
| | }); |
| | })(); |
| | |
| | |
| | |
| | (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; |
| | |
| | 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"); |
| | } |
| | } |
| | |
| | |
| | |
| | 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); |
| | }); |
| | |
| | |
| | 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> |
| |
|