|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
<title>GPS Trackers – v10 (splitters fixed + full tabs)</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
<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>
|
|
<style>
|
|
:root{
|
|
--blue:#1f7ae0; --bg:#ffffff; --line:#e8e8ef; --card:#fff;
|
|
--sidebar:#0f2642; --sidebar-2:#122b4a; --accent:#208dee;
|
|
--shadow:0 8px 24px rgba(16,24,40,.08);
|
|
/* Resizable variables */
|
|
--sidebarW:280px; --catW:320px; --splitterW:10px;
|
|
--topRowH: 360px; --hSplitH: 10px;
|
|
--tabW:240px; --miniSplitW:10px;
|
|
--detailSplitW:10px; --detailRightW:440px;
|
|
}
|
|
*{box-sizing:border-box} html,body{height:100%}
|
|
body{margin:0;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,"Helvetica Neue",Arial;background:var(--bg);color:#0b1324}
|
|
.app{display:grid;grid-template-columns:var(--sidebarW) 1fr;min-height:100vh;transition:grid-template-columns .12s ease}
|
|
/* Sidebar */
|
|
.sidebar{background:linear-gradient(180deg,var(--sidebar),var(--sidebar-2)); color:#e8eef9; height:100vh; position:relative; overflow:auto; box-shadow:2px 0 0 rgba(0,0,0,.06); display:flex; flex-direction:column}
|
|
.brand{display:flex; align-items:center; gap:12px; padding:16px}
|
|
.avatar{width:48px; height:48px; border-radius:50%; background:#fff; display:grid; place-items:center; color:#0f2642; font-weight:800}
|
|
.who{display:flex; flex-direction:column}
|
|
.menu{padding:8px}
|
|
.menu a{display:flex; align-items:center; gap:12px; padding:10px 12px; margin:4px; border-radius:12px; color:#e8eef9; text-decoration:none; transition:background .15s ease}
|
|
.menu a:hover{background:rgba(255,255,255,.08)}
|
|
.menu a.active{background:rgba(255,255,255,.16)}
|
|
.toggle-btn{cursor:ew-resize; position:fixed; top:50%; left:var(--sidebarW); transform:translate(-50%,-50%); padding:8px 6px; border:none; background:transparent; line-height:0; z-index:9}
|
|
.toggle-btn svg{stroke:#2f7fe6}
|
|
.app.compact .who, .app.compact .menu a span{display:none}
|
|
.app.compact .menu a{justify-content:center}
|
|
/* Topbar */
|
|
main{display:grid;grid-template-rows:auto 1fr}
|
|
.topbar{background:var(--blue);color:#fff;display:flex;align-items:center;justify-content:center;height:64px;box-shadow:var(--shadow)}
|
|
.topbar .title{font-size:26px;font-weight:800}
|
|
/* Content grid */
|
|
.content{padding:20px;display:grid;grid-template-columns: var(--catW) var(--splitterW) 1fr;grid-template-rows: var(--topRowH) var(--hSplitH) 1fr;gap:16px;align-items:stretch}
|
|
.section{display:flex;flex-direction:column;min-height:0}
|
|
.section .hd{display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--line)}
|
|
.section .h2{font-size:16px;font-weight:800}
|
|
.section .body{padding:10px 0;min-height:0;overflow:auto;flex:1}
|
|
/* Splitters */
|
|
.vsplitter{grid-column:2/3; grid-row:1/2; width:var(--splitterW); background:#e9eef5; border:1px solid #dbe4ee; display:flex;align-items:center;justify-content:center; cursor:col-resize;}
|
|
.dots-col{display:grid;gap:4px}
|
|
.dots-col span{width:5px;height:5px;border-radius:50%;background:#7d8896;margin:0 auto}
|
|
.hsplitter{grid-column:1/-1; grid-row:2/3; height:var(--hSplitH); background:#e9eef5; border:1px solid #dbe4ee; display:flex;align-items:center;justify-content:center; cursor:row-resize;}
|
|
.dots-row{display:flex;gap:6px}
|
|
.dots-row span{width:5px;height:5px;border-radius:50%;background:#7d8896}
|
|
/* Left: Devices & Filters */
|
|
.tree{font-size:13px}
|
|
.tree .node{display:flex;align-items:center;gap:8px;padding:6px 6px;border-radius:6px;cursor:pointer;user-select:none}
|
|
.tree .node:hover{background:#f7f7f7}
|
|
.tree .node.active{background:#eef2ff}
|
|
.device-row{display:flex;align-items:center;gap:8px;padding:8px;border-radius:8px;border:1px solid #eef0f5;margin-bottom:8px;background:#fff;cursor:pointer}
|
|
.dev-dot{width:10px;height:10px;border-radius:3px}
|
|
.dot-online{background:#16a34a}.dot-moving{background:#1f7ae0}.dot-offline{background:#9ca3af}.dot-alarm{background:#dc2626}
|
|
.muted{color:#6b7280;font-size:12px}
|
|
/* Map */
|
|
#mapPane{height:100%;}
|
|
#map{width:100%;height:100%;min-height:300px;border:1px solid var(--line);border-radius:12px}
|
|
.toolbar{display:flex;align-items:center;gap:8px;padding:0 0 8px;border-bottom:1px solid var(--line)}
|
|
.btn{padding:8px 12px;border:1px solid var(--line);background:#fff;border-radius:8px;cursor:pointer}
|
|
.btn.primary{background:var(--accent);color:#fff;border-color:transparent}
|
|
/* Right: Tracker Details tabs */
|
|
.details-grid{display:grid;grid-template-columns: var(--tabW) var(--miniSplitW) 1fr var(--detailSplitW) var(--detailRightW);gap:24px;align-items:start}
|
|
.tabs{border:1px solid var(--line); border-radius:12px; overflow:hidden; background:#fff; height:100%}
|
|
.tab-item{display:flex; align-items:center; gap:10px; padding:10px 12px; cursor:pointer; user-select:none; border-bottom:1px solid #f1f3f7; font-size:14px}
|
|
.tab-item:last-child{border-bottom:none}
|
|
.tab-item:hover{background:#f7f9fc}
|
|
.tab-item.active{background:#e8f1ff; font-weight:700}
|
|
.mini-splitter{width:var(--miniSplitW); border:1px solid #dbe4ee; background:#e9eef5; border-radius:12px; height:100%; display:flex; align-items:center; justify-content:center; user-select:none}
|
|
/* INNER vertical splitter between form and vehicle map */
|
|
.inner-vsplitter{width:var(--detailSplitW); border:1px solid #dbe4ee; background:#e9eef5; border-radius:12px; height:100%; display:flex; align-items:center; justify-content:center; cursor:col-resize; user-select:none}
|
|
.card{border:1px solid var(--line);border-radius:10px;padding:12px;background:#fff}
|
|
.form-grid{display:grid;grid-template-columns:180px 1fr;gap:10px;align-items:center}
|
|
.input{padding:10px;border:1px solid var(--line);border-radius:8px;width:100%}
|
|
.kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
|
|
table{width:100%;border-collapse:collapse}
|
|
th,td{padding:8px;border-bottom:1px solid var(--line);text-align:left;font-size:14px}
|
|
th{font-size:12px;text-transform:uppercase;color:#6b7280;background:#fafafa}
|
|
.car-table tbody tr{cursor:pointer}
|
|
.car-table tbody tr:hover{background:#f7f9fc}
|
|
.toast{position:fixed;bottom:16px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:60}
|
|
.toast .t{background:#111827;color:#fff;padding:10px 12px;border-radius:8px;opacity:.95}
|
|
@media(max-width:1180px){
|
|
.details-grid{grid-template-columns:1fr var(--detailSplitW) var(--detailRightW)}
|
|
.mini-splitter{display:none}
|
|
}
|
|
/* Ensure details form only shows on its tab */
|
|
#pane-details-main{display:none}
|
|
/* Toggle + Car grid styles */
|
|
.seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
|
|
.seg button{border:0;background:#fff;padding:6px 10px;cursor:pointer}
|
|
.seg button.active{background:var(--accent);color:#fff}
|
|
.grid-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
|
|
.car-card{border:1px solid var(--line);border-radius:10px;padding:10px;background:#fff;display:flex;flex-direction:column;gap:8px}
|
|
.car-card .hdr{display:flex;justify-content:space-between;align-items:center}
|
|
.tag{font-size:11px;padding:2px 6px;border-radius:6px;background:#eef2ff;color:#1e3a8a}
|
|
.btn.small{padding:6px 8px;font-size:12px}
|
|
/* Details map */
|
|
#detailsMapCard{display:none}
|
|
#singleMap{height:420px;border:1px solid var(--line);border-radius:8px}
|
|
/* Disabled styling for view mode */
|
|
.input[disabled]{background:#fafafa;color:#6b7280}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app" id="appRoot">
|
|
<!-- Sidebar -->
|
|
<aside class="sidebar">
|
|
<div class="brand">
|
|
<div class="avatar">A</div>
|
|
<div class="who"><strong>Andre</strong><small>hardwood4us@gmail.com</small></div>
|
|
</div>
|
|
<nav class="menu">
|
|
<a href="#"><span>Dashboard</span></a>
|
|
<a href="#" class="active"><span>GPS Trackers</span></a>
|
|
<a href="#"><span>Cars List</span></a>
|
|
<a href="#"><span>Customers List</span></a>
|
|
<a href="#"><span>Payments</span></a>
|
|
</nav>
|
|
<button class="toggle-btn" id="sidebarToggle" title="Resize sidebar">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>
|
|
</svg>
|
|
</button>
|
|
</aside>
|
|
|
|
<main>
|
|
<header class="topbar"><div class="title">GPS Trackers</div></header>
|
|
|
|
<section class="content">
|
|
<!-- Left: Filters & Device List -->
|
|
<div class="section" style="grid-column:1/2; grid-row:1/2">
|
|
<div class="hd">
|
|
<div class="h2">Devices</div>
|
|
<div class="muted" id="devCount">0 online</div>
|
|
</div>
|
|
<div class="body tree">
|
|
<div class="node active" data-filter="all"><strong>Display</strong></div>
|
|
<div class="node" data-filter="online">Online</div>
|
|
<div class="node" data-filter="moving">Moving</div>
|
|
<div class="node" data-filter="offline">Offline</div>
|
|
<div class="node" data-filter="alarm">Alarm</div>
|
|
<hr style="border:none;border-top:1px dashed var(--line);margin:8px 0"/>
|
|
<div id="deviceList"></div>
|
|
</div>
|
|
</div>
|
|
<div class="vsplitter" id="catSplitter"><div class="dots-col"><span></span><span></span><span></span></div></div>
|
|
|
|
<!-- Map / Cars Center -->
|
|
<div class="section" style="grid-column:3/4; grid-row:1/2">
|
|
<div class="toolbar">
|
|
<div class="seg" id="viewToggle">
|
|
<button class="active" data-pane="map">Map</button>
|
|
<button data-pane="cars">Cars</button>
|
|
</div>
|
|
<input class="input" id="carSearch" placeholder="Search cars…" style="max-width:220px;margin-left:8px"/>
|
|
<!-- Top-bar Cards/List toggle (only visible when Cars view is active) -->
|
|
<div class="seg" id="carViewSegTop" style="display:none;margin-left:8px">
|
|
<button class="active" data-carview="grid">Cards</button>
|
|
<button data-carview="list">List</button>
|
|
</div>
|
|
<div style="flex:1"></div>
|
|
<button class="btn" id="btnGeofence">Add Geofence</button>
|
|
<button class="btn" id="btnReplay">Replay</button>
|
|
<button class="btn" id="btnNYC">NYC</button>
|
|
<button class="btn primary" id="btnSettings">Set</button>
|
|
</div>
|
|
<div class="body" style="padding:10px 0 0 0">
|
|
<div id="mapPane"><div id="map"></div></div>
|
|
|
|
<div id="carPane" style="display:none">
|
|
<div id="carGrid" class="grid-cards"></div>
|
|
<div id="carList" class="car-table" style="display:none">
|
|
<table>
|
|
<thead>
|
|
<tr><th>Vehicle</th><th>Status</th><th>Speed</th><th>Last Seen</th><th>Actions</th></tr>
|
|
</thead>
|
|
<tbody id="carListBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="hsplitter" id="rowSplitter"><div class="dots-row"><span></span><span></span><span></span></div></div>
|
|
|
|
<!-- Details bottom -->
|
|
<div class="section" style="grid-column:1/-1; grid-row:3/4">
|
|
<div class="hd"><div class="h2">GPS Tracker Details</div><div class="muted" id="hint">Select a device</div></div>
|
|
<div class="body">
|
|
<div class="details-grid">
|
|
<div class="tabs" id="detailTabs">
|
|
<div class="tab-item active" data-tab="details"><span class="tab-title">Tracker details</span></div>
|
|
<div class="tab-item" data-tab="mileage"><span class="tab-title">Mileage record</span></div>
|
|
<div class="tab-item" id="tab-oil" data-tab="oil"><span class="tab-title">Oil Life</span></div>
|
|
<div class="tab-item" data-tab="reports"><span class="tab-title">Tracker Report</span></div>
|
|
<div class="tab-item" data-tab="gas"><span class="tab-title">Gas Cut</span></div>
|
|
<div class="tab-item" data-tab="geof"><span class="tab-title">Geofences</span></div>
|
|
<div class="tab-item" data-tab="history"><span class="tab-title">History</span></div>
|
|
<div class="tab-item" data-tab="fleet"><span class="tab-title">Fleet Maps</span></div>
|
|
</div>
|
|
<div class="mini-splitter" id="miniSplitter"><div class="dots-col"><span></span><span></span><span></span></div></div>
|
|
|
|
<!-- Center details card -->
|
|
<div id="pane-details-main" class="card" style="min-height:260px; display:none">
|
|
<div class="toolbar" id="detailsBar" style="padding:0 0 8px;border:0;border-bottom:1px solid var(--line)">
|
|
<div style="font-weight:700" id="barTitle">Vehicle • Status</div>
|
|
<div style="flex:1"></div>
|
|
<div class="seg" id="viewEditSeg">
|
|
<button class="active" data-mode="view">View</button>
|
|
<button data-mode="edit">Edit</button>
|
|
</div>
|
|
<button class="btn" id="barSave" style="display:none">Save</button>
|
|
<button class="btn" id="barCancel" style="display:none">Cancel</button>
|
|
<button class="btn" id="barMore">More</button>
|
|
</div>
|
|
|
|
<div class="form-grid">
|
|
<div>Vehicle</div><input id="fVehicle" class="input" placeholder="Toyota Prius 2007" disabled/>
|
|
<div>Alias</div><input id="fAlias" class="input" placeholder="KTL667" disabled/>
|
|
<div>Device ID (IMEI)</div><input id="fImei" class="input" placeholder="9175179158" disabled/>
|
|
<div>Phone #</div><input id="fPhone" class="input" placeholder="(914) 438-1664" disabled/>
|
|
<div>SIM ICCID</div><input id="fIccid" class="input" placeholder="8901…" disabled/>
|
|
<div>Cycle Ends</div><input id="fCycle" class="input" placeholder="2026-10-25" disabled/>
|
|
<div>Provider Portal</div><input id="fPortal" class="input" placeholder="https://speedtalkmobile.com/" disabled/>
|
|
<div>Provider Password</div><input id="fPwd" class="input" type="password" placeholder="••••••" disabled/>
|
|
<div>Status</div><select id="fStatus" class="input" disabled><option>Active</option><option>Paused</option><option>Lost</option></select>
|
|
<div>Device Model</div><select id="fModel" class="input" disabled><option>ST-901</option><option>ST-906</option></select>
|
|
</div>
|
|
<div class="kpis" style="margin-top:12px">
|
|
<div class="card"><div class="muted">Last seen</div><div id="kLast">—</div></div>
|
|
<div class="card"><div class="muted">Speed</div><div id="kSpeed">—</div></div>
|
|
<div class="card"><div class="muted">Power</div><div id="kPower">—</div></div>
|
|
</div>
|
|
<div style="display:flex;gap:8px;margin-top:12px">
|
|
<button class="btn" id="btnPing">Test Ping</button>
|
|
<button class="btn" id="btnBind">Bind to Vehicle</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- INNER vertical splitter (between form and map) -->
|
|
<div class="inner-vsplitter" id="detailInnerSplit">
|
|
<div class="dots-col"><span></span><span></span><span></span></div>
|
|
</div>
|
|
|
|
<!-- Right: single-car map (only on details tab) -->
|
|
<div class="card" id="detailsMapCard">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
<div style="font-weight:700">Vehicle Map</div>
|
|
<div class="muted" id="singleMapMeta">—</div>
|
|
</div>
|
|
<div id="singleMap"></div>
|
|
<div style="display:flex;gap:8px;margin-top:10px">
|
|
<button class="btn small" id="btnZoomTo">Zoom to Car</button>
|
|
<button class="btn small" id="btnDropPin">Drop Pin</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right dynamic pane (non-details tabs) -->
|
|
<div class="card" id="dynamicPane" style="min-height:260px">
|
|
<div id="pane-details" style="display:none">(Details moved to center column)</div>
|
|
<div id="pane-mileage" style="display:none">
|
|
<table><thead><tr><th>Date</th><th>Distance</th><th>Idle</th><th>Max Speed</th></tr></thead><tbody id="mileRows"></tbody></table>
|
|
</div>
|
|
<div id="pane-reports" style="display:none">
|
|
<table><thead><tr><th>Time</th><th>Event</th><th>Details</th></tr></thead><tbody><tr><td>09:18</td><td>Start</td><td>Ignition on</td></tr></tbody></table>
|
|
</div>
|
|
<div id="pane-gas" style="display:none">
|
|
<div style="display:flex;gap:8px">
|
|
<button class="btn" id="btnCut">Cut Fuel</button>
|
|
<button class="btn" id="btnResume">Resume</button>
|
|
</div>
|
|
<table style="margin-top:10px"><thead><tr><th>Time</th><th>Action</th><th>Status</th></tr></thead><tbody id="immLog"></tbody></table>
|
|
</div>
|
|
<div id="pane-geof" style="display:none">
|
|
<button class="btn" id="btnAddFence">Add Fence at Map Center</button>
|
|
<table style="margin-top:10px"><thead><tr><th>Name</th><th>Type</th><th>Rule</th></tr></thead><tbody id="fenceRows"></tbody></table>
|
|
</div>
|
|
<div id="pane-history" style="display:none">
|
|
<input type="range" min="0" max="100" value="0" id="histSlider" style="width:100%"/>
|
|
<table style="margin-top:10px"><thead><tr><th>Time</th><th>Speed</th><th>Event</th></tr></thead><tbody id="histRows"></tbody></table>
|
|
</div>
|
|
|
|
|
|
<div id="pane-oil" style="display:none;min-height:300px;">
|
|
<div class="card" style="margin-bottom:10px;border:2px dashed #bbb">
|
|
<div style="font-weight:700">Oil pane loaded — if you see this, the tab is working.</div>
|
|
</div>
|
|
|
|
<!-- Top row: Oil bar + summary + actions -->
|
|
<div class="card" style="display:flex;flex-wrap:wrap;gap:16px;align-items:center">
|
|
<div style="flex:1 1 280px;min-width:260px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
<div style="font-weight:700;margin-bottom:6px">Oil Life</div>
|
|
<div id="oilStatusTag" class="tag">OK</div>
|
|
</div>
|
|
<div style="position:relative">
|
|
<div style="height:20px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#f5f7fb">
|
|
<div id="oilBar" style="height:100%;width:100%;background:#16a34a"></div>
|
|
</div>
|
|
<!-- ticks -->
|
|
<div style="position:absolute;inset:0;pointer-events:none">
|
|
<div style="position:absolute;left:15%;top:-4px;height:28px;width:1px;background:#e2e8f0"></div>
|
|
<div style="position:absolute;left:40%;top:-4px;height:28px;width:1px;background:#e2e8f0"></div>
|
|
<div style="position:absolute;left:60%;top:-4px;height:28px;width:1px;background:#e2e8f0"></div>
|
|
<div style="position:absolute;left:85%;top:-4px;height:28px;width:1px;background:#e2e8f0"></div>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:14px;align-items:center;margin-top:6px" class="muted">
|
|
<div id="oilPct" style="min-width:48px">100%</div>
|
|
<div id="oilNext" class="muted">Next due: —</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:flex;gap:18px;align-items:center">
|
|
<div>
|
|
<div class="muted">Miles since last change</div>
|
|
<div id="oilMilesSince" style="font-weight:700">0 mi</div>
|
|
</div>
|
|
<div>
|
|
<div class="muted">Miles remaining</div>
|
|
<div id="oilMilesLeft" style="font-weight:700">2500 mi</div>
|
|
</div>
|
|
<div>
|
|
<div class="muted">Est. next date</div>
|
|
<div id="oilDueDate" style="font-weight:700">—</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:flex;gap:8px;margin-left:auto">
|
|
<button class="btn small" id="btnOilReset">Reset (changed)</button>
|
|
<button class="btn small" id="btnOilNotifyTest">Test Alert</button>
|
|
<button class="btn small" id="btnOilContact">Schedule Contact</button>
|
|
<button class="btn small" id="btnOilWork">Create Work Order</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Settings -->
|
|
<div class="card" style="display:flex;flex-wrap:wrap;gap:18px;align-items:center;margin-top:10px">
|
|
<div style="font-weight:700">Per‑car Settings</div>
|
|
<label class="muted">Interval (mi)
|
|
<input id="oilSetInterval" type="number" min="1000" max="10000" step="100" value="2500" class="input" style="width:110px;margin-left:6px" />
|
|
</label>
|
|
<label class="muted">Warn soon ≤ %
|
|
<input id="oilSetSoon" type="number" min="5" max="60" step="1" value="40" class="input" style="width:80px;margin-left:6px" />
|
|
</label>
|
|
<label class="muted">Auto‑notify
|
|
<input id="oilSetAuto" type="checkbox" style="margin-left:6px" />
|
|
</label>
|
|
<div class="muted" id="oilInfoSaved" style="margin-left:auto;display:none">Saved ✓</div>
|
|
|
|
<!-- Discrepancy (GPS vs Odometer) -->
|
|
<div class="card" style="margin-top:10px;display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:14px;align-items:end">
|
|
<div>
|
|
<div class="muted">GPS miles since oil</div>
|
|
<div id="oilGpsSince" style="font-weight:700">—</div>
|
|
</div>
|
|
<div>
|
|
<label class="muted">Odometer: current (mi)
|
|
<input id="odoNow" type="number" step="1" class="input" style="width:140px;margin-left:6px"/>
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label class="muted">Odometer: at last oil change (mi)
|
|
<input id="odoAtChange" type="number" step="1" class="input" style="width:140px;margin-left:6px"/>
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<div class="muted">Odometer miles since oil</div>
|
|
<div id="odoSince" style="font-weight:700">—</div>
|
|
</div>
|
|
<div>
|
|
<div class="muted">Delta (Odo − GPS)</div>
|
|
<div id="odoGpsDelta" style="font-weight:700">—</div>
|
|
<div id="odoGpsBadge" class="tag" style="margin-top:6px;display:inline-block">within threshold</div>
|
|
</div>
|
|
<div style="display:flex;gap:8px">
|
|
<button class="btn small" id="btnAlignToGPS" title="Set odometer baseline so delta becomes 0 using GPS miles">Reconcile → GPS</button>
|
|
<button class="btn small" id="btnAlignToODO" title="Set GPS baseline so delta becomes 0 using odometer miles">Reconcile → Odometer</button>
|
|
</div>
|
|
<div class="muted" style="grid-column: span 2">Tip: Use this when the GPS‑based miles and the dashboard odometer diverge due to signal gaps, tire/calibration, or manual entry errors.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Log -->
|
|
<table style="margin-top:10px"><thead><tr><th>TIME</th><th>EVENT</th><th>DETAILS</th></tr></thead><tbody id="oilLog"></tbody></table>
|
|
</div>
|
|
|
|
<div class="card" style="margin-bottom:10px;border:2px dashed #bbb">
|
|
<div style="font-weight:700">Oil pane loaded — if you see this, the tab is working.</div>
|
|
</div>
|
|
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
|
|
<div style="min-width:260px">
|
|
<div style="font-weight:700;margin-bottom:6px">Oil Life</div>
|
|
<div style="height:18px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#f5f7fb">
|
|
<div id="oilBar" style="height:100%;width:100%;background:#16a34a"></div>
|
|
</div>
|
|
<div id="oilPct" class="muted" style="margin-top:6px">100%</div>
|
|
</div>
|
|
<div class="card" style="display:flex;gap:16px;align-items:center">
|
|
<div>
|
|
<div class="muted">Miles since last change</div>
|
|
<div id="oilMilesSince" style="font-weight:700">0 mi</div>
|
|
</div>
|
|
<div>
|
|
<div class="muted">Miles remaining (to 2500)</div>
|
|
<div id="oilMilesLeft" style="font-weight:700">2500 mi</div>
|
|
</div>
|
|
<div><div class="muted">Policy</div>Change every 2,500 miles</div>
|
|
<div style="display:flex;gap:8px;margin-left:auto">
|
|
<button class="btn small" id="btnOilReset">Reset (changed)</button>
|
|
<button class="btn small" id="btnOilNotifyTest">Test Alert</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<table style="margin-top:6px"><thead><tr><th>Time</th><th>Event</th><th>Details</th></tr></thead><tbody id="oilLog"></tbody></table>
|
|
</div>
|
|
<div id="pane-fleet" style="display:none">
|
|
|
|
<div id="fleetGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
/* ========== Sidebar resizer ========== */
|
|
const app=document.getElementById('appRoot'); const toggleBtn=document.getElementById('sidebarToggle');
|
|
const getSidebarW=()=>parseInt(getComputedStyle(app).getPropertyValue('--sidebarW'))||280;
|
|
const setSidebarW=(px)=>{app.style.setProperty('--sidebarW',px+'px');app.classList.toggle('compact',px<140);};
|
|
const updateHandle=()=>{if(toggleBtn) toggleBtn.style.left=getComputedStyle(app).getPropertyValue('--sidebarW');};
|
|
if(toggleBtn){toggleBtn.addEventListener('mousedown',(e)=>{ const start=getSidebarW(), sx=e.clientX; const mm=(ev)=>{setSidebarW(Math.min(420,Math.max(72,start+(ev.clientX-sx)))); updateHandle(); try{map.invalidateSize(); singleMap?.invalidateSize()}catch{} }; const mu=()=>{window.removeEventListener('mousemove',mm); window.removeEventListener('mouseup',mu);}; window.addEventListener('mousemove',mm); window.addEventListener('mouseup',mu); });}
|
|
updateHandle();
|
|
|
|
/* ========== Grid splitters ========== */
|
|
const root=document.documentElement;
|
|
const vSplit=document.getElementById('catSplitter');
|
|
const rowSplit=document.getElementById('rowSplitter');
|
|
function getCSSpx(varName, fallback){ return parseInt(getComputedStyle(root).getPropertyValue(varName)) || fallback; }
|
|
function setCSSpx(varName, px){ root.style.setProperty(varName, px + 'px'); }
|
|
|
|
// Vertical splitter (left categories width)
|
|
if(vSplit){vSplit.addEventListener('mousedown',e=>{
|
|
const start=getCSSpx('--catW',320), sx=e.clientX;
|
|
const mm=ev=>{ const w=Math.max(220,Math.min(560,start+(ev.clientX-sx))); setCSSpx('--catW',w); try{map.invalidateSize(); singleMap?.invalidateSize()}catch{} };
|
|
const mu=()=>{window.removeEventListener('mousemove',mm);window.removeEventListener('mouseup',mu)};
|
|
window.addEventListener('mousemove',mm); window.addEventListener('mouseup',mu);
|
|
});}
|
|
|
|
// Horizontal splitter (between top map/cars and details)
|
|
if(rowSplit){rowSplit.addEventListener('mousedown',e=>{
|
|
const start=getCSSpx('--topRowH',360), sy=e.clientY;
|
|
const mm=ev=>{ const h=Math.max(220,Math.min(innerHeight-220,start+(ev.clientY-sy))); setCSSpx('--topRowH',h); try{map.invalidateSize(); singleMap?.invalidateSize()}catch{} };
|
|
const mu=()=>{window.removeEventListener('mousemove',mm);window.removeEventListener('mouseup',mu)};
|
|
window.addEventListener('mousemove',mm); window.addEventListener('mouseup',mu);
|
|
});}
|
|
|
|
// Inner vertical splitter (between form and vehicle map)
|
|
const innerSplit=document.getElementById('detailInnerSplit');
|
|
if(innerSplit){ innerSplit.addEventListener('mousedown', (e)=>{
|
|
const start=getCSSpx('--detailRightW',440), sx=e.clientX;
|
|
const mm=(ev)=>{ const w=Math.max(320, Math.min(720, start - (ev.clientX - sx))); setCSSpx('--detailRightW',w); try{ singleMap?.invalidateSize(); }catch{} };
|
|
const mu=()=>{ window.removeEventListener('mousemove', mm); window.removeEventListener('mouseup', mu); };
|
|
window.addEventListener('mousemove', mm); window.addEventListener('mouseup', mu);
|
|
});}
|
|
|
|
/* ========== Toast helper ========== */
|
|
const toastBox=document.getElementById('toast'); function toast(msg){ const el=document.createElement('div'); el.className='t'; el.textContent=msg; toastBox.appendChild(el); setTimeout(()=>el.remove(),3000) }
|
|
|
|
/* ========== Fake device data (mph / miles) ========== */
|
|
const devices=[
|
|
{id:1,name:'Prius Gray 07 KKL161',status:'online',moving:false,lat:40.7308,lng:-73.9975,speed:0,milesSinceOil: Math.floor(Math.random()*1200+200)},
|
|
{id:2,name:'Prius Silver 08 KTL667',status:'moving',moving:true,lat:40.741,lng:-73.98,speed:32,milesSinceOil: Math.floor(Math.random()*1200+200)},
|
|
{id:3,name:'Prius Black 06 KTL666',status:'online',moving:false,lat:40.7128,lng:-74.006,speed:0,milesSinceOil: Math.floor(Math.random()*1200+200)},
|
|
{id:4,name:'Corolla Silver',status:'offline',moving:false,lat:40.706,lng:-73.93,speed:0,milesSinceOil: Math.floor(Math.random()*1200+200)},
|
|
{id:5,name:'Prius Green 09 KWC658',status:'alarm',moving:true,lat:40.76,lng:-73.96,speed:65,milesSinceOil: Math.floor(Math.random()*1200+200)},
|
|
{id:6,name:'Prius Blue 06 KWC950',status:'online',moving:false,lat:40.718,lng:-73.94,speed:0,milesSinceOil: Math.floor(Math.random()*1200+200)},
|
|
{id:7,name:'Prius Silver 07 KTL558',status:'online',moving:false,lat:40.724,lng:-74.02,speed:0,milesSinceOil: Math.floor(Math.random()*1200+200)},
|
|
{id:8,name:'Prius Black 05 KTN447',status:'moving',moving:true,lat:40.748,lng:-73.99,speed:28,milesSinceOil: Math.floor(Math.random()*1200+200)},
|
|
{id:9,name:'Prius Red 08 KTR112',status:'offline',moving:false,lat:40.702,lng:-73.92,speed:0,milesSinceOil: Math.floor(Math.random()*1200+200)},
|
|
{id:10,name:'Prius Gold 07 KTX220',status:'online',moving:false,lat:40.769,lng:-73.98,speed:0,milesSinceOil: Math.floor(Math.random()*1200+200)},
|
|
{id:11,name:'Prius Gray 06 KTA331',status:'moving',moving:true,lat:40.733,lng:-73.955,speed:40,milesSinceOil: Math.floor(Math.random()*1200+200)},
|
|
{id:12,name:'Prius White 09 KTB902',status:'online',moving:false,lat:40.715,lng:-74.035,speed:0,milesSinceOil: Math.floor(Math.random()*1200+200)}
|
|
];
|
|
let current=devices[2];
|
|
|
|
/* ========== Left device list & filters ========== */
|
|
const deviceList=document.getElementById('deviceList'); const devCount=document.getElementById('devCount');
|
|
function renderList(filter='all'){
|
|
const filt=devices.filter(d=> filter==='all' || d.status===filter || (filter==='online' && d.status!=='offline' && d.status!=='alarm'));
|
|
devCount.textContent = `${filt.filter(d=>d.status!=='offline').length} online`;
|
|
deviceList.innerHTML='';
|
|
filt.forEach(d=>{
|
|
const row=document.createElement('div'); row.className='device-row'; row.onclick=()=>focusDevice(d);
|
|
const dot=document.createElement('div'); dot.className='dev-dot '+(d.status==='moving'?'dot-moving':d.status==='alarm'?'dot-alarm':d.status==='offline'?'dot-offline':'dot-online');
|
|
const meta=document.createElement('div'); meta.innerHTML=`<div><strong>${d.name}</strong></div><div class='muted'>${d.status.toUpperCase()} • ${d.speed} mph</div>`;
|
|
row.append(dot,meta); deviceList.appendChild(row);
|
|
})
|
|
}
|
|
const filterTree=document.querySelector('.tree'); filterTree.addEventListener('click',e=>{ const n=e.target.closest('.node'); if(!n) return; [...filterTree.querySelectorAll('.node')].forEach(x=>x.classList.remove('active')); n.classList.add('active'); renderList(n.dataset.filter||'all'); });
|
|
|
|
/* ========== Map (top center) ========== */
|
|
let map, poly, hmark;
|
|
function initMap(){
|
|
map=L.map('map').setView([40.73,-73.98],12);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OpenStreetMap'}).addTo(map);
|
|
initMarkers();
|
|
const path=[[40.73,-73.99],[40.735,-73.985],[40.742,-73.98],[40.748,-73.975],[40.752,-73.97],[40.756,-73.965]];
|
|
poly=L.polyline(path,{color:'#1f7ae0'}).addTo(map);
|
|
hmark=L.circleMarker(path[0],{radius:6,color:'#1f7ae0',fillOpacity:1}).addTo(map);
|
|
setTimeout(()=>map.invalidateSize(), 0);
|
|
}
|
|
let markers={};
|
|
function initMarkers(){
|
|
devices.forEach(d=>{
|
|
const color=d.status==='offline'?'#9ca3af':d.status==='moving'?'#1f7ae0':d.status==='alarm'?'#dc2626':'#16a34a';
|
|
const icon=L.divIcon({className:'',html:`<div style="background:${color};width:14px;height:14px;border-radius:50%;border:2px solid white;box-shadow:0 0 0 2px ${color}22"></div>`});
|
|
const m=L.marker([d.lat,d.lng],{icon}).addTo(map).bindPopup(`<b>${d.name}</b><br/>Speed: ${d.speed} mph`);
|
|
markers[d.id]=m;
|
|
});
|
|
}
|
|
|
|
/* ========== Single-car map (details right) ========== */
|
|
let singleMap, singleMarker;
|
|
function ensureSingleMap(){
|
|
if(singleMap) return;
|
|
singleMap = L.map('singleMap', { zoomControl:true, attributionControl:false }).setView([40.73,-73.98], 14);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(singleMap);
|
|
const iconHtml = `<div style="background:#1f7ae0;width:14px;height:14px;border-radius:50%;border:2px solid white;box-shadow:0 0 0 2px #1f7ae022"></div>`;
|
|
const icon = L.divIcon({className:'', html:iconHtml});
|
|
singleMarker = L.marker([40.73,-73.98], {icon}).addTo(singleMap);
|
|
}
|
|
function updateSingleMap(d){
|
|
if(!singleMap) return;
|
|
singleMap.setView([d.lat,d.lng], 15);
|
|
singleMarker.setLatLng([d.lat,d.lng]).bindPopup(`<b>${d.name}</b><br>${d.speed} mph`).openPopup();
|
|
document.getElementById('singleMapMeta').textContent = `${d.status.toUpperCase()} • ${d.speed} mph`;
|
|
}
|
|
|
|
/* ========== Focus device (select) ========== */
|
|
function focusDevice(d){
|
|
current=d;
|
|
try{map.setView([d.lat,d.lng],14); markers[d.id].openPopup();}catch{}
|
|
document.getElementById('hint').textContent=d.name;
|
|
document.getElementById('barTitle').textContent = `${d.name} • ${d.status.toUpperCase()}`;
|
|
document.getElementById('kSpeed').textContent=d.speed+' mph';
|
|
document.getElementById('kLast').textContent='just now';
|
|
document.getElementById('kPower').textContent='12.4 V';
|
|
document.getElementById('fVehicle').value='Toyota Prius';
|
|
document.getElementById('fAlias').value=d.name.split(' ').slice(-1)[0];
|
|
document.getElementById('fImei').value='9175179158';
|
|
document.getElementById('fPhone').value='(914) 438-1664';
|
|
document.getElementById('fIccid').value='8901…';
|
|
document.getElementById('fCycle').value='2026-10-25';
|
|
document.getElementById('fPortal').value='https://speedtalkmobile.com/';
|
|
document.getElementById('fPwd').value='';
|
|
document.getElementById('fStatus').value='Active';
|
|
document.getElementById('fModel').value='ST-901';
|
|
updateSingleMap(d);
|
|
}
|
|
|
|
/* ========== Cars pane: grid/list ========== */
|
|
let carView='grid';
|
|
const carPane=document.getElementById('carPane');
|
|
const carGrid=document.getElementById('carGrid');
|
|
const carList=document.getElementById('carList');
|
|
const carListBody=document.getElementById('carListBody');
|
|
const carViewSegTop=document.getElementById('carViewSegTop');
|
|
function setCarView(view){
|
|
carView=view;
|
|
[...carViewSegTop.querySelectorAll('button')].forEach(b=>b.classList.toggle('active',b.dataset.carview===view));
|
|
carGrid.style.display = view==='grid' ? 'grid' : 'none';
|
|
carList.style.display = view==='list' ? 'block' : 'none';
|
|
if(view==='grid') renderCarGrid(); else renderCarList();
|
|
}
|
|
if(carViewSegTop){ carViewSegTop.addEventListener('click', e=>{ const b=e.target.closest('button'); if(!b) return; setCarView(b.dataset.carview); }); }
|
|
|
|
function getStatusColor(d){
|
|
return d.status==='offline'?'#9ca3af':d.status==='moving'?'#1f7ae0':d.status==='alarm'?'#dc2626':'#16a34a';
|
|
}
|
|
function getLastSeen(d){
|
|
if(d.status==='moving') return 'now';
|
|
if(d.status==='offline') return '2h ago';
|
|
return '12m ago';
|
|
}
|
|
function renderCarGrid(){
|
|
const q=(document.getElementById('carSearch')?.value||'').toLowerCase();
|
|
carGrid.innerHTML='';
|
|
devices.filter(d=>d.name.toLowerCase().includes(q)).forEach(d=>{
|
|
const color=getStatusColor(d);
|
|
const card=document.createElement('div'); card.className='car-card';
|
|
card.innerHTML=`<div class="hdr"><strong>${d.name}</strong><span class="tag" style="background:${color}22;color:${color}">${d.status.toUpperCase()}</span></div>
|
|
<div class="muted">Speed: ${d.speed} mph</div><div class="muted">${oilBadgeHTML(d)}</div>
|
|
<div style="display:flex;gap:8px"><button class="btn small" data-act="center" data-id="${d.id}">Center</button><button class="btn small" data-act="details" data-id="${d.id}">Details</button><button class="btn small" data-act="imm" data-id="${d.id}">Immobilize</button></div>`;
|
|
carGrid.appendChild(card);
|
|
});
|
|
}
|
|
function renderCarList(){
|
|
const q=(document.getElementById('carSearch')?.value||'').toLowerCase();
|
|
carListBody.innerHTML='';
|
|
devices.filter(d=>d.name.toLowerCase().includes(q)).forEach(d=>{
|
|
const color=getStatusColor(d);
|
|
const tr=document.createElement('tr');
|
|
tr.innerHTML=`
|
|
<td><strong>${d.name}</strong></td>
|
|
<td><span class="tag" style="background:${color}22;color:${color}">${d.status.toUpperCase()}</span></td>
|
|
<td>${d.speed} mph</td>
|
|
<td>${getLastSeen(d)}<br/>${oilBadgeHTML(d)}</td>
|
|
<td>
|
|
<button class="btn small" data-act="center" data-id="${d.id}">Center</button>
|
|
<button class="btn small" data-act="details" data-id="${d.id}">Details</button>
|
|
<button class="btn small" data-act="imm" data-id="${d.id}">Immobilize</button>
|
|
</td>`;
|
|
tr.addEventListener('dblclick', ()=>{ setTopPane('map'); focusDevice(d); });
|
|
carListBody.appendChild(tr);
|
|
});
|
|
}
|
|
const carSearch=document.getElementById('carSearch'); if(carSearch){ carSearch.addEventListener('input', ()=>{ carView==='grid'?renderCarGrid():renderCarList(); }); }
|
|
|
|
// Delegate actions from grid or list
|
|
document.addEventListener('click',e=>{
|
|
const btn=e.target.closest('#carGrid [data-act], #carList [data-act]');
|
|
if(!btn) return;
|
|
const id=+btn.dataset.id; const d=devices.find(x=>x.id===id); if(!d) return;
|
|
if(btn.dataset.act==='center'){ setTopPane('map'); focusDevice(d); }
|
|
else if(btn.dataset.act==='details'){ activateTab('details'); focusDevice(d); }
|
|
else if(btn.dataset.act==='imm'){ activateTab('gas'); toast('Open immobilizer tab to send command'); }
|
|
});
|
|
|
|
/* ========== Map/Cars toggle ========== */
|
|
const viewToggle=document.getElementById('viewToggle');
|
|
function setTopPane(pane){
|
|
document.querySelectorAll('#viewToggle button').forEach(b=>b.classList.toggle('active',b.dataset.pane===pane));
|
|
const mapPane=document.getElementById('mapPane');
|
|
if(pane==='map'){
|
|
mapPane.style.display='block'; carPane.style.display='none';
|
|
carViewSegTop.style.display='none';
|
|
setTimeout(()=>{ try{map.invalidateSize()}catch{} },100);
|
|
} else {
|
|
mapPane.style.display='none'; carPane.style.display='block';
|
|
carViewSegTop.style.display='inline-flex';
|
|
carView==='grid'?renderCarGrid():renderCarList();
|
|
}
|
|
}
|
|
if(viewToggle){ viewToggle.addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b) return; setTopPane(b.dataset.pane); }); }
|
|
|
|
|
|
/* ========== Oil Life (v12) ========== */
|
|
const OIL_INTERVAL_MILES = 2500;
|
|
function oilPercent(d){
|
|
const used = Math.max(0, d.milesSinceOil||0);
|
|
return Math.max(0, Math.min(100, Math.round((1 - used/OIL_INTERVAL_MILES)*100)));
|
|
}
|
|
function renderOilTab(d){
|
|
const pct = oilPercent(d);
|
|
const used = Math.max(0, d.milesSinceOil||0);
|
|
const left = Math.max(0, OIL_INTERVAL_MILES - used);
|
|
const bar = document.getElementById('oilBar');
|
|
const pctEl = document.getElementById('oilPct');
|
|
const ms = document.getElementById('oilMilesSince');
|
|
const ml = document.getElementById('oilMilesLeft');
|
|
if(!bar||!pctEl||!ms||!ml) return;
|
|
bar.style.width = pct + '%';
|
|
bar.style.background = pct<=15 ? '#dc2626' : (pct<=40 ? '#f59e0b' : '#16a34a');
|
|
pctEl.textContent = pct + '%';
|
|
ms.textContent = used + ' mi';
|
|
ml.textContent = left + ' mi';
|
|
}
|
|
document.addEventListener('click',(e)=>{
|
|
if(e.target.id==='btnOilReset' && current){
|
|
current.milesSinceOil = 0;
|
|
renderOilTab(current);
|
|
toast('Oil life reset for '+current.name);
|
|
const tb=document.getElementById('oilLog');
|
|
if(tb) tb.insertAdjacentHTML('afterbegin', `<tr><td>${new Date().toLocaleTimeString()}</td><td>Reset</td><td>Set milesSinceOil=0</td></tr>`);
|
|
}
|
|
if(e.target.id==='btnOilNotifyTest' && current){
|
|
toast(`${current.name}: Oil service required`);
|
|
}
|
|
});
|
|
function oilBadgeHTML(d){
|
|
return `<span class="tag" style="background:#eef3f6;color:#0b63bd">OIL ${oilPercent(d)}%</span>`;
|
|
}
|
|
|
|
|
|
/* ========== Oil Life PRO (v13) ========== */
|
|
|
|
/* --- Discrepancy persistence --- */
|
|
function oilOdoKey(d){ return 'oil:odo:'+d.id; }
|
|
function loadOdo(d){
|
|
try{
|
|
const raw=localStorage.getItem(oilOdoKey(d));
|
|
if(!raw) return {odoNow:null, odoAtChange:null};
|
|
const o=JSON.parse(raw);
|
|
return {odoNow: (o.odoNow??null), odoAtChange:(o.odoAtChange??null)};
|
|
}catch{ return {odoNow:null, odoAtChange:null}; }
|
|
}
|
|
function saveOdo(d,o){
|
|
try{ localStorage.setItem(oilOdoKey(d), JSON.stringify(o)); }catch{}
|
|
}
|
|
|
|
const OIL_DELTA_THRESHOLD_MILES = 50; // show warning above 50 mi difference
|
|
const OIL_DELTA_THRESHOLD_PCT = 8; // and/or above 8%
|
|
|
|
function renderDiscrepancy(d){
|
|
const prefs = oilLoadPrefs(d);
|
|
const odo = loadOdo(d);
|
|
const gpsSince = Math.max(0, d.milesSinceOil||0);
|
|
const gpsEl = document.getElementById('oilGpsSince');
|
|
const odoSinceEl = document.getElementById('odoSince');
|
|
const deltaEl = document.getElementById('odoGpsDelta');
|
|
const badge = document.getElementById('odoGpsBadge');
|
|
const odoNow = document.getElementById('odoNow');
|
|
const odoAtChange = document.getElementById('odoAtChange');
|
|
if(!gpsEl||!odoSinceEl||!deltaEl||!badge||!odoNow||!odoAtChange) return;
|
|
|
|
// preload inputs
|
|
odoNow.value = (odo.odoNow??'');
|
|
odoAtChange.value = (odo.odoAtChange??'');
|
|
|
|
// compute odometer miles since oil (if both provided)
|
|
let odoSince = null;
|
|
if(odo.odoNow!=null && odo.odoAtChange!=null){
|
|
odoSince = Math.max(0, parseInt(odo.odoNow) - parseInt(odo.odoAtChange));
|
|
}
|
|
gpsEl.textContent = gpsSince + ' mi';
|
|
odoSinceEl.textContent = (odoSince==null? '—' : (odoSince+' mi'));
|
|
|
|
let deltaText = '—';
|
|
let warn = false;
|
|
if(odoSince!=null){
|
|
const delta = odoSince - gpsSince;
|
|
const pct = gpsSince>0 ? Math.round(Math.abs(delta)/gpsSince*100) : 0;
|
|
warn = (Math.abs(delta) >= OIL_DELTA_THRESHOLD_MILES) || (pct >= OIL_DELTA_THRESHOLD_PCT);
|
|
deltaText = `${delta>=0?'+':''}${delta} mi (${pct}%)`;
|
|
badge.textContent = warn ? 'check discrepancy' : 'within threshold';
|
|
const color = warn ? '#f59e0b' : '#16a34a';
|
|
badge.style.background = color + '22';
|
|
badge.style.color = color;
|
|
}
|
|
deltaEl.textContent = deltaText;
|
|
|
|
return {warn};
|
|
}
|
|
|
|
/* --- Reconcile buttons --- */
|
|
document.addEventListener('click',(e)=>{
|
|
if(!current) return;
|
|
const odo = loadOdo(current);
|
|
const gpsSince = Math.max(0, current.milesSinceOil||0);
|
|
if(e.target.id==='btnAlignToGPS'){
|
|
if(odo.odoNow==null){ toast('Enter current odometer first'); return; }
|
|
// Adjust baseline so (odoNow - odoAtChange) equals gpsSince
|
|
odo.odoAtChange = parseInt(odo.odoNow) - gpsSince;
|
|
saveOdo(current, odo);
|
|
renderDiscrepancy(current);
|
|
toast('Reconciled to GPS for '+current.name);
|
|
}
|
|
if(e.target.id==='btnAlignToODO'){
|
|
if(odo.odoNow==null || odo.odoAtChange==null){ toast('Enter odometer values first'); return; }
|
|
// Set GPS baseline so milesSinceOil equals odometer miles
|
|
const odoSince = Math.max(0, parseInt(odo.odoNow) - parseInt(odo.odoAtChange));
|
|
current.milesSinceOil = odoSince; // in a real app, you would persist the GPS baseline instead
|
|
renderOilTab(current);
|
|
renderDiscrepancy(current);
|
|
toast('Reconciled to Odometer for '+current.name);
|
|
}
|
|
});
|
|
|
|
/* --- Save odometer inputs --- */
|
|
document.addEventListener('change',(e)=>{
|
|
if(!current) return;
|
|
const odo = loadOdo(current);
|
|
if(e.target.id==='odoNow'){ odo.odoNow = (e.target.value===''? null : parseInt(e.target.value)); saveOdo(current, odo); renderDiscrepancy(current); }
|
|
if(e.target.id==='odoAtChange'){ odo.odoAtChange = (e.target.value===''? null : parseInt(e.target.value)); saveOdo(current, odo); renderDiscrepancy(current); }
|
|
});
|
|
|
|
/* --- Delta pill on cards/list if warn --- */
|
|
function oilDeltaPill(d){
|
|
const {warn} = renderDiscrepancyCardOnly(d);
|
|
return warn ? ' <span class="tag" style="background:#f59e0b22;color:#f59e0b">Δ mileage</span>' : '';
|
|
}
|
|
function renderDiscrepancyCardOnly(d){
|
|
// lightweight compute without DOM inputs
|
|
const odo = loadOdo(d);
|
|
const gpsSince = Math.max(0, d.milesSinceOil||0);
|
|
let warn=false;
|
|
if(odo.odoNow!=null && odo.odoAtChange!=null){
|
|
const odoSince = Math.max(0, parseInt(odo.odoNow) - parseInt(odo.odoAtChange));
|
|
const delta = odoSince - gpsSince;
|
|
const pct = gpsSince>0 ? Math.round(Math.abs(delta)/gpsSince*100) : 0;
|
|
warn = (Math.abs(delta) >= OIL_DELTA_THRESHOLD_MILES) || (pct >= OIL_DELTA_THRESHOLD_PCT);
|
|
}
|
|
return {warn};
|
|
}
|
|
|
|
function oilKey(d){ return 'oil:'+d.id; }
|
|
function oilLoadPrefs(d){
|
|
try{
|
|
const raw=localStorage.getItem(oilKey(d));
|
|
if(!raw) return {interval:2500, soonPct:40, auto:false, baseline: null};
|
|
const o=JSON.parse(raw);
|
|
return {interval:o.interval??2500, soonPct:o.soonPct??40, auto:!!o.auto, baseline:o.baseline??null};
|
|
}catch{ return {interval:2500, soonPct:40, auto:false, baseline:null}; }
|
|
}
|
|
function oilSavePrefs(d,p){
|
|
try{ localStorage.setItem(oilKey(d), JSON.stringify(p)); }catch{}
|
|
}
|
|
|
|
function oilPercentWithPrefs(d, prefs){
|
|
const used = Math.max(0, d.milesSinceOil||0);
|
|
const pct = Math.max(0, Math.min(100, Math.round((1 - used / (prefs?.interval||OIL_INTERVAL_MILES))*100)));
|
|
return pct;
|
|
}
|
|
|
|
function oilStatusTag(pct, prefs){
|
|
const soon = (prefs?.soonPct??40);
|
|
if(pct <= 15) return {label:'OIL DUE', color:'#dc2626'};
|
|
if(pct <= soon) return {label:'OIL SOON', color:'#f59e0b'};
|
|
return {label:'OK', color:'#16a34a'};
|
|
}
|
|
|
|
function estimateDailyMiles(){
|
|
// Simple fallback estimate: 50 mi/day
|
|
return 50;
|
|
}
|
|
function estimateDueDate(remainingMiles){
|
|
const days = Math.max(0, Math.ceil(remainingMiles / Math.max(1, estimateDailyMiles())));
|
|
const dt = new Date(Date.now() + days*86400000);
|
|
return dt.toLocaleDateString();
|
|
}
|
|
|
|
function renderOilTab(d){
|
|
const prefs = oilLoadPrefs(d);
|
|
const used = Math.max(0, d.milesSinceOil||0);
|
|
const interval = prefs.interval||OIL_INTERVAL_MILES;
|
|
const left = Math.max(0, interval - used);
|
|
const pct = oilPercentWithPrefs(d, prefs);
|
|
|
|
const bar = document.getElementById('oilBar');
|
|
const pctEl = document.getElementById('oilPct');
|
|
const ms = document.getElementById('oilMilesSince');
|
|
const ml = document.getElementById('oilMilesLeft');
|
|
const next = document.getElementById('oilNext');
|
|
const due = document.getElementById('oilDueDate');
|
|
const tag = document.getElementById('oilStatusTag');
|
|
|
|
if(!bar||!pctEl||!ms||!ml||!next||!due||!tag) return;
|
|
|
|
const st = oilStatusTag(pct, prefs);
|
|
tag.textContent = `${st.label}`;
|
|
tag.style.background = st.color + '22';
|
|
tag.style.color = st.color;
|
|
|
|
bar.style.width = pct + '%';
|
|
bar.style.background = pct<=15 ? '#dc2626' : (pct<=prefs.soonPct ? '#f59e0b' : '#16a34a');
|
|
pctEl.textContent = pct + '%';
|
|
ms.textContent = used + ' mi';
|
|
ml.textContent = left + ' mi';
|
|
next.textContent = `Next due at ${(used+left)} mi`;
|
|
due.textContent = estimateDueDate(left);
|
|
|
|
// load controls
|
|
const iv = document.getElementById('oilSetInterval');
|
|
const sp = document.getElementById('oilSetSoon');
|
|
const au = document.getElementById('oilSetAuto');
|
|
if(iv) iv.value = interval;
|
|
if(sp) sp.value = prefs.soonPct;
|
|
if(au) au.checked = !!prefs.auto;
|
|
}
|
|
|
|
function oilShowSaved(){ const el=document.getElementById('oilInfoSaved'); if(!el) return; el.style.display='block'; setTimeout(()=>el.style.display='none', 900); }
|
|
|
|
document.addEventListener('change', (e)=>{
|
|
if(!current) return;
|
|
const prefs = oilLoadPrefs(current);
|
|
let changed = false;
|
|
if(e.target && e.target.id==='oilSetInterval'){ prefs.interval = Math.max(1000, parseInt(e.target.value||'2500')); changed=true; }
|
|
if(e.target && e.target.id==='oilSetSoon'){ prefs.soonPct = Math.min(60, Math.max(5, parseInt(e.target.value||'40'))); changed=true; }
|
|
if(e.target && e.target.id==='oilSetAuto'){ prefs.auto = !!e.target.checked; changed=true; }
|
|
if(changed){ oilSavePrefs(current, prefs); oilShowSaved(); renderOilTab(current); }
|
|
});
|
|
|
|
document.addEventListener('click', (e)=>{
|
|
if(!current) return;
|
|
if(e.target.id==='btnOilReset'){
|
|
current.milesSinceOil = 0;
|
|
// also reset baseline in prefs
|
|
const prefs=oilLoadPrefs(current);
|
|
prefs.baseline = Date.now();
|
|
oilSavePrefs(current, prefs);
|
|
renderOilTab(current);
|
|
toast('Oil life reset for '+current.name);
|
|
const tb=document.getElementById('oilLog');
|
|
if(tb) tb.insertAdjacentHTML('afterbegin', `<tr><td>${new Date().toLocaleTimeString()}</td><td>Reset</td><td>Set milesSinceOil=0; interval=${prefs.interval}mi</td></tr>`);
|
|
}
|
|
if(e.target.id==='btnOilNotifyTest'){
|
|
toast(`${current.name}: Oil service required`);
|
|
const tb=document.getElementById('oilLog');
|
|
if(tb) tb.insertAdjacentHTML('afterbegin', `<tr><td>${new Date().toLocaleTimeString()}</td><td>Test Alert</td><td>Manual</td></tr>`);
|
|
}
|
|
if(e.target.id==='btnOilContact'){
|
|
toast('Contact scheduled for '+current.name);
|
|
const tb=document.getElementById('oilLog');
|
|
if(tb) tb.insertAdjacentHTML('afterbegin', `<tr><td>${new Date().toLocaleTimeString()}</td><td>Schedule Contact</td><td>Nearest slot</td></tr>`);
|
|
}
|
|
if(e.target.id==='btnOilWork'){
|
|
toast('Work order created for '+current.name);
|
|
const tb=document.getElementById('oilLog');
|
|
if(tb) tb.insertAdjacentHTML('afterbegin', `<tr><td>${new Date().toLocaleTimeString()}</td><td>Create Work Order</td><td>Oil change</td></tr>`);
|
|
}
|
|
});
|
|
|
|
// Enrich badges in list/grid with a bell if SOON/DUE
|
|
function oilBadgeHTML(d){
|
|
const prefs = oilLoadPrefs(d);
|
|
const pct = oilPercentWithPrefs(d, prefs);
|
|
const st = oilStatusTag(pct, prefs);
|
|
const bell = (st.label!=='OK') ? ' 🔔' : '';
|
|
return `<span class="tag" style="background:${st.color}22;color:${st.color}">${st.label} • ${pct}%${bell}</span>`;
|
|
}
|
|
|
|
/* ========== Tabs ========== */
|
|
const tabs=document.getElementById('detailTabs');
|
|
function activateTab(key){
|
|
tabs.querySelectorAll('.tab-item').forEach(x=>x.classList.toggle('active',x.dataset.tab===key));
|
|
// center details form
|
|
const show=(id,ok)=>{ const el=document.getElementById(id); if(el) el.style.display=ok?'block':'none'; };
|
|
show('pane-details-main', key==='details');
|
|
const dyn=document.getElementById('dynamicPane');
|
|
const dMap=document.getElementById('detailsMapCard');
|
|
const inner=document.getElementById('detailInnerSplit');
|
|
if(key==='details'){
|
|
dyn.style.display='none';
|
|
dMap.style.display='block';
|
|
inner.style.display='flex';
|
|
ensureSingleMap();
|
|
setTimeout(()=>{ try{singleMap.invalidateSize()}catch{}; if(current) updateSingleMap(current); }, 50);
|
|
} else {
|
|
dyn.style.display='block';
|
|
dMap.style.display='none';
|
|
inner.style.display='none';
|
|
// toggle right panes
|
|
show('pane-mileage', key==='mileage');
|
|
show('pane-reports', key==='reports');
|
|
show('pane-gas', key==='gas');
|
|
show('pane-geof', key==='geof');
|
|
show('pane-history', key==='history');
|
|
show('pane-fleet', key==='fleet');
|
|
show('pane-oil', key==='oil');
|
|
if(key==='oil' && current){ renderOilTab(current); renderDiscrepancy(current);}
|
|
if(key==='fleet') renderFleetMaps();
|
|
}
|
|
}
|
|
if(tabs){ tabs.addEventListener('click',e=>{ const item=e.target.closest('.tab-item'); if(!item) return; activateTab(item.dataset.tab); }); }
|
|
|
|
/* ========== Mileage (miles + mph) ========== */
|
|
function renderMileage(){
|
|
const tb=document.getElementById('mileRows'); tb.innerHTML='';
|
|
for(let i=0;i<7;i++){
|
|
const day=new Date(Date.now()-i*86400000).toISOString().slice(0,10);
|
|
const dist=(Math.random()*60+10).toFixed(1)+' mi';
|
|
const idle=(Math.random()*90|0)+'m';
|
|
const vmax=(35+Math.random()*40|0)+' mph';
|
|
tb.insertAdjacentHTML('beforeend',`<tr><td>${day}</td><td>${dist}</td><td>${idle}</td><td>${vmax}</td></tr>`);
|
|
}
|
|
}
|
|
|
|
/* ========== Immobilize log ========== */
|
|
function logImm(action,status='queued'){ const ts=new Date().toLocaleTimeString(); document.getElementById('immLog').insertAdjacentHTML('afterbegin',`<tr><td>${ts}</td><td>${action}</td><td>${status}</td></tr>`); }
|
|
|
|
/* ========== Geofences ========== */
|
|
const fences=[]; const fenceRows=document.getElementById('fenceRows'); let fenceLayer;
|
|
function addFence(){ const c=map.getCenter(); L.circle(c,{radius:150,color:'#f59e0b'}).addTo(fenceLayer); fences.push({name:'Fence '+fences.length,type:'circle',rule:'enter/exit'}); fenceRows.innerHTML=fences.map(f=>`<tr><td>${f.name}</td><td>${f.type}</td><td>${f.rule}</td></tr>`).join(''); toast('Geofence created'); }
|
|
|
|
/* ========== History demo ========== */
|
|
const path=[[40.73,-73.99],[40.735,-73.985],[40.742,-73.98],[40.748,-73.975],[40.752,-73.97],[40.756,-73.965]];
|
|
let idx=0; function step(){ if(!poly) return; idx=(idx+1)%path.length; hmark.setLatLng(path[idx]); document.getElementById('histSlider').value=idx/(path.length-1)*100; document.getElementById('histRows').insertAdjacentHTML('afterbegin',`<tr><td>${new Date().toLocaleTimeString()}</td><td>${(20+idx*3)} mph</td><td>${idx==3?'Stop 4m':''}</td></tr>`); setTimeout(step,700); }
|
|
|
|
/* ========== Fleet mini-maps ========== */
|
|
function renderFleetMaps(){
|
|
const grid=document.getElementById('fleetGrid'); if(!grid) return; grid.innerHTML='';
|
|
devices.forEach(d=>{
|
|
const card=document.createElement('div'); card.className='card'; card.style.padding='8px';
|
|
card.innerHTML=`<div style="font-weight:600;font-size:14px;display:flex;justify-content:space-between;align-items:center">
|
|
<span>${d.name}</span>
|
|
<span style="font-size:11px;color:#6b7280">${d.status.toUpperCase()} • ${d.speed} mph</span>
|
|
</div>
|
|
<div id="mini-map-${d.id}" style="height:160px;border:1px solid #e5e7eb;border-radius:8px;margin-top:6px"></div>`;
|
|
grid.appendChild(card);
|
|
const mini=L.map(`mini-map-${d.id}`,{zoomControl:false,attributionControl:false}).setView([d.lat,d.lng],14);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(mini);
|
|
const color=d.status==='offline'?'#9ca3af':d.status==='moving'?'#1f7ae0':d.status==='alarm'?'#dc2626':'#16a34a';
|
|
const icon=L.divIcon({className:'',html:`<div style="background:${color};width:12px;height:12px;border-radius:50%;border:2px solid white;box-shadow:0 0 0 2px ${color}22"></div>`});
|
|
L.marker([d.lat,d.lng],{icon}).addTo(mini);
|
|
});
|
|
}
|
|
|
|
/* ========== Buttons ========== */
|
|
document.getElementById('btnNYC').onclick=()=>{ try{map.setView([40.73,-73.98],12)}catch{} };
|
|
document.getElementById('btnReplay').onclick=()=>{ activateTab('history'); toast('Replay ready.'); };
|
|
document.getElementById('btnSettings').onclick=()=>toast('Settings modal here');
|
|
document.getElementById('btnPing').onclick=()=>toast('Ping queued');
|
|
document.getElementById('btnCut').onclick=()=>{ logImm('Cut fuel'); toast('Cut command queued'); };
|
|
document.getElementById('btnResume').onclick=()=>{ logImm('Resume'); toast('Resume command queued'); };
|
|
document.getElementById('btnAddFence').onclick=addFence;
|
|
document.getElementById('btnGeofence').onclick=addFence;
|
|
document.getElementById('btnZoomTo').onclick=()=>{ if(current){ updateSingleMap(current); }};
|
|
document.getElementById('btnDropPin').onclick=()=>{ if(singleMap){ const c=singleMap.getCenter(); L.marker(c).addTo(singleMap).bindPopup('Note').openPopup(); }};
|
|
|
|
/* ========== View/Edit bar logic ========== */
|
|
let editMode=false, savedSnapshot={};
|
|
const editableIds=['fVehicle','fAlias','fImei','fPhone','fIccid','fCycle','fPortal','fPwd','fStatus','fModel'];
|
|
function setEdit(on){
|
|
editMode=on;
|
|
editableIds.forEach(id=>{ const el=document.getElementById(id); if(!el) return; el.disabled=!on; if(on && savedSnapshot[id]===undefined) savedSnapshot[id]=el.value; });
|
|
document.getElementById('barSave').style.display=on?'inline-block':'none';
|
|
document.getElementById('barCancel').style.display=on?'inline-block':'none';
|
|
const seg=document.getElementById('viewEditSeg'); if(seg){ seg.querySelectorAll('button').forEach(b=>b.classList.toggle('active', (on?b.dataset.mode==='edit':b.dataset.mode==='view'))); }
|
|
}
|
|
document.getElementById('viewEditSeg').addEventListener('click', (e)=>{ const b=e.target.closest('button'); if(!b) return; setEdit(b.dataset.mode==='edit'); });
|
|
document.getElementById('barSave').onclick=()=>{ toast('Saved'); savedSnapshot={}; setEdit(false); };
|
|
document.getElementById('barCancel').onclick=()=>{ editableIds.forEach(id=>{ const el=document.getElementById(id); if(el && savedSnapshot[id]!==undefined) el.value=savedSnapshot[id]; }); toast('Changes discarded'); savedSnapshot={}; setEdit(false); };
|
|
document.getElementById('barMore').onclick=()=>{ toast('More actions… (share/export/commands)'); };
|
|
|
|
/* ========== App init ========== */
|
|
document.addEventListener('DOMContentLoaded', ()=>{
|
|
try{
|
|
if(!current && Array.isArray(devices) && devices.length){ current = devices[0]; }
|
|
if(current){ renderOilTab(current); renderDiscrepancy(current); }
|
|
}catch(e){ console.warn('init oil error', e); }
|
|
});
|
|
|
|
function initApp(){
|
|
renderList('all');
|
|
initMap();
|
|
fenceLayer=L.layerGroup().addTo(map);
|
|
renderMileage();
|
|
step();
|
|
setTopPane('map'); // default top pane
|
|
setCarView('grid'); // default car view
|
|
activateTab('details'); // default tab
|
|
focusDevice(current); // populate details
|
|
setEdit(false);
|
|
window.addEventListener('resize', ()=>{ try{map.invalidateSize(); singleMap?.invalidateSize()}catch{} });
|
|
}
|
|
window.addEventListener('load', initApp);
|
|
</script>
|
|
</body>
|
|
</html>
|