From 03cf4b9281ea15f2ce25a6879c4253b294182961 Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 1 Jun 2026 13:10:37 -0400 Subject: [PATCH] feat: Route tab overhaul + work order filters + Next Stop + Google Maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server.py | 39 +++++++++- static/index.html | 194 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 221 insertions(+), 12 deletions(-) diff --git a/server.py b/server.py index 9c24137..e42c353 100644 --- a/server.py +++ b/server.py @@ -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 ? """, diff --git a/static/index.html b/static/index.html index 68bb9ab..eeaa58d 100644 --- a/static/index.html +++ b/static/index.html @@ -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 @@ + + +
+
+ + Apply filters before loading route +
+ +
+
+
-

Finds today's scheduled/active work orders and plans the optimal route

+

Finds open/scheduled work orders matching filters and plans the optimal route

@@ -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 ? '' + esc(stop.booking_status) + '' : ''; const woLink = '' + esc(stop.name) + ''; - stopHtml += '
' + - '
' + (i + 1) + '
' + + 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 += '
' + + '
' + + (isDone ? 'โœ“' : (i + 1)) + '
' + '
' + '
' + woLink + ' ' + statusBadge + '
' + '
' + esc(stop.account_name || '') + (addr ? ' โ€” ' + esc(addr) : '') + '
' + - '
' + esc(stop.work_type || '') + (stop.priority ? ' ยท ' + esc(stop.priority) : '') + '
' + + '
' + esc(stop.work_type || '') + (stop.priority ? ' ยท ' + esc(stop.priority) : '') + (stop.booking_status ? ' ยท ' + esc(stop.booking_status) : '') + '
' + (drive ? '
' + drive + '
' : '') + (cum ? '
' + cum + '
' : '') + + '
' + + (gps ? '' : '') + + (isCurrent ? '' : '') + + '
' + '
'; }); 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 => + '' + esc(c) + '' + ).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) {