feat: Route tab overhaul + work order filters + Next Stop + Google Maps

- Added work order filter bar to Route tab matching the Work Orders tab UX:
  status, priority, work type dropdowns + date range selector
- Filter params passed to /api/workorders/today endpoint (backend updated
  with new query params: status, priority, work_type, date_range)
- Added Google Maps Navigate button on every route stop
- Added Mark Done & Next Stop button — tracks current stop index,
  marks completed stops with green check, advances to next with
  auto-prompt to open Google Maps navigation
- Animated stop progress: done stops dimmed with green ✓, current
  stop highlighted with accent border
- Updated /api/workorders/today to support date_range (week/month/all)
  and additional filter WHERE clauses
This commit is contained in:
2026-06-01 13:10:37 -04:00
parent 2d232941da
commit 03cf4b9281
2 changed files with 221 additions and 12 deletions
+36 -3
View File
@@ -3311,11 +3311,16 @@ async def workorders_lookup(body: WorkorderLookupRequest):
async def workorders_today(
tech: list[str] = Query([], description="Filter by technician names"),
limit: int = Query(100, ge=1, le=500),
status: str = Query("", description="Filter by work order status"),
priority: str = Query("", description="Filter by priority (Urgent/High/Medium/Low)"),
work_type: str = Query("", description="Filter by work type (Install/Repair/PM/Emergency)"),
date_range: str = Query("today", description="Date range: today, week, month, all"),
):
"""Fetch today's active work orders via bookableresourcebooking.
Pass ?tech=Shawn+Canada&tech=John+Doe to filter by one or more technicians.
Omit tech entirely to return all technicians.
Supports optional ?status=, ?priority=, ?work_type=, ?date_range= filters.
"""
conn = _get_extraction_db()
if not conn:
@@ -3323,20 +3328,47 @@ async def workorders_today(
try:
cur = conn.cursor()
today = date.today().isoformat()
today_start = f"{today}T00:00:00Z"
today_end = f"{today}T23:59:59Z"
# Determine date window
date_window_start = f"{today}T00:00:00Z"
date_window_end = f"{today}T23:59:59Z"
if date_range == "week":
from datetime import timedelta
week_ago = (date.today() - timedelta(days=7)).isoformat()
date_window_start = f"{week_ago}T00:00:00Z"
elif date_range == "month":
from datetime import timedelta
month_ago = (date.today() - timedelta(days=30)).isoformat()
date_window_start = f"{month_ago}T00:00:00Z"
elif date_range == "all":
date_window_start = "2000-01-01T00:00:00Z"
date_window_end = "2099-12-31T23:59:59Z"
active_booking_statuses = ",".join(
f"'{s}'"
for s in ["Scheduled", "Unscheduled", "On Site", "Traveling", "On Break", "Hold", "Committed"]
)
params = [today_start, today_end]
params = [date_window_start, date_window_end]
tech_clause = ""
if tech:
placeholders_t = ",".join(["?" for _ in tech])
tech_clause = f'AND b."resource!name" IN ({placeholders_t})'
params.extend(tech)
# Additional filters
filter_clauses = []
if status:
filter_clauses.append("w.msdyn_systemstatus = ?")
params.append(status)
if priority:
filter_clauses.append('w."msdyn_priority!name" = ?')
params.append(priority)
if work_type:
filter_clauses.append('w."msdyn_workordertype!name" = ?')
params.append(work_type)
filter_sql = " ".join(filter_clauses)
params.append(limit)
cur.execute(
@@ -3365,6 +3397,7 @@ async def workorders_today(
AND b.starttime <= ?
AND b."bookingstatus!name" IN ({active_booking_statuses})
{tech_clause}
{filter_sql}
ORDER BY b.starttime ASC
LIMIT ?
""",
+185 -9
View File
@@ -1068,6 +1068,12 @@
border-radius: 6px; padding: 10px 12px; margin-bottom: 6px;
display: flex; align-items: flex-start; gap: 10px;
}
.stop-item.stop-done { opacity: 0.5; }
.stop-item.stop-done .stop-num { background: var(--green,#3fb950) !important; }
.stop-item.stop-current {
background: var(--accent-bg,#1a2744); border-color: var(--accent,#58a6ff);
border-left: 3px solid var(--accent,#58a6ff);
}
.stop-num {
background: var(--accent, #58a6ff); color: #fff; width: 26px; height: 26px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
@@ -1663,12 +1669,69 @@
</label>
</div>
</div>
<!-- Filter bar (matching WO tab style) -->
<div style="text-align:left;margin-bottom:10px;">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:6px;">
<button class="filter-toggle" id="rpFilterToggle" onclick="rpToggleFilterPanel()" title="Toggle filter panel" style="font-size:12px;padding:4px 10px;">
<svg class="gi" aria-hidden="true"><use href="#gi-target"/></svg> Filters <span id="rpFilterCount" class="ft-count" style="display:none;">0</span>
</button>
<span style="font-size:11px;color:var(--text2,#999);">Apply filters before loading route</span>
</div>
<div class="filter-panel" id="rpFilterPanel" style="display:none;">
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Status</div>
<select id="rpFilterStatus" class="input-field" onchange="rpOnFilterChange()">
<option value="">All statuses</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Priority</div>
<select id="rpFilterPriority" class="input-field" onchange="rpOnFilterChange()">
<option value="">All priorities</option>
<option value="Urgent">🔴 Urgent</option>
<option value="High">🟠 High</option>
<option value="Medium">🟡 Medium</option>
<option value="Low">🟢 Low</option>
</select>
</div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Work Type</div>
<select id="rpFilterType" class="input-field" onchange="rpOnFilterChange()">
<option value="">All types</option>
<option value="Install">🔧 Install</option>
<option value="Repair">🔧 Repair</option>
<option value="PM">📋 PM</option>
<option value="Emergency">🚨 Emergency</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Date Range</div>
<select id="rpFilterDate" class="input-field" onchange="rpOnFilterChange()">
<option value="today">Today</option>
<option value="week">This Week</option>
<option value="month">This Month</option>
<option value="all">🌐 All</option>
</select>
</div>
</div>
<div class="fp-actions">
<button class="btn btn-outline btn-xs" onclick="rpClearFilters()" style="flex:1;"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg> Clear filters</button>
<button class="btn btn-outline btn-xs" onclick="rpToggleFilterPanel()" style="flex:1;"><svg class="gi" aria-hidden="true"><use href="#gi-check"/></svg> Done</button>
</div>
</div>
<div class="active-filters" id="rpActiveFilters"></div>
</div>
<div class="btn-row">
<button class="btn btn-primary" style="font-size:15px;padding:12px 24px;flex:1;" onclick="rpLoadToday()">
<svg class="gi" aria-hidden="true"><use href="#gi-rocket"/></svg> Load Today's Route
<svg class="gi" aria-hidden="true"><use href="#gi-rocket"/></svg> Load Route
</button>
</div>
<p style="font-size:11px;color:var(--text2,#999);margin-top:6px;">Finds today's scheduled/active work orders and plans the optimal route</p>
<p style="font-size:11px;color:var(--text2,#999);margin-top:6px;">Finds open/scheduled work orders matching filters and plans the optimal route</p>
</div>
<!-- Starting Point -->
@@ -6025,8 +6088,9 @@
map: null,
markers: [],
polyline: null,
searchTimer: null,
isReordering: false,
searchTimer: null,
currentStopIndex: 0, // which stop is currently active
};
// ── Init on tab visit ────────────────────────────────────────────────
@@ -6207,19 +6271,27 @@
document.querySelectorAll('#rpTechCheckboxes input[type="checkbox"]:not(#rpTechAll):checked').forEach(cb => techs.push(cb.value));
if (techs.length === 0) { showToast('Select at least one technician', true); return; }
rpShowLoading('Loading today\u2019s work orders...');
rpShowLoading('Loading work orders...');
try {
const filters = rpGetFilters();
const params = techs.map(t => 'tech=' + encodeURIComponent(t)).join('&');
// Add filter params
if (filters.status) params += '&status=' + encodeURIComponent(filters.status);
if (filters.priority) params += '&priority=' + encodeURIComponent(filters.priority);
if (filters.work_type) params += '&work_type=' + encodeURIComponent(filters.work_type);
if (filters.date_range) params += '&date_range=' + encodeURIComponent(filters.date_range);
const data = await api('/api/workorders/today?' + params);
if (!data.workorders || data.workorders.length === 0) {
rpHideLoading();
showToast('No work orders found for today', true); return;
showToast('No work orders found matching filters', true); return;
}
const woIds = data.workorders.map(wo => wo.name);
rpState.currentStopIndex = 0;
await rpOptimizeRoute(woIds, data.workorders);
} catch (e) {
rpHideLoading();
showToast('Error loading today\'s route: ' + e.message, true);
showToast('Error loading route: ' + e.message, true);
}
}
@@ -6350,20 +6422,31 @@
// Stop list
let stopHtml = '';
let hasReachedCurrent = false;
route.forEach((stop, i) => {
const addr = stop.address ? [stop.address.line1, stop.address.city].filter(Boolean).join(', ') : '';
const drive = stop.drive_min_to_next ? '🚗 ' + stop.drive_min_to_next + ' min (' + stop.distance_to_next_km + ' km)' : '';
const cum = stop.cumulative_min != null ? '⏱ ' + stop.cumulative_min + ' min from start' : '';
const statusBadge = stop.booking_status ? '<span class="badge badge-info">' + esc(stop.booking_status) + '</span>' : '';
const woLink = '<span style="color:var(--accent,#58a6ff);cursor:pointer;" onclick="rpShowAsset(\'' + stop.name + '\')">' + esc(stop.name) + '</span>';
stopHtml += '<div class="stop-item">' +
'<div class="stop-num">' + (i + 1) + '</div>' +
const isCurrent = i === rpState.currentStopIndex;
if (isCurrent) hasReachedCurrent = true;
const gps = stop.gps;
const mapsUrl = gps ? 'https://www.google.com/maps/dir/?api=1&destination=' + gps.lat + ',' + gps.lng : '';
const isDone = i < rpState.currentStopIndex;
stopHtml += '<div class="stop-item' + (isCurrent ? ' stop-current' : '') + (isDone ? ' stop-done' : '') + '">' +
'<div class="stop-num" style="' + (isDone ? 'background:var(--green,#3fb950);' : '') + (isCurrent ? 'background:var(--accent,#58a6ff);box-shadow:0 0 0 3px var(--accent-bg,#1a2744);' : '') + '">' +
(isDone ? '✓' : (i + 1)) + '</div>' +
'<div class="stop-body">' +
'<div class="stop-name">' + woLink + ' ' + statusBadge + '</div>' +
'<div class="stop-addr">' + esc(stop.account_name || '') + (addr ? ' — ' + esc(addr) : '') + '</div>' +
'<div class="stop-meta">' + esc(stop.work_type || '') + (stop.priority ? ' · ' + esc(stop.priority) : '') + '</div>' +
'<div class="stop-meta">' + esc(stop.work_type || '') + (stop.priority ? ' · ' + esc(stop.priority) : '') + (stop.booking_status ? ' · ' + esc(stop.booking_status) : '') + '</div>' +
(drive ? '<div class="stop-drive">' + drive + '</div>' : '') +
(cum ? '<div class="stop-cumulative">' + cum + '</div>' : '') +
'<div class="stop-actions" style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;">' +
(gps ? '<button class="btn btn-secondary btn-xs" onclick="window.open(\'' + mapsUrl + '\',\'_blank\')" title="Open in Google Maps"><svg class="gi" aria-hidden="true"><use href="#gi-compass"/></svg> Navigate</button>' : '') +
(isCurrent ? '<button class="btn btn-primary btn-xs" onclick="rpNextStop()"><svg class="gi" aria-hidden="true"><use href="#gi-check"/></svg> Mark Done & Next</button>' : '') +
'</div>' +
'</div></div>';
});
document.getElementById('rpStopList').innerHTML = stopHtml;
@@ -6495,6 +6578,99 @@
}
}
// ── Next Stop ──────────────────────────────────────────────────────────
function rpNextStop() {
const route = rpState.currentRoute?.route || [];
if (rpState.currentStopIndex >= route.length - 1) {
showToast('✅ All stops completed! Start a new route.', false);
return;
}
rpState.currentStopIndex++;
rpDisplayRoute(rpState.currentRoute, null);
// Auto-navigate to next stop via Google Maps
const nextStop = route[rpState.currentStopIndex];
if (nextStop && nextStop.gps) {
const url = 'https://www.google.com/maps/dir/?api=1&destination=' +
nextStop.gps.lat + ',' + nextStop.gps.lng +
'&travelmode=driving';
showToast('→ Next: ' + (nextStop.name || 'Stop ' + (rpState.currentStopIndex + 1)), false);
// Offer to navigate
setTimeout(() => {
if (confirm('Navigate to ' + (nextStop.account_name || nextStop.name) + '?')) {
window.open(url, '_blank');
}
}, 500);
}
}
// ── Route Filters ──────────────────────────────────────────────────────
function rpToggleFilterPanel() {
const panel = document.getElementById('rpFilterPanel');
panel.style.display = panel.style.display === 'none' ? '' : 'none';
}
function rpGetFilters() {
return {
status: document.getElementById('rpFilterStatus').value,
priority: document.getElementById('rpFilterPriority').value,
work_type: document.getElementById('rpFilterType').value,
date_range: document.getElementById('rpFilterDate').value,
};
}
function rpOnFilterChange() {
const f = rpGetFilters();
let count = 0;
if (f.status) count++;
if (f.priority) count++;
if (f.work_type) count++;
const badge = document.getElementById('rpFilterCount');
if (count > 0) { badge.textContent = count; badge.style.display = ''; }
else { badge.style.display = 'none'; }
// Show active filter chips
const chips = [];
if (f.status) chips.push('Status: ' + f.status);
if (f.priority) chips.push('Priority: ' + f.priority);
if (f.work_type) chips.push('Type: ' + f.work_type);
const container = document.getElementById('rpActiveFilters');
if (chips.length > 0) {
container.innerHTML = chips.map(c =>
'<span class="filter-chip">' + esc(c) + '</span>'
).join('');
container.style.display = '';
} else {
container.style.display = 'none';
}
}
function rpClearFilters() {
document.getElementById('rpFilterStatus').value = '';
document.getElementById('rpFilterPriority').value = '';
document.getElementById('rpFilterType').value = '';
document.getElementById('rpFilterDate').value = 'today';
rpOnFilterChange();
}
function rpLoadStatusOptions() {
// Load status options from WO tab or use defaults
const statusSelect = document.getElementById('rpFilterStatus');
if (!statusSelect) return;
// Match the WO tab options
const statuses = ['Scheduled', 'In Progress', 'Completed', 'Cancelled', 'On Hold'];
statuses.forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
statusSelect.appendChild(opt);
});
}
// Call on init
document.addEventListener('rpTabActivated', () => {
rpLoadStatusOptions();
});
// Game-icons SVG helper for dynamic content
function gi(name) {