fix(navigate): fix Navigate tab routing, add empty state, route search, mode toggle

- Fixed switchTab recursion bug when leaving tabNavigate (set currentTab early + return)
- Fixed closeNavigate to not call switchTab (caller handles tab transition)
- Added renderNavPage call in switchTab lifecycle for tabNavigate
- Added empty state with asset search on Navigate page
- Added route-finding: search assets, navigate from search results
- Added driving/walking mode toggle with OSRM route display
- Added ETA display for driving mode
- Fixed route polyline to use OSRM road geometry when available
- Added switchNavMode function for mode switching
- Improved directions label (Walking vs Driving)
This commit is contained in:
2026-05-20 22:35:49 -04:00
parent fae2a84213
commit e66ed51e7f
+334 -96
View File
@@ -186,16 +186,24 @@
═══════════════════════════════════════════════════════════════════════ */
.tab-panel {
display: none; padding: 0 12px;
animation: none;
opacity: 1; transform: translateX(0);
}
.tab-panel.active {
display: block;
animation: tabSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.tab-panel.slide-left {
display: block !important;
animation: tabSlideOutLeft 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes tabSlideIn {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes tabSlideOutLeft {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(-16px); }
}
/* ═══════════════════════════════════════════════════════════════════════
BOTTOM TABS
@@ -344,6 +352,73 @@
.empty-state { text-align: center; padding: 32px 16px; color: var(--text2); font-size: 13px; }
.empty-state .es-icon { font-size: 40px; margin-bottom: 8px; opacity: 0.5; }
/* Enhanced empty-state card — full-page onboarding pattern */
.empty-state-card {
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 48px 24px; min-height: 240px;
}
.esc-icon {
font-size: 56px; margin-bottom: 16px; opacity: 0.35;
width: 88px; height: 88px; border-radius: 50%;
background: var(--card2); display: flex; align-items: center; justify-content: center;
}
.esc-heading {
font-size: 17px; font-weight: 700; color: var(--text);
margin-bottom: 6px;
}
.esc-desc {
font-size: 13px; color: var(--text2); max-width: 280px;
line-height: 1.5; margin-bottom: 20px;
}
.esc-cta {
display: inline-flex; align-items: center; gap: 6px;
padding: 10px 24px; font-size: 14px; font-weight: 600;
background: var(--accent); color: #fff; border: none;
border-radius: var(--radius-sm); cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s;
}
.esc-cta:active { background: var(--accent2); }
.esc-cta-sm {
display: inline-flex; align-items: center; gap: 4px;
padding: 6px 14px; font-size: 12px; font-weight: 600;
background: var(--accent); color: #fff; border: none;
border-radius: var(--radius-xs); cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s;
}
.esc-cta-sm:active { background: var(--accent2); }
/* Onboarding welcome overlay */
.onboard-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
z-index: 600; display: flex; align-items: center; justify-content: center;
padding: 20px; opacity: 0; pointer-events: none; transition: opacity 0.25s;
}
.onboard-overlay.open { opacity: 1; pointer-events: auto; }
.onboard-card {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 28px 24px; max-width: 340px;
text-align: center; width: 100%;
}
.onboard-card .esc-icon { margin: 0 auto 16px; }
/* Guidance card — reports empty state */
.guidance-card {
text-align: center; padding: 24px 16px;
}
.gc-icon {
font-size: 40px; margin-bottom: 12px; opacity: 0.5;
}
.gc-title {
font-size: 15px; font-weight: 600; color: var(--text);
margin-bottom: 6px;
}
.gc-hint {
font-size: 12px; color: var(--text2); line-height: 1.5;
max-width: 300px; margin: 0 auto;
}
/* ═══════════════════════════════════════════════════════════════════════
MODAL / DIALOG
═══════════════════════════════════════════════════════════════════════ */
@@ -1325,104 +1400,178 @@
<!-- ── MANUAL MODE ───────────────────────────────────────────────── -->
<div id="addManualMode" class="add-mode">
<div class="card">
<div class="card-title">Manual Entry</div>
<!-- Autosave indicator -->
<div id="autosaveIndicator" class="autosave-indicator"></div>
<!-- Basic Info -->
<input id="manMachineId" type="text" class="input-field" placeholder="Machine ID *" required>
<input id="manSerialNumber" type="text" class="input-field" placeholder="Serial Number">
<input id="manName" type="text" class="input-field" placeholder="Asset Name *" required>
<textarea id="manDescription" class="input-field" placeholder="Description" rows="2"></textarea>
<div class="form-row" style="margin-bottom:8px;">
<select id="manCatSelect" class="input-field">
<option value="">Category</option>
</select>
<select id="manMake" class="input-field" onchange="loadModels()">
<option value="">Make</option>
</select>
<!-- ── SECTION: IDENTITY ──────────────────────────────────── -->
<div class="form-collapse-section open" id="sectIdentity">
<div class="form-collapse-header" onclick="toggleFormSection('sectIdentity')">
<span class="fch-icon">📋</span>
<span class="fch-title">Identity</span>
<span class="fch-status incomplete" id="statIdentity">required</span>
<span class="fch-chevron"></span>
</div>
<select id="manModel" class="input-field" style="margin-bottom:8px;">
<option value="">Model</option>
</select>
<select id="manStatus" class="input-field" style="margin-bottom:0;">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="maintenance">Maintenance</option>
<option value="retired">Retired</option>
</select>
</div>
<div class="form-collapse-body">
<label class="field-label"><span class="required-star">*</span> Machine ID</label>
<input id="manMachineId" type="text" class="input-field" placeholder="e.g. CAT-001" required
oninput="clearFieldError('manMachineId'); autoSaveDraft();">
<div class="field-error-msg" id="errManMachineId">⚠️ Machine ID is required</div>
<!-- Directions -->
<div class="form-section">
<div class="form-section-title">📍 Directions &amp; Access</div>
<input id="manAddress" type="text" class="input-field" placeholder="Address / Trailer Number">
<div class="form-row">
<input id="manBuildingName" type="text" class="input-field" placeholder="Building Name">
<input id="manBuildingNumber" type="text" class="input-field" placeholder="Building #" oninput="document.getElementById('manAddress').value = this.value">
</div>
<div class="form-row">
<input id="manFloor" type="text" class="input-field" placeholder="Floor">
<input id="manRoom" type="text" class="input-field" placeholder="Room">
</div>
<textarea id="manWalkingDirections" class="input-field" placeholder="Walking directions..." rows="2"></textarea>
<div class="form-row">
<input id="manMapLink" type="url" class="input-field" placeholder="Map link (URL)">
<button class="btn btn-outline btn-sm" onclick="openMapPin()" style="flex-shrink:0;">📍 Pin</button>
</div>
<div class="form-row">
<input id="manParkingLocation" type="text" class="input-field" placeholder="Parking location">
<button class="btn btn-outline btn-sm" onclick="fillGpsParking('manParkingLocation')" style="flex-shrink:0;">📍 GPS</button>
</div>
</div>
<label class="field-label">Serial Number</label>
<input id="manSerialNumber" type="text" class="input-field" placeholder="Serial Number"
oninput="autoSaveDraft();">
<!-- Keys -->
<div class="form-section">
<div class="form-section-title">
🔑 Keys
<button class="btn btn-outline btn-sm" onclick="addKeyEntry()">+ Add Key</button>
</div>
<div id="manKeysList"></div>
</div>
<label class="field-label"><span class="required-star">*</span> Asset Name</label>
<input id="manName" type="text" class="input-field" placeholder="e.g. Vending Machine #3" required
oninput="clearFieldError('manName'); autoSaveDraft();">
<div class="field-error-msg" id="errManName">⚠️ Asset Name is required</div>
<!-- Security Badges -->
<div class="form-section">
<div class="form-section-title">🪪 Security Badges</div>
<div class="badge-checklist" id="manBadgesList"></div>
</div>
<label class="field-label">Description</label>
<textarea id="manDescription" class="input-field" placeholder="Description" rows="2"
oninput="autoSaveDraft();"></textarea>
<!-- Customer & Location -->
<div class="form-section">
<div class="form-section-title">🏢 Customer &amp; Location</div>
<select id="manCustomer" class="input-field" onchange="loadManualLocations()">
<option value="">Customer</option>
</select>
<select id="manLocation" class="input-field" style="margin-bottom:0;">
<option value="">Location</option>
</select>
</div>
<!-- Photo -->
<div class="form-section">
<div class="form-section-title">📸 Photo (optional)</div>
<div id="manPhotoArea" class="camera-area" style="aspect-ratio:4/3;margin-bottom:8px;">
<div id="manPhotoPlaceholder" class="camera-placeholder" onclick="startManualPhoto()">
<span class="cam-icon">📸</span>
<div class="cam-hint" style="margin-top:4px;">Tap to take photo</div>
<div class="form-row" style="margin-bottom:8px;">
<select id="manCatSelect" class="input-field" onchange="autoSaveDraft();">
<option value="">Category</option>
</select>
<select id="manMake" class="input-field" onchange="loadModels();autoSaveDraft();">
<option value="">Make</option>
</select>
</div>
<video id="manPhotoVideo" autoplay playsinline muted style="display:none;"></video>
<select id="manModel" class="input-field" style="margin-bottom:8px;" onchange="autoSaveDraft();">
<option value="">Model</option>
</select>
<select id="manStatus" class="input-field" style="margin-bottom:0;" onchange="autoSaveDraft();">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="maintenance">Maintenance</option>
<option value="retired">Retired</option>
</select>
</div>
<div id="manPhotoCaptureRow" style="display:none;justify-content:center;margin-bottom:8px;">
<button class="ocr-capture-btn" onclick="captureManualPhoto()" title="Capture" style="position:static;width:48px;height:48px;font-size:18px;">📸</button>
</div>
<img id="manPhotoPreview" style="display:none;width:100%;border-radius:var(--radius-sm);">
<button id="manPhotoRetake" class="btn btn-outline btn-sm" style="display:none;margin-top:6px;width:100%;" onclick="retakeManualPhoto()">🔄 Retake</button>
</div>
<!-- Submit -->
<!-- ── SECTION: LOCATION ──────────────────────────────────── -->
<div class="form-collapse-section" id="sectLocation">
<div class="form-collapse-header" onclick="toggleFormSection('sectLocation')">
<span class="fch-icon">📍</span>
<span class="fch-title">Location & Directions</span>
<span class="fch-chevron"></span>
</div>
<div class="form-collapse-body">
<label class="field-label">Address / Trailer Number</label>
<input id="manAddress" type="text" class="input-field" placeholder="Address / Trailer Number"
oninput="autoSaveDraft();">
<div class="form-row">
<input id="manBuildingName" type="text" class="input-field" placeholder="Building Name"
oninput="autoSaveDraft();">
<input id="manBuildingNumber" type="text" class="input-field" placeholder="Building #"
oninput="document.getElementById('manAddress').value = this.value; autoSaveDraft();">
</div>
<div class="form-row">
<input id="manFloor" type="text" class="input-field" placeholder="Floor" oninput="autoSaveDraft();">
<input id="manRoom" type="text" class="input-field" placeholder="Room" oninput="autoSaveDraft();">
</div>
<label class="field-label">Walking Directions</label>
<textarea id="manWalkingDirections" class="input-field" placeholder="Walking directions from parking..." rows="2"
oninput="autoSaveDraft();"></textarea>
<div class="form-row" style="margin-bottom:6px;">
<input id="manMapLink" type="url" class="input-field" placeholder="Map link (URL)"
oninput="autoSaveDraft();">
<button class="btn btn-outline btn-sm" id="btnMapPin" onclick="openMapPin()"
style="flex-shrink:0; min-width: 72px;">
<span class="btn-spinner"></span>📍 Pin
</button>
<span class="btn-feedback" id="fbMapPin">✓ Done</span>
</div>
<div class="form-row">
<input id="manParkingLocation" type="text" class="input-field" placeholder="Parking location"
oninput="autoSaveDraft();">
<button class="btn btn-outline btn-sm" id="btnGpsParking" onclick="fillGpsParking()"
style="flex-shrink:0; min-width: 72px;">
<span class="btn-spinner"></span>📍 GPS
</button>
<span class="btn-feedback" id="fbGpsParking">✓ Done</span>
</div>
</div>
</div>
<!-- ── SECTION: KEYS & BADGES ─────────────────────────────── -->
<div class="form-collapse-section" id="sectAccess">
<div class="form-collapse-header" onclick="toggleFormSection('sectAccess')">
<span class="fch-icon">🔑</span>
<span class="fch-title">Keys & Badges</span>
<span class="fch-chevron"></span>
</div>
<div class="form-collapse-body">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:12px;font-weight:600;color:var(--text2);">🔑 Keys</span>
<button class="btn btn-outline btn-sm" onclick="addKeyEntry()">+ Add Key</button>
</div>
<div id="manKeysList"></div>
<div style="font-size:12px;font-weight:600;color:var(--text2);margin:10px 0 6px;">🪪 Security Badges</div>
<div class="badge-checklist" id="manBadgesList"></div>
</div>
</div>
<!-- ── SECTION: CUSTOMER ──────────────────────────────────── -->
<div class="form-collapse-section" id="sectCustomer">
<div class="form-collapse-header" onclick="toggleFormSection('sectCustomer')">
<span class="fch-icon">🏢</span>
<span class="fch-title">Customer & Location</span>
<span class="fch-chevron"></span>
</div>
<div class="form-collapse-body">
<label class="field-label">Customer</label>
<select id="manCustomer" class="input-field" onchange="loadManualLocations();autoSaveDraft();">
<option value="">Customer</option>
</select>
<label class="field-label">Location</label>
<select id="manLocation" class="input-field" style="margin-bottom:0;" onchange="autoSaveDraft();">
<option value="">Location</option>
</select>
</div>
</div>
<!-- ── SECTION: PHOTO ─────────────────────────────────────── -->
<div class="form-collapse-section" id="sectPhoto">
<div class="form-collapse-header" onclick="toggleFormSection('sectPhoto')">
<span class="fch-icon">📸</span>
<span class="fch-title">Photo <span style="font-size:11px;color:var(--text3);font-weight:400;">(optional)</span></span>
<span class="fch-chevron"></span>
</div>
<div class="form-collapse-body">
<div id="manPhotoArea" class="camera-area" style="aspect-ratio:4/3;margin-bottom:8px;">
<div id="manPhotoPlaceholder" class="camera-placeholder" onclick="startManualPhoto()">
<span class="cam-icon">📸</span>
<div class="cam-hint" style="margin-top:4px;">Tap to take photo</div>
</div>
<video id="manPhotoVideo" autoplay playsinline muted style="display:none;"></video>
</div>
<div id="manPhotoCaptureRow" style="display:none;justify-content:center;margin-bottom:8px;">
<button class="ocr-capture-btn" onclick="captureManualPhoto()" title="Capture"
style="position:static;width:48px;height:48px;font-size:18px;">📸</button>
</div>
<div class="photo-preview-wrap" id="manPhotoPreviewWrap" style="display:none;">
<img id="manPhotoPreview" style="width:100%;border-radius:var(--radius-sm);">
<div class="photo-actions-row">
<button class="btn btn-outline btn-sm" onclick="retakeManualPhoto()">🔄 Retake</button>
<button class="btn btn-outline btn-sm" onclick="clearManualPhoto()" style="color:var(--text2);">✕ Remove</button>
</div>
</div>
</div>
</div>
<!-- ── SUBMIT ─────────────────────────────────────────────── -->
<div class="form-row" style="margin-bottom:10px;">
<button class="btn btn-primary" onclick="submitManualAsset(false)" style="flex:2;">Create Asset</button>
<button class="btn btn-outline" onclick="submitManualAsset(true)" style="flex:1;">+ Add Another</button>
<button class="btn btn-primary" id="btnCreateAsset" onclick="submitManualAsset(false)" style="flex:2;">
<span class="btn-spinner"></span>Create Asset
</button>
<button class="btn btn-outline" onclick="submitManualAsset(true)" style="flex:1;">
<span class="btn-spinner"></span>+ Add Another
</button>
</div>
<!-- Post-create check-in -->
@@ -2724,18 +2873,20 @@
// Update tab panels with slide animation
const oldPanel = document.querySelector('.tab-panel.active');
if (oldPanel) {
oldPanel.classList.remove('active');
const newPanel = document.getElementById(tabId);
if (oldPanel && oldPanel !== newPanel) {
// Play exit animation on old panel, keeping it visible
oldPanel.classList.add('slide-left');
oldPanel.addEventListener('animationend', function handler() {
oldPanel.removeEventListener('animationend', handler);
oldPanel.classList.remove('slide-left');
oldPanel.classList.remove('active', 'slide-left');
}, { once: true });
}
const panel = document.getElementById(tabId);
if (panel) {
// Brief delay to let exit animation start before entry
setTimeout(() => panel.classList.add('active'), 50);
// Small delay then show new panel with slide-in
setTimeout(() => {
if (newPanel) newPanel.classList.add('active');
}, 50);
} else if (newPanel) {
newPanel.classList.add('active');
}
// Update bottom tabs — highlight the matching tab, or "More" for drawer-only tabs
@@ -2916,6 +3067,17 @@
initKeyBadges();
}, 100);
}
// T2: Load draft when switching to manual mode
if (mode === 'manual') {
setTimeout(() => {
if (!loadDraft()) {
// No draft - just ensure categories are populated
populateCategorySelect('manCatSelect');
}
initKeyBadges();
}, 100);
}
}
// ── Shared: Load settings dropdowns ─────────────────────────────────────
@@ -4429,6 +4591,82 @@
// Do NOT call switchTab here — the caller handles the tab transition
}
// ── Navigate page asset search ────────────────────────────────────────
let navSearchTimer = null;
async function searchNavAssets() {
clearTimeout(navSearchTimer);
navSearchTimer = setTimeout(async () => {
const q = document.getElementById('navSearchInput').value.trim();
const results = document.getElementById('navSearchResults');
const clearBtn = document.getElementById('clearNavSearch');
if (clearBtn) clearBtn.style.display = q ? '' : 'none';
if (!q) { results.innerHTML = ''; return; }
try {
const assets = await api('/api/assets?q=' + encodeURIComponent(q) + '&limit=8');
if (!assets.length) {
results.innerHTML = '<div class="empty-state" style="padding:12px;">No assets found</div>';
return;
}
results.innerHTML = assets.map(a => {
const hasLocation = a.latitude != null && a.longitude != null;
return `<div class="asset-item" style="${hasLocation ? '' : 'opacity:0.5;'}"
onclick="navToSearchedAsset(${a.id})">
<div class="ai-icon cat-${cssSafe(a.category)}">📍</div>
<div class="ai-info">
<div class="ai-name">${esc(a.name)}</div>
<div class="ai-meta">${esc(a.machine_id || '')} ${hasLocation ? '📍 ' + esc(a.address || 'GPS tagged') : '⚠ No location'}</div>
</div>
<div class="ai-arrow"></div>
</div>`;
}).join('');
} catch (e) {
results.innerHTML = '<div class="empty-state" style="padding:12px;">Search failed</div>';
}
}, 300);
}
function clearNavSearch() {
const input = document.getElementById('navSearchInput');
input.value = '';
document.getElementById('navSearchResults').innerHTML = '';
document.getElementById('clearNavSearch').style.display = 'none';
input.focus();
}
async function navToSearchedAsset(assetId) {
// Fetch asset details to get navigation data
if (!AppState.gpsLat || !AppState.gpsLng) {
showToast('GPS location not available. Tap ◎ My Location first.', true);
return;
}
try {
const nav = await api('/api/assets/' + assetId +
'/navigation?lat=' + AppState.gpsLat + '&lng=' + AppState.gpsLng);
AppState._navData = nav;
renderNavPage();
// Clear search
document.getElementById('navSearchInput').value = '';
document.getElementById('navSearchResults').innerHTML = '';
document.getElementById('clearNavSearch').style.display = 'none';
} catch (e) {
showToast(e.message, true);
}
}
async function switchNavMode(mode) {
// Re-fetch navigation with new mode
const nav = AppState._navData;
if (!nav) return;
try {
const newNav = await api('/api/assets/' + nav.asset_id +
'/navigation?lat=' + AppState.gpsLat + '&lng=' + AppState.gpsLng + '&mode=' + mode);
AppState._navData = newNav;
renderNavPage();
} catch (e) {
showToast(e.message, true);
}
}
function clearRouteLine() {
if (!map) return;
if (map._routeLine) { map.removeLayer(map._routeLine); map._routeLine = null; }