From de853d6e1930c5bd2eff9018a0f87f5c403adf52 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 20 Mar 2026 09:04:48 -0700 Subject: [PATCH 1/2] RE1-T105 Fixing issues with routes. --- CLAUDE.md | 189 ++++++++ .../Areas/User/Routes/Routes.en.resx | 31 +- Core/Resgrid.Model/Services/IRouteService.cs | 5 + Core/Resgrid.Services/RouteService.cs | 25 ++ .../Controllers/v4/MappingController.cs | 289 +++++++++++- .../Controllers/v4/RoutesController.cs | 424 +++++++++++++++++- .../v4/Mapping/GetAllActiveLayersResult.cs | 25 ++ .../Models/v4/Mapping/GetCustomMapsResult.cs | 8 + .../Models/v4/Mapping/GetIndoorMapsResult.cs | 8 + .../v4/Mapping/SearchAllMapFeaturesResult.cs | 30 ++ .../Models/v4/Routes/RouteInputModels.cs | 7 + .../Models/v4/Routes/RouteResultModels.cs | 160 +++++++ .../Resgrid.Web.Services.xml | 106 ++++- .../User/Controllers/RoutesController.cs | 91 +++- .../User/Models/Routes/RouteViewModels.cs | 42 +- .../User/Views/Routes/ArchivedRoutes.cshtml | 73 +++ .../User/Views/Routes/ArchivedView.cshtml | 100 +++++ .../Areas/User/Views/Routes/Directions.cshtml | 223 +++++++++ .../Areas/User/Views/Routes/Edit.cshtml | 60 ++- .../Areas/User/Views/Routes/Index.cshtml | 12 +- .../Areas/User/Views/Routes/New.cshtml | 56 ++- .../Areas/User/Views/Routes/StartRoute.cshtml | 84 ++++ .../Areas/User/Views/Routes/View.cshtml | 21 +- .../routes/resgrid.routes.directions.js | 221 +++++++++ .../internal/routes/resgrid.routes.edit.js | 173 ++++++- .../app/internal/routes/resgrid.routes.new.js | 168 ++++++- .../internal/routes/resgrid.routes.view.js | 1 + 27 files changed, 2604 insertions(+), 28 deletions(-) create mode 100644 CLAUDE.md create mode 100644 Web/Resgrid.Web.Services/Models/v4/Mapping/GetAllActiveLayersResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Mapping/SearchAllMapFeaturesResult.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Routes/ArchivedRoutes.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Routes/ArchivedView.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Routes/Directions.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Routes/StartRoute.cshtml create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.directions.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a306ab6f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,189 @@ + +# Dual-Graph Context Policy + +This project uses a local dual-graph MCP server for efficient context retrieval. + +## MANDATORY: Adaptive graph_continue rule + +**Call `graph_continue` ONLY when you do NOT already know the relevant files.** + +### Call `graph_continue` when: +- This is the first message of a new task / conversation +- The task shifts to a completely different area of the codebase +- You need files you haven't read yet in this session + +### SKIP `graph_continue` when: +- You already identified the relevant files earlier in this conversation +- You are doing follow-up work on files already read (verify, refactor, test, docs, cleanup, commit) +- The task is pure text (writing a commit message, summarising, explaining) + +**If skipping, go directly to `graph_read` on the already-known `file::symbol`.** + +## When you DO call graph_continue + +1. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with `pwd`. Do NOT ask the user. + +2. **If `graph_continue` returns `skip=true`**: fewer than 5 files — read only specifically named files. + +3. **Read `recommended_files`** using `graph_read`. + - Always use `file::symbol` notation (e.g. `src/auth.ts::handleLogin`) — never read whole files. + - `recommended_files` entries that already contain `::` must be passed verbatim. + +4. **Obey confidence caps:** + - `confidence=high` → Stop. Do NOT grep or explore further. + - `confidence=medium` → `fallback_rg` at most `max_supplementary_greps` times, then `graph_read` at most `max_supplementary_files` more symbols. Stop. + - `confidence=low` → same as medium. Stop. + +## Session State (compact, update after every turn) + +Maintain a short JSON block in your working memory. Update it after each turn: + +```json +{ + "files_identified": ["path/to/file.py"], + "symbols_changed": ["module::function"], + "fix_applied": true, + "features_added": ["description"], + "open_issues": ["one-line note"] +} +``` + +Use this state — not prose summaries — to remember what's been done across turns. + +## Token Usage + +A `token-counter` MCP is available for tracking live token usage. + +- Before reading a large file: `count_tokens({text: ""})` to check cost first. +- To show running session cost: `get_session_stats()` +- To log completed task: `log_usage({input_tokens: N, output_tokens: N, description: "task"})` + +## Rules + +- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue` (when required). +- Do NOT do broad/recursive exploration at any confidence level. +- `max_supplementary_greps` and `max_supplementary_files` are hard caps — never exceed them. +- Do NOT call `graph_continue` more than once per turn. +- Always use `file::symbol` notation with `graph_read` — never bare filenames. +- After edits, call `graph_register_edit` with changed files using `file::symbol` notation. + +## Context Store + +Whenever you make a decision, identify a task, note a next step, fact, or blocker during a conversation, append it to `.dual-graph/context-store.json`. + +**Entry format:** +```json +{"type": "decision|task|next|fact|blocker", "content": "one sentence max 15 words", "tags": ["topic"], "files": ["relevant/file.ts"], "date": "YYYY-MM-DD"} +``` + +**To append:** Read the file → add the new entry to the array → Write it back → call `graph_register_edit` on `.dual-graph/context-store.json`. + +**Rules:** +- Only log things worth remembering across sessions (not every minor detail) +- `content` must be under 15 words +- `files` lists the files this decision/task relates to (can be empty) +- Log immediately when the item arises — not at session end + +## Session End + +When the user signals they are done (e.g. "bye", "done", "wrap up", "end session"), proactively update `CONTEXT.md` in the project root with: +- **Current Task**: one sentence on what was being worked on +- **Key Decisions**: bullet list, max 3 items +- **Next Steps**: bullet list, max 3 items + +Keep `CONTEXT.md` under 20 lines total. Do NOT summarize the full conversation — only what's needed to resume next session. + +--- + +# Coding Standards + +## General + +Write C# code that maximises readability, maintainability, and correctness while minimising complexity and coupling. Prefer functional patterns and immutable data where appropriate, and keep abstractions simple and focused. + +- Write clear, self-documenting code +- Keep abstractions simple and focused +- Minimise dependencies and coupling +- Use modern C# features appropriately +- Use the repository pattern with Dapper at the repository layer for SQL Server and PostgreSQL database communication + +## Code Organisation + +**Use meaningful names — no unclear abbreviations:** +```csharp +// Good +public async Task> ProcessOrderAsync(OrderRequest request, CancellationToken cancellationToken) + +// Avoid +public async Task> ProcAsync(ReqDto r, CancellationToken ct) +``` + +**Separate state from behaviour:** +```csharp +// Good +public sealed record Order(OrderId Id, List Lines); + +public static class OrderOperations +{ + public static decimal CalculateTotal(Order order) => + order.Lines.Sum(line => line.Price * line.Quantity); +} +``` + +**Prefer pure methods — avoid hidden side effects:** +```csharp +// Good +public static decimal CalculateTotalPrice(IEnumerable lines, decimal taxRate) => + lines.Sum(line => line.Price * line.Quantity) * (1 + taxRate); + +// Avoid +public void CalculateAndUpdateTotalPrice() +{ + this.Total = this.Lines.Sum(l => l.Price * l.Quantity); + this.UpdateDatabase(); +} +``` + +**Use extension methods for domain-specific operations:** +```csharp +public static class OrderExtensions +{ + public static bool CanBeFulfilled(this Order order, Inventory inventory) => + order.Lines.All(line => inventory.HasStock(line.ProductId, line.Quantity)); +} +``` + +**Design for testability — avoid hidden dependencies:** +```csharp +// Good: pure, easily testable +public static decimal CalculateDiscount(decimal price, int quantity, CustomerTier tier) => ... + +// Avoid: hidden service calls make this impossible to unit test +public decimal CalculateDiscount() +{ + var user = _userService.GetCurrentUser(); + var settings = _configService.GetSettings(); + ... +} +``` + +## Dependency Management + +**Minimise constructor injection — too many dependencies signal a design problem:** +```csharp +// Good +public sealed class OrderProcessor(IOrderRepository repository) { } + +// Avoid +public class OrderProcessor( + IOrderRepository repository, + ILogger logger, + IEmailService emailService, + IMetrics metrics, + IValidator validator) { } +``` + +**Prefer composition via interfaces:** +```csharp +public sealed class EnhancedLogger(ILogger baseLogger, IMetrics metrics) : ILogger { } +``` diff --git a/Core/Resgrid.Localization/Areas/User/Routes/Routes.en.resx b/Core/Resgrid.Localization/Areas/User/Routes/Routes.en.resx index 0cc46f03..fc41b42f 100644 --- a/Core/Resgrid.Localization/Areas/User/Routes/Routes.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Routes/Routes.en.resx @@ -88,7 +88,10 @@ New Route - Active Routes + In Progress + + + Archived Routes Stops @@ -135,20 +138,38 @@ - Active Routes + In Progress Routes - Active Routes + In Progress Routes - Active + In Progress - No Active Routes + No Routes In Progress There are no route instances currently in progress. + + Archived Routes + + + Archived Routes + + + Archived + + + No Archived Routes + + + There are no archived route plans. + + + Archived Route Detail + Unit diff --git a/Core/Resgrid.Model/Services/IRouteService.cs b/Core/Resgrid.Model/Services/IRouteService.cs index 8e3435e8..0b644697 100644 --- a/Core/Resgrid.Model/Services/IRouteService.cs +++ b/Core/Resgrid.Model/Services/IRouteService.cs @@ -54,5 +54,10 @@ public interface IRouteService // Geofence Task CheckGeofenceProximityAsync(int unitId, decimal latitude, decimal longitude); + + // Single-record lookups + Task GetRouteStopByIdAsync(string routeStopId); + Task GetInstanceStopByIdAsync(string routeInstanceStopId); + Task UpdateInstanceStopNotesAsync(string routeInstanceStopId, string notes, CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Services/RouteService.cs b/Core/Resgrid.Services/RouteService.cs index d00dd068..1df1f403 100644 --- a/Core/Resgrid.Services/RouteService.cs +++ b/Core/Resgrid.Services/RouteService.cs @@ -415,6 +415,31 @@ public async Task UpdateRouteGeometryAsync(string routePlanId, string #endregion Mapbox Integration + #region Single-record lookups + + public async Task GetRouteStopByIdAsync(string routeStopId) + { + return await _routeStopsRepository.GetByIdAsync(routeStopId); + } + + public async Task GetInstanceStopByIdAsync(string routeInstanceStopId) + { + return await _routeInstanceStopsRepository.GetByIdAsync(routeInstanceStopId); + } + + public async Task UpdateInstanceStopNotesAsync(string routeInstanceStopId, string notes, CancellationToken cancellationToken = default) + { + var instanceStop = await _routeInstanceStopsRepository.GetByIdAsync(routeInstanceStopId); + if (instanceStop == null) + throw new ArgumentException("Route instance stop not found.", nameof(routeInstanceStopId)); + + instanceStop.Notes = notes; + await _routeInstanceStopsRepository.SaveOrUpdateAsync(instanceStop, cancellationToken); + return instanceStop; + } + + #endregion Single-record lookups + #region Geofence public async Task CheckGeofenceProximityAsync(int unitId, decimal latitude, decimal longitude) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs index e0495292..7ae7cf0c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs @@ -16,6 +16,7 @@ using GeoJSON.Net; using System.Collections.Generic; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using GeoJSON.Net.Feature; namespace Resgrid.Web.Services.Controllers.v4 @@ -724,7 +725,7 @@ public async Task GetIndoorMapFloorImage(string floorId) [HttpGet("SearchIndoorLocations")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Call_View)] - public async Task> SearchIndoorLocations(string term) + public async Task> SearchIndoorLocations(string term, string mapId = null) { var result = new SearchIndoorLocationsResult(); @@ -734,8 +735,18 @@ public async Task> SearchIndoorLocatio if (zones != null && zones.Count > 0) { + HashSet floorIdsForMap = null; + if (!string.IsNullOrWhiteSpace(mapId)) + { + var floors = await _indoorMapService.GetFloorsForMapAsync(mapId); + floorIdsForMap = floors?.Select(f => f.IndoorMapFloorId).ToHashSet() ?? new HashSet(); + } + foreach (var z in zones) { + if (floorIdsForMap != null && !floorIdsForMap.Contains(z.IndoorMapFloorId)) + continue; + result.Data.Add(new IndoorMapZoneResultData { IndoorMapZoneId = z.IndoorMapZoneId, @@ -1001,7 +1012,7 @@ public async Task GetCustomMapLayerImage(string layerId) [HttpGet("SearchCustomMapRegions")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Call_View)] - public async Task> SearchCustomMapRegions(string term) + public async Task> SearchCustomMapRegions(string term, string layerId = null) { var result = new SearchCustomMapRegionsResult(); @@ -1013,6 +1024,9 @@ public async Task> SearchCustomMapReg { foreach (var r in regions) { + if (!string.IsNullOrWhiteSpace(layerId) && r.IndoorMapFloorId != layerId) + continue; + result.Data.Add(new CustomMapRegionResultData { IndoorMapZoneId = r.IndoorMapZoneId, @@ -1041,5 +1055,276 @@ public async Task> SearchCustomMapReg return Ok(result); } + + /// + /// Returns raw GeoJSON FeatureCollection for a MapLayer (legacy vector layers). + /// + /// Map layer id + [HttpGet("GetMapLayerGeoJSON/{layerId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task GetMapLayerGeoJSON(string layerId) + { + var layer = await _mappingService.GetMapLayersByIdAsync(layerId); + if (layer == null || layer.DepartmentId != DepartmentId) + return NotFound(); + + var fc = layer.Data?.Convert() ?? new FeatureCollection(); + var geoJson = JsonConvert.SerializeObject(fc); + + return Content(geoJson, "application/geo+json"); + } + + /// + /// Returns a summary of all map layers that are on by default for the department. + /// + [HttpGet("GetAllActiveLayers")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task> GetAllActiveLayers() + { + var result = new GetAllActiveLayersResult(); + + var mapLayers = await _mappingService.GetMapLayersForTypeDepartmentAsync(DepartmentId, MapLayerTypes.TopLevel); + if (mapLayers != null) + { + foreach (var layer in mapLayers.Where(l => !l.IsDeleted && l.IsOnByDefault)) + { + result.Data.Add(new ActiveLayerResultData + { + Id = layer.GetId(), + Name = layer.Name, + LayerSource = "maplayer", + Type = layer.Type, + Color = layer.Color, + IsSearchable = layer.IsSearchable, + IsOnByDefault = layer.IsOnByDefault + }); + } + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Unified search across indoor zones and custom map regions. + /// + /// Search term + /// Filter: "all" (default), "indoor", or "custom" + [HttpGet("SearchAllMapFeatures")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task> SearchAllMapFeatures(string term, string type = "all") + { + var result = new SearchAllMapFeaturesResult(); + + if (string.IsNullOrWhiteSpace(term)) + { + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + if (type == "all" || type == "indoor") + { + var zones = await _indoorMapService.SearchZonesAsync(DepartmentId, term); + if (zones != null) + { + foreach (var z in zones) + { + var floor = await _indoorMapService.GetFloorByIdAsync(z.IndoorMapFloorId); + var map = floor != null ? await _indoorMapService.GetIndoorMapByIdAsync(floor.IndoorMapId) : null; + + result.Data.Add(new MapFeatureResultData + { + FeatureType = "indoor_zone", + Id = z.IndoorMapZoneId, + Name = z.Name, + Description = z.Description, + MapId = map?.IndoorMapId, + MapName = map?.Name, + FloorOrLayerId = z.IndoorMapFloorId, + FloorOrLayerName = floor?.Name, + CenterLatitude = z.CenterLatitude, + CenterLongitude = z.CenterLongitude, + IsDispatchable = z.IsDispatchable + }); + } + } + } + + if (type == "all" || type == "custom") + { + var regions = await _customMapService.SearchRegionsAsync(DepartmentId, term); + if (regions != null) + { + foreach (var r in regions) + { + var layer = await _customMapService.GetLayerByIdAsync(r.IndoorMapFloorId); + var map = layer != null ? await _customMapService.GetCustomMapByIdAsync(layer.IndoorMapId) : null; + + result.Data.Add(new MapFeatureResultData + { + FeatureType = "custom_region", + Id = r.IndoorMapZoneId, + Name = r.Name, + Description = r.Description, + MapId = map?.IndoorMapId, + MapName = map?.Name, + FloorOrLayerId = r.IndoorMapFloorId, + FloorOrLayerName = layer?.Name, + CenterLatitude = r.CenterLatitude, + CenterLongitude = r.CenterLongitude, + IsDispatchable = r.IsDispatchable + }); + } + } + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Returns indoor maps whose bounding box is within radiusMeters of the given lat/lon. + /// + /// Latitude + /// Longitude + /// Search radius in meters + [HttpGet("GetNearbyIndoorMaps")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task> GetNearbyIndoorMaps(double lat, double lon, double radiusMeters = 200) + { + var result = new GetIndoorMapsResult(); + + var maps = await _indoorMapService.GetIndoorMapsForDepartmentAsync(DepartmentId); + if (maps != null) + { + foreach (var m in maps) + { + if (IsPointNearBounds(lat, lon, radiusMeters, m.BoundsNELat, m.BoundsNELon, m.BoundsSWLat, m.BoundsSWLon)) + { + result.Data.Add(new IndoorMapResultData + { + IndoorMapId = m.IndoorMapId, + Name = m.Name, + Description = m.Description, + CenterLatitude = m.CenterLatitude, + CenterLongitude = m.CenterLongitude, + BoundsNELat = m.BoundsNELat, + BoundsNELon = m.BoundsNELon, + BoundsSWLat = m.BoundsSWLat, + BoundsSWLon = m.BoundsSWLon, + DefaultFloorId = m.DefaultFloorId + }); + } + } + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Returns all zones for an indoor map floor as a GeoJSON FeatureCollection for direct rnmapbox consumption. + /// + /// Indoor map floor id + [HttpGet("GetIndoorMapZonesGeoJSON/{floorId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task GetIndoorMapZonesGeoJSON(string floorId) + { + var floor = await _indoorMapService.GetFloorByIdAsync(floorId); + if (floor == null) + return NotFound(); + + var map = await _indoorMapService.GetIndoorMapByIdAsync(floor.IndoorMapId); + if (map == null || map.DepartmentId != DepartmentId) + return NotFound(); + + var zones = await _indoorMapService.GetZonesForFloorAsync(floorId); + var geoJson = BuildZonesGeoJson(zones ?? new List()); + + return Content(geoJson, "application/geo+json"); + } + + /// + /// Returns all regions for a custom map layer as a GeoJSON FeatureCollection for direct rnmapbox consumption. + /// + /// Custom map layer id + [HttpGet("GetCustomMapRegionsGeoJSON/{layerId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task GetCustomMapRegionsGeoJSON(string layerId) + { + var layer = await _customMapService.GetLayerByIdAsync(layerId); + if (layer == null) + return NotFound(); + + var map = await _customMapService.GetCustomMapByIdAsync(layer.IndoorMapId); + if (map == null || map.DepartmentId != DepartmentId) + return NotFound(); + + var regions = await _customMapService.GetRegionsForLayerAsync(layerId); + var geoJson = BuildZonesGeoJson(regions ?? new List()); + + return Content(geoJson, "application/geo+json"); + } + + private static string BuildZonesGeoJson(List zones) + { + var features = zones + .Where(z => !z.IsDeleted && !string.IsNullOrWhiteSpace(z.GeoGeometry)) + .Select(z => + { + JToken geometry; + try { geometry = JToken.Parse(z.GeoGeometry); } + catch { geometry = JValue.CreateNull(); } + + return new + { + type = "Feature", + id = z.IndoorMapZoneId, + geometry, + properties = new + { + id = z.IndoorMapZoneId, + name = z.Name, + description = z.Description, + zoneType = z.ZoneType, + color = z.Color, + isSearchable = z.IsSearchable, + isDispatchable = z.IsDispatchable, + metadata = z.Metadata + } + }; + }) + .ToList(); + + return JsonConvert.SerializeObject(new { type = "FeatureCollection", features }); + } + + private static bool IsPointNearBounds(double lat, double lon, double radiusMeters, + decimal boundsNELat, decimal boundsNELon, decimal boundsSWLat, decimal boundsSWLon) + { + // ~111,320 meters per degree of latitude + double pad = radiusMeters / 111320.0; + return lat <= (double)boundsNELat + pad && lat >= (double)boundsSWLat - pad + && lon <= (double)boundsNELon + pad && lon >= (double)boundsSWLon - pad; + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RoutesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RoutesController.cs index b9dc605f..f3879fc3 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RoutesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RoutesController.cs @@ -4,6 +4,7 @@ using Resgrid.Model; using Resgrid.Model.Services; using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; using Resgrid.Web.Services.Models.v4.Routes; using System; using System.Collections.Generic; @@ -21,10 +22,12 @@ namespace Resgrid.Web.Services.Controllers.v4 public class RoutesController : V4AuthenticatedApiControllerbase { private readonly IRouteService _routeService; + private readonly IContactsService _contactsService; - public RoutesController(IRouteService routeService) + public RoutesController(IRouteService routeService, IContactsService contactsService) { _routeService = routeService; + _contactsService = contactsService; } /// @@ -58,6 +61,7 @@ public async Task> GetRoutePlans() } result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); return Ok(result); } @@ -74,6 +78,7 @@ public async Task> GetRoutePlansForUnit(int un foreach (var plan in plans.Where(p => p.DepartmentId == DepartmentId)) { + var stops = await _routeService.GetRouteStopsForPlanAsync(plan.RoutePlanId); result.Data.Add(new RoutePlanResultData { RoutePlanId = plan.RoutePlanId, @@ -82,12 +87,14 @@ public async Task> GetRoutePlansForUnit(int un UnitId = plan.UnitId, RouteStatus = plan.RouteStatus, RouteColor = plan.RouteColor, + StopsCount = stops.Count, MapboxRouteProfile = plan.MapboxRouteProfile, AddedOn = plan.AddedOn }); } result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); return Ok(result); } @@ -146,6 +153,7 @@ public async Task> GetRoutePlan(string id) PlannedArrivalTime = s.PlannedArrivalTime, PlannedDepartureTime = s.PlannedDepartureTime, EstimatedDwellMinutes = s.EstimatedDwellMinutes, + ContactId = s.ContactId, ContactName = s.ContactName, ContactNumber = s.ContactNumber, Notes = s.Notes @@ -164,6 +172,7 @@ public async Task> GetRoutePlan(string id) }).ToList() }; + ResponseHelper.PopulateV4ResponseData(result); return Ok(result); } @@ -251,8 +260,9 @@ public async Task> CreateRoutePlan([FromBody] } } - return CreatedAtAction(nameof(GetRoutePlan), new { id = plan.RoutePlanId }, - new SaveRoutePlanResult { Id = plan.RoutePlanId, Status = "Created" }); + var createResult = new SaveRoutePlanResult { Id = plan.RoutePlanId, Status = "Created" }; + ResponseHelper.PopulateV4ResponseData(createResult); + return CreatedAtAction(nameof(GetRoutePlan), new { id = plan.RoutePlanId }, createResult); } /// @@ -287,7 +297,9 @@ public async Task> UpdateRoutePlan([FromBody] await _routeService.SaveRoutePlanAsync(plan); - return Ok(new SaveRoutePlanResult { Id = plan.RoutePlanId, Status = "Updated" }); + var updateResult = new SaveRoutePlanResult { Id = plan.RoutePlanId, Status = "Updated" }; + ResponseHelper.PopulateV4ResponseData(updateResult); + return Ok(updateResult); } /// @@ -319,6 +331,7 @@ public async Task> StartRoute([FromBody] St var result = new GetRouteInstanceResult(); result.Data = MapInstanceToResult(instance); + ResponseHelper.PopulateV4ResponseData(result); return Ok(result); } @@ -334,6 +347,7 @@ public async Task> EndRoute([FromBody] EndR var result = new GetRouteInstanceResult(); result.Data = MapInstanceToResult(instance); + ResponseHelper.PopulateV4ResponseData(result); return Ok(result); } @@ -349,6 +363,7 @@ public async Task> PauseRoute([FromBody] Pa var result = new GetRouteInstanceResult(); result.Data = MapInstanceToResult(instance); + ResponseHelper.PopulateV4ResponseData(result); return Ok(result); } @@ -364,6 +379,7 @@ public async Task> ResumeRoute([FromBody] R var result = new GetRouteInstanceResult(); result.Data = MapInstanceToResult(instance); + ResponseHelper.PopulateV4ResponseData(result); return Ok(result); } @@ -379,6 +395,7 @@ public async Task> CancelRoute([FromBody] C var result = new GetRouteInstanceResult(); result.Data = MapInstanceToResult(instance); + ResponseHelper.PopulateV4ResponseData(result); return Ok(result); } @@ -395,7 +412,9 @@ public async Task> GetActiveRouteForUnit(in if (instance == null || instance.DepartmentId != DepartmentId) return NotFound(); - return Ok(await BuildProgressResult(instance)); + var result = await BuildProgressResult(instance); + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); } /// @@ -416,6 +435,7 @@ public async Task> GetActiveRoutes() } result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); return Ok(result); } @@ -443,6 +463,7 @@ public async Task> GetRouteHistory(string } result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); return Ok(result); } @@ -459,7 +480,9 @@ public async Task> GetRouteProgress(string if (instance == null || instance.DepartmentId != DepartmentId) return NotFound(); - return Ok(await BuildProgressResult(instance)); + var result = await BuildProgressResult(instance); + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); } /// @@ -540,6 +563,7 @@ public async Task> GetUnacknowledgedDevia }).ToList(); result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); return Ok(result); } @@ -555,8 +579,396 @@ public async Task AcknowledgeDeviation(string id) return Ok(); } + /// + /// Gets all stops for an active route instance, merging plan data with execution state + /// + [HttpGet("GetStopsForInstance/{instanceId}")] + [Authorize(Policy = ResgridResources.Route_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetStopsForInstance(string instanceId) + { + var instance = await _routeService.GetInstanceByIdAsync(instanceId); + if (instance == null || instance.DepartmentId != DepartmentId) + return NotFound(); + + var planStops = await _routeService.GetRouteStopsForPlanAsync(instance.RoutePlanId); + var instanceStops = await _routeService.GetInstanceStopsAsync(instanceId); + var instanceStopLookup = instanceStops.ToDictionary(s => s.RouteStopId); + + var result = new GetRouteStopsWithDetailsResult(); + foreach (var stop in planStops.OrderBy(s => s.StopOrder)) + { + instanceStopLookup.TryGetValue(stop.RouteStopId, out var iStop); + + var data = new RouteStopWithDetailsResultData + { + RouteStopId = stop.RouteStopId, + RoutePlanId = stop.RoutePlanId, + StopOrder = stop.StopOrder, + Name = stop.Name, + Description = stop.Description, + StopType = stop.StopType, + CallId = stop.CallId, + Latitude = stop.Latitude, + Longitude = stop.Longitude, + Address = stop.Address, + GeofenceRadiusMeters = stop.GeofenceRadiusMeters, + Priority = stop.Priority, + PlannedArrivalTime = stop.PlannedArrivalTime, + PlannedDepartureTime = stop.PlannedDepartureTime, + EstimatedDwellMinutes = stop.EstimatedDwellMinutes, + ContactId = stop.ContactId, + ContactName = stop.ContactName, + ContactNumber = stop.ContactNumber, + PlanNotes = stop.Notes + }; + + if (iStop != null) + { + data.RouteInstanceStopId = iStop.RouteInstanceStopId; + data.RouteInstanceId = iStop.RouteInstanceId; + data.Status = iStop.Status; + data.CheckInOn = iStop.CheckInOn; + data.CheckInType = iStop.CheckInType; + data.CheckInLatitude = iStop.CheckInLatitude; + data.CheckInLongitude = iStop.CheckInLongitude; + data.CheckOutOn = iStop.CheckOutOn; + data.CheckOutLatitude = iStop.CheckOutLatitude; + data.CheckOutLongitude = iStop.CheckOutLongitude; + data.DwellSeconds = iStop.DwellSeconds; + data.SkipReason = iStop.SkipReason; + data.InstanceNotes = iStop.Notes; + data.EstimatedArrivalOn = iStop.EstimatedArrivalOn; + data.ActualArrivalDeviation = iStop.ActualArrivalDeviation; + } + + result.Data.Add(data); + } + + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Gets the full contact record for a route stop's assigned contact + /// + [HttpGet("GetStopContact/{routeStopId}")] + [Authorize(Policy = ResgridResources.Route_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetStopContact(string routeStopId) + { + var stop = await _routeService.GetRouteStopByIdAsync(routeStopId); + if (stop == null) + return NotFound(); + + var plan = await _routeService.GetRoutePlanByIdAsync(stop.RoutePlanId); + if (plan == null || plan.DepartmentId != DepartmentId) + return NotFound(); + + if (string.IsNullOrWhiteSpace(stop.ContactId)) + return NotFound(); + + var contact = await _contactsService.GetContactByIdAsync(stop.ContactId); + if (contact == null || contact.DepartmentId != DepartmentId) + return NotFound(); + + var result = new GetStopContactResult + { + Data = MapContactToResult(contact, stop.RouteStopId, stop.Name) + }; + + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Gets all unique contacts attached to stops on a route plan + /// + [HttpGet("GetRouteContacts/{routePlanId}")] + [Authorize(Policy = ResgridResources.Route_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetRouteContacts(string routePlanId) + { + var plan = await _routeService.GetRoutePlanByIdAsync(routePlanId); + if (plan == null || plan.DepartmentId != DepartmentId) + return NotFound(); + + var stops = await _routeService.GetRouteStopsForPlanAsync(routePlanId); + var result = new GetRouteContactsResult(); + + var seenContactIds = new HashSet(); + foreach (var stop in stops.Where(s => !string.IsNullOrWhiteSpace(s.ContactId))) + { + if (!seenContactIds.Add(stop.ContactId)) + continue; + + var contact = await _contactsService.GetContactByIdAsync(stop.ContactId); + if (contact != null && contact.DepartmentId == DepartmentId) + result.Data.Add(MapContactToResult(contact, stop.RouteStopId, stop.Name)); + } + + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Gets the stored route geometry and waypoints for a route plan + /// + [HttpGet("GetDirections/{routePlanId}")] + [Authorize(Policy = ResgridResources.Route_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetDirections(string routePlanId) + { + var plan = await _routeService.GetRoutePlanByIdAsync(routePlanId); + if (plan == null || plan.DepartmentId != DepartmentId) + return NotFound(); + + var stops = await _routeService.GetRouteStopsForPlanAsync(routePlanId); + + var result = new GetRouteDirectionsResult + { + Data = new RouteDirectionsResultData + { + RoutePlanId = routePlanId, + Geometry = plan.MapboxRouteGeometry, + EstimatedDistanceMeters = plan.EstimatedDistanceMeters, + EstimatedDurationSeconds = plan.EstimatedDurationSeconds, + RouteProfile = plan.MapboxRouteProfile ?? "driving", + Waypoints = stops.OrderBy(s => s.StopOrder).Select(s => new RouteDirectionWaypointData + { + RouteStopId = s.RouteStopId, + StopOrder = s.StopOrder, + Name = s.Name, + Latitude = s.Latitude, + Longitude = s.Longitude, + Address = s.Address, + Status = 0 + }).ToList() + } + }; + + result.PageSize = 1; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Gets the route geometry and remaining waypoints for an active route instance. + /// Pass the unit's current latitude and longitude as query parameters to route from + /// the current position rather than the first remaining stop. + /// + [HttpGet("GetInstanceDirections/{instanceId}")] + [Authorize(Policy = ResgridResources.Route_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetInstanceDirections( + string instanceId, + [FromQuery] decimal? latitude = null, + [FromQuery] decimal? longitude = null) + { + var instance = await _routeService.GetInstanceByIdAsync(instanceId); + if (instance == null || instance.DepartmentId != DepartmentId) + return NotFound(); + + var plan = await _routeService.GetRoutePlanByIdAsync(instance.RoutePlanId); + var planStops = await _routeService.GetRouteStopsForPlanAsync(instance.RoutePlanId); + var instanceStops = await _routeService.GetInstanceStopsAsync(instanceId); + var instanceStopLookup = instanceStops.ToDictionary(s => s.RouteStopId); + + var waypoints = planStops.OrderBy(s => s.StopOrder) + .Select(s => + { + instanceStopLookup.TryGetValue(s.RouteStopId, out var iStop); + return new RouteDirectionWaypointData + { + RouteStopId = s.RouteStopId, + RouteInstanceStopId = iStop?.RouteInstanceStopId, + StopOrder = s.StopOrder, + Name = s.Name, + Latitude = s.Latitude, + Longitude = s.Longitude, + Address = s.Address, + Status = iStop?.Status ?? 0 + }; + }) + .Where(w => w.Status == 0 || w.Status == 1) // Pending or CheckedIn only + .ToList(); + + // When the caller provides a current position, record it as the origin so + // clients can route from the live location instead of the first stop. + decimal? originLat = null; + decimal? originLng = null; + if (latitude.HasValue && longitude.HasValue && + latitude.Value != 0 && longitude.Value != 0) + { + originLat = latitude.Value; + originLng = longitude.Value; + } + + var result = new GetRouteDirectionsResult + { + Data = new RouteDirectionsResultData + { + RoutePlanId = instance.RoutePlanId, + RouteInstanceId = instanceId, + Geometry = plan?.MapboxRouteGeometry, + EstimatedDistanceMeters = plan?.EstimatedDistanceMeters, + EstimatedDurationSeconds = plan?.EstimatedDurationSeconds, + RouteProfile = plan?.MapboxRouteProfile ?? "driving", + OriginLatitude = originLat, + OriginLongitude = originLng, + Waypoints = waypoints + } + }; + + result.PageSize = 1; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Updates notes on an active route instance stop + /// + [HttpPost("UpdateStopNotes")] + [Authorize(Policy = ResgridResources.Route_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateStopNotes([FromBody] UpdateStopNotesInput input) + { + var instanceStop = await _routeService.GetInstanceStopByIdAsync(input.RouteInstanceStopId); + if (instanceStop == null) + return NotFound(); + + var instance = await _routeService.GetInstanceByIdAsync(instanceStop.RouteInstanceId); + if (instance == null || instance.DepartmentId != DepartmentId) + return NotFound(); + + await _routeService.UpdateInstanceStopNotesAsync(input.RouteInstanceStopId, input.Notes); + return Ok(); + } + + /// + /// Gets all active route instances across the department with progress details + /// + [HttpGet("GetActiveRouteInstances")] + [Authorize(Policy = ResgridResources.Route_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetActiveRouteInstances() + { + var instances = await _routeService.GetInstancesForDepartmentAsync(DepartmentId); + var active = instances.Where(i => i.Status == (int)RouteInstanceStatus.InProgress || i.Status == (int)RouteInstanceStatus.Paused); + + var result = new GetActiveRouteInstancesResult(); + foreach (var instance in active) + { + var plan = await _routeService.GetRoutePlanByIdAsync(instance.RoutePlanId); + var instanceStops = await _routeService.GetInstanceStopsAsync(instance.RouteInstanceId); + + var currentStop = instanceStops.Where(s => s.Status == 0 || s.Status == 1) + .OrderBy(s => s.StopOrder).FirstOrDefault(); + string currentStopName = null; + if (currentStop != null) + { + var routeStop = await _routeService.GetRouteStopByIdAsync(currentStop.RouteStopId); + currentStopName = routeStop?.Name; + } + + int progressPercent = instance.StopsTotal > 0 + ? (int)Math.Round((double)instance.StopsCompleted / instance.StopsTotal * 100) + : 0; + + result.Data.Add(new ActiveRouteInstanceResultData + { + RouteInstanceId = instance.RouteInstanceId, + RoutePlanId = instance.RoutePlanId, + RoutePlanName = plan?.Name, + UnitId = instance.UnitId, + Status = instance.Status, + ActualStartOn = instance.ActualStartOn, + StopsCompleted = instance.StopsCompleted, + StopsTotal = instance.StopsTotal, + ProgressPercent = progressPercent, + CurrentStopIndex = currentStop?.StopOrder ?? -1, + CurrentStopName = currentStopName + }); + } + + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Gets all active schedules for the department's route plans + /// + [HttpGet("GetScheduledRoutes")] + [Authorize(Policy = ResgridResources.Route_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetScheduledRoutes() + { + var plans = await _routeService.GetRoutePlansForDepartmentAsync(DepartmentId); + var result = new GetScheduledRoutesResult(); + + foreach (var plan in plans) + { + var schedules = await _routeService.GetSchedulesForPlanAsync(plan.RoutePlanId); + foreach (var schedule in schedules.Where(s => s.IsActive)) + { + result.Data.Add(new ScheduledRouteResultData + { + RouteScheduleId = schedule.RouteScheduleId, + RoutePlanId = plan.RoutePlanId, + RoutePlanName = plan.Name, + UnitId = plan.UnitId, + RecurrenceType = schedule.RecurrenceType, + RecurrenceCron = schedule.RecurrenceCron, + DaysOfWeek = schedule.DaysOfWeek, + DayOfMonth = schedule.DayOfMonth, + ScheduledStartTime = schedule.ScheduledStartTime, + EffectiveFrom = schedule.EffectiveFrom, + EffectiveTo = schedule.EffectiveTo, + IsActive = schedule.IsActive + }); + } + } + + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + #region Helpers + private static RouteStopContactResultData MapContactToResult(Contact contact, string routeStopId, string stopName) + { + return new RouteStopContactResultData + { + ContactId = contact.ContactId, + RouteStopId = routeStopId, + StopName = stopName, + ContactType = contact.ContactType, + FirstName = contact.FirstName, + LastName = contact.LastName, + CompanyName = contact.CompanyName, + Email = contact.Email, + HomePhoneNumber = contact.HomePhoneNumber, + CellPhoneNumber = contact.CellPhoneNumber, + OfficePhoneNumber = contact.OfficePhoneNumber, + Website = contact.Website, + LocationGpsCoordinates = contact.LocationGpsCoordinates, + EntranceGpsCoordinates = contact.EntranceGpsCoordinates, + ExitGpsCoordinates = contact.ExitGpsCoordinates, + LocationGeofence = contact.LocationGeofence, + Description = contact.Description + }; + } + private RouteInstanceResultData MapInstanceToResult(RouteInstance instance) { return new RouteInstanceResultData diff --git a/Web/Resgrid.Web.Services/Models/v4/Mapping/GetAllActiveLayersResult.cs b/Web/Resgrid.Web.Services/Models/v4/Mapping/GetAllActiveLayersResult.cs new file mode 100644 index 00000000..123bcee5 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Mapping/GetAllActiveLayersResult.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.Mapping +{ + public class GetAllActiveLayersResult : StandardApiResponseV4Base + { + public List Data { get; set; } + + public GetAllActiveLayersResult() + { + Data = new List(); + } + } + + public class ActiveLayerResultData + { + public string Id { get; set; } + public string Name { get; set; } + public string LayerSource { get; set; } // "maplayer" or "custommaplayer" + public int Type { get; set; } + public string Color { get; set; } + public bool IsSearchable { get; set; } + public bool IsOnByDefault { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Mapping/GetCustomMapsResult.cs b/Web/Resgrid.Web.Services/Models/v4/Mapping/GetCustomMapsResult.cs index e9d5c89c..543b54b1 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Mapping/GetCustomMapsResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Mapping/GetCustomMapsResult.cs @@ -52,4 +52,12 @@ public SearchCustomMapRegionsResult() Data = new List(); } } + + public class GetCustomMapRegionsGeoJSONResult : StandardApiResponseV4Base + { + /// + /// GeoJSON FeatureCollection string ready for direct rnmapbox ShapeSource consumption + /// + public string GeoJson { get; set; } + } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Mapping/GetIndoorMapsResult.cs b/Web/Resgrid.Web.Services/Models/v4/Mapping/GetIndoorMapsResult.cs index ebb77068..907bc84b 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Mapping/GetIndoorMapsResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Mapping/GetIndoorMapsResult.cs @@ -76,4 +76,12 @@ public SearchIndoorLocationsResult() Data = new List(); } } + + public class GetIndoorMapZonesGeoJSONResult : StandardApiResponseV4Base + { + /// + /// GeoJSON FeatureCollection string ready for direct rnmapbox ShapeSource consumption + /// + public string GeoJson { get; set; } + } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Mapping/SearchAllMapFeaturesResult.cs b/Web/Resgrid.Web.Services/Models/v4/Mapping/SearchAllMapFeaturesResult.cs new file mode 100644 index 00000000..1c454ce5 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Mapping/SearchAllMapFeaturesResult.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.Mapping +{ + public class SearchAllMapFeaturesResult : StandardApiResponseV4Base + { + public List Data { get; set; } + + public SearchAllMapFeaturesResult() + { + Data = new List(); + } + } + + public class MapFeatureResultData + { + /// "indoor_zone" or "custom_region" + public string FeatureType { get; set; } + public string Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string MapId { get; set; } + public string MapName { get; set; } + public string FloorOrLayerId { get; set; } + public string FloorOrLayerName { get; set; } + public decimal CenterLatitude { get; set; } + public decimal CenterLongitude { get; set; } + public bool IsDispatchable { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Routes/RouteInputModels.cs b/Web/Resgrid.Web.Services/Models/v4/Routes/RouteInputModels.cs index 8ee3dac9..c3c0c53c 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Routes/RouteInputModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Routes/RouteInputModels.cs @@ -138,4 +138,11 @@ public class UpdateRouteGeometryInput public double DistanceMeters { get; set; } public double DurationSeconds { get; set; } } + + public class UpdateStopNotesInput + { + [Required] + public string RouteInstanceStopId { get; set; } + public string Notes { get; set; } + } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Routes/RouteResultModels.cs b/Web/Resgrid.Web.Services/Models/v4/Routes/RouteResultModels.cs index 7f073a6d..403642c8 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Routes/RouteResultModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Routes/RouteResultModels.cs @@ -3,6 +3,165 @@ namespace Resgrid.Web.Services.Models.v4.Routes { + public class GetRouteStopsWithDetailsResult : StandardApiResponseV4Base + { + public List Data { get; set; } + public GetRouteStopsWithDetailsResult() { Data = new List(); } + } + + public class RouteStopWithDetailsResultData + { + // Plan-level stop data + public string RouteStopId { get; set; } + public string RoutePlanId { get; set; } + public int StopOrder { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public int StopType { get; set; } + public int? CallId { get; set; } + public decimal Latitude { get; set; } + public decimal Longitude { get; set; } + public string Address { get; set; } + public int? GeofenceRadiusMeters { get; set; } + public int Priority { get; set; } + public DateTime? PlannedArrivalTime { get; set; } + public DateTime? PlannedDepartureTime { get; set; } + public int? EstimatedDwellMinutes { get; set; } + public string ContactId { get; set; } + public string ContactName { get; set; } + public string ContactNumber { get; set; } + public string PlanNotes { get; set; } + + // Instance-level execution data (null when not yet started) + public string RouteInstanceStopId { get; set; } + public string RouteInstanceId { get; set; } + public int Status { get; set; } + public DateTime? CheckInOn { get; set; } + public int? CheckInType { get; set; } + public decimal? CheckInLatitude { get; set; } + public decimal? CheckInLongitude { get; set; } + public DateTime? CheckOutOn { get; set; } + public decimal? CheckOutLatitude { get; set; } + public decimal? CheckOutLongitude { get; set; } + public int? DwellSeconds { get; set; } + public string SkipReason { get; set; } + public string InstanceNotes { get; set; } + public DateTime? EstimatedArrivalOn { get; set; } + public int? ActualArrivalDeviation { get; set; } + } + + public class GetStopContactResult : StandardApiResponseV4Base + { + public RouteStopContactResultData Data { get; set; } + public GetStopContactResult() { Data = new RouteStopContactResultData(); } + } + + public class GetRouteContactsResult : StandardApiResponseV4Base + { + public List Data { get; set; } + public GetRouteContactsResult() { Data = new List(); } + } + + public class RouteStopContactResultData + { + public string ContactId { get; set; } + public string RouteStopId { get; set; } + public string StopName { get; set; } + public int ContactType { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public string CompanyName { get; set; } + public string Email { get; set; } + public string HomePhoneNumber { get; set; } + public string CellPhoneNumber { get; set; } + public string OfficePhoneNumber { get; set; } + public string Website { get; set; } + public string LocationGpsCoordinates { get; set; } + public string EntranceGpsCoordinates { get; set; } + public string ExitGpsCoordinates { get; set; } + public string LocationGeofence { get; set; } + public string Description { get; set; } + } + + public class GetRouteDirectionsResult : StandardApiResponseV4Base + { + public RouteDirectionsResultData Data { get; set; } + public GetRouteDirectionsResult() { Data = new RouteDirectionsResultData(); } + } + + public class RouteDirectionsResultData + { + public string RoutePlanId { get; set; } + public string RouteInstanceId { get; set; } + public string Geometry { get; set; } + public double? EstimatedDistanceMeters { get; set; } + public double? EstimatedDurationSeconds { get; set; } + /// Route profile to use for turn-by-turn navigation: driving, walking, cycling, driving-traffic. + public string RouteProfile { get; set; } + /// Origin latitude supplied by the caller (unit's current location). Null means start from the first waypoint. + public decimal? OriginLatitude { get; set; } + /// Origin longitude supplied by the caller (unit's current location). Null means start from the first waypoint. + public decimal? OriginLongitude { get; set; } + public List Waypoints { get; set; } + public RouteDirectionsResultData() { Waypoints = new List(); } + } + + public class RouteDirectionWaypointData + { + public string RouteStopId { get; set; } + public string RouteInstanceStopId { get; set; } + public int StopOrder { get; set; } + public string Name { get; set; } + public decimal Latitude { get; set; } + public decimal Longitude { get; set; } + public string Address { get; set; } + public int Status { get; set; } + } + + public class GetActiveRouteInstancesResult : StandardApiResponseV4Base + { + public List Data { get; set; } + public GetActiveRouteInstancesResult() { Data = new List(); } + } + + public class ActiveRouteInstanceResultData + { + public string RouteInstanceId { get; set; } + public string RoutePlanId { get; set; } + public string RoutePlanName { get; set; } + public int UnitId { get; set; } + public int Status { get; set; } + public DateTime? ActualStartOn { get; set; } + public int StopsCompleted { get; set; } + public int StopsTotal { get; set; } + public int ProgressPercent { get; set; } + public int CurrentStopIndex { get; set; } + public string CurrentStopName { get; set; } + } + + public class GetScheduledRoutesResult : StandardApiResponseV4Base + { + public List Data { get; set; } + public GetScheduledRoutesResult() { Data = new List(); } + } + + public class ScheduledRouteResultData + { + public string RouteScheduleId { get; set; } + public string RoutePlanId { get; set; } + public string RoutePlanName { get; set; } + public int? UnitId { get; set; } + public int RecurrenceType { get; set; } + public string RecurrenceCron { get; set; } + public string DaysOfWeek { get; set; } + public int? DayOfMonth { get; set; } + public string ScheduledStartTime { get; set; } + public DateTime EffectiveFrom { get; set; } + public DateTime? EffectiveTo { get; set; } + public bool IsActive { get; set; } + } + + public class GetRoutePlansResult : StandardApiResponseV4Base { public List Data { get; set; } @@ -108,6 +267,7 @@ public class RouteStopResultData public DateTime? PlannedArrivalTime { get; set; } public DateTime? PlannedDepartureTime { get; set; } public int? EstimatedDwellMinutes { get; set; } + public string ContactId { get; set; } public string ContactName { get; set; } public string ContactNumber { get; set; } public string Notes { get; set; } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 5bfeea13..d3787cbc 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -679,7 +679,7 @@ Indoor map floor id Floor image file - + Searches for indoor map zones matching the specified term. @@ -724,13 +724,51 @@ Layer id Layer image file - + Searches for custom map regions matching the specified term. Search term SearchCustomMapRegionsResult object + + + Returns raw GeoJSON FeatureCollection for a MapLayer (legacy vector layers). + + Map layer id + + + + Returns a summary of all map layers that are on by default for the department. + + + + + Unified search across indoor zones and custom map regions. + + Search term + Filter: "all" (default), "indoor", or "custom" + + + + Returns indoor maps whose bounding box is within radiusMeters of the given lat/lon. + + Latitude + Longitude + Search radius in meters + + + + Returns all zones for an indoor map floor as a GeoJSON FeatureCollection for direct rnmapbox consumption. + + Indoor map floor id + + + + Returns all regions for a custom map layer as a GeoJSON FeatureCollection for direct rnmapbox consumption. + + Custom map layer id + Messaging system interaction @@ -1076,6 +1114,48 @@ Acknowledge a deviation + + + Gets all stops for an active route instance, merging plan data with execution state + + + + + Gets the full contact record for a route stop's assigned contact + + + + + Gets all unique contacts attached to stops on a route plan + + + + + Gets the stored route geometry and waypoints for a route plan + + + + + Gets the route geometry and remaining waypoints for an active route instance. + Pass the unit's current latitude and longitude as query parameters to route from + the current position rather than the first remaining stop. + + + + + Updates notes on an active route instance stop + + + + + Gets all active route instances across the department with progress details + + + + + Gets all active schedules for the department's route plans + + SCIM 2.0 provisioning endpoint for automated user lifecycle management @@ -5973,6 +6053,11 @@ Can the API services talk to the cache + + + GeoJSON FeatureCollection string ready for direct rnmapbox ShapeSource consumption + + Response Data @@ -6013,6 +6098,11 @@ Default constructor + + + GeoJSON FeatureCollection string ready for direct rnmapbox ShapeSource consumption + + Response Data @@ -6043,6 +6133,9 @@ Map Layers + + "indoor_zone" or "custom_region" + The result of deleting a message @@ -7233,6 +7326,15 @@ Response Data + + Route profile to use for turn-by-turn navigation: driving, walking, cycling, driving-traffic. + + + Origin latitude supplied by the caller (unit's current location). Null means start from the first waypoint. + + + Origin longitude supplied by the caller (unit's current location). Null means start from the first waypoint. + Response Data diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RoutesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RoutesController.cs index 7607bb29..d750ce4b 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RoutesController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RoutesController.cs @@ -36,7 +36,13 @@ public RoutesController(IRouteService routeService, IUnitsService unitsService, public async Task Index() { var model = new RouteIndexView(); - model.Plans = await _routeService.GetRoutePlansForDepartmentAsync(DepartmentId); + var allPlans = await _routeService.GetRoutePlansForDepartmentAsync(DepartmentId); + model.Plans = allPlans.Where(p => p.RouteStatus != (int)RouteStatus.Archived).ToList(); + foreach (var plan in model.Plans) + { + var stops = await _routeService.GetRouteStopsForPlanAsync(plan.RoutePlanId); + model.StopCounts[plan.RoutePlanId] = stops.Count; + } return View(model); } @@ -143,6 +149,8 @@ public async Task Edit(string id) var plan = await _routeService.GetRoutePlanByIdAsync(id); if (plan == null || plan.DepartmentId != DepartmentId) return RedirectToAction("Index"); + if (plan.RouteStatus == (int)RouteStatus.Archived) + return RedirectToAction("ArchivedView", new { id }); var model = new RouteEditView(); model.Plan = plan; @@ -163,6 +171,8 @@ public async Task Edit(RouteEditView model, CancellationToken can var existing = await _routeService.GetRoutePlanByIdAsync(model.Plan.RoutePlanId); if (existing == null || existing.DepartmentId != DepartmentId) return RedirectToAction("Index"); + if (existing.RouteStatus == (int)RouteStatus.Archived) + return RedirectToAction("ArchivedView", new { id = model.Plan.RoutePlanId }); model.Plan.DepartmentId = DepartmentId; model.Plan.UpdatedById = UserId; @@ -186,7 +196,7 @@ public async Task Edit(RouteEditView model, CancellationToken can public async Task Delete(string id, CancellationToken cancellationToken) { var plan = await _routeService.GetRoutePlanByIdAsync(id); - if (plan != null && plan.DepartmentId == DepartmentId) + if (plan != null && plan.DepartmentId == DepartmentId && plan.RouteStatus != (int)RouteStatus.Archived) { await _routeService.DeleteRoutePlanAsync(id, cancellationToken); } @@ -343,6 +353,83 @@ public async Task DeleteStop(string stopId, CancellationToken can return Json(new { success = deleted }); } + [HttpGet] + [Authorize(Policy = ResgridResources.Route_Update)] + public async Task StartRoute(string id) + { + var plan = await _routeService.GetRoutePlanByIdAsync(id); + if (plan == null || plan.DepartmentId != DepartmentId) + return RedirectToAction("Index"); + if (plan.RouteStatus != (int)RouteStatus.Active) + return RedirectToAction("View", new { id }); + + var model = new RouteStartView(); + model.Plan = plan; + model.RoutePlanId = id; + model.Units = (await _unitsService.GetUnitsForDepartmentAsync(DepartmentId)).ToList(); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Route_Update)] + public async Task StartRoute(RouteStartView model, CancellationToken cancellationToken) + { + var plan = await _routeService.GetRoutePlanByIdAsync(model.RoutePlanId); + if (plan == null || plan.DepartmentId != DepartmentId) + return RedirectToAction("Index"); + if (plan.RouteStatus != (int)RouteStatus.Active) + return RedirectToAction("View", new { id = model.RoutePlanId }); + + var instance = await _routeService.StartRouteAsync(model.RoutePlanId, model.SelectedUnitId, UserId, cancellationToken); + return RedirectToAction("InstanceDetail", new { instanceId = instance.RouteInstanceId }); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Route_View)] + public async Task Directions(string id) + { + var plan = await _routeService.GetRoutePlanByIdAsync(id); + if (plan == null || plan.DepartmentId != DepartmentId) + return RedirectToAction("Index"); + if (plan.RouteStatus == (int)RouteStatus.Archived) + return RedirectToAction("ArchivedView", new { id }); + + var model = new RouteDirectionsView(); + model.Plan = plan; + model.Stops = (await _routeService.GetRouteStopsForPlanAsync(id)).OrderBy(s => s.StopOrder).ToList(); + return View(model); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Route_View)] + public async Task ArchivedRoutes() + { + var model = new ArchivedRouteIndexView(); + var allPlans = await _routeService.GetRoutePlansForDepartmentAsync(DepartmentId); + model.Plans = allPlans.Where(p => p.RouteStatus == (int)RouteStatus.Archived).ToList(); + foreach (var plan in model.Plans) + { + var stops = await _routeService.GetRouteStopsForPlanAsync(plan.RoutePlanId); + model.StopCounts[plan.RoutePlanId] = stops.Count; + } + return View(model); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Route_View)] + public async Task ArchivedView(string id) + { + var plan = await _routeService.GetRoutePlanByIdAsync(id); + if (plan == null || plan.DepartmentId != DepartmentId || plan.RouteStatus != (int)RouteStatus.Archived) + return RedirectToAction("ArchivedRoutes"); + + var model = new RouteDetailView(); + model.Plan = plan; + model.Stops = await _routeService.GetRouteStopsForPlanAsync(id); + return View(model); + } + [HttpGet] [Authorize(Policy = ResgridResources.Route_View)] public async Task InstanceDetail(string instanceId) diff --git a/Web/Resgrid.Web/Areas/User/Models/Routes/RouteViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Routes/RouteViewModels.cs index b1923f7d..83f81e66 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Routes/RouteViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Routes/RouteViewModels.cs @@ -6,7 +6,23 @@ namespace Resgrid.Web.Areas.User.Models.Routes public class RouteIndexView : BaseUserModel { public List Plans { get; set; } - public RouteIndexView() { Plans = new List(); } + public Dictionary StopCounts { get; set; } + public RouteIndexView() + { + Plans = new List(); + StopCounts = new Dictionary(); + } + } + + public class ArchivedRouteIndexView : BaseUserModel + { + public List Plans { get; set; } + public Dictionary StopCounts { get; set; } + public ArchivedRouteIndexView() + { + Plans = new List(); + StopCounts = new Dictionary(); + } } public class RouteNewView : BaseUserModel @@ -104,4 +120,28 @@ public RouteInstanceDetailView() Stops = new List(); } } + + public class RouteStartView : BaseUserModel + { + public RoutePlan Plan { get; set; } + public List Units { get; set; } + public string RoutePlanId { get; set; } + public int SelectedUnitId { get; set; } + public RouteStartView() + { + Plan = new RoutePlan(); + Units = new List(); + } + } + + public class RouteDirectionsView : BaseUserModel + { + public RoutePlan Plan { get; set; } + public List Stops { get; set; } + public RouteDirectionsView() + { + Plan = new RoutePlan(); + Stops = new List(); + } + } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/ArchivedRoutes.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/ArchivedRoutes.cshtml new file mode 100644 index 00000000..5c141a0e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/ArchivedRoutes.cshtml @@ -0,0 +1,73 @@ +@model Resgrid.Web.Areas.User.Models.Routes.ArchivedRouteIndexView +@using Resgrid.Model +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["ArchivedRoutesPageTitle"]; +} + +
+
+

@localizer["ArchivedRoutesHeader"]

+ +
+
+ +
+
+
+
+
+ @if (Model.Plans.Count == 0) + { +
+

@localizer["NoArchivedRoutes"]

+

@localizer["NoArchivedRoutesMessage"]

+
+ } + else + { +
+ + + + + + + + + + + + @foreach (var plan in Model.Plans) + { + + + + + + + + } + +
@localizer["Name"]@localizer["Stops"]@localizer["Profile"]@localizer["Created"]@localizer["Actions"]
@plan.Name@(Model.StopCounts.TryGetValue(plan.RoutePlanId, out var sc) ? sc.ToString() : "0")@(plan.MapboxRouteProfile ?? "driving")@plan.AddedOn.ToString("yyyy-MM-dd") + + @localizer["View"] + +
+
+ } +
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/ArchivedView.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/ArchivedView.cshtml new file mode 100644 index 00000000..5d16ffcc --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/ArchivedView.cshtml @@ -0,0 +1,100 @@ +@model Resgrid.Web.Areas.User.Models.Routes.RouteDetailView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["ArchivedRouteDetailPageTitle"]; +} + +
+
+

@Model.Plan.Name

+ +
+
+ +
+
+
+
+
@localizer["RouteMapTitle"]
+
+
+
+
+
+
+
+
@localizer["DetailsTitle"]
+
+
+ This route is archived and cannot be started, edited, or deleted. +
+
+
@localizer["StatusLabel"]
+
@((Resgrid.Model.RouteStatus)Model.Plan.RouteStatus)
+
@localizer["ProfileLabel"]
+
@(Model.Plan.MapboxRouteProfile ?? "driving")
+
@localizer["GeofenceLabel"]
+
@Model.Plan.GeofenceRadiusMeters m
+ @if (Model.Plan.EstimatedDistanceMeters.HasValue) + { +
@localizer["DistanceLabel"]
+
@(Math.Round(Model.Plan.EstimatedDistanceMeters.Value / 1000, 1)) km
+ } + @if (Model.Plan.EstimatedDurationSeconds.HasValue) + { +
@localizer["DurationLabel"]
+
@(Math.Round(Model.Plan.EstimatedDurationSeconds.Value / 60, 0)) min
+ } +
+
+
+ +
+
@localizer["Stops"] (@Model.Stops.Count)
+
+
+ @foreach (var stop in Model.Stops.OrderBy(s => s.StopOrder)) + { +
+
+ @(stop.StopOrder + 1). @stop.Name + @if (!string.IsNullOrEmpty(stop.Address)) + { +
@stop.Address + } +
+
+ } +
+
+
+ + +
+
+
+ +@section Scripts { + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/Directions.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/Directions.cshtml new file mode 100644 index 00000000..a15a9959 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/Directions.cshtml @@ -0,0 +1,223 @@ +@model Resgrid.Web.Areas.User.Models.Routes.RouteDirectionsView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | Directions - " + Model.Plan.Name; +} + + + +
+
+

Directions — @Model.Plan.Name

+ +
+
+
+ + + Start Route + +
+
+
+ +
+
+
+
+
+
@Model.Plan.Name
+ +
+
+
+
+
+
+
+ +
+
+
+
+
Turn-by-Turn Directions
+
+
+
+ +

Calculating route…

+
+
+ + Could not retrieve driving directions. The stop addresses or coordinates may not be reachable by road. + The stops are listed below in order. +
+
+ + @* Fallback stop list — always shown below directions *@ +
+

Stop Summary

+ + + + + + + @if (Model.Stops.Any(s => s.PlannedArrivalTime.HasValue)) + { + + } + @if (Model.Stops.Any(s => s.EstimatedDwellMinutes.HasValue)) + { + + } + @if (Model.Stops.Any(s => !string.IsNullOrEmpty(s.ContactName))) + { + + } + @if (Model.Stops.Any(s => !string.IsNullOrEmpty(s.Notes))) + { + + } + + + + @{ int stopNum = 0; } + @foreach (var stop in Model.Stops) + { + stopNum++; + + + + + @if (Model.Stops.Any(s => s.PlannedArrivalTime.HasValue)) + { + + } + @if (Model.Stops.Any(s => s.EstimatedDwellMinutes.HasValue)) + { + + } + @if (Model.Stops.Any(s => !string.IsNullOrEmpty(s.ContactName))) + { + + } + @if (Model.Stops.Any(s => !string.IsNullOrEmpty(s.Notes))) + { + + } + + } + +
#Stop NameAddressPlanned ArrivalDwell (min)ContactNotes
@stopNum@stop.Name@(stop.Address ?? $"{stop.Latitude}, {stop.Longitude}")@(stop.PlannedArrivalTime?.ToString("yyyy-MM-dd HH:mm") ?? "-")@(stop.EstimatedDwellMinutes?.ToString() ?? "-") + @if (!string.IsNullOrEmpty(stop.ContactName)) + { + @stop.ContactName + if (!string.IsNullOrEmpty(stop.ContactNumber)) + { +
@stop.ContactNumber + } + } + else { - } +
@(stop.Notes ?? "-")
+
+
+
+
+
+ +@section Scripts { + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/Edit.cshtml index d8db16f7..c4236ffc 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Routes/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/Edit.cshtml @@ -94,10 +94,10 @@
+ + + + + +
@@ -294,6 +346,10 @@ var editStops =@Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(Model.Stops.OrderBy(s => s.StopOrder).Select(s => new { id = s.RouteStopId, s.Name, lat = s.Latitude, lng = s.Longitude }))); var routePlanId = '@Model.Plan.RoutePlanId'; var availableContacts = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject((Model.Contacts ?? Enumerable.Empty()).Select(c => new { id = c.ContactId ?? "", name = c.GetName() ?? "", phone = c.CellPhoneNumber ?? c.OfficePhoneNumber ?? c.HomePhoneNumber ?? "" }), new Newtonsoft.Json.JsonSerializerSettings { StringEscapeHandling = Newtonsoft.Json.StringEscapeHandling.EscapeHtml })); + var existingStartLat = @(Model.Plan.StartLatitude.HasValue ? Model.Plan.StartLatitude.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "null"); + var existingStartLng = @(Model.Plan.StartLongitude.HasValue ? Model.Plan.StartLongitude.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "null"); + var existingEndLat = @(Model.Plan.EndLatitude.HasValue ? Model.Plan.EndLatitude.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "null"); + var existingEndLng = @(Model.Plan.EndLongitude.HasValue ? Model.Plan.EndLongitude.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "null"); } diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/Index.cshtml index 7ac3cdf5..c3584f03 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Routes/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/Index.cshtml @@ -21,6 +21,7 @@
@@ -48,7 +49,7 @@ @plan.Name @((RouteStatus)plan.RouteStatus) - - + @(Model.StopCounts.TryGetValue(plan.RoutePlanId, out var sc) ? sc.ToString() : "0") @(plan.MapboxRouteProfile ?? "driving") @plan.AddedOn.ToString("yyyy-MM-dd") @@ -58,6 +59,15 @@ @localizer["Edit"] + @if (plan.RouteStatus == (int)RouteStatus.Active) + { + + Start + + } + + Directions + @localizer["History"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/New.cshtml index 0c71398e..944a8441 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Routes/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/New.cshtml @@ -93,10 +93,10 @@
+ + + + + +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/StartRoute.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/StartRoute.cshtml new file mode 100644 index 00000000..78a29fd4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/StartRoute.cshtml @@ -0,0 +1,84 @@ +@model Resgrid.Web.Areas.User.Models.Routes.RouteStartView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | Start Route"; +} + +
+
+

Start Route — @Model.Plan.Name

+ +
+
+ +
+
+
+
+
+
Start Route: @Model.Plan.Name
+
+
+
+ @Html.AntiForgeryToken() + + +
+ +
+ + Select the unit that will execute this route. +
+
+ + @if (Model.Plan.EstimatedDistanceMeters.HasValue || Model.Plan.EstimatedDurationSeconds.HasValue) + { +
+ +
+ @if (Model.Plan.EstimatedDistanceMeters.HasValue) + { + + + @(Math.Round(Model.Plan.EstimatedDistanceMeters.Value / 1000, 1)) km + +   + } + @if (Model.Plan.EstimatedDurationSeconds.HasValue) + { + + + @(Math.Round(Model.Plan.EstimatedDurationSeconds.Value / 60, 0)) min estimated + + } +
+
+ } + +
+
+ + + Cancel + +
+
+
+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/View.cshtml index 14938540..12787e3e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Routes/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/View.cshtml @@ -5,7 +5,7 @@ }
-
+

@Model.Plan.Name

+
+
+ @if (Model.Plan.RouteStatus == (int)Resgrid.Model.RouteStatus.Active) + { + + Start Route + + } + @if (Model.Plan.RouteStatus != (int)Resgrid.Model.RouteStatus.Archived) + { + + Directions + + + @localizer["Edit"] + + } +
+
diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.directions.js b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.directions.js new file mode 100644 index 00000000..4545a322 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.directions.js @@ -0,0 +1,221 @@ +$(document).ready(function () { + + function escapeHtml(str) { + return String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function formatDist(meters) { + if (meters >= 1000) return (meters / 1000).toFixed(1) + ' km'; + return Math.round(meters) + ' m'; + } + + function formatDuration(seconds) { + var h = Math.floor(seconds / 3600); + var m = Math.round((seconds % 3600) / 60); + if (h > 0) return h + ' hr ' + m + ' min'; + return m + ' min'; + } + + // Direction icon mapping by OSRM maneuver + function stepIcon(type, modifier) { + if (type === 'depart') return 'fa-flag-o'; + if (type === 'arrive') return 'fa-map-marker'; + if (type === 'roundabout' || type === 'rotary') return 'fa-refresh'; + if (!modifier) return 'fa-arrow-up'; + switch (modifier) { + case 'left': return 'fa-arrow-left'; + case 'slight left': return 'fa-arrow-left'; + case 'sharp left': return 'fa-arrow-left'; + case 'right': return 'fa-arrow-right'; + case 'slight right':return 'fa-arrow-right'; + case 'sharp right': return 'fa-arrow-right'; + case 'uturn': return 'fa-undo'; + default: return 'fa-arrow-up'; + } + } + + // Build human-readable instruction from OSRM step + function buildInstruction(step) { + var type = step.maneuver.type; + var modifier = step.maneuver.modifier || ''; + var name = step.name ? ' onto ' + escapeHtml(step.name) + '' : ''; + + if (type === 'depart') return 'Head ' + escapeHtml(modifier) + name; + if (type === 'arrive') return 'Arrive at destination' + (step.name ? ': ' + escapeHtml(step.name) + '' : ''); + if (type === 'continue' || type === 'new name') return 'Continue' + name; + if (type === 'merge') return 'Merge ' + escapeHtml(modifier) + name; + if (type === 'fork') return 'At fork, keep ' + escapeHtml(modifier) + name; + if (type === 'off ramp')return 'Take the ramp ' + escapeHtml(modifier) + name; + if (type === 'on ramp') return 'Take the on-ramp ' + escapeHtml(modifier) + name; + if (type === 'end of road') return 'At end of road turn ' + escapeHtml(modifier) + name; + if (type === 'roundabout' || type === 'rotary') return 'Enter roundabout' + name; + if (type === 'exit roundabout' || type === 'exit rotary') return 'Exit roundabout' + name; + if (type === 'turn') { + var dir = modifier ? modifier.replace('slight ', 'slightly ').replace('sharp ', 'sharply ') : ''; + return 'Turn ' + escapeHtml(dir) + name; + } + return 'Continue' + name; + } + + // ── Map setup ────────────────────────────────────────────────────────────── + var tiles = L.tileLayer(osmTileUrl, { maxZoom: 19, attribution: osmTileAttribution }); + var map = L.map('routeMap', { scrollWheelZoom: false }) + .setView([39.8283, -98.5795], 4) + .addLayer(tiles); + + var markers = []; + var group = []; + + // Add start point marker if a configured origin exists + if (typeof startPoint !== 'undefined' && startPoint && + Number.isFinite(Number(startPoint.lat)) && Number.isFinite(Number(startPoint.lng))) { + var startIcon = L.divIcon({ className: '', html: '
S
', iconSize: [28, 28], iconAnchor: [14, 14] }); + var sm = L.marker([Number(startPoint.lat), Number(startPoint.lng)], { icon: startIcon }).addTo(map); + sm.bindTooltip('Start', { permanent: true, direction: 'right' }); + group.push(sm); + } + + if (typeof routeStops !== 'undefined' && routeStops.length > 0) { + routeStops.forEach(function (stop, index) { + var lat = Number(stop.lat); + var lng = Number(stop.lng); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; + + var m = L.marker([lat, lng]).addTo(map); + m.bindTooltip((index + 1) + '. ' + escapeHtml(stop.name), { permanent: true, direction: 'right' }); + m.bindPopup('' + (index + 1) + '. ' + escapeHtml(stop.name) + '' + + (stop.address ? '
' + escapeHtml(stop.address) + '' : '')); + markers.push(m); + group.push(m); + }); + + if (group.length > 0) { + map.fitBounds(L.featureGroup(group).getBounds(), { padding: [60, 60] }); + } + } + + // ── OSRM routing ────────────────────────────────────────────────────────── + var validStops = routeStops.filter(function (s) { + return Number.isFinite(Number(s.lat)) && Number.isFinite(Number(s.lng)); + }); + + // Prepend the plan's configured start point (or last checked-in location) + // when one is available so routing begins from there rather than stop #1. + if (typeof startPoint !== 'undefined' && startPoint && + Number.isFinite(Number(startPoint.lat)) && Number.isFinite(Number(startPoint.lng))) { + validStops = [{ name: startPoint.name || 'Start', address: '', lat: startPoint.lat, lng: startPoint.lng }].concat(validStops); + } + + if (validStops.length < 2) { + $('#directionsError').show(); + return; + } + + // Map route profile names to OSRM profile names. + // driving-traffic falls back to driving (OSRM has no live traffic layer). + var osrmProfile = 'driving'; + if (routeProfile === 'walking' || routeProfile === 'foot') osrmProfile = 'foot'; + else if (routeProfile === 'cycling' || routeProfile === 'bike') osrmProfile = 'bike'; + // driving and driving-traffic both use the OSRM 'driving' profile + + // OSRM uses lon,lat order + var coords = validStops.map(function (s) { return Number(s.lng) + ',' + Number(s.lat); }).join(';'); + var osrmUrl = 'https://router.project-osrm.org/route/v1/' + osrmProfile + '/' + coords + + '?steps=true&geometries=geojson&overview=full&annotations=false'; + + $('#directionsLoading').show(); + + fetch(osrmUrl) + .then(function (r) { + if (!r.ok) throw new Error('OSRM error: ' + r.status); + return r.json(); + }) + .then(function (data) { + $('#directionsLoading').hide(); + + if (!data || data.code !== 'Ok' || !data.routes || !data.routes[0]) { + $('#directionsError').show(); + return; + } + + var route = data.routes[0]; + var totalDist = route.distance; + var totalDur = route.duration; + + // Summary + $('#routeSummary').text(formatDist(totalDist) + ' \u2022 ' + formatDuration(totalDur)); + + // Draw route polyline + if (route.geometry) { + L.geoJSON(route.geometry, { + style: { color: routeColor || '#1c84c6', weight: 5, opacity: 0.75 } + }).addTo(map); + map.fitBounds(L.geoJSON(route.geometry).getBounds(), { padding: [50, 50] }); + } + + // Build turn-by-turn HTML + var legs = route.legs; // one leg per segment between consecutive waypoints + var html = ''; + var stepNum = 0; + + legs.forEach(function (leg, legIndex) { + // Stop header (destination of this leg = next stop) + var destStop = validStops[legIndex + 1]; + if (destStop) { + html += '
' + + '' + (legIndex + 2) + '' + + escapeHtml(destStop.name) + + (destStop.address ? ' — ' + escapeHtml(destStop.address) + '' : '') + + ' ' + formatDist(leg.distance) + ' • ' + formatDuration(leg.duration) + '' + + '
'; + } else if (legIndex === 0) { + var startStop = validStops[0]; + html = '
' + + '1' + + escapeHtml(startStop.name) + + (startStop.address ? ' — ' + escapeHtml(startStop.address) + '' : '') + + '
' + html; + } + + if (leg.steps && leg.steps.length > 0) { + leg.steps.forEach(function (step) { + if (step.distance < 5 && step.maneuver.type !== 'depart' && step.maneuver.type !== 'arrive') return; + stepNum++; + var icon = stepIcon(step.maneuver.type, step.maneuver.modifier); + html += '
' + + '' + formatDist(step.distance) + '' + + '' + stepNum + '' + + '' + + buildInstruction(step) + + '
'; + }); + } + }); + + // Prepend start stop header if it wasn't added yet + if (validStops.length > 0 && html.indexOf('stop-num">1<') === -1) { + var startStop = validStops[0]; + html = '
' + + '1' + + escapeHtml(startStop.name) + + (startStop.address ? ' — ' + escapeHtml(startStop.address) + '' : '') + + '
' + html; + } + + if (!html) { + html = '

No detailed steps available for this route.

'; + } + + $('#directionsContainer').html(html); + }) + .catch(function (err) { + $('#directionsLoading').hide(); + $('#directionsError').show(); + console.error('Routing error:', err); + }); +}); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.edit.js b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.edit.js index e263549a..c653af81 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.edit.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.edit.js @@ -1,5 +1,7 @@ $(document).ready(function () { var map, stopPickerMap, stopMarker, pickerMarker; + var startPickerMap, startMarker, startPickerInitialized = false; + var endPickerMap, endMarker, endPickerInitialized = false; var antiForgeryToken = $('input[name="__RequestVerificationToken"]').first().val(); function getAuthToken() { @@ -80,8 +82,7 @@ $(document).ready(function () { reverseGeocodeStop(pos.lat, pos.lng); }); } - stopPickerMap.panTo(new L.LatLng(lat, lng)); - stopPickerMap.setZoom(15); + stopPickerMap.setView(new L.LatLng(lat, lng), 15); if (reverseGeocode) { reverseGeocodeStop(lat, lng); } @@ -98,6 +99,174 @@ $(document).ready(function () { .catch(function (err) { console.error('Stop reverse geocode error:', err); }); } + // ── Start/End location pickers ──────────────────────────────────────────── + function initStartPickerMap(initialLat, initialLng) { + if (startPickerInitialized) { + setTimeout(function () { startPickerMap.invalidateSize(); }, 50); + return; + } + var tiles = L.tileLayer(osmTileUrl, { maxZoom: 19, attribution: osmTileAttribution }); + startPickerMap = L.map('startPickerMap', { scrollWheelZoom: false }) + .setView([39.8283, -98.5795], 4) + .addLayer(tiles); + startPickerMap.on('click', function (e) { setStartLocation(e.latlng.lat, e.latlng.lng); }); + startPickerInitialized = true; + if (initialLat && initialLng) { setStartLocation(initialLat, initialLng); } + } + + function setStartLocation(lat, lng) { + var latStr = parseFloat(lat).toFixed(6); + var lngStr = parseFloat(lng).toFixed(6); + $('#startLat').val(latStr); + $('#startLng').val(lngStr); + $('#startLatDisplay').val(latStr); + $('#startLngDisplay').val(lngStr); + if (startMarker) { + startMarker.setLatLng(new L.LatLng(lat, lng)); + } else { + startMarker = L.marker(new L.LatLng(lat, lng), { draggable: true }).addTo(startPickerMap); + startMarker.on('dragend', function (event) { + var pos = event.target.getLatLng(); + setStartLocation(pos.lat, pos.lng); + }); + } + startPickerMap.setView(new L.LatLng(lat, lng), 15); + } + + function initEndPickerMap(initialLat, initialLng) { + if (endPickerInitialized) { + setTimeout(function () { endPickerMap.invalidateSize(); }, 50); + return; + } + var tiles = L.tileLayer(osmTileUrl, { maxZoom: 19, attribution: osmTileAttribution }); + endPickerMap = L.map('endPickerMap', { scrollWheelZoom: false }) + .setView([39.8283, -98.5795], 4) + .addLayer(tiles); + endPickerMap.on('click', function (e) { setEndLocation(e.latlng.lat, e.latlng.lng); }); + endPickerInitialized = true; + if (initialLat && initialLng) { setEndLocation(initialLat, initialLng); } + } + + function setEndLocation(lat, lng) { + var latStr = parseFloat(lat).toFixed(6); + var lngStr = parseFloat(lng).toFixed(6); + $('#endLat').val(latStr); + $('#endLng').val(lngStr); + $('#endLatDisplay').val(latStr); + $('#endLngDisplay').val(lngStr); + if (endMarker) { + endMarker.setLatLng(new L.LatLng(lat, lng)); + } else { + endMarker = L.marker(new L.LatLng(lat, lng), { draggable: true }).addTo(endPickerMap); + endMarker.on('dragend', function (event) { + var pos = event.target.getLatLng(); + setEndLocation(pos.lat, pos.lng); + }); + } + endPickerMap.setView(new L.LatLng(lat, lng), 15); + } + + function updateLocationPickerVisibility() { + if (!$('#chkUseStationAsStart').prop('checked')) { + $('#startLocationGroup').show(); + initStartPickerMap( + (typeof existingStartLat !== 'undefined' && existingStartLat) ? existingStartLat : null, + (typeof existingStartLng !== 'undefined' && existingStartLng) ? existingStartLng : null + ); + } else { + $('#startLocationGroup').hide(); + } + if (!$('#chkUseStationAsEnd').prop('checked')) { + $('#endLocationGroup').show(); + initEndPickerMap( + (typeof existingEndLat !== 'undefined' && existingEndLat) ? existingEndLat : null, + (typeof existingEndLng !== 'undefined' && existingEndLng) ? existingEndLng : null + ); + } else { + $('#endLocationGroup').hide(); + } + } + + $('#chkUseStationAsStart').on('change', updateLocationPickerVisibility); + $('#chkUseStationAsEnd').on('change', updateLocationPickerVisibility); + + // Start – address geocode + $('#startAddress').on('keypress', function (e) { if (e.keyCode === 13) { $('#geocodeStartBtn').click(); return false; } }); + $('#geocodeStartBtn').on('click', function (evt) { + evt.preventDefault(); + var where = $.trim($('#startAddress').val()); + if (!where) return; + fetch(resgrid.absoluteApiBaseUrl + '/api/v4/Geocoding/ForwardGeocode?address=' + encodeURIComponent(where), { headers: { 'Authorization': 'Bearer ' + getAuthToken() } }) + .then(function (r) { return r.json(); }) + .then(function (result) { + if (result && result.Data && result.Data.Latitude && result.Data.Longitude) { + setStartLocation(result.Data.Latitude, result.Data.Longitude); + } else { alert('Address not found.'); } + }) + .catch(function (err) { console.error('Geocode error:', err); }); + }); + + // Start – What3Words + $('#startW3W').on('keypress', function (e) { if (e.keyCode === 13) { $('#findStartW3WBtn').click(); return false; } }); + $('#findStartW3WBtn').on('click', function (evt) { + evt.preventDefault(); + var word = $.trim($('#startW3W').val()); + if (!word) return; + $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetCoordinatesFromW3W?words=' + encodeURIComponent(word), type: 'GET' }) + .done(function (data) { + if (data && data.Latitude && data.Longitude) { setStartLocation(data.Latitude, data.Longitude); } + else { alert('What3Words was unable to find a location for those words.'); } + }); + }); + + // Start – manual lat/lng + $('#applyStartLatLngBtn').on('click', function () { + var lat = parseFloat($('#startLatDisplay').val()); + var lng = parseFloat($('#startLngDisplay').val()); + if (isNaN(lat) || isNaN(lng)) { alert('Please enter valid coordinates.'); return; } + setStartLocation(lat, lng); + }); + + // End – address geocode + $('#endAddress').on('keypress', function (e) { if (e.keyCode === 13) { $('#geocodeEndBtn').click(); return false; } }); + $('#geocodeEndBtn').on('click', function (evt) { + evt.preventDefault(); + var where = $.trim($('#endAddress').val()); + if (!where) return; + fetch(resgrid.absoluteApiBaseUrl + '/api/v4/Geocoding/ForwardGeocode?address=' + encodeURIComponent(where), { headers: { 'Authorization': 'Bearer ' + getAuthToken() } }) + .then(function (r) { return r.json(); }) + .then(function (result) { + if (result && result.Data && result.Data.Latitude && result.Data.Longitude) { + setEndLocation(result.Data.Latitude, result.Data.Longitude); + } else { alert('Address not found.'); } + }) + .catch(function (err) { console.error('Geocode error:', err); }); + }); + + // End – What3Words + $('#endW3W').on('keypress', function (e) { if (e.keyCode === 13) { $('#findEndW3WBtn').click(); return false; } }); + $('#findEndW3WBtn').on('click', function (evt) { + evt.preventDefault(); + var word = $.trim($('#endW3W').val()); + if (!word) return; + $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetCoordinatesFromW3W?words=' + encodeURIComponent(word), type: 'GET' }) + .done(function (data) { + if (data && data.Latitude && data.Longitude) { setEndLocation(data.Latitude, data.Longitude); } + else { alert('What3Words was unable to find a location for those words.'); } + }); + }); + + // End – manual lat/lng + $('#applyEndLatLngBtn').on('click', function () { + var lat = parseFloat($('#endLatDisplay').val()); + var lng = parseFloat($('#endLngDisplay').val()); + if (isNaN(lat) || isNaN(lng)) { alert('Please enter valid coordinates.'); return; } + setEndLocation(lat, lng); + }); + + // Initialise visibility on page load (pre-populates from existingStart/EndLat/Lng if set) + updateLocationPickerVisibility(); + // ── Populate contact picker ─────────────────────────────────────────────── function populateContactPicker() { var $sel = $('#stopContactPicker'); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.new.js b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.new.js index 01668487..e0b67b53 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.new.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.new.js @@ -1,5 +1,7 @@ $(document).ready(function () { var map, stopPickerMap, pickerMarker; + var startPickerMap, startMarker, startPickerInitialized = false; + var endPickerMap, endMarker, endPickerInitialized = false; var pendingStops = []; var stopIdCounter = 0; @@ -74,8 +76,7 @@ $(document).ready(function () { reverseGeocodeStop(pos.lat, pos.lng); }); } - stopPickerMap.panTo(new L.LatLng(lat, lng)); - stopPickerMap.setZoom(15); + stopPickerMap.setView(new L.LatLng(lat, lng), 15); if (reverseGeocode) { reverseGeocodeStop(lat, lng); } @@ -92,6 +93,168 @@ $(document).ready(function () { .catch(function (err) { console.error('Stop reverse geocode error:', err); }); } + // ── Start/End location pickers ──────────────────────────────────────────── + function initStartPickerMap(initialLat, initialLng) { + if (startPickerInitialized) { + setTimeout(function () { startPickerMap.invalidateSize(); }, 50); + return; + } + var tiles = L.tileLayer(osmTileUrl, { maxZoom: 19, attribution: osmTileAttribution }); + startPickerMap = L.map('startPickerMap', { scrollWheelZoom: false }) + .setView([39.8283, -98.5795], 4) + .addLayer(tiles); + startPickerMap.on('click', function (e) { setStartLocation(e.latlng.lat, e.latlng.lng); }); + startPickerInitialized = true; + if (initialLat && initialLng) { setStartLocation(initialLat, initialLng); } + } + + function setStartLocation(lat, lng) { + var latStr = parseFloat(lat).toFixed(6); + var lngStr = parseFloat(lng).toFixed(6); + $('#startLat').val(latStr); + $('#startLng').val(lngStr); + $('#startLatDisplay').val(latStr); + $('#startLngDisplay').val(lngStr); + if (startMarker) { + startMarker.setLatLng(new L.LatLng(lat, lng)); + } else { + startMarker = L.marker(new L.LatLng(lat, lng), { draggable: true }).addTo(startPickerMap); + startMarker.on('dragend', function (event) { + var pos = event.target.getLatLng(); + setStartLocation(pos.lat, pos.lng); + }); + } + startPickerMap.setView(new L.LatLng(lat, lng), 15); + } + + function initEndPickerMap(initialLat, initialLng) { + if (endPickerInitialized) { + setTimeout(function () { endPickerMap.invalidateSize(); }, 50); + return; + } + var tiles = L.tileLayer(osmTileUrl, { maxZoom: 19, attribution: osmTileAttribution }); + endPickerMap = L.map('endPickerMap', { scrollWheelZoom: false }) + .setView([39.8283, -98.5795], 4) + .addLayer(tiles); + endPickerMap.on('click', function (e) { setEndLocation(e.latlng.lat, e.latlng.lng); }); + endPickerInitialized = true; + if (initialLat && initialLng) { setEndLocation(initialLat, initialLng); } + } + + function setEndLocation(lat, lng) { + var latStr = parseFloat(lat).toFixed(6); + var lngStr = parseFloat(lng).toFixed(6); + $('#endLat').val(latStr); + $('#endLng').val(lngStr); + $('#endLatDisplay').val(latStr); + $('#endLngDisplay').val(lngStr); + if (endMarker) { + endMarker.setLatLng(new L.LatLng(lat, lng)); + } else { + endMarker = L.marker(new L.LatLng(lat, lng), { draggable: true }).addTo(endPickerMap); + endMarker.on('dragend', function (event) { + var pos = event.target.getLatLng(); + setEndLocation(pos.lat, pos.lng); + }); + } + endPickerMap.setView(new L.LatLng(lat, lng), 15); + } + + function updateLocationPickerVisibility() { + if (!$('#chkUseStationAsStart').prop('checked')) { + $('#startLocationGroup').show(); + initStartPickerMap(); + } else { + $('#startLocationGroup').hide(); + } + if (!$('#chkUseStationAsEnd').prop('checked')) { + $('#endLocationGroup').show(); + initEndPickerMap(); + } else { + $('#endLocationGroup').hide(); + } + } + + $('#chkUseStationAsStart').on('change', updateLocationPickerVisibility); + $('#chkUseStationAsEnd').on('change', updateLocationPickerVisibility); + + // Start – address geocode + $('#startAddress').on('keypress', function (e) { if (e.keyCode === 13) { $('#geocodeStartBtn').click(); return false; } }); + $('#geocodeStartBtn').on('click', function (evt) { + evt.preventDefault(); + var where = $.trim($('#startAddress').val()); + if (!where) return; + fetch(resgrid.absoluteApiBaseUrl + '/api/v4/Geocoding/ForwardGeocode?address=' + encodeURIComponent(where), { headers: { 'Authorization': 'Bearer ' + getAuthToken() } }) + .then(function (r) { return r.json(); }) + .then(function (result) { + if (result && result.Data && result.Data.Latitude && result.Data.Longitude) { + setStartLocation(result.Data.Latitude, result.Data.Longitude); + } else { alert('Address not found.'); } + }) + .catch(function (err) { console.error('Geocode error:', err); }); + }); + + // Start – What3Words + $('#startW3W').on('keypress', function (e) { if (e.keyCode === 13) { $('#findStartW3WBtn').click(); return false; } }); + $('#findStartW3WBtn').on('click', function (evt) { + evt.preventDefault(); + var word = $.trim($('#startW3W').val()); + if (!word) return; + $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetCoordinatesFromW3W?words=' + encodeURIComponent(word), type: 'GET' }) + .done(function (data) { + if (data && data.Latitude && data.Longitude) { setStartLocation(data.Latitude, data.Longitude); } + else { alert('What3Words was unable to find a location for those words.'); } + }); + }); + + // Start – manual lat/lng + $('#applyStartLatLngBtn').on('click', function () { + var lat = parseFloat($('#startLatDisplay').val()); + var lng = parseFloat($('#startLngDisplay').val()); + if (isNaN(lat) || isNaN(lng)) { alert('Please enter valid coordinates.'); return; } + setStartLocation(lat, lng); + }); + + // End – address geocode + $('#endAddress').on('keypress', function (e) { if (e.keyCode === 13) { $('#geocodeEndBtn').click(); return false; } }); + $('#geocodeEndBtn').on('click', function (evt) { + evt.preventDefault(); + var where = $.trim($('#endAddress').val()); + if (!where) return; + fetch(resgrid.absoluteApiBaseUrl + '/api/v4/Geocoding/ForwardGeocode?address=' + encodeURIComponent(where), { headers: { 'Authorization': 'Bearer ' + getAuthToken() } }) + .then(function (r) { return r.json(); }) + .then(function (result) { + if (result && result.Data && result.Data.Latitude && result.Data.Longitude) { + setEndLocation(result.Data.Latitude, result.Data.Longitude); + } else { alert('Address not found.'); } + }) + .catch(function (err) { console.error('Geocode error:', err); }); + }); + + // End – What3Words + $('#endW3W').on('keypress', function (e) { if (e.keyCode === 13) { $('#findEndW3WBtn').click(); return false; } }); + $('#findEndW3WBtn').on('click', function (evt) { + evt.preventDefault(); + var word = $.trim($('#endW3W').val()); + if (!word) return; + $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetCoordinatesFromW3W?words=' + encodeURIComponent(word), type: 'GET' }) + .done(function (data) { + if (data && data.Latitude && data.Longitude) { setEndLocation(data.Latitude, data.Longitude); } + else { alert('What3Words was unable to find a location for those words.'); } + }); + }); + + // End – manual lat/lng + $('#applyEndLatLngBtn').on('click', function () { + var lat = parseFloat($('#endLatDisplay').val()); + var lng = parseFloat($('#endLngDisplay').val()); + if (isNaN(lat) || isNaN(lng)) { alert('Please enter valid coordinates.'); return; } + setEndLocation(lat, lng); + }); + + // Initialise visibility on page load + updateLocationPickerVisibility(); + function renderStopsTable() { var $body = $('#stopsTableBody'); $body.empty(); @@ -119,6 +282,7 @@ $(document).ready(function () { pendingStops.forEach(function (stop, index) { if (stop.latitude && stop.longitude) { var m = L.marker([stop.latitude, stop.longitude]).addTo(map); + m.bindTooltip((index + 1) + '. ' + escapeHtml(stop.name), { permanent: true, direction: 'right' }); m.bindPopup('' + (index + 1) + '. ' + escapeHtml(stop.name) + ''); group.push(m); } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.view.js b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.view.js index dbac8b4c..369104d8 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.view.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.view.js @@ -42,6 +42,7 @@ $(document).ready(function () { routeStops.forEach(function (stop, index) { if (stop.lat != null && stop.lng != null && Number.isFinite(Number(stop.lat)) && Number.isFinite(Number(stop.lng))) { var marker = L.marker([stop.lat, stop.lng]).addTo(map); + marker.bindTooltip((index + 1) + '. ' + escapeHtml(stop.Name), { permanent: true, direction: 'right' }); marker.bindPopup('' + (index + 1) + '. ' + escapeHtml(stop.Name) + ''); group.push(marker); } From daa2ef928ea0cc569ad57e7b211a8ea9568c71c5 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 20 Mar 2026 09:50:01 -0700 Subject: [PATCH 2/2] RE1-T105 PR#305 fixes --- .../Areas/User/Routes/Routes.en.resx | 28 ++++++++++++++++ .../User/Controllers/RoutesController.cs | 20 ++++++++++-- .../Areas/User/Views/Routes/StartRoute.cshtml | 32 ++++++++++++------- .../internal/routes/resgrid.routes.edit.js | 28 ++++++++++------ .../app/internal/routes/resgrid.routes.new.js | 20 ++++++++---- 5 files changed, 98 insertions(+), 30 deletions(-) diff --git a/Core/Resgrid.Localization/Areas/User/Routes/Routes.en.resx b/Core/Resgrid.Localization/Areas/User/Routes/Routes.en.resx index fc41b42f..d1f9f1fc 100644 --- a/Core/Resgrid.Localization/Areas/User/Routes/Routes.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Routes/Routes.en.resx @@ -252,6 +252,34 @@ Create Route + + + Start Route + + + Start Route + + + Start Route + + + Assign Unit + + + -- Select a Unit -- + + + Select the unit that will execute this route. + + + Route Info + + + min estimated + + + Start Route Now + Edit Route diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RoutesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RoutesController.cs index d750ce4b..a750e1df 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RoutesController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RoutesController.cs @@ -381,8 +381,24 @@ public async Task StartRoute(RouteStartView model, CancellationTo if (plan.RouteStatus != (int)RouteStatus.Active) return RedirectToAction("View", new { id = model.RoutePlanId }); - var instance = await _routeService.StartRouteAsync(model.RoutePlanId, model.SelectedUnitId, UserId, cancellationToken); - return RedirectToAction("InstanceDetail", new { instanceId = instance.RouteInstanceId }); + try + { + var instance = await _routeService.StartRouteAsync(model.RoutePlanId, model.SelectedUnitId, UserId, cancellationToken); + return RedirectToAction("InstanceDetail", new { instanceId = instance.RouteInstanceId }); + } + catch (InvalidOperationException ex) + { + // Unit already has an active route — let the user pick a different unit. + ModelState.AddModelError(nameof(model.SelectedUnitId), ex.Message); + model.Plan = plan; + model.Units = (await _unitsService.GetUnitsForDepartmentAsync(DepartmentId)).ToList(); + return View(model); + } + catch (ArgumentException) + { + // Route plan disappeared between validation and start — redirect to Index. + return RedirectToAction("Index"); + } } [HttpGet] diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/StartRoute.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/StartRoute.cshtml index 78a29fd4..e8cf2fb6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Routes/StartRoute.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/StartRoute.cshtml @@ -1,17 +1,17 @@ @model Resgrid.Web.Areas.User.Models.Routes.RouteStartView @inject IStringLocalizer localizer @{ - ViewBag.Title = "Resgrid | Start Route"; + ViewBag.Title = "Resgrid | " + localizer["StartRoutePageTitle"]; }
-

Start Route — @Model.Plan.Name

+

@localizer["StartRouteHeader"] — @Model.Plan.Name

@@ -21,31 +21,39 @@
-
Start Route: @Model.Plan.Name
+
@localizer["StartRouteHeader"]: @Model.Plan.Name
@Html.AntiForgeryToken() -
- + @if (!ViewData.ModelState.IsValid) + { +
+ + @Html.ValidationSummary(excludePropertyErrors: false, message: "", htmlAttributes: new { @class = "mb-0" }) +
+ } + +
0 ? "has-error" : "")"> +
- Select the unit that will execute this route. + @localizer["AssignUnitHelp"]
@if (Model.Plan.EstimatedDistanceMeters.HasValue || Model.Plan.EstimatedDurationSeconds.HasValue) {
- +
@if (Model.Plan.EstimatedDistanceMeters.HasValue) { @@ -59,7 +67,7 @@ { - @(Math.Round(Model.Plan.EstimatedDurationSeconds.Value / 60, 0)) min estimated + @(Math.Round(Model.Plan.EstimatedDurationSeconds.Value / 60, 0)) @localizer["MinEstimated"] }
@@ -69,10 +77,10 @@
- Cancel + @localizer["Cancel"]
diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.edit.js b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.edit.js index c653af81..a0fc1643 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.edit.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.edit.js @@ -111,7 +111,7 @@ $(document).ready(function () { .addLayer(tiles); startPickerMap.on('click', function (e) { setStartLocation(e.latlng.lat, e.latlng.lng); }); startPickerInitialized = true; - if (initialLat && initialLng) { setStartLocation(initialLat, initialLng); } + if (initialLat != null && initialLng != null) { setStartLocation(initialLat, initialLng); } } function setStartLocation(lat, lng) { @@ -144,7 +144,7 @@ $(document).ready(function () { .addLayer(tiles); endPickerMap.on('click', function (e) { setEndLocation(e.latlng.lat, e.latlng.lng); }); endPickerInitialized = true; - if (initialLat && initialLng) { setEndLocation(initialLat, initialLng); } + if (initialLat != null && initialLng != null) { setEndLocation(initialLat, initialLng); } } function setEndLocation(lat, lng) { @@ -169,21 +169,29 @@ $(document).ready(function () { function updateLocationPickerVisibility() { if (!$('#chkUseStationAsStart').prop('checked')) { $('#startLocationGroup').show(); + $('#startLat').prop('disabled', false); + $('#startLng').prop('disabled', false); initStartPickerMap( - (typeof existingStartLat !== 'undefined' && existingStartLat) ? existingStartLat : null, - (typeof existingStartLng !== 'undefined' && existingStartLng) ? existingStartLng : null + (typeof existingStartLat !== 'undefined' && existingStartLat != null) ? existingStartLat : null, + (typeof existingStartLng !== 'undefined' && existingStartLng != null) ? existingStartLng : null ); } else { $('#startLocationGroup').hide(); + $('#startLat').val('').prop('disabled', true); + $('#startLng').val('').prop('disabled', true); } if (!$('#chkUseStationAsEnd').prop('checked')) { $('#endLocationGroup').show(); + $('#endLat').prop('disabled', false); + $('#endLng').prop('disabled', false); initEndPickerMap( - (typeof existingEndLat !== 'undefined' && existingEndLat) ? existingEndLat : null, - (typeof existingEndLng !== 'undefined' && existingEndLng) ? existingEndLng : null + (typeof existingEndLat !== 'undefined' && existingEndLat != null) ? existingEndLat : null, + (typeof existingEndLng !== 'undefined' && existingEndLng != null) ? existingEndLng : null ); } else { $('#endLocationGroup').hide(); + $('#endLat').val('').prop('disabled', true); + $('#endLng').val('').prop('disabled', true); } } @@ -199,7 +207,7 @@ $(document).ready(function () { fetch(resgrid.absoluteApiBaseUrl + '/api/v4/Geocoding/ForwardGeocode?address=' + encodeURIComponent(where), { headers: { 'Authorization': 'Bearer ' + getAuthToken() } }) .then(function (r) { return r.json(); }) .then(function (result) { - if (result && result.Data && result.Data.Latitude && result.Data.Longitude) { + if (result && result.Data && result.Data.Latitude != null && result.Data.Longitude != null) { setStartLocation(result.Data.Latitude, result.Data.Longitude); } else { alert('Address not found.'); } }) @@ -214,7 +222,7 @@ $(document).ready(function () { if (!word) return; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetCoordinatesFromW3W?words=' + encodeURIComponent(word), type: 'GET' }) .done(function (data) { - if (data && data.Latitude && data.Longitude) { setStartLocation(data.Latitude, data.Longitude); } + if (data && data.Latitude != null && data.Longitude != null) { setStartLocation(data.Latitude, data.Longitude); } else { alert('What3Words was unable to find a location for those words.'); } }); }); @@ -236,7 +244,7 @@ $(document).ready(function () { fetch(resgrid.absoluteApiBaseUrl + '/api/v4/Geocoding/ForwardGeocode?address=' + encodeURIComponent(where), { headers: { 'Authorization': 'Bearer ' + getAuthToken() } }) .then(function (r) { return r.json(); }) .then(function (result) { - if (result && result.Data && result.Data.Latitude && result.Data.Longitude) { + if (result && result.Data && result.Data.Latitude != null && result.Data.Longitude != null) { setEndLocation(result.Data.Latitude, result.Data.Longitude); } else { alert('Address not found.'); } }) @@ -251,7 +259,7 @@ $(document).ready(function () { if (!word) return; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetCoordinatesFromW3W?words=' + encodeURIComponent(word), type: 'GET' }) .done(function (data) { - if (data && data.Latitude && data.Longitude) { setEndLocation(data.Latitude, data.Longitude); } + if (data && data.Latitude != null && data.Longitude != null) { setEndLocation(data.Latitude, data.Longitude); } else { alert('What3Words was unable to find a location for those words.'); } }); }); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.new.js b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.new.js index e0b67b53..a82b9460 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.new.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.new.js @@ -105,7 +105,7 @@ $(document).ready(function () { .addLayer(tiles); startPickerMap.on('click', function (e) { setStartLocation(e.latlng.lat, e.latlng.lng); }); startPickerInitialized = true; - if (initialLat && initialLng) { setStartLocation(initialLat, initialLng); } + if (Number.isFinite(Number(initialLat)) && Number.isFinite(Number(initialLng))) { setStartLocation(initialLat, initialLng); } } function setStartLocation(lat, lng) { @@ -138,7 +138,7 @@ $(document).ready(function () { .addLayer(tiles); endPickerMap.on('click', function (e) { setEndLocation(e.latlng.lat, e.latlng.lng); }); endPickerInitialized = true; - if (initialLat && initialLng) { setEndLocation(initialLat, initialLng); } + if (Number.isFinite(Number(initialLat)) && Number.isFinite(Number(initialLng))) { setEndLocation(initialLat, initialLng); } } function setEndLocation(lat, lng) { @@ -163,15 +163,23 @@ $(document).ready(function () { function updateLocationPickerVisibility() { if (!$('#chkUseStationAsStart').prop('checked')) { $('#startLocationGroup').show(); + $('#startLat').prop('disabled', false); + $('#startLng').prop('disabled', false); initStartPickerMap(); } else { $('#startLocationGroup').hide(); + $('#startLat').val('').prop('disabled', true); + $('#startLng').val('').prop('disabled', true); } if (!$('#chkUseStationAsEnd').prop('checked')) { $('#endLocationGroup').show(); + $('#endLat').prop('disabled', false); + $('#endLng').prop('disabled', false); initEndPickerMap(); } else { $('#endLocationGroup').hide(); + $('#endLat').val('').prop('disabled', true); + $('#endLng').val('').prop('disabled', true); } } @@ -187,7 +195,7 @@ $(document).ready(function () { fetch(resgrid.absoluteApiBaseUrl + '/api/v4/Geocoding/ForwardGeocode?address=' + encodeURIComponent(where), { headers: { 'Authorization': 'Bearer ' + getAuthToken() } }) .then(function (r) { return r.json(); }) .then(function (result) { - if (result && result.Data && result.Data.Latitude && result.Data.Longitude) { + if (result && result.Data && result.Data.Latitude != null && result.Data.Longitude != null) { setStartLocation(result.Data.Latitude, result.Data.Longitude); } else { alert('Address not found.'); } }) @@ -202,7 +210,7 @@ $(document).ready(function () { if (!word) return; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetCoordinatesFromW3W?words=' + encodeURIComponent(word), type: 'GET' }) .done(function (data) { - if (data && data.Latitude && data.Longitude) { setStartLocation(data.Latitude, data.Longitude); } + if (data && data.Latitude != null && data.Longitude != null) { setStartLocation(data.Latitude, data.Longitude); } else { alert('What3Words was unable to find a location for those words.'); } }); }); @@ -224,7 +232,7 @@ $(document).ready(function () { fetch(resgrid.absoluteApiBaseUrl + '/api/v4/Geocoding/ForwardGeocode?address=' + encodeURIComponent(where), { headers: { 'Authorization': 'Bearer ' + getAuthToken() } }) .then(function (r) { return r.json(); }) .then(function (result) { - if (result && result.Data && result.Data.Latitude && result.Data.Longitude) { + if (result && result.Data && result.Data.Latitude != null && result.Data.Longitude != null) { setEndLocation(result.Data.Latitude, result.Data.Longitude); } else { alert('Address not found.'); } }) @@ -239,7 +247,7 @@ $(document).ready(function () { if (!word) return; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetCoordinatesFromW3W?words=' + encodeURIComponent(word), type: 'GET' }) .done(function (data) { - if (data && data.Latitude && data.Longitude) { setEndLocation(data.Latitude, data.Longitude); } + if (data && data.Latitude != null && data.Longitude != null) { setEndLocation(data.Latitude, data.Longitude); } else { alert('What3Words was unable to find a location for those words.'); } }); });