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:
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user