v0.4.0 - merge from claude/dev-015sto5mf2MpPCE57TbNKtaF #1
121
backend/main.py
@@ -3,8 +3,10 @@ from fastapi import FastAPI, Request, Depends
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Dict
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.database import engine, Base, get_db
|
from backend.database import engine, Base, get_db
|
||||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs
|
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs
|
||||||
@@ -18,7 +20,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.3.0"
|
VERSION = "0.3.1"
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Seismo Fleet Manager",
|
title="Seismo Fleet Manager",
|
||||||
description="Backend API for managing seismograph fleet status",
|
description="Backend API for managing seismograph fleet status",
|
||||||
@@ -104,6 +106,99 @@ async def settings_page(request: Request):
|
|||||||
return templates.TemplateResponse("settings.html", {"request": request})
|
return templates.TemplateResponse("settings.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
# ===== PWA ROUTES =====
|
||||||
|
|
||||||
|
@app.get("/sw.js")
|
||||||
|
async def service_worker():
|
||||||
|
"""Serve service worker with proper headers for PWA"""
|
||||||
|
return FileResponse(
|
||||||
|
"backend/static/sw.js",
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={
|
||||||
|
"Service-Worker-Allowed": "/",
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/offline-db.js")
|
||||||
|
async def offline_db_script():
|
||||||
|
"""Serve offline database script"""
|
||||||
|
return FileResponse(
|
||||||
|
"backend/static/offline-db.js",
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={"Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic model for sync edits
|
||||||
|
class EditItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
unitId: str
|
||||||
|
changes: Dict
|
||||||
|
timestamp: int
|
||||||
|
|
||||||
|
|
||||||
|
class SyncEditsRequest(BaseModel):
|
||||||
|
edits: List[EditItem]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/sync-edits")
|
||||||
|
async def sync_edits(request: SyncEditsRequest, db: Session = Depends(get_db)):
|
||||||
|
"""Process offline edit queue and sync to database"""
|
||||||
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
|
results = []
|
||||||
|
synced_ids = []
|
||||||
|
|
||||||
|
for edit in request.edits:
|
||||||
|
try:
|
||||||
|
# Find the unit
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=edit.unitId).first()
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "error",
|
||||||
|
"reason": f"Unit {edit.unitId} not found"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply changes
|
||||||
|
for key, value in edit.changes.items():
|
||||||
|
if hasattr(unit, key):
|
||||||
|
# Handle boolean conversions
|
||||||
|
if key in ['deployed', 'retired']:
|
||||||
|
setattr(unit, key, value in ['true', True, 'True', '1', 1])
|
||||||
|
else:
|
||||||
|
setattr(unit, key, value if value != '' else None)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "success"
|
||||||
|
})
|
||||||
|
synced_ids.append(edit.id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "error",
|
||||||
|
"reason": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
synced_count = len(synced_ids)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"synced": synced_count,
|
||||||
|
"total": len(request.edits),
|
||||||
|
"synced_ids": synced_ids,
|
||||||
|
"results": results
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/partials/roster-deployed", response_class=HTMLResponse)
|
@app.get("/partials/roster-deployed", response_class=HTMLResponse)
|
||||||
async def roster_deployed_partial(request: Request):
|
async def roster_deployed_partial(request: Request):
|
||||||
"""Partial template for deployed units tab"""
|
"""Partial template for deployed units tab"""
|
||||||
@@ -114,12 +209,15 @@ async def roster_deployed_partial(request: Request):
|
|||||||
for unit_id, unit_data in snapshot["active"].items():
|
for unit_id, unit_data in snapshot["active"].items():
|
||||||
units_list.append({
|
units_list.append({
|
||||||
"id": unit_id,
|
"id": unit_id,
|
||||||
"status": unit_data["status"],
|
"status": unit_data.get("status", "Unknown"),
|
||||||
"age": unit_data["age"],
|
"age": unit_data.get("age", "N/A"),
|
||||||
"last_seen": unit_data["last"],
|
"last_seen": unit_data.get("last", "Never"),
|
||||||
"deployed": unit_data["deployed"],
|
"deployed": unit_data.get("deployed", False),
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"address": unit_data.get("address", ""),
|
||||||
|
"coordinates": unit_data.get("coordinates", ""),
|
||||||
|
"project_id": unit_data.get("project_id", ""),
|
||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
@@ -149,12 +247,15 @@ async def roster_benched_partial(request: Request):
|
|||||||
for unit_id, unit_data in snapshot["benched"].items():
|
for unit_id, unit_data in snapshot["benched"].items():
|
||||||
units_list.append({
|
units_list.append({
|
||||||
"id": unit_id,
|
"id": unit_id,
|
||||||
"status": unit_data["status"],
|
"status": unit_data.get("status", "N/A"),
|
||||||
"age": unit_data["age"],
|
"age": unit_data.get("age", "N/A"),
|
||||||
"last_seen": unit_data["last"],
|
"last_seen": unit_data.get("last", "Never"),
|
||||||
"deployed": unit_data["deployed"],
|
"deployed": unit_data.get("deployed", False),
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"address": unit_data.get("address", ""),
|
||||||
|
"coordinates": unit_data.get("coordinates", ""),
|
||||||
|
"project_id": unit_data.get("project_id", ""),
|
||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
|||||||
78
backend/static/icons/ICON_GENERATION_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# PWA Icon Generation Instructions
|
||||||
|
|
||||||
|
The PWA manifest requires 8 icon sizes for full compatibility across devices.
|
||||||
|
|
||||||
|
## Required Icon Sizes
|
||||||
|
|
||||||
|
- 72x72px
|
||||||
|
- 96x96px
|
||||||
|
- 128x128px
|
||||||
|
- 144x144px
|
||||||
|
- 152x152px
|
||||||
|
- 192x192px
|
||||||
|
- 384x384px
|
||||||
|
- 512x512px (maskable)
|
||||||
|
|
||||||
|
## Design Guidelines
|
||||||
|
|
||||||
|
**Background:** Navy blue (#142a66)
|
||||||
|
**Icon/Logo:** Orange (#f48b1c)
|
||||||
|
**Style:** Simple, recognizable design that works at small sizes
|
||||||
|
|
||||||
|
## Quick Generation Methods
|
||||||
|
|
||||||
|
### Option 1: Online PWA Icon Generator
|
||||||
|
|
||||||
|
1. Visit: https://www.pwabuilder.com/imageGenerator
|
||||||
|
2. Upload a 512x512px source image
|
||||||
|
3. Download the generated icon pack
|
||||||
|
4. Copy PNG files to this directory
|
||||||
|
|
||||||
|
### Option 2: ImageMagick (Command Line)
|
||||||
|
|
||||||
|
If you have a 512x512px source image called `source-icon.png`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the icons directory
|
||||||
|
for size in 72 96 128 144 152 192 384 512; do
|
||||||
|
convert source-icon.png -resize ${size}x${size} icon-${size}.png
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Photoshop/GIMP
|
||||||
|
|
||||||
|
1. Create a 512x512px canvas
|
||||||
|
2. Add your design (navy background + orange icon)
|
||||||
|
3. Save/Export for each required size
|
||||||
|
4. Name files as: icon-72.png, icon-96.png, etc.
|
||||||
|
|
||||||
|
## Temporary Placeholder
|
||||||
|
|
||||||
|
For testing, you can use a simple colored square:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate simple colored placeholder icons
|
||||||
|
for size in 72 96 128 144 152 192 384 512; do
|
||||||
|
convert -size ${size}x${size} xc:#142a66 \
|
||||||
|
-gravity center \
|
||||||
|
-fill '#f48b1c' \
|
||||||
|
-pointsize $((size / 2)) \
|
||||||
|
-annotate +0+0 'SFM' \
|
||||||
|
icon-${size}.png
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After generating icons, verify:
|
||||||
|
- All 8 sizes exist in this directory
|
||||||
|
- Files are named exactly: icon-72.png, icon-96.png, etc.
|
||||||
|
- Images have transparent or navy background
|
||||||
|
- Logo/text is clearly visible at smallest size (72px)
|
||||||
|
|
||||||
|
## Testing PWA Installation
|
||||||
|
|
||||||
|
1. Open SFM in Chrome on Android or Safari on iOS
|
||||||
|
2. Look for "Install App" or "Add to Home Screen" prompt
|
||||||
|
3. Check that the correct icon appears in the install dialog
|
||||||
|
4. After installation, verify icon on home screen
|
||||||
BIN
backend/static/icons/icon-128.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
4
backend/static/icons/icon-128.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="128" height="128" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="128" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
BIN
backend/static/icons/icon-144.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
4
backend/static/icons/icon-144.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="144" height="144" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 287 B |
BIN
backend/static/icons/icon-152.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
4
backend/static/icons/icon-152.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="152" height="152" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="152" height="152" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="152" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
BIN
backend/static/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
4
backend/static/icons/icon-192.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="192" height="192" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="192" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
BIN
backend/static/icons/icon-384.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
4
backend/static/icons/icon-384.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="384" height="384" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="384" height="384" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="38" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 287 B |
BIN
backend/static/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
4
backend/static/icons/icon-512.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="512" height="512" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="512" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
BIN
backend/static/icons/icon-72.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
4
backend/static/icons/icon-72.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="72" height="72" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="72" height="72" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
BIN
backend/static/icons/icon-96.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
4
backend/static/icons/icon-96.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="96" height="96" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="96" height="96" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="96" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
78
backend/static/manifest.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"name": "Seismo Fleet Manager",
|
||||||
|
"short_name": "SFM",
|
||||||
|
"description": "Real-time seismograph and modem fleet monitoring and management",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#142a66",
|
||||||
|
"theme_color": "#f48b1c",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/static/screenshots/dashboard.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "540x720",
|
||||||
|
"form_factor": "narrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["utilities", "productivity"],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"short_name": "Dashboard",
|
||||||
|
"description": "View fleet status dashboard",
|
||||||
|
"url": "/",
|
||||||
|
"icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fleet Roster",
|
||||||
|
"short_name": "Roster",
|
||||||
|
"description": "View and manage fleet roster",
|
||||||
|
"url": "/roster",
|
||||||
|
"icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
564
backend/static/mobile.css
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
/* Mobile-specific styles for Seismo Fleet Manager */
|
||||||
|
/* Touch-optimized, portrait-first design */
|
||||||
|
|
||||||
|
/* ===== MOBILE TOUCH TARGETS ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
/* Buttons - 44x44px minimum (iOS standard) */
|
||||||
|
.btn, button:not(.tab-button), .button, a.button {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon-only buttons */
|
||||||
|
.icon-button, .btn-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form inputs - 48px height, 16px font prevents iOS zoom */
|
||||||
|
input:not([type="checkbox"]):not([type="radio"]),
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
min-height: 48px;
|
||||||
|
font-size: 16px !important;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkboxes and radio buttons - larger touch targets */
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom nav buttons - 56px industry standard */
|
||||||
|
.bottom-nav button {
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Increase spacing between clickable elements */
|
||||||
|
.btn + .btn,
|
||||||
|
button + button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== HAMBURGER MENU ===== */
|
||||||
|
.hamburger-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
z-index: 50;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hamburger-btn {
|
||||||
|
background-color: #1e293b;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger icon */
|
||||||
|
.hamburger-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-line {
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background-color: #374151;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hamburger-line {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger animation when menu open */
|
||||||
|
.menu-open .hamburger-line:nth-child(1) {
|
||||||
|
transform: translateY(8px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-open .hamburger-line:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-open .hamburger-line:nth-child(3) {
|
||||||
|
transform: translateY(-8px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SIDEBAR (RESPONSIVE) ===== */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 16rem; /* 256px */
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 40;
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(0) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BACKDROP ===== */
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 30;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop.show {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BOTTOM NAVIGATION ===== */
|
||||||
|
.bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4rem;
|
||||||
|
background-color: white;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: 0 -1px 3px 0 rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bottom-nav {
|
||||||
|
background-color: #1e293b;
|
||||||
|
border-top-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #6b7280;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bottom-nav-btn:active {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn.active {
|
||||||
|
color: #f48b1c; /* seismo-orange */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn span {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.bottom-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MAIN CONTENT ADJUSTMENTS ===== */
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-bottom: 5rem; /* 80px for bottom nav */
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
margin-left: 16rem; /* 256px sidebar width */
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE ROSTER CARDS ===== */
|
||||||
|
.unit-card {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .unit-card {
|
||||||
|
background-color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-card:hover {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== UNIT DETAIL MODAL (BOTTOM SHEET) ===== */
|
||||||
|
.unit-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal.show {
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
background-color: white;
|
||||||
|
border-top-left-radius: 1rem;
|
||||||
|
border-top-right-radius: 1rem;
|
||||||
|
box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
overflow-y: auto;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal.show .unit-modal-content {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .unit-modal-content {
|
||||||
|
background-color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.unit-modal {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal-content {
|
||||||
|
max-width: 42rem; /* 672px */
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal.show .unit-modal-content {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal handle bar (mobile only) */
|
||||||
|
.modal-handle {
|
||||||
|
height: 4px;
|
||||||
|
width: 3rem;
|
||||||
|
background-color: #d1d5db;
|
||||||
|
border-radius: 9999px;
|
||||||
|
margin: 0.75rem auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.modal-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== OFFLINE INDICATOR ===== */
|
||||||
|
.offline-indicator {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: #eab308; /* yellow-500 */
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 50;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-indicator.show {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SYNC TOAST ===== */
|
||||||
|
.sync-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 6rem; /* Above bottom nav */
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background-color: #22c55e; /* green-500 */
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
z-index: 50;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: opacity 0.3s, transform 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sync-toast {
|
||||||
|
bottom: 1rem;
|
||||||
|
left: auto;
|
||||||
|
right: 1rem;
|
||||||
|
max-width: 20rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE SEARCH BAR (STICKY) ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-search-sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
margin: -1rem -1rem 1rem -1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .mobile-search-sticky {
|
||||||
|
background-color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mobile-search-sticky {
|
||||||
|
position: static;
|
||||||
|
background-color: transparent;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== STATUS BADGES ===== */
|
||||||
|
.status-dot {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== DEVICE TYPE BADGES ===== */
|
||||||
|
.device-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE MAP HEIGHT ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
#fleet-map {
|
||||||
|
height: 16rem !important; /* 256px on mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
#unit-map {
|
||||||
|
height: 16rem !important; /* 256px on mobile */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== PENDING SYNC BADGE ===== */
|
||||||
|
.pending-sync-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background-color: #fef3c7; /* amber-100 */
|
||||||
|
color: #92400e; /* amber-800 */
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .pending-sync-badge {
|
||||||
|
background-color: #78350f;
|
||||||
|
color: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-sync-badge::before {
|
||||||
|
content: "⏳";
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE-SPECIFIC UTILITY CLASSES ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-text-lg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-text-xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-mb-4 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== ACCESSIBILITY ===== */
|
||||||
|
/* Improve focus visibility on mobile */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
button:focus-visible,
|
||||||
|
a:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 2px solid #f48b1c;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent text selection on buttons (better mobile UX) */
|
||||||
|
button,
|
||||||
|
.btn,
|
||||||
|
.button {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SMOOTH SCROLLING ===== */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent overscroll bounce on iOS */
|
||||||
|
body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== LOADING STATES ===== */
|
||||||
|
.loading-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SAFE AREA SUPPORT (iOS notch) ===== */
|
||||||
|
@supports (padding: env(safe-area-inset-bottom)) {
|
||||||
|
.bottom-nav {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
height: calc(4rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding-bottom: calc(5rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
588
backend/static/mobile.js
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
/* 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;
|
||||||
352
backend/static/offline-db.js
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
/* IndexedDB wrapper for offline data storage in SFM */
|
||||||
|
/* Handles unit data, status snapshots, and pending edit queue */
|
||||||
|
|
||||||
|
class OfflineDB {
|
||||||
|
constructor() {
|
||||||
|
this.dbName = 'sfm-offline-db';
|
||||||
|
this.version = 1;
|
||||||
|
this.db = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
async init() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(this.dbName, this.version);
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error('IndexedDB error:', request.error);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
this.db = request.result;
|
||||||
|
console.log('IndexedDB initialized successfully');
|
||||||
|
resolve(this.db);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
|
||||||
|
// Units store - full unit details
|
||||||
|
if (!db.objectStoreNames.contains('units')) {
|
||||||
|
const unitsStore = db.createObjectStore('units', { keyPath: 'id' });
|
||||||
|
unitsStore.createIndex('device_type', 'device_type', { unique: false });
|
||||||
|
unitsStore.createIndex('deployed', 'deployed', { unique: false });
|
||||||
|
console.log('Created units object store');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status snapshot store - latest status data
|
||||||
|
if (!db.objectStoreNames.contains('status-snapshot')) {
|
||||||
|
db.createObjectStore('status-snapshot', { keyPath: 'timestamp' });
|
||||||
|
console.log('Created status-snapshot object store');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending edits store - offline edit queue
|
||||||
|
if (!db.objectStoreNames.contains('pending-edits')) {
|
||||||
|
const editsStore = db.createObjectStore('pending-edits', {
|
||||||
|
keyPath: 'id',
|
||||||
|
autoIncrement: true
|
||||||
|
});
|
||||||
|
editsStore.createIndex('unitId', 'unitId', { unique: false });
|
||||||
|
editsStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||||
|
console.log('Created pending-edits object store');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UNITS OPERATIONS =====
|
||||||
|
|
||||||
|
// Save or update a unit
|
||||||
|
async saveUnit(unit) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('units');
|
||||||
|
const request = store.put(unit);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a single unit by ID
|
||||||
|
async getUnit(unitId) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readonly');
|
||||||
|
const store = transaction.objectStore('units');
|
||||||
|
const request = store.get(unitId);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all units
|
||||||
|
async getAllUnits() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readonly');
|
||||||
|
const store = transaction.objectStore('units');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a unit
|
||||||
|
async deleteUnit(unitId) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('units');
|
||||||
|
const request = store.delete(unitId);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== STATUS SNAPSHOT OPERATIONS =====
|
||||||
|
|
||||||
|
// Save status snapshot
|
||||||
|
async saveSnapshot(snapshot) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['status-snapshot'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('status-snapshot');
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
const snapshotWithTimestamp = {
|
||||||
|
...snapshot,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = store.put(snapshotWithTimestamp);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get latest status snapshot
|
||||||
|
async getLatestSnapshot() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['status-snapshot'], 'readonly');
|
||||||
|
const store = transaction.objectStore('status-snapshot');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const snapshots = request.result;
|
||||||
|
if (snapshots.length > 0) {
|
||||||
|
// Return the most recent snapshot
|
||||||
|
const latest = snapshots.reduce((prev, current) =>
|
||||||
|
(prev.timestamp > current.timestamp) ? prev : current
|
||||||
|
);
|
||||||
|
resolve(latest);
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear old snapshots (keep only latest)
|
||||||
|
async clearOldSnapshots() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['status-snapshot'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('status-snapshot');
|
||||||
|
const getAllRequest = store.getAll();
|
||||||
|
|
||||||
|
getAllRequest.onsuccess = () => {
|
||||||
|
const snapshots = getAllRequest.result;
|
||||||
|
|
||||||
|
if (snapshots.length > 1) {
|
||||||
|
// Sort by timestamp, keep only the latest
|
||||||
|
snapshots.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
|
||||||
|
// Delete all except the first (latest)
|
||||||
|
for (let i = 1; i < snapshots.length; i++) {
|
||||||
|
store.delete(snapshots[i].timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
getAllRequest.onerror = () => reject(getAllRequest.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== PENDING EDITS OPERATIONS =====
|
||||||
|
|
||||||
|
// Queue an edit for offline sync
|
||||||
|
async queueEdit(unitId, changes) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
|
||||||
|
const edit = {
|
||||||
|
unitId,
|
||||||
|
changes,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = store.add(edit);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log(`Queued edit for unit ${unitId}`);
|
||||||
|
resolve(request.result);
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all pending edits
|
||||||
|
async getPendingEdits() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readonly');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending edits count
|
||||||
|
async getPendingEditsCount() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readonly');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.count();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear a synced edit
|
||||||
|
async clearEdit(editId) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.delete(editId);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log(`Cleared edit ${editId} from queue`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all pending edits
|
||||||
|
async clearAllEdits() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.clear();
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log('Cleared all pending edits');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UTILITY OPERATIONS =====
|
||||||
|
|
||||||
|
// Clear all data (for debugging/reset)
|
||||||
|
async clearAllData() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const storeNames = ['units', 'status-snapshot', 'pending-edits'];
|
||||||
|
const transaction = this.db.transaction(storeNames, 'readwrite');
|
||||||
|
|
||||||
|
storeNames.forEach(storeName => {
|
||||||
|
transaction.objectStore(storeName).clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction.oncomplete = () => {
|
||||||
|
console.log('Cleared all offline data');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get database statistics
|
||||||
|
async getStats() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
const unitsCount = await new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readonly');
|
||||||
|
const request = transaction.objectStore('units').count();
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingEditsCount = await this.getPendingEditsCount();
|
||||||
|
|
||||||
|
const hasSnapshot = await new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['status-snapshot'], 'readonly');
|
||||||
|
const request = transaction.objectStore('status-snapshot').count();
|
||||||
|
request.onsuccess = () => resolve(request.result > 0);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
unitsCount,
|
||||||
|
pendingEditsCount,
|
||||||
|
hasSnapshot
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create global instance
|
||||||
|
window.offlineDB = new OfflineDB();
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
try {
|
||||||
|
await window.offlineDB.init();
|
||||||
|
console.log('Offline database ready');
|
||||||
|
|
||||||
|
// Display pending edits count if any
|
||||||
|
const pendingCount = await window.offlineDB.getPendingEditsCount();
|
||||||
|
if (pendingCount > 0) {
|
||||||
|
console.log(`${pendingCount} pending edits in queue`);
|
||||||
|
// Could show a badge in the UI here
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize offline database:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in other scripts
|
||||||
|
export default OfflineDB;
|
||||||
347
backend/static/sw.js
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
/* Service Worker for Seismo Fleet Manager PWA */
|
||||||
|
/* Network-first strategy with cache fallback for real-time data */
|
||||||
|
|
||||||
|
const CACHE_VERSION = 'v1';
|
||||||
|
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
|
||||||
|
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
|
||||||
|
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
|
||||||
|
|
||||||
|
// Files to precache (critical app shell)
|
||||||
|
const STATIC_FILES = [
|
||||||
|
'/',
|
||||||
|
'/static/style.css',
|
||||||
|
'/static/mobile.css',
|
||||||
|
'/static/mobile.js',
|
||||||
|
'/static/offline-db.js',
|
||||||
|
'/static/manifest.json',
|
||||||
|
'https://cdn.tailwindcss.com',
|
||||||
|
'https://unpkg.com/htmx.org@1.9.10',
|
||||||
|
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
|
||||||
|
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install event - cache static files
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
console.log('[SW] Installing service worker...');
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(STATIC_CACHE)
|
||||||
|
.then((cache) => {
|
||||||
|
console.log('[SW] Precaching static files');
|
||||||
|
return cache.addAll(STATIC_FILES);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('[SW] Static files cached successfully');
|
||||||
|
return self.skipWaiting(); // Activate immediately
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[SW] Precaching failed:', error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event - clean up old caches
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[SW] Activating service worker...');
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map((cacheName) => {
|
||||||
|
// Delete old caches that don't match current version
|
||||||
|
if (cacheName !== STATIC_CACHE &&
|
||||||
|
cacheName !== DYNAMIC_CACHE &&
|
||||||
|
cacheName !== DATA_CACHE) {
|
||||||
|
console.log('[SW] Deleting old cache:', cacheName);
|
||||||
|
return caches.delete(cacheName);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('[SW] Service worker activated');
|
||||||
|
return self.clients.claim(); // Take control of all pages
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event - network-first strategy
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET requests
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip chrome-extension and other non-http(s) requests
|
||||||
|
if (!url.protocol.startsWith('http')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API requests - network first, cache fallback
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
event.respondWith(networkFirstStrategy(request, DATA_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets - cache first
|
||||||
|
if (isStaticAsset(url.pathname)) {
|
||||||
|
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML pages - network first with cache fallback
|
||||||
|
if (request.headers.get('accept')?.includes('text/html')) {
|
||||||
|
event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else - network first
|
||||||
|
event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Network-first strategy
|
||||||
|
async function networkFirstStrategy(request, cacheName) {
|
||||||
|
try {
|
||||||
|
// Try network first
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
|
// Cache successful responses
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
// Network failed, try cache
|
||||||
|
console.log('[SW] Network failed, trying cache:', request.url);
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
console.log('[SW] Serving from cache:', request.url);
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache available, return offline page or error
|
||||||
|
console.error('[SW] No cache available for:', request.url);
|
||||||
|
|
||||||
|
// For HTML requests, return a basic offline page
|
||||||
|
if (request.headers.get('accept')?.includes('text/html')) {
|
||||||
|
return new Response(
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Offline - SFM</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 { color: #f48b1c; margin-bottom: 1rem; }
|
||||||
|
p { margin-bottom: 1.5rem; color: #6b7280; }
|
||||||
|
button {
|
||||||
|
background: #f48b1c;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: #d97706; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>📡 You're Offline</h1>
|
||||||
|
<p>SFM requires an internet connection for this page.</p>
|
||||||
|
<p>Please check your connection and try again.</p>
|
||||||
|
<button onclick="location.reload()">Retry</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'text/html' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other requests, return error
|
||||||
|
return new Response('Network error', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first strategy
|
||||||
|
async function cacheFirstStrategy(request, cacheName) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in cache, fetch from network
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
|
// Cache successful responses
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Fetch failed:', request.url, error);
|
||||||
|
return new Response('Network error', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if URL is a static asset
|
||||||
|
function isStaticAsset(pathname) {
|
||||||
|
const staticExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.svg', '.ico', '.woff', '.woff2'];
|
||||||
|
return staticExtensions.some(ext => pathname.endsWith(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background Sync - for offline edits
|
||||||
|
self.addEventListener('sync', (event) => {
|
||||||
|
console.log('[SW] Background sync event:', event.tag);
|
||||||
|
|
||||||
|
if (event.tag === 'sync-edits') {
|
||||||
|
event.waitUntil(syncPendingEdits());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync pending edits to server
|
||||||
|
async function syncPendingEdits() {
|
||||||
|
console.log('[SW] Syncing pending edits...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get pending edits from IndexedDB
|
||||||
|
const db = await openDatabase();
|
||||||
|
const edits = await getPendingEdits(db);
|
||||||
|
|
||||||
|
if (edits.length === 0) {
|
||||||
|
console.log('[SW] No pending edits to sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SW] Syncing ${edits.length} pending edits`);
|
||||||
|
|
||||||
|
// Send edits to server
|
||||||
|
const response = await fetch('/api/sync-edits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ edits })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('[SW] Sync successful:', result);
|
||||||
|
|
||||||
|
// Clear synced edits from IndexedDB
|
||||||
|
await clearSyncedEdits(db, result.synced_ids || []);
|
||||||
|
|
||||||
|
// Notify all clients about successful sync
|
||||||
|
const clients = await self.clients.matchAll();
|
||||||
|
clients.forEach(client => {
|
||||||
|
client.postMessage({
|
||||||
|
type: 'SYNC_COMPLETE',
|
||||||
|
synced: result.synced
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('[SW] Sync failed:', response.status);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Sync error:', error);
|
||||||
|
throw error; // Will retry sync later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexedDB helpers (simplified versions - full implementations in offline-db.js)
|
||||||
|
function openDatabase() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open('sfm-offline-db', 1);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('pending-edits')) {
|
||||||
|
db.createObjectStore('pending-edits', { keyPath: 'id', autoIncrement: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingEdits(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['pending-edits'], 'readonly');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSyncedEdits(db, editIds) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['pending-edits'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
|
||||||
|
editIds.forEach(id => {
|
||||||
|
store.delete(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message event - handle messages from clients
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
console.log('[SW] Message received:', event.data);
|
||||||
|
|
||||||
|
if (event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'CLEAR_CACHE') {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map((cacheName) => caches.delete(cacheName))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[SW] Service Worker loaded');
|
||||||
@@ -15,6 +15,16 @@
|
|||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<!-- Mobile CSS -->
|
||||||
|
<link rel="stylesheet" href="/static/mobile.css">
|
||||||
|
|
||||||
|
<!-- PWA Manifest -->
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#f48b1c">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="SFM">
|
||||||
|
|
||||||
<!-- Custom Tailwind Config -->
|
<!-- Custom Tailwind Config -->
|
||||||
<script>
|
<script>
|
||||||
tailwind.config = {
|
tailwind.config = {
|
||||||
@@ -59,9 +69,28 @@
|
|||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||||
|
<!-- Hamburger Button (Mobile Only) -->
|
||||||
|
<button id="hamburgerBtn" class="hamburger-btn md:hidden" onclick="toggleMenu()" aria-label="Menu">
|
||||||
|
<div class="hamburger-icon">
|
||||||
|
<div class="hamburger-line"></div>
|
||||||
|
<div class="hamburger-line"></div>
|
||||||
|
<div class="hamburger-line"></div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Offline Indicator -->
|
||||||
|
<div id="offlineIndicator" class="offline-indicator">
|
||||||
|
📡 Offline - Changes will sync when connected
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Toast -->
|
||||||
|
<div id="syncToast" class="sync-toast">
|
||||||
|
✓ Synced successfully
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar (Responsive) -->
|
||||||
<aside class="w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h1 class="text-2xl font-bold text-seismo-navy dark:text-seismo-orange">
|
<h1 class="text-2xl font-bold text-seismo-navy dark:text-seismo-orange">
|
||||||
@@ -122,14 +151,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<!-- Backdrop (Mobile Only) -->
|
||||||
|
<div id="backdrop" class="backdrop" onclick="closeMenuFromBackdrop()"></div>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="flex-1 overflow-y-auto">
|
<main class="main-content flex-1 overflow-y-auto">
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation (Mobile Only) -->
|
||||||
|
<nav class="bottom-nav">
|
||||||
|
<div class="grid grid-cols-4 h-16">
|
||||||
|
<button class="bottom-nav-btn" data-href="/" onclick="window.location.href='/'">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-href="/roster" onclick="window.location.href='/roster'">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Roster</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-href="/roster?action=add" onclick="window.location.href='/roster?action=add'">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Add Unit</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Dark mode toggle
|
// Dark mode toggle
|
||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
@@ -261,6 +324,12 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Offline Database -->
|
||||||
|
<script src="/static/offline-db.js?v=0.3.1"></script>
|
||||||
|
|
||||||
|
<!-- Mobile JavaScript -->
|
||||||
|
<script src="/static/mobile.js?v=0.3.1"></script>
|
||||||
|
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -42,15 +42,15 @@
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
||||||
<span id="total-units" class="text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
|
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
|
||||||
<span id="deployed-units" class="text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
|
<span id="deployed-units" class="text-3xl md:text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
||||||
<span id="benched-units" class="text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed units</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed units</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="fleet-map" class="w-full h-96 rounded-lg"></div>
|
<div id="fleet-map" class="w-full h-64 md:h-96 rounded-lg"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fleet Status Section with Tabs -->
|
<!-- Fleet Status Section with Tabs -->
|
||||||
@@ -224,17 +224,16 @@ function updateDashboard(event) {
|
|||||||
|
|
||||||
// ===== Alerts =====
|
// ===== Alerts =====
|
||||||
const alertsList = document.getElementById('alerts-list');
|
const alertsList = document.getElementById('alerts-list');
|
||||||
// Only show alerts for deployed units (not benched)
|
// Only show alerts for deployed units that are MISSING (not pending)
|
||||||
const missingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Missing');
|
const missingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Missing');
|
||||||
const pendingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Pending');
|
|
||||||
|
|
||||||
if (!missingUnits.length && !pendingUnits.length) {
|
if (!missingUnits.length) {
|
||||||
alertsList.innerHTML =
|
alertsList.innerHTML =
|
||||||
'<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
|
'<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
|
||||||
} else {
|
} else {
|
||||||
let alertsHtml = '';
|
let alertsHtml = '';
|
||||||
|
|
||||||
missingUnits.slice(0, 3).forEach(([id, unit]) => {
|
missingUnits.forEach(([id, unit]) => {
|
||||||
alertsHtml += `
|
alertsHtml += `
|
||||||
<div class="flex items-start space-x-2 text-sm">
|
<div class="flex items-start space-x-2 text-sm">
|
||||||
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
|
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
|
||||||
@@ -245,17 +244,6 @@ function updateDashboard(event) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
pendingUnits.slice(0, 2).forEach(([id, unit]) => {
|
|
||||||
alertsHtml += `
|
|
||||||
<div class="flex items-start space-x-2 text-sm">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-yellow-500 mt-1.5"></span>
|
|
||||||
<div>
|
|
||||||
<a href="/unit/${id}" class="font-medium text-yellow-600 dark:text-yellow-400 hover:underline">${id}</a>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">Pending for ${unit.age}</p>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
alertsList.innerHTML = alertsHtml;
|
alertsList.innerHTML = alertsHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
<!-- Desktop Table View -->
|
||||||
|
<div class="hidden md:block rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||||
<table id="roster-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table id="roster-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -183,6 +184,160 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Card View -->
|
||||||
|
<div class="md:hidden space-y-3">
|
||||||
|
{% for unit in units %}
|
||||||
|
<div class="unit-card"
|
||||||
|
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||||
|
data-unit-id="{{ unit.id }}"
|
||||||
|
data-status="{{ unit.status }}"
|
||||||
|
data-age="{{ unit.age }}">
|
||||||
|
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if unit.status == 'OK' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
|
{% elif unit.status == 'Pending' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
|
{% elif unit.status == 'Missing' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-gray-400" title="No Data"></span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||||
|
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||||
|
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||||
|
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||||
|
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||||
|
{% endif %}">
|
||||||
|
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type Badge -->
|
||||||
|
<div class="mb-2">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location (Tap to Navigate) -->
|
||||||
|
{% if unit.address %}
|
||||||
|
<div class="text-sm mb-1">
|
||||||
|
<a href="https://www.google.com/maps/search/?api=1&query={{ unit.address | urlencode }}"
|
||||||
|
target="_blank"
|
||||||
|
onclick="event.stopPropagation()"
|
||||||
|
class="flex items-center gap-1 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>
|
||||||
|
</div>
|
||||||
|
{% elif unit.coordinates %}
|
||||||
|
<div class="text-sm mb-1">
|
||||||
|
<a href="https://www.google.com/maps/search/?api=1&query={{ unit.coordinates | urlencode }}"
|
||||||
|
target="_blank"
|
||||||
|
onclick="event.stopPropagation()"
|
||||||
|
class="flex items-center gap-1 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 underline">{{ unit.coordinates }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Project ID -->
|
||||||
|
{% if unit.project_id %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
🏗️ {{ unit.project_id }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Last Seen -->
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||||
|
🕐 {{ unit.age }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deployed/Benched Indicator -->
|
||||||
|
<div class="mt-2">
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
⚡ Deployed
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
📦 Benched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tap Hint -->
|
||||||
|
<div class="text-xs text-gray-400 mt-2 text-center border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||||
|
Tap for details
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Mobile Last Updated -->
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||||
|
Last updated: <span id="last-updated-mobile">{{ timestamp }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit Detail Modal -->
|
||||||
|
<div id="unitModal" class="unit-modal">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="unit-modal-backdrop" onclick="closeUnitModal(event)"></div>
|
||||||
|
|
||||||
|
<!-- Modal Content -->
|
||||||
|
<div class="unit-modal-content">
|
||||||
|
<!-- Handle Bar (Mobile Only) -->
|
||||||
|
<div class="modal-handle"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 id="modalUnitId" class="text-xl font-bold text-gray-900 dark:text-white"></h3>
|
||||||
|
<button onclick="closeUnitModal(event)" data-close-modal class="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div id="modalContent" class="p-6">
|
||||||
|
<!-- Content will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||||
|
<button id="modalEditBtn" class="w-full h-12 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
||||||
|
Edit Unit
|
||||||
|
</button>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button id="modalDeployBtn" class="h-12 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Deploy/Bench
|
||||||
|
</button>
|
||||||
|
<button id="modalDeleteBtn" class="h-12 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.sort-indicator::after {
|
.sort-indicator::after {
|
||||||
content: '⇅';
|
content: '⇅';
|
||||||
@@ -201,7 +356,14 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Update timestamp
|
// Update timestamp
|
||||||
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
|
const timestampElement = document.getElementById('last-updated');
|
||||||
|
if (timestampElement) {
|
||||||
|
timestampElement.textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
const timestampMobileElement = document.getElementById('last-updated-mobile');
|
||||||
|
if (timestampMobileElement) {
|
||||||
|
timestampMobileElement.textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
// Sorting state
|
// Sorting state
|
||||||
let currentSort = { column: null, direction: 'asc' };
|
let currentSort = { column: null, direction: 'asc' };
|
||||||
|
|||||||