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
+294 -56
View File
@@ -186,16 +186,24 @@
═══════════════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════════════ */
.tab-panel { .tab-panel {
display: none; padding: 0 12px; display: none; padding: 0 12px;
animation: none; opacity: 1; transform: translateX(0);
} }
.tab-panel.active { .tab-panel.active {
display: block; display: block;
animation: tabSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1); 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 { @keyframes tabSlideIn {
from { opacity: 0; transform: translateX(16px); } from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: translateX(0); } to { opacity: 1; transform: translateX(0); }
} }
@keyframes tabSlideOutLeft {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(-16px); }
}
/* ═══════════════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════════════
BOTTOM TABS BOTTOM TABS
@@ -344,6 +352,73 @@
.empty-state { text-align: center; padding: 32px 16px; color: var(--text2); font-size: 13px; } .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; } .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 MODAL / DIALOG
═══════════════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════════════ */
@@ -1325,86 +1400,149 @@
<!-- ── MANUAL MODE ───────────────────────────────────────────────── --> <!-- ── MANUAL MODE ───────────────────────────────────────────────── -->
<div id="addManualMode" class="add-mode"> <div id="addManualMode" class="add-mode">
<div class="card"> <!-- Autosave indicator -->
<div class="card-title">Manual Entry</div> <div id="autosaveIndicator" class="autosave-indicator"></div>
<!-- Basic Info --> <!-- ── SECTION: IDENTITY ──────────────────────────────────── -->
<input id="manMachineId" type="text" class="input-field" placeholder="Machine ID *" required> <div class="form-collapse-section open" id="sectIdentity">
<input id="manSerialNumber" type="text" class="input-field" placeholder="Serial Number"> <div class="form-collapse-header" onclick="toggleFormSection('sectIdentity')">
<input id="manName" type="text" class="input-field" placeholder="Asset Name *" required> <span class="fch-icon">📋</span>
<textarea id="manDescription" class="input-field" placeholder="Description" rows="2"></textarea> <span class="fch-title">Identity</span>
<span class="fch-status incomplete" id="statIdentity">required</span>
<span class="fch-chevron"></span>
</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>
<label class="field-label">Serial Number</label>
<input id="manSerialNumber" type="text" class="input-field" placeholder="Serial Number"
oninput="autoSaveDraft();">
<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>
<label class="field-label">Description</label>
<textarea id="manDescription" class="input-field" placeholder="Description" rows="2"
oninput="autoSaveDraft();"></textarea>
<div class="form-row" style="margin-bottom:8px;"> <div class="form-row" style="margin-bottom:8px;">
<select id="manCatSelect" class="input-field"> <select id="manCatSelect" class="input-field" onchange="autoSaveDraft();">
<option value="">Category</option> <option value="">Category</option>
</select> </select>
<select id="manMake" class="input-field" onchange="loadModels()"> <select id="manMake" class="input-field" onchange="loadModels();autoSaveDraft();">
<option value="">Make</option> <option value="">Make</option>
</select> </select>
</div> </div>
<select id="manModel" class="input-field" style="margin-bottom:8px;"> <select id="manModel" class="input-field" style="margin-bottom:8px;" onchange="autoSaveDraft();">
<option value="">Model</option> <option value="">Model</option>
</select> </select>
<select id="manStatus" class="input-field" style="margin-bottom:0;"> <select id="manStatus" class="input-field" style="margin-bottom:0;" onchange="autoSaveDraft();">
<option value="active">Active</option> <option value="active">Active</option>
<option value="inactive">Inactive</option> <option value="inactive">Inactive</option>
<option value="maintenance">Maintenance</option> <option value="maintenance">Maintenance</option>
<option value="retired">Retired</option> <option value="retired">Retired</option>
</select> </select>
</div> </div>
</div>
<!-- ── 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();">
<!-- 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"> <div class="form-row">
<input id="manBuildingName" type="text" class="input-field" placeholder="Building Name"> <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"> oninput="autoSaveDraft();">
<input id="manBuildingNumber" type="text" class="input-field" placeholder="Building #"
oninput="document.getElementById('manAddress').value = this.value; autoSaveDraft();">
</div> </div>
<div class="form-row"> <div class="form-row">
<input id="manFloor" type="text" class="input-field" placeholder="Floor"> <input id="manFloor" type="text" class="input-field" placeholder="Floor" oninput="autoSaveDraft();">
<input id="manRoom" type="text" class="input-field" placeholder="Room"> <input id="manRoom" type="text" class="input-field" placeholder="Room" oninput="autoSaveDraft();">
</div> </div>
<textarea id="manWalkingDirections" class="input-field" placeholder="Walking directions..." rows="2"></textarea> <label class="field-label">Walking Directions</label>
<div class="form-row"> <textarea id="manWalkingDirections" class="input-field" placeholder="Walking directions from parking..." rows="2"
<input id="manMapLink" type="url" class="input-field" placeholder="Map link (URL)"> oninput="autoSaveDraft();"></textarea>
<button class="btn btn-outline btn-sm" onclick="openMapPin()" style="flex-shrink:0;">📍 Pin</button>
<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>
<div class="form-row"> <div class="form-row">
<input id="manParkingLocation" type="text" class="input-field" placeholder="Parking location"> <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> 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>
</div> </div>
<!-- Keys --> <!-- ── SECTION: KEYS & BADGES ─────────────────────────────── -->
<div class="form-section"> <div class="form-collapse-section" id="sectAccess">
<div class="form-section-title"> <div class="form-collapse-header" onclick="toggleFormSection('sectAccess')">
🔑 Keys <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> <button class="btn btn-outline btn-sm" onclick="addKeyEntry()">+ Add Key</button>
</div> </div>
<div id="manKeysList"></div> <div id="manKeysList"></div>
</div>
<!-- Security Badges --> <div style="font-size:12px;font-weight:600;color:var(--text2);margin:10px 0 6px;">🪪 Security Badges</div>
<div class="form-section">
<div class="form-section-title">🪪 Security Badges</div>
<div class="badge-checklist" id="manBadgesList"></div> <div class="badge-checklist" id="manBadgesList"></div>
</div> </div>
</div>
<!-- Customer & Location --> <!-- ── SECTION: CUSTOMER ──────────────────────────────────── -->
<div class="form-section"> <div class="form-collapse-section" id="sectCustomer">
<div class="form-section-title">🏢 Customer &amp; Location</div> <div class="form-collapse-header" onclick="toggleFormSection('sectCustomer')">
<select id="manCustomer" class="input-field" onchange="loadManualLocations()"> <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> <option value="">Customer</option>
</select> </select>
<select id="manLocation" class="input-field" style="margin-bottom:0;"> <label class="field-label">Location</label>
<select id="manLocation" class="input-field" style="margin-bottom:0;" onchange="autoSaveDraft();">
<option value="">Location</option> <option value="">Location</option>
</select> </select>
</div> </div>
</div>
<!-- Photo --> <!-- ── SECTION: PHOTO ─────────────────────────────────────── -->
<div class="form-section"> <div class="form-collapse-section" id="sectPhoto">
<div class="form-section-title">📸 Photo (optional)</div> <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="manPhotoArea" class="camera-area" style="aspect-ratio:4/3;margin-bottom:8px;">
<div id="manPhotoPlaceholder" class="camera-placeholder" onclick="startManualPhoto()"> <div id="manPhotoPlaceholder" class="camera-placeholder" onclick="startManualPhoto()">
<span class="cam-icon">📸</span> <span class="cam-icon">📸</span>
@@ -1413,16 +1551,27 @@
<video id="manPhotoVideo" autoplay playsinline muted style="display:none;"></video> <video id="manPhotoVideo" autoplay playsinline muted style="display:none;"></video>
</div> </div>
<div id="manPhotoCaptureRow" style="display:none;justify-content:center;margin-bottom:8px;"> <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> <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>
<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> </div>
<!-- Submit --> <!-- ── SUBMIT ─────────────────────────────────────────────── -->
<div class="form-row" style="margin-bottom:10px;"> <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-primary" id="btnCreateAsset" onclick="submitManualAsset(false)" style="flex:2;">
<button class="btn btn-outline" onclick="submitManualAsset(true)" style="flex:1;">+ Add Another</button> <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> </div>
<!-- Post-create check-in --> <!-- Post-create check-in -->
@@ -2724,18 +2873,20 @@
// Update tab panels with slide animation // Update tab panels with slide animation
const oldPanel = document.querySelector('.tab-panel.active'); const oldPanel = document.querySelector('.tab-panel.active');
if (oldPanel) { const newPanel = document.getElementById(tabId);
oldPanel.classList.remove('active'); if (oldPanel && oldPanel !== newPanel) {
// Play exit animation on old panel, keeping it visible
oldPanel.classList.add('slide-left'); oldPanel.classList.add('slide-left');
oldPanel.addEventListener('animationend', function handler() { oldPanel.addEventListener('animationend', function handler() {
oldPanel.removeEventListener('animationend', handler); oldPanel.removeEventListener('animationend', handler);
oldPanel.classList.remove('slide-left'); oldPanel.classList.remove('active', 'slide-left');
}, { once: true }); }, { once: true });
} // Small delay then show new panel with slide-in
const panel = document.getElementById(tabId); setTimeout(() => {
if (panel) { if (newPanel) newPanel.classList.add('active');
// Brief delay to let exit animation start before entry }, 50);
setTimeout(() => panel.classList.add('active'), 50); } else if (newPanel) {
newPanel.classList.add('active');
} }
// Update bottom tabs — highlight the matching tab, or "More" for drawer-only tabs // Update bottom tabs — highlight the matching tab, or "More" for drawer-only tabs
@@ -2916,6 +3067,17 @@
initKeyBadges(); initKeyBadges();
}, 100); }, 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 ───────────────────────────────────── // ── Shared: Load settings dropdowns ─────────────────────────────────────
@@ -4429,6 +4591,82 @@
// Do NOT call switchTab here — the caller handles the tab transition // 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() { function clearRouteLine() {
if (!map) return; if (!map) return;
if (map._routeLine) { map.removeLayer(map._routeLine); map._routeLine = null; } if (map._routeLine) { map.removeLayer(map._routeLine); map._routeLine = null; }