589 lines
19 KiB
JavaScript
589 lines
19 KiB
JavaScript
/* Mobile JavaScript for Seismo Fleet Manager */
|
|
/* Handles hamburger menu, modals, offline sync, and mobile interactions */
|
|
|
|
// ===== GLOBAL STATE =====
|
|
let currentUnitData = null;
|
|
let isOnline = navigator.onLine;
|
|
|
|
// ===== HAMBURGER MENU TOGGLE =====
|
|
function toggleMenu() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const backdrop = document.getElementById('backdrop');
|
|
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
|
|
|
if (sidebar && backdrop) {
|
|
const isOpen = sidebar.classList.contains('open');
|
|
|
|
if (isOpen) {
|
|
// Close menu
|
|
sidebar.classList.remove('open');
|
|
backdrop.classList.remove('show');
|
|
hamburgerBtn?.classList.remove('menu-open');
|
|
document.body.style.overflow = '';
|
|
} else {
|
|
// Open menu
|
|
sidebar.classList.add('open');
|
|
backdrop.classList.add('show');
|
|
hamburgerBtn?.classList.add('menu-open');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close menu when clicking backdrop
|
|
function closeMenuFromBackdrop() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const backdrop = document.getElementById('backdrop');
|
|
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
|
|
|
if (sidebar && backdrop) {
|
|
sidebar.classList.remove('open');
|
|
backdrop.classList.remove('show');
|
|
hamburgerBtn?.classList.remove('menu-open');
|
|
document.body.style.overflow = '';
|
|
}
|
|
}
|
|
|
|
// Close menu when window is resized to desktop
|
|
function handleResize() {
|
|
if (window.innerWidth >= 768) {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const backdrop = document.getElementById('backdrop');
|
|
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
|
|
|
if (sidebar && backdrop) {
|
|
sidebar.classList.remove('open');
|
|
backdrop.classList.remove('show');
|
|
hamburgerBtn?.classList.remove('menu-open');
|
|
document.body.style.overflow = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== UNIT DETAIL MODAL =====
|
|
function openUnitModal(unitId, status = null, age = null) {
|
|
const modal = document.getElementById('unitModal');
|
|
if (!modal) return;
|
|
|
|
// Store the status info passed from the card
|
|
// Accept status if it's a non-empty string, use age if provided or default to '--'
|
|
const cardStatusInfo = (status && status !== '') ? {
|
|
status: status,
|
|
age: age || '--'
|
|
} : null;
|
|
|
|
console.log('openUnitModal:', { unitId, status, age, cardStatusInfo });
|
|
|
|
// Fetch unit data and populate modal
|
|
fetchUnitDetails(unitId).then(unit => {
|
|
if (unit) {
|
|
currentUnitData = unit;
|
|
// Pass the card status info to the populate function
|
|
populateUnitModal(unit, cardStatusInfo);
|
|
modal.classList.add('show');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
});
|
|
}
|
|
|
|
function closeUnitModal(event) {
|
|
// Only close if clicking backdrop or close button
|
|
if (event && event.target.closest('.unit-modal-content') && !event.target.closest('[data-close-modal]')) {
|
|
return;
|
|
}
|
|
|
|
const modal = document.getElementById('unitModal');
|
|
if (modal) {
|
|
modal.classList.remove('show');
|
|
document.body.style.overflow = '';
|
|
currentUnitData = null;
|
|
}
|
|
}
|
|
|
|
async function fetchUnitDetails(unitId) {
|
|
try {
|
|
// Try to fetch from network first
|
|
const response = await fetch(`/api/roster/${unitId}`);
|
|
if (response.ok) {
|
|
const unit = await response.json();
|
|
|
|
// Save to IndexedDB if offline support is available
|
|
if (window.offlineDB) {
|
|
await window.offlineDB.saveUnit(unit);
|
|
}
|
|
|
|
return unit;
|
|
}
|
|
} catch (error) {
|
|
console.log('Network fetch failed, trying offline storage:', error);
|
|
|
|
// Fall back to offline storage
|
|
if (window.offlineDB) {
|
|
return await window.offlineDB.getUnit(unitId);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function populateUnitModal(unit, cardStatusInfo = null) {
|
|
// Set unit ID in header
|
|
const modalUnitId = document.getElementById('modalUnitId');
|
|
if (modalUnitId) {
|
|
modalUnitId.textContent = unit.id;
|
|
}
|
|
|
|
// Populate modal content
|
|
const modalContent = document.getElementById('modalContent');
|
|
if (!modalContent) return;
|
|
|
|
// Use status from card if provided, otherwise get from snapshot or derive from unit
|
|
let statusInfo = cardStatusInfo || getUnitStatus(unit.id, unit);
|
|
console.log('populateUnitModal:', { unit, cardStatusInfo, statusInfo });
|
|
|
|
const statusColor = statusInfo.status === 'OK' ? 'green' :
|
|
statusInfo.status === 'Pending' ? 'yellow' :
|
|
statusInfo.status === 'Missing' ? 'red' : 'gray';
|
|
|
|
const statusTextColor = statusInfo.status === 'OK' ? 'text-green-600 dark:text-green-400' :
|
|
statusInfo.status === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' :
|
|
statusInfo.status === 'Missing' ? 'text-red-600 dark:text-red-400' :
|
|
'text-gray-600 dark:text-gray-400';
|
|
|
|
// Determine status label (show "Benched" instead of "Unknown" for non-deployed units)
|
|
let statusLabel = statusInfo.status;
|
|
if ((statusInfo.status === 'Unknown' || statusInfo.status === 'N/A') && !unit.deployed) {
|
|
statusLabel = 'Benched';
|
|
}
|
|
|
|
// Create navigation URL for location
|
|
const createNavUrl = (address, coordinates) => {
|
|
if (address) {
|
|
// Use address for navigation
|
|
const encodedAddress = encodeURIComponent(address);
|
|
// Universal link that works on iOS and Android
|
|
return `https://www.google.com/maps/search/?api=1&query=${encodedAddress}`;
|
|
} else if (coordinates) {
|
|
// Use coordinates for navigation (format: lat,lon)
|
|
const encodedCoords = encodeURIComponent(coordinates);
|
|
return `https://www.google.com/maps/search/?api=1&query=${encodedCoords}`;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const navUrl = createNavUrl(unit.address, unit.coordinates);
|
|
|
|
modalContent.innerHTML = `
|
|
<!-- Status Section -->
|
|
<div class="flex items-center justify-between pb-4 border-b border-gray-200 dark:border-gray-700">
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-4 h-4 rounded-full bg-${statusColor}-500"></span>
|
|
<span class="font-semibold ${statusTextColor}">${statusLabel}</span>
|
|
</div>
|
|
<span class="text-sm text-gray-500">${statusInfo.age || '--'}</span>
|
|
</div>
|
|
|
|
<!-- Device Info -->
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Device Type</label>
|
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.device_type || '--'}</p>
|
|
</div>
|
|
|
|
${unit.unit_type ? `
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Unit Type</label>
|
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.unit_type}</p>
|
|
</div>
|
|
` : ''}
|
|
|
|
${unit.project_id ? `
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Project ID</label>
|
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.project_id}</p>
|
|
</div>
|
|
` : ''}
|
|
|
|
${unit.address ? `
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Address</label>
|
|
${navUrl ? `
|
|
<a href="${navUrl}" target="_blank" class="mt-1 flex items-center gap-2 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
|
|
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
</svg>
|
|
<span class="underline">${unit.address}</span>
|
|
</a>
|
|
` : `
|
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.address}</p>
|
|
`}
|
|
</div>
|
|
` : ''}
|
|
|
|
${unit.coordinates && !unit.address ? `
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Coordinates</label>
|
|
${navUrl ? `
|
|
<a href="${navUrl}" target="_blank" class="mt-1 flex items-center gap-2 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
|
|
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
</svg>
|
|
<span class="font-mono text-sm underline">${unit.coordinates}</span>
|
|
</a>
|
|
` : `
|
|
<p class="mt-1 text-gray-900 dark:text-white font-mono text-sm">${unit.coordinates}</p>
|
|
`}
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Seismograph-specific fields -->
|
|
${unit.device_type === 'seismograph' ? `
|
|
${unit.last_calibrated ? `
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label>
|
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.last_calibrated}</p>
|
|
</div>
|
|
` : ''}
|
|
|
|
${unit.next_calibration_due ? `
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label>
|
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.next_calibration_due}</p>
|
|
</div>
|
|
` : ''}
|
|
|
|
${unit.deployed_with_modem_id ? `
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
|
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.deployed_with_modem_id}</p>
|
|
</div>
|
|
` : ''}
|
|
` : ''}
|
|
|
|
<!-- Modem-specific fields -->
|
|
${unit.device_type === 'modem' ? `
|
|
${unit.ip_address ? `
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">IP Address</label>
|
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.ip_address}</p>
|
|
</div>
|
|
` : ''}
|
|
|
|
${unit.phone_number ? `
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Phone Number</label>
|
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.phone_number}</p>
|
|
</div>
|
|
` : ''}
|
|
|
|
${unit.hardware_model ? `
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Hardware Model</label>
|
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.hardware_model}</p>
|
|
</div>
|
|
` : ''}
|
|
` : ''}
|
|
|
|
${unit.note ? `
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
|
<p class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">${unit.note}</p>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="grid grid-cols-2 gap-3 pt-2">
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Deployed</label>
|
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.deployed ? 'Yes' : 'No'}</p>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Retired</label>
|
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.retired ? 'Yes' : 'No'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Update action buttons
|
|
const editBtn = document.getElementById('modalEditBtn');
|
|
const deployBtn = document.getElementById('modalDeployBtn');
|
|
const deleteBtn = document.getElementById('modalDeleteBtn');
|
|
|
|
if (editBtn) {
|
|
editBtn.onclick = () => {
|
|
window.location.href = `/unit/${unit.id}`;
|
|
};
|
|
}
|
|
|
|
if (deployBtn) {
|
|
deployBtn.textContent = unit.deployed ? 'Bench Unit' : 'Deploy Unit';
|
|
deployBtn.onclick = () => toggleDeployStatus(unit.id, !unit.deployed);
|
|
}
|
|
|
|
if (deleteBtn) {
|
|
deleteBtn.onclick = () => deleteUnit(unit.id);
|
|
}
|
|
}
|
|
|
|
function getUnitStatus(unitId, unit = null) {
|
|
// Try to get status from dashboard snapshot if it exists
|
|
if (window.lastStatusSnapshot && window.lastStatusSnapshot.units && window.lastStatusSnapshot.units[unitId]) {
|
|
const unitStatus = window.lastStatusSnapshot.units[unitId];
|
|
return {
|
|
status: unitStatus.status,
|
|
age: unitStatus.age,
|
|
last: unitStatus.last
|
|
};
|
|
}
|
|
|
|
// Fallback: if unit data is provided, derive status from deployment state
|
|
if (unit) {
|
|
if (unit.deployed) {
|
|
// For deployed units without status data, default to "Unknown"
|
|
return { status: 'Unknown', age: '--', last: '--' };
|
|
} else {
|
|
// For benched units, use "N/A" which will be displayed as "Benched"
|
|
return { status: 'N/A', age: '--', last: '--' };
|
|
}
|
|
}
|
|
|
|
return { status: 'Unknown', age: '--', last: '--' };
|
|
}
|
|
|
|
async function toggleDeployStatus(unitId, deployed) {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('deployed', deployed ? 'true' : 'false');
|
|
|
|
const response = await fetch(`/api/roster/edit/${unitId}`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast('✓ Unit updated successfully');
|
|
closeUnitModal();
|
|
|
|
// Trigger HTMX refresh if on roster page
|
|
const rosterTable = document.querySelector('[hx-get*="roster"]');
|
|
if (rosterTable) {
|
|
htmx.trigger(rosterTable, 'refresh');
|
|
}
|
|
} else {
|
|
showToast('❌ Failed to update unit', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling deploy status:', error);
|
|
showToast('❌ Failed to update unit', 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteUnit(unitId) {
|
|
if (!confirm(`Are you sure you want to delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/roster/${unitId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast('✓ Unit deleted successfully');
|
|
closeUnitModal();
|
|
|
|
// Refresh roster page if present
|
|
const rosterTable = document.querySelector('[hx-get*="roster"]');
|
|
if (rosterTable) {
|
|
htmx.trigger(rosterTable, 'refresh');
|
|
}
|
|
} else {
|
|
showToast('❌ Failed to delete unit', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting unit:', error);
|
|
showToast('❌ Failed to delete unit', 'error');
|
|
}
|
|
}
|
|
|
|
// ===== ONLINE/OFFLINE STATUS =====
|
|
function updateOnlineStatus() {
|
|
isOnline = navigator.onLine;
|
|
const offlineIndicator = document.getElementById('offlineIndicator');
|
|
|
|
if (offlineIndicator) {
|
|
if (isOnline) {
|
|
offlineIndicator.classList.remove('show');
|
|
// Trigger sync when coming back online
|
|
if (window.offlineDB) {
|
|
syncPendingEdits();
|
|
}
|
|
} else {
|
|
offlineIndicator.classList.add('show');
|
|
}
|
|
}
|
|
}
|
|
|
|
window.addEventListener('online', updateOnlineStatus);
|
|
window.addEventListener('offline', updateOnlineStatus);
|
|
|
|
// ===== SYNC FUNCTIONALITY =====
|
|
async function syncPendingEdits() {
|
|
if (!window.offlineDB) return;
|
|
|
|
try {
|
|
const pendingEdits = await window.offlineDB.getPendingEdits();
|
|
|
|
if (pendingEdits.length === 0) return;
|
|
|
|
console.log(`Syncing ${pendingEdits.length} pending edits...`);
|
|
|
|
for (const edit of pendingEdits) {
|
|
try {
|
|
const formData = new FormData();
|
|
for (const [key, value] of Object.entries(edit.changes)) {
|
|
formData.append(key, value);
|
|
}
|
|
|
|
const response = await fetch(`/api/roster/edit/${edit.unitId}`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
await window.offlineDB.clearEdit(edit.id);
|
|
console.log(`Synced edit ${edit.id} for unit ${edit.unitId}`);
|
|
} else {
|
|
console.error(`Failed to sync edit ${edit.id}`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error syncing edit ${edit.id}:`, error);
|
|
// Keep in queue for next sync attempt
|
|
}
|
|
}
|
|
|
|
// Show success toast
|
|
showToast('✓ Synced successfully');
|
|
|
|
} catch (error) {
|
|
console.error('Error in syncPendingEdits:', error);
|
|
}
|
|
}
|
|
|
|
// Manual sync button
|
|
function manualSync() {
|
|
if (!isOnline) {
|
|
showToast('⚠️ Cannot sync while offline', 'warning');
|
|
return;
|
|
}
|
|
|
|
syncPendingEdits();
|
|
}
|
|
|
|
// ===== TOAST NOTIFICATIONS =====
|
|
function showToast(message, type = 'success') {
|
|
const toast = document.getElementById('syncToast');
|
|
if (!toast) return;
|
|
|
|
// Update toast appearance based on type
|
|
toast.classList.remove('bg-green-500', 'bg-red-500', 'bg-yellow-500');
|
|
|
|
if (type === 'success') {
|
|
toast.classList.add('bg-green-500');
|
|
} else if (type === 'error') {
|
|
toast.classList.add('bg-red-500');
|
|
} else if (type === 'warning') {
|
|
toast.classList.add('bg-yellow-500');
|
|
}
|
|
|
|
toast.textContent = message;
|
|
toast.classList.add('show');
|
|
|
|
// Auto-hide after 3 seconds
|
|
setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
}, 3000);
|
|
}
|
|
|
|
// ===== BOTTOM NAV ACTIVE STATE =====
|
|
function updateBottomNavActiveState() {
|
|
const currentPath = window.location.pathname;
|
|
const navButtons = document.querySelectorAll('.bottom-nav-btn');
|
|
|
|
navButtons.forEach(btn => {
|
|
const href = btn.getAttribute('data-href');
|
|
if (href && (currentPath === href || (href !== '/' && currentPath.startsWith(href)))) {
|
|
btn.classList.add('active');
|
|
} else {
|
|
btn.classList.remove('active');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ===== INITIALIZATION =====
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Initialize online/offline status
|
|
updateOnlineStatus();
|
|
|
|
// Update bottom nav active state
|
|
updateBottomNavActiveState();
|
|
|
|
// Add resize listener
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
// Close menu on navigation (for mobile)
|
|
document.addEventListener('click', (e) => {
|
|
const link = e.target.closest('a');
|
|
if (link && link.closest('#sidebar')) {
|
|
// Delay to allow navigation to start
|
|
setTimeout(() => {
|
|
if (window.innerWidth < 768) {
|
|
closeMenuFromBackdrop();
|
|
}
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
// Prevent scroll when modals are open (iOS fix)
|
|
document.addEventListener('touchmove', (e) => {
|
|
const modal = document.querySelector('.unit-modal.show, #sidebar.open');
|
|
if (modal && !e.target.closest('.unit-modal-content, #sidebar')) {
|
|
e.preventDefault();
|
|
}
|
|
}, { passive: false });
|
|
|
|
console.log('Mobile.js initialized');
|
|
});
|
|
|
|
// ===== SERVICE WORKER REGISTRATION =====
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('/sw.js')
|
|
.then(registration => {
|
|
console.log('Service Worker registered:', registration);
|
|
|
|
// Check for updates periodically
|
|
setInterval(() => {
|
|
registration.update();
|
|
}, 60 * 60 * 1000); // Check every hour
|
|
})
|
|
.catch(error => {
|
|
console.error('Service Worker registration failed:', error);
|
|
});
|
|
|
|
// Listen for service worker updates
|
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
console.log('Service Worker updated, reloading page...');
|
|
window.location.reload();
|
|
});
|
|
}
|
|
|
|
// Export functions for global use
|
|
window.toggleMenu = toggleMenu;
|
|
window.closeMenuFromBackdrop = closeMenuFromBackdrop;
|
|
window.openUnitModal = openUnitModal;
|
|
window.closeUnitModal = closeUnitModal;
|
|
window.manualSync = manualSync;
|
|
window.showToast = showToast;
|