| <!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> |
|
|