From 3efacf32a4f3ecf4c3fb35bafdc6098784ba951b Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 16 Mar 2026 10:26:20 -0700 Subject: [PATCH 1/2] RE1-T106 Bug fixes --- Core/Resgrid.Model/DepartmentNotification.cs | 8 +++---- .../User/Controllers/ContactsController.cs | 2 ++ .../User/Views/Shared/_UserLayout.cshtml | 21 +++++-------------- .../analytics/resgrid.common.analytics.js | 17 +++++++-------- 4 files changed, 19 insertions(+), 29 deletions(-) diff --git a/Core/Resgrid.Model/DepartmentNotification.cs b/Core/Resgrid.Model/DepartmentNotification.cs index 359cd0cc..c872e867 100644 --- a/Core/Resgrid.Model/DepartmentNotification.cs +++ b/Core/Resgrid.Model/DepartmentNotification.cs @@ -147,7 +147,7 @@ public string TranslateBefore(List customStates = null) break; case EventTypes.PersonnelStatusChanged: if (string.IsNullOrWhiteSpace(BeforeData)) - return "None"; + return "Any"; if (BeforeData == "-1") return "Any"; else @@ -209,7 +209,7 @@ public string TranslateCurrent(List customStates = null) { case EventTypes.UnitStatusChanged: if (string.IsNullOrWhiteSpace(CurrentData)) - return "None"; + return "Any"; if (CurrentData == "-1") return "Any"; else @@ -238,7 +238,7 @@ public string TranslateCurrent(List customStates = null) break; case EventTypes.PersonnelStaffingChanged: if (string.IsNullOrWhiteSpace(CurrentData)) - return "None"; + return "Any"; if (CurrentData == "-1") return "Any"; else @@ -267,7 +267,7 @@ public string TranslateCurrent(List customStates = null) break; case EventTypes.PersonnelStatusChanged: if (string.IsNullOrWhiteSpace(CurrentData)) - return "None"; + return "Any"; if (CurrentData == "-1") return "Any"; else diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs index 10d57dc9..e74e5452 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs @@ -359,6 +359,8 @@ public async Task Edit(string contactId) if (model.Contact.DepartmentId != DepartmentId) return Unauthorized(); + + if (!String.IsNullOrWhiteSpace(model.Contact.EntranceGpsCoordinates)) { var entranceGpsCoordinates = model.Contact.EntranceGpsCoordinates.Split(','); model.LocationGpsLatitude = entranceGpsCoordinates[0]; diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml index db4abd64..e0743208 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml @@ -36,33 +36,22 @@ { } - @if (!String.IsNullOrWhiteSpace(Resgrid.Config.TelemetryConfig.PostHogUrl) && !String.IsNullOrWhiteSpace(Resgrid.Config.TelemetryConfig.PostHogApiKey)) - { - - } - @if (!String.IsNullOrWhiteSpace(Resgrid.Config.TelemetryConfig.CountlyUrl) && !String.IsNullOrWhiteSpace(Resgrid.Config.TelemetryConfig.CountlyWebKey)) { + + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Import.cshtml b/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Import.cshtml new file mode 100644 index 00000000..e07bc1ba --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Import.cshtml @@ -0,0 +1,106 @@ +@model Resgrid.Web.Areas.User.Models.CustomMaps.CustomMapImportView +@using Resgrid.Model +@{ + ViewBag.Title = "Resgrid | Import - " + Model.Map.Name; +} + +
+
+

Import - @Model.Map.Name

+ +
+
+ +
+
+
+
+
+
Import Geospatial Data
+
+
+ @if (!string.IsNullOrWhiteSpace(Model.Message)) + { +
@Model.Message
+ } +
+ @Html.AntiForgeryToken() + +
+ +
+ +
+
+
+ +
+ + Supported formats: GeoJSON (.geojson, .json), KML (.kml), KMZ (.kmz) +
+
+ +
+
+
+
+
+ +
+
+
Import History
+
+
+
+ + + + + + + + + + + + @if (Model.Imports != null) + { + foreach (var imp in Model.Imports) + { + + + + + + + + } + } + +
FileTypeStatusDateError
@imp.SourceFileName@((CustomMapImportFileType)imp.SourceFileType) + @{ + var status = (CustomMapImportStatus)imp.Status; + var labelClass = status == CustomMapImportStatus.Complete ? "label-success" : + status == CustomMapImportStatus.Failed ? "label-danger" : + status == CustomMapImportStatus.Processing ? "label-warning" : "label-default"; + } + @status + @imp.ImportedOn.ToString("g")@imp.ErrorMessage
+
+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Index.cshtml new file mode 100644 index 00000000..a47ecc41 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Index.cshtml @@ -0,0 +1,89 @@ +@model Resgrid.Web.Areas.User.Models.CustomMaps.CustomMapIndexView +@using Resgrid.Model +@{ + ViewBag.Title = "Resgrid | Custom Maps"; +} + +
+
+

Custom Maps

+ +
+
+ +
+
+ +
+
+
+
+
+ +
+ + + + + + + + + + + + @if (Model.Maps != null) + { + foreach (var map in Model.Maps) + { + + + + + + + + } + } + +
NameTypeDescriptionCreated
@map.Name@((CustomMapType)map.MapType)@map.Description@map.AddedOn.ToString("g") + Layers + Import + Edit + Delete +
+
+
+
+
+
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Layers.cshtml b/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Layers.cshtml new file mode 100644 index 00000000..681e0ba6 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Layers.cshtml @@ -0,0 +1,113 @@ +@model Resgrid.Web.Areas.User.Models.CustomMaps.CustomMapLayersView +@using Resgrid.Model +@{ + ViewBag.Title = "Resgrid | Layers - " + Model.Map.Name; +} + +
+
+

Layers - @Model.Map.Name

+ +
+
+ +
+
+
+
+
+
Add Layer
+
+
+
+ @Html.AntiForgeryToken() + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+ + Images larger than 2048px will be automatically tiled. +
+
+ +
+
+
+
+
+ +
+
+
Layers
+
+
+
+ + + + + + + + + + + + + @if (Model.Layers != null) + { + foreach (var layer in Model.Layers) + { + + + + + + + + + } + } + +
OrderNameTypeHas ImageTiled
@layer.FloorOrder@layer.Name@((CustomMapLayerType)layer.LayerType)@(layer.ImageData != null || layer.IsTiled ? "Yes" : "No") + @if (layer.IsTiled) + { + Tiled (z@(layer.TileMinZoom)-@(layer.TileMaxZoom)) + } + else + { + No + } + + Region Editor + Delete +
+
+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/CustomMaps/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/CustomMaps/New.cshtml new file mode 100644 index 00000000..788e9edc --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CustomMaps/New.cshtml @@ -0,0 +1,98 @@ +@model Resgrid.Web.Areas.User.Models.CustomMaps.CustomMapNewView +@using Resgrid.Model +@{ + ViewBag.Title = "Resgrid | New Custom Map"; +} + +@section Styles { + + +} + +
+
+

New Custom Map

+ +
+
+ +
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(Model.Message)) + { +
@Model.Message
+ } +
+ @Html.AntiForgeryToken() +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+ +
+

Draw a rectangle on the map below to define the bounds. Click the map to set the center point.

+
+
+ +
+ + + + + + + + + +
+
+
+ Cancel + +
+
+
+
+
+
+
+
+ +@section Scripts { + + + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CustomMaps/RegionEditor.cshtml b/Web/Resgrid.Web/Areas/User/Views/CustomMaps/RegionEditor.cshtml new file mode 100644 index 00000000..81957703 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CustomMaps/RegionEditor.cshtml @@ -0,0 +1,138 @@ +@model Resgrid.Web.Areas.User.Models.CustomMaps.CustomMapRegionEditorView +@using Resgrid.Model +@{ + ViewBag.Title = "Resgrid | Region Editor - " + Model.Layer.Name; +} + +@section Styles { + + + +} + +
+
+

Region Editor - @Model.Map.Name > @Model.Layer.Name

+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
Regions
+
+
+ @if (Model.Regions != null) + { + foreach (var region in Model.Regions) + { +
+ @region.Name + + + +
@((IndoorMapZoneType)region.ZoneType) + @if (region.IsDispatchable) + { + Dispatchable + } +
+ } + } +
+
+ +
+
+
Region Properties
+
+ +
+
+
+
+ +@section Scripts { + + + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/Dashboard.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/Dashboard.cshtml index 39efb095..641cf8b2 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/Dashboard.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/Dashboard.cshtml @@ -45,8 +45,12 @@ @localizer["ArchivedCalls"] @if (ClaimsAuthorizationHelper.CanCreateCall()) { - @localizer["NewCall"] + @localizer["NewCall"] } + @if (ClaimsAuthorizationHelper.CanViewRoutes()) + { + Routes + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml index 7c21f043..d99e37ab 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml @@ -177,6 +177,18 @@
+
+ +
+
+
+ +
+
+ + +
+
@@ -615,6 +627,34 @@ $("#alertNotesModal").modal("show"); } }); + + $('#indoorZoneSearch').select2({ + placeholder: 'Search indoor locations...', + allowClear: true, + minimumInputLength: 2, + ajax: { + url: '@Url.Action("SearchZones", "IndoorMaps", new { area = "User" })', + dataType: 'json', + delay: 300, + data: function(params) { + return { term: params.term }; + }, + processResults: function(data) { + return { results: data.results }; + } + } + }); + + $('#indoorZoneSearch').on('select2:select', function(e) { + var data = e.params.data; + $('#IndoorMapZoneId').val(data.id); + $('#IndoorMapFloorId').val(data.floorId); + }); + + $('#indoorZoneSearch').on('select2:clear', function() { + $('#IndoorMapZoneId').val(''); + $('#IndoorMapFloorId').val(''); + }); }); } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml index 6301496a..7058a2bf 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml @@ -178,6 +178,18 @@
+
+ +
+
+
+ +
+
+ + +
+
@@ -485,6 +497,34 @@ $("#AdditionalContacts :selected").map(function(i, el) { checkAlertNotes($(el).val(), "#additionalNotesIcon"); }).get(); + + $('#indoorZoneSearch').select2({ + placeholder: 'Search indoor locations...', + allowClear: true, + minimumInputLength: 2, + ajax: { + url: '@Url.Action("SearchZones", "IndoorMaps", new { area = "User" })', + dataType: 'json', + delay: 300, + data: function(params) { + return { term: params.term }; + }, + processResults: function(data) { + return { results: data.results }; + } + } + }); + + $('#indoorZoneSearch').on('select2:select', function(e) { + var data = e.params.data; + $('#IndoorMapZoneId').val(data.id); + $('#IndoorMapFloorId').val(data.floorId); + }); + + $('#indoorZoneSearch').on('select2:clear', function() { + $('#IndoorMapZoneId').val(''); + $('#IndoorMapFloorId').val(''); + }); }); } diff --git a/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Edit.cshtml new file mode 100644 index 00000000..f812b6e3 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Edit.cshtml @@ -0,0 +1,88 @@ +@model Resgrid.Web.Areas.User.Models.IndoorMaps.IndoorMapNewView +@{ + ViewBag.Title = "Resgrid | Edit Indoor Map"; +} + +
+
+

Edit Indoor Map

+ +
+
+ +
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(Model.Message)) + { +
@Model.Message
+ } +
+ @Html.AntiForgeryToken() + +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+
+
+ Cancel + +
+
+
+
+
+
+
+
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Floors.cshtml b/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Floors.cshtml new file mode 100644 index 00000000..a82165f8 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Floors.cshtml @@ -0,0 +1,89 @@ +@model Resgrid.Web.Areas.User.Models.IndoorMaps.IndoorMapFloorsView +@{ + ViewBag.Title = "Resgrid | Floors - " + Model.IndoorMap.Name; +} + +
+
+

Floors - @Model.IndoorMap.Name

+ +
+
+ +
+
+
+
+
+
Add Floor
+
+
+
+ @Html.AntiForgeryToken() + +
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+
+
Floors
+
+
+
+ + + + + + + + + + + @if (Model.Floors != null) + { + foreach (var floor in Model.Floors) + { + + + + + + + } + } + +
OrderNameHas Image
@floor.FloorOrder@floor.Name@(floor.ImageData != null ? "Yes" : "No") + Zone Editor + Delete +
+
+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Index.cshtml new file mode 100644 index 00000000..30a2457a --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Index.cshtml @@ -0,0 +1,74 @@ +@model Resgrid.Web.Areas.User.Models.IndoorMaps.IndoorMapIndexView +@{ + ViewBag.Title = "Resgrid | Indoor Maps"; +} + +
+
+

Indoor Maps

+ +
+
+ +
+
+ +
+
+
+
+
+
+ + + + + + + + + + + @if (Model.IndoorMaps != null) + { + foreach (var map in Model.IndoorMaps) + { + + + + + + + } + } + +
NameDescriptionCreated
@map.Name@map.Description@map.AddedOn.ToString("g") + Floors + Edit + Delete +
+
+
+
+
+
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/New.cshtml new file mode 100644 index 00000000..31336db9 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/New.cshtml @@ -0,0 +1,87 @@ +@model Resgrid.Web.Areas.User.Models.IndoorMaps.IndoorMapNewView +@{ + ViewBag.Title = "Resgrid | New Indoor Map"; +} + +
+
+

New Indoor Map

+ +
+
+ +
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(Model.Message)) + { +
@Model.Message
+ } +
+ @Html.AntiForgeryToken() +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+
+
+ Cancel + +
+
+
+
+
+
+
+
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/ZoneEditor.cshtml b/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/ZoneEditor.cshtml new file mode 100644 index 00000000..361594c6 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/ZoneEditor.cshtml @@ -0,0 +1,119 @@ +@model Resgrid.Web.Areas.User.Models.IndoorMaps.IndoorMapZoneEditorView +@{ + ViewBag.Title = "Resgrid | Zone Editor - " + Model.Floor.Name; +} + +@section Styles { + + + +} + +
+
+

Zone Editor - @Model.IndoorMap.Name > @Model.Floor.Name

+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
Zones
+
+
+ @if (Model.Zones != null) + { + foreach (var zone in Model.Zones) + { +
+ @zone.Name + + + +
@((IndoorMapZoneType)zone.ZoneType) +
+ } + } +
+
+ +
+
+
Zone Properties
+
+ +
+
+
+
+ +@section Scripts { + + + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Mapping/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Mapping/Index.cshtml index ec0d298f..e9dfde19 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Mapping/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Mapping/Index.cshtml @@ -41,7 +41,10 @@ { } diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/ActiveRoutes.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/ActiveRoutes.cshtml new file mode 100644 index 00000000..9866f83b --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/ActiveRoutes.cshtml @@ -0,0 +1,69 @@ +@model Resgrid.Web.Areas.User.Models.Routes.ActiveRoutesView +@{ + ViewBag.Title = "Resgrid | Active Routes"; +} + +
+
+

Active Routes

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

No Active Routes

+

There are no route instances currently in progress.

+
+
+
+ } + @foreach (var instance in Model.Instances) + { + var plan = Model.Plans.FirstOrDefault(p => p.RoutePlanId == instance.RoutePlanId); + var pct = instance.StopsTotal > 0 ? (instance.StopsCompleted * 100 / instance.StopsTotal) : 0; +
+
+
+
@(plan?.Name ?? "Unknown Route")
+ @((Resgrid.Model.RouteInstanceStatus)instance.Status) +
+
+
+
+ Unit +

@instance.UnitId

+
+
+ Stops +

@instance.StopsCompleted / @instance.StopsTotal

+
+
+
+
+
+
+ Started: @(instance.ActualStartOn?.ToString("HH:mm") ?? "-") +
+ + View Details + +
+
+
+ } +
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/Edit.cshtml new file mode 100644 index 00000000..8266d80d --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/Edit.cshtml @@ -0,0 +1,160 @@ +@model Resgrid.Web.Areas.User.Models.Routes.RouteEditView +@{ + ViewBag.Title = "Resgrid | Edit Route"; +} + +
+
+

Edit Route Plan

+ +
+
+ +
+
+
+
+
Route Details
+
+
+ @Html.AntiForgeryToken() + + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+
+ + + +
+
+ +
+ +

Stops (@Model.Stops.Count)

+
+ + + + + + + + + + + + @foreach (var stop in Model.Stops) + { + + + + + + + + } + +
#NameTypeAddressPriority
@stop.StopOrder@stop.Name@((Resgrid.Model.RouteStopType)stop.StopType)@stop.Address@((Resgrid.Model.RouteStopPriority)stop.Priority)
+
+ +
+ +
+ +
+
+ Cancel + +
+
+
+
+
+
+
+
+ +@section Scripts { + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/Index.cshtml new file mode 100644 index 00000000..3d27dd6c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/Index.cshtml @@ -0,0 +1,84 @@ +@model Resgrid.Web.Areas.User.Models.Routes.RouteIndexView +@using Resgrid.Model +@{ + ViewBag.Title = "Resgrid | Routes"; +} + +
+
+

Routes

+ +
+ +
+ +
+
+
+
+
+
+ + + + + + + + + + + + + @foreach (var plan in Model.Plans) + { + + + + + + + + + } + +
NameStatusStopsProfileCreatedActions
@plan.Name@((RouteStatus)plan.RouteStatus)-@(plan.MapboxRouteProfile ?? "driving")@plan.AddedOn.ToString("yyyy-MM-dd") + + View + + + Edit + + + History + +
+ @Html.AntiForgeryToken() + + +
+
+
+
+
+
+
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/InstanceDetail.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/InstanceDetail.cshtml new file mode 100644 index 00000000..72333d6c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/InstanceDetail.cshtml @@ -0,0 +1,100 @@ +@model Resgrid.Web.Areas.User.Models.Routes.RouteInstanceDetailView +@{ + ViewBag.Title = "Resgrid | Route Instance Detail"; +} + +
+
+

Route Instance - @Model.Plan.Name

+ +
+
+ +
+
+
+
+
Route Map
+
+
+
+
+
+
+
+
Instance Info
+
+
+
Status
+
@((Resgrid.Model.RouteInstanceStatus)Model.Instance.Status)
+
Unit
+
@Model.Instance.UnitId
+
Started
+
@(Model.Instance.ActualStartOn?.ToString("yyyy-MM-dd HH:mm") ?? "-")
+
Ended
+
@(Model.Instance.ActualEndOn?.ToString("yyyy-MM-dd HH:mm") ?? "-")
+
Progress
+
@Model.Instance.StopsCompleted / @Model.Instance.StopsTotal
+
+
+ @{ + var pct = Model.Instance.StopsTotal > 0 ? (Model.Instance.StopsCompleted * 100 / Model.Instance.StopsTotal) : 0; + } +
+
+
+
+ +
+
Stop Timeline
+
+
+ @foreach (var stop in Model.Stops.OrderBy(s => s.StopOrder)) + { + var statusClass = stop.Status switch { 1 => "lazur-bg", 2 => "navy-bg", 3 => "warning-bg", _ => "default-bg" }; + var statusText = stop.Status switch { 0 => "Pending", 1 => "Checked In", 2 => "Completed", 3 => "Skipped", _ => "Unknown" }; +
+
+ +
+
+

Stop @(stop.StopOrder + 1)

+

@statusText

+ @if (stop.CheckInOn.HasValue) + { +

In: @stop.CheckInOn.Value.ToString("HH:mm:ss")

+ } + @if (stop.CheckOutOn.HasValue) + { +

Out: @stop.CheckOutOn.Value.ToString("HH:mm:ss")

+ } + @if (stop.DwellSeconds.HasValue) + { +

Dwell: @(stop.DwellSeconds.Value / 60)m @(stop.DwellSeconds.Value % 60)s

+ } + @if (!string.IsNullOrEmpty(stop.SkipReason)) + { +

Skip: @stop.SkipReason

+ } +
+
+ } +
+
+
+
+
+
+ +@section Scripts { + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/Instances.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/Instances.cshtml new file mode 100644 index 00000000..feb90027 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/Instances.cshtml @@ -0,0 +1,57 @@ +@model Resgrid.Web.Areas.User.Models.Routes.RouteInstancesView +@{ + ViewBag.Title = "Resgrid | Route History"; +} + +
+
+

Route History - @Model.Plan.Name

+ +
+
+ +
+
+
+
+
+
+ + + + + + + + + + + + + @foreach (var instance in Model.Instances) + { + + + + + + + + + } + +
StatusUnitStartedEndedStopsActions
@((Resgrid.Model.RouteInstanceStatus)instance.Status)@instance.UnitId@(instance.ActualStartOn?.ToString("yyyy-MM-dd HH:mm") ?? "-")@(instance.ActualEndOn?.ToString("yyyy-MM-dd HH:mm") ?? "-")@instance.StopsCompleted / @instance.StopsTotal + + Detail + +
+
+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/New.cshtml new file mode 100644 index 00000000..09b248b9 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/New.cshtml @@ -0,0 +1,133 @@ +@model Resgrid.Web.Areas.User.Models.Routes.RouteNewView +@{ + ViewBag.Title = "Resgrid | New Route"; +} + +
+
+

New Route Plan

+ +
+
+ +
+
+
+
+
+
Route Details
+
+
+
+ @Html.AntiForgeryToken() + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+
+ + + +
+
+ +
+ +

Map & Stops

+

Stops can be managed after creating the route via the API or edit form.

+
+ +
+ +
+
+ Cancel + +
+
+
+
+
+
+
+
+ +@section Scripts { + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/View.cshtml new file mode 100644 index 00000000..9050d60e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/View.cshtml @@ -0,0 +1,84 @@ +@model Resgrid.Web.Areas.User.Models.Routes.RouteDetailView +@{ + ViewBag.Title = "Resgrid | Route Detail"; +} + +
+
+

@Model.Plan.Name

+ +
+
+ +
+
+
+
+
Route Map
+
+
+
+
+
+
+
+
Details
+
+
+
Status
+
@((Resgrid.Model.RouteStatus)Model.Plan.RouteStatus)
+
Profile
+
@(Model.Plan.MapboxRouteProfile ?? "driving")
+
Geofence
+
@Model.Plan.GeofenceRadiusMeters m
+ @if (Model.Plan.EstimatedDistanceMeters.HasValue) + { +
Distance
+
@(Math.Round(Model.Plan.EstimatedDistanceMeters.Value / 1000, 1)) km
+ } + @if (Model.Plan.EstimatedDurationSeconds.HasValue) + { +
Duration
+
@(Math.Round(Model.Plan.EstimatedDurationSeconds.Value / 60, 0)) min
+ } +
+
+
+ +
+
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/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs index 49988863..88f4302b 100644 --- a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs @@ -208,5 +208,25 @@ public static bool CanDeleteContacts() { return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Contacts, ResgridClaimTypes.Actions.Delete); } + + public static bool CanViewRoutes() + { + return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Route, ResgridClaimTypes.Actions.View); + } + + public static bool CanCreateRoutes() + { + return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Route, ResgridClaimTypes.Actions.Create); + } + + public static bool CanEditRoutes() + { + return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Route, ResgridClaimTypes.Actions.Update); + } + + public static bool CanDeleteRoutes() + { + return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Route, ResgridClaimTypes.Actions.Delete); + } } } diff --git a/Web/Resgrid.Web/Startup.cs b/Web/Resgrid.Web/Startup.cs index 119e7b88..45966ddd 100644 --- a/Web/Resgrid.Web/Startup.cs +++ b/Web/Resgrid.Web/Startup.cs @@ -340,6 +340,11 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy(ResgridResources.Udf_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Udf, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.Udf_Create, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Udf, ResgridClaimTypes.Actions.Create)); options.AddPolicy(ResgridResources.Udf_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Udf, ResgridClaimTypes.Actions.Delete)); + + options.AddPolicy(ResgridResources.Route_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Route, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.Route_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Route, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.Route_Create, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Route, ResgridClaimTypes.Actions.Create)); + options.AddPolicy(ResgridResources.Route_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Route, ResgridClaimTypes.Actions.Delete)); }); #endregion Auth Roles diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/custommaps/resgrid.custommaps.editor.js b/Web/Resgrid.Web/wwwroot/js/app/internal/custommaps/resgrid.custommaps.editor.js new file mode 100644 index 00000000..1b0007e3 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/custommaps/resgrid.custommaps.editor.js @@ -0,0 +1,101 @@ +$(document).ready(function () { + if (!document.getElementById('boundsMap')) return; + + var lat = parseFloat(initialLat) || 39.7392; + var lon = parseFloat(initialLon) || -104.9903; + + var map = L.map('boundsMap').setView([lat, lon], 16); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 22, + attribution: '© OpenStreetMap' + }).addTo(map); + + // Initialize Leaflet.draw + var drawnItems = new L.FeatureGroup(); + map.addLayer(drawnItems); + + var drawControl = new L.Control.Draw({ + draw: { + polyline: false, + polygon: false, + circle: false, + circlemarker: false, + marker: false, + rectangle: { + shapeOptions: { + color: '#3388ff', + weight: 2, + fillOpacity: 0.1 + } + } + }, + edit: { + featureGroup: drawnItems, + remove: true + } + }); + map.addControl(drawControl); + + // Load existing bounds if present + var neLat = parseFloat($('#Map_BoundsNELat').val()); + var neLon = parseFloat($('#Map_BoundsNELon').val()); + var swLat = parseFloat($('#Map_BoundsSWLat').val()); + var swLon = parseFloat($('#Map_BoundsSWLon').val()); + + if (!isNaN(neLat) && !isNaN(neLon) && !isNaN(swLat) && !isNaN(swLon) && neLat !== 0) { + var bounds = [[swLat, swLon], [neLat, neLon]]; + var rect = L.rectangle(bounds, { color: '#3388ff', weight: 2, fillOpacity: 0.1 }); + drawnItems.addLayer(rect); + map.fitBounds(bounds); + } + + function updateBoundsFromLayer(layer) { + var bounds = layer.getBounds(); + var ne = bounds.getNorthEast(); + var sw = bounds.getSouthWest(); + + $('#Map_BoundsNELat').val(ne.lat.toFixed(7)); + $('#Map_BoundsNELon').val(ne.lng.toFixed(7)); + $('#Map_BoundsSWLat').val(sw.lat.toFixed(7)); + $('#Map_BoundsSWLon').val(sw.lng.toFixed(7)); + + // Center = midpoint + var centerLat = (ne.lat + sw.lat) / 2; + var centerLon = (ne.lng + sw.lng) / 2; + $('#Map_CenterLatitude').val(centerLat.toFixed(7)); + $('#Map_CenterLongitude').val(centerLon.toFixed(7)); + + // GeoJSON polygon + var geoJson = JSON.stringify(layer.toGeoJSON().geometry); + $('#Map_BoundsGeoJson').val(geoJson); + } + + map.on(L.Draw.Event.CREATED, function (e) { + // Remove existing rectangles + drawnItems.clearLayers(); + drawnItems.addLayer(e.layer); + updateBoundsFromLayer(e.layer); + }); + + map.on(L.Draw.Event.EDITED, function (e) { + e.layers.eachLayer(function (layer) { + updateBoundsFromLayer(layer); + }); + }); + + map.on(L.Draw.Event.DELETED, function () { + $('#Map_BoundsNELat').val(''); + $('#Map_BoundsNELon').val(''); + $('#Map_BoundsSWLat').val(''); + $('#Map_BoundsSWLon').val(''); + $('#Map_CenterLatitude').val(''); + $('#Map_CenterLongitude').val(''); + $('#Map_BoundsGeoJson').val(''); + }); + + // Click to set center + map.on('click', function (e) { + $('#Map_CenterLatitude').val(e.latlng.lat.toFixed(7)); + $('#Map_CenterLongitude').val(e.latlng.lng.toFixed(7)); + }); +}); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/custommaps/resgrid.custommaps.import.js b/Web/Resgrid.Web/wwwroot/js/app/internal/custommaps/resgrid.custommaps.import.js new file mode 100644 index 00000000..eb811a5a --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/custommaps/resgrid.custommaps.import.js @@ -0,0 +1,14 @@ +$(document).ready(function () { + // File upload preview + $('input[name="importFile"]').on('change', function () { + var file = this.files[0]; + if (file) { + var ext = file.name.split('.').pop().toLowerCase(); + var supported = ['geojson', 'json', 'kml', 'kmz']; + if (supported.indexOf(ext) === -1) { + alert('Unsupported file format. Please use GeoJSON, KML, or KMZ files.'); + $(this).val(''); + } + } + }); +}); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/custommaps/resgrid.custommaps.index.js b/Web/Resgrid.Web/wwwroot/js/app/internal/custommaps/resgrid.custommaps.index.js new file mode 100644 index 00000000..70014e42 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/custommaps/resgrid.custommaps.index.js @@ -0,0 +1,5 @@ +$(document).ready(function () { + $('#customMapsTable').DataTable({ + pageLength: 25 + }); +}); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/custommaps/resgrid.custommaps.regioneditor.js b/Web/Resgrid.Web/wwwroot/js/app/internal/custommaps/resgrid.custommaps.regioneditor.js new file mode 100644 index 00000000..8322e1ba --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/custommaps/resgrid.custommaps.regioneditor.js @@ -0,0 +1,241 @@ +$(document).ready(function () { + var imageBounds = [boundsSW, boundsNE]; + var map; + + if (isTiled) { + // Tiled layer — use standard map with tile overlay + map = L.map('regionMap', { + minZoom: tileMinZoom, + maxZoom: Math.max(tileMaxZoom, 22) + }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 22, + attribution: '© OpenStreetMap' + }).addTo(map); + + // Add custom tile layer overlay + L.tileLayer(tileUrlTemplate, { + minZoom: tileMinZoom, + maxZoom: tileMaxZoom, + opacity: 0.8, + tms: false + }).addTo(map); + + map.fitBounds(imageBounds); + } else if (boundsNE[0] !== 0 && boundsSW[0] !== 0) { + // Non-tiled with geo bounds + map = L.map('regionMap', { + minZoom: 14, + maxZoom: 22 + }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 22, + attribution: '© OpenStreetMap' + }).addTo(map); + + L.imageOverlay(layerImageUrl, imageBounds, { + opacity: 0.8, + interactive: false + }).addTo(map); + + map.fitBounds(imageBounds); + } else { + // Simple CRS (pixel-based) + map = L.map('regionMap', { + crs: L.CRS.Simple, + minZoom: -3, + maxZoom: 5 + }); + + imageBounds = [[0, 0], [1000, 1000]]; + L.imageOverlay(layerImageUrl, imageBounds, { + opacity: 0.8, + interactive: false + }).addTo(map); + + map.fitBounds(imageBounds); + } + + // Add Geoman controls + map.pm.addControls({ + position: 'topleft', + drawCircle: false, + drawCircleMarker: false, + drawMarker: false, + drawPolyline: false, + drawText: false, + cutPolygon: false, + rotateMode: false + }); + + var currentLayer = null; + var regionLayerMap = {}; + + // Load existing regions + if (existingRegions && existingRegions.length > 0) { + existingRegions.forEach(function (region) { + var geom = region.GeoGeometry || region.PixelGeometry; + if (geom) { + try { + var coords = JSON.parse(geom); + var latLngs; + + // Check if it's GeoJSON or simple coordinate array + if (coords.type) { + // GeoJSON object + var geoLayer = L.geoJSON(coords, { + style: { color: region.Color || '#3388ff', fillOpacity: 0.3 } + }); + geoLayer.addTo(map); + geoLayer.bindTooltip(region.Name, { permanent: false, direction: 'center' }); + geoLayer.regionData = region; + regionLayerMap[region.IndoorMapZoneId] = geoLayer; + geoLayer.on('click', function () { + selectRegion(region, geoLayer); + }); + } else { + // Simple coordinate array + latLngs = coords.map(function(c) { return [c[1], c[0]]; }); + var polygon = L.polygon(latLngs, { + color: region.Color || '#3388ff', + fillOpacity: 0.3 + }).addTo(map); + + polygon.bindTooltip(region.Name, { permanent: false, direction: 'center' }); + polygon.regionData = region; + regionLayerMap[region.IndoorMapZoneId] = polygon; + + polygon.on('click', function () { + selectRegion(region, polygon); + }); + } + } catch (e) { + console.error('Failed to parse region geometry', e); + } + } + }); + } + + // When a new shape is created + map.on('pm:create', function (e) { + currentLayer = e.layer; + $('#regionProperties').show(); + $('#regionName').val(''); + $('#regionDescription').val(''); + $('#regionType').val('0'); + $('#regionColor').val('#3388ff'); + $('#regionSearchable').prop('checked', true); + $('#regionDispatchable').prop('checked', true); + }); + + function selectRegion(region, layer) { + currentLayer = layer; + $('#regionProperties').show(); + $('#regionName').val(region.Name); + $('#regionDescription').val(region.Description || ''); + $('#regionType').val(region.ZoneType); + $('#regionColor').val(region.Color || '#3388ff'); + $('#regionSearchable').prop('checked', region.IsSearchable); + $('#regionDispatchable').prop('checked', region.IsDispatchable); + currentLayer.existingRegionId = region.IndoorMapZoneId; + } + + // Save region + $('#saveRegionBtn').on('click', function () { + if (!currentLayer) return; + + var latLngs; + if (currentLayer.getLatLngs) { + latLngs = currentLayer.getLatLngs()[0]; + } else if (currentLayer.getLayers) { + var layers = currentLayer.getLayers(); + if (layers.length > 0 && layers[0].getLatLngs) { + latLngs = layers[0].getLatLngs()[0]; + } + } + + if (!latLngs) return; + + var pixelCoords = latLngs.map(function (ll) { return [ll.lng, ll.lat]; }); + + // Calculate centroid + var cx = 0, cy = 0; + pixelCoords.forEach(function (c) { cx += c[0]; cy += c[1]; }); + cx /= pixelCoords.length; + cy /= pixelCoords.length; + + var regionData = { + IndoorMapZoneId: currentLayer.existingRegionId || '', + IndoorMapFloorId: layerId, + Name: $('#regionName').val(), + Description: $('#regionDescription').val(), + ZoneType: parseInt($('#regionType').val()), + PixelGeometry: JSON.stringify(pixelCoords), + GeoGeometry: '', + CenterPixelX: cx, + CenterPixelY: cy, + CenterLatitude: cy, + CenterLongitude: cx, + Color: $('#regionColor').val(), + Metadata: '', + IsSearchable: $('#regionSearchable').is(':checked'), + IsDispatchable: $('#regionDispatchable').is(':checked'), + IsDeleted: false + }; + + $.ajax({ + url: saveRegionUrl, + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(regionData), + headers: { + 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val() + }, + success: function (result) { + if (result.success) { + currentLayer.existingRegionId = result.regionId; + if (currentLayer.setStyle) { + currentLayer.setStyle({ color: $('#regionColor').val() }); + } + if (currentLayer.bindTooltip) { + currentLayer.bindTooltip($('#regionName').val(), { permanent: false, direction: 'center' }); + } + location.reload(); + } else { + alert('Failed to save region: ' + (result.message || '')); + } + }, + error: function () { + alert('Error saving region'); + } + }); + }); + + // Delete region + $(document).on('click', '.delete-region', function (e) { + e.stopPropagation(); + var regionId = $(this).data('region-id'); + if (!confirm('Delete this region?')) return; + + $.ajax({ + url: deleteRegionUrl, + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({ RegionId: regionId }), + headers: { + 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val() + }, + success: function (result) { + if (result.success) { + if (regionLayerMap[regionId]) { + map.removeLayer(regionLayerMap[regionId]); + delete regionLayerMap[regionId]; + } + $('[data-region-id="' + regionId + '"]').remove(); + } + } + }); + }); +}); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/indoormaps/resgrid.indoormaps.editor.js b/Web/Resgrid.Web/wwwroot/js/app/internal/indoormaps/resgrid.indoormaps.editor.js new file mode 100644 index 00000000..fd5bba6d --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/indoormaps/resgrid.indoormaps.editor.js @@ -0,0 +1,40 @@ +$(document).ready(function () { + if (document.getElementById('boundsMap')) { + var lat = parseFloat($('#IndoorMap_CenterLatitude').val()) || 39.7392; + var lon = parseFloat($('#IndoorMap_CenterLongitude').val()) || -104.9903; + + var map = L.map('boundsMap').setView([lat, lon], 16); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 22, + attribution: '© OpenStreetMap' + }).addTo(map); + + var rect = null; + + function updateRect() { + var neLat = parseFloat($('#IndoorMap_BoundsNELat').val()); + var neLon = parseFloat($('#IndoorMap_BoundsNELon').val()); + var swLat = parseFloat($('#IndoorMap_BoundsSWLat').val()); + var swLon = parseFloat($('#IndoorMap_BoundsSWLon').val()); + + if (!isNaN(neLat) && !isNaN(neLon) && !isNaN(swLat) && !isNaN(swLon)) { + var bounds = [[swLat, swLon], [neLat, neLon]]; + if (rect) { + rect.setBounds(bounds); + } else { + rect = L.rectangle(bounds, { color: '#3388ff', weight: 2, fillOpacity: 0.1 }).addTo(map); + } + map.fitBounds(bounds); + } + } + + $('#IndoorMap_BoundsNELat, #IndoorMap_BoundsNELon, #IndoorMap_BoundsSWLat, #IndoorMap_BoundsSWLon').on('change', updateRect); + + map.on('click', function (e) { + $('#IndoorMap_CenterLatitude').val(e.latlng.lat.toFixed(7)); + $('#IndoorMap_CenterLongitude').val(e.latlng.lng.toFixed(7)); + }); + + updateRect(); + } +}); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/indoormaps/resgrid.indoormaps.index.js b/Web/Resgrid.Web/wwwroot/js/app/internal/indoormaps/resgrid.indoormaps.index.js new file mode 100644 index 00000000..f894acf3 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/indoormaps/resgrid.indoormaps.index.js @@ -0,0 +1,5 @@ +$(document).ready(function () { + $('#indoorMapsTable').DataTable({ + pageLength: 25 + }); +}); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/indoormaps/resgrid.indoormaps.zoneeditor.js b/Web/Resgrid.Web/wwwroot/js/app/internal/indoormaps/resgrid.indoormaps.zoneeditor.js new file mode 100644 index 00000000..c5f09e85 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/indoormaps/resgrid.indoormaps.zoneeditor.js @@ -0,0 +1,180 @@ +$(document).ready(function () { + var imageBounds = [boundsSW, boundsNE]; + var map = L.map('zoneMap', { + crs: L.CRS.Simple, + minZoom: -3, + maxZoom: 5 + }); + + // Use CRS.Simple with pixel-based bounds + var bounds = [[0, 0], [1000, 1000]]; + + // If we have geo bounds, use EPSG3857 + if (boundsNE[0] !== 0 && boundsSW[0] !== 0) { + map.remove(); + map = L.map('zoneMap', { + minZoom: 14, + maxZoom: 22 + }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 22, + attribution: '© OpenStreetMap' + }).addTo(map); + + imageBounds = [boundsSW, boundsNE]; + } else { + imageBounds = [[0, 0], [1000, 1000]]; + } + + var imageOverlay = L.imageOverlay(floorImageUrl, imageBounds, { + opacity: 0.8, + interactive: false + }).addTo(map); + + map.fitBounds(imageBounds); + + // Add Geoman controls + map.pm.addControls({ + position: 'topleft', + drawCircle: false, + drawCircleMarker: false, + drawMarker: false, + drawPolyline: false, + drawText: false, + cutPolygon: false, + rotateMode: false + }); + + var currentLayer = null; + var zoneLayerMap = {}; + + // Load existing zones + if (existingZones && existingZones.length > 0) { + existingZones.forEach(function (zone) { + if (zone.PixelGeometry) { + try { + var coords = JSON.parse(zone.PixelGeometry); + var latLngs = coords.map(function(c) { return [c[1], c[0]]; }); + var polygon = L.polygon(latLngs, { + color: zone.Color || '#3388ff', + fillOpacity: 0.3 + }).addTo(map); + + polygon.bindTooltip(zone.Name, { permanent: false, direction: 'center' }); + polygon.zoneData = zone; + zoneLayerMap[zone.IndoorMapZoneId] = polygon; + + polygon.on('click', function () { + selectZone(zone, polygon); + }); + } catch (e) { + console.error('Failed to parse zone geometry', e); + } + } + }); + } + + // When a new shape is created + map.on('pm:create', function (e) { + currentLayer = e.layer; + $('#zoneProperties').show(); + $('#zoneName').val(''); + $('#zoneDescription').val(''); + $('#zoneType').val('0'); + $('#zoneColor').val('#3388ff'); + $('#zoneSearchable').prop('checked', true); + }); + + function selectZone(zone, layer) { + currentLayer = layer; + $('#zoneProperties').show(); + $('#zoneName').val(zone.Name); + $('#zoneDescription').val(zone.Description || ''); + $('#zoneType').val(zone.ZoneType); + $('#zoneColor').val(zone.Color || '#3388ff'); + $('#zoneSearchable').prop('checked', zone.IsSearchable); + currentLayer.existingZoneId = zone.IndoorMapZoneId; + } + + // Save zone + $('#saveZoneBtn').on('click', function () { + if (!currentLayer) return; + + var latLngs = currentLayer.getLatLngs()[0]; + var pixelCoords = latLngs.map(function (ll) { return [ll.lng, ll.lat]; }); + + // Calculate centroid + var cx = 0, cy = 0; + pixelCoords.forEach(function (c) { cx += c[0]; cy += c[1]; }); + cx /= pixelCoords.length; + cy /= pixelCoords.length; + + var zoneData = { + IndoorMapZoneId: currentLayer.existingZoneId || '', + IndoorMapFloorId: floorId, + Name: $('#zoneName').val(), + Description: $('#zoneDescription').val(), + ZoneType: parseInt($('#zoneType').val()), + PixelGeometry: JSON.stringify(pixelCoords), + GeoGeometry: '', + CenterPixelX: cx, + CenterPixelY: cy, + CenterLatitude: cy, + CenterLongitude: cx, + Color: $('#zoneColor').val(), + Metadata: '', + IsSearchable: $('#zoneSearchable').is(':checked'), + IsDeleted: false + }; + + $.ajax({ + url: saveZoneUrl, + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(zoneData), + headers: { + 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val() + }, + success: function (result) { + if (result.success) { + currentLayer.existingZoneId = result.zoneId; + currentLayer.setStyle({ color: $('#zoneColor').val() }); + currentLayer.bindTooltip($('#zoneName').val(), { permanent: false, direction: 'center' }); + location.reload(); + } else { + alert('Failed to save zone: ' + (result.message || '')); + } + }, + error: function () { + alert('Error saving zone'); + } + }); + }); + + // Delete zone + $(document).on('click', '.delete-zone', function (e) { + e.stopPropagation(); + var zoneId = $(this).data('zone-id'); + if (!confirm('Delete this zone?')) return; + + $.ajax({ + url: deleteZoneUrl, + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({ ZoneId: zoneId }), + headers: { + 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val() + }, + success: function (result) { + if (result.success) { + if (zoneLayerMap[zoneId]) { + map.removeLayer(zoneLayerMap[zoneId]); + delete zoneLayerMap[zoneId]; + } + $('[data-zone-id="' + zoneId + '"]').remove(); + } + } + }); + }); +}); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.activeroutes.js b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.activeroutes.js new file mode 100644 index 00000000..3f1d2bc6 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.activeroutes.js @@ -0,0 +1,6 @@ +$(document).ready(function () { + // Auto-refresh active routes every 30 seconds + setInterval(function () { + location.reload(); + }, 30000); +}); 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 new file mode 100644 index 00000000..4db4d7b8 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.edit.js @@ -0,0 +1,33 @@ +$(document).ready(function () { + if (document.getElementById('routeMap')) { + var tiles = L.tileLayer( + osmTileUrl, + { + maxZoom: 19, + attribution: osmTileAttribution + } + ); + + var map = L.map('routeMap', { + scrollWheelZoom: false + }).setView([39.8283, -98.5795], 4).addLayer(tiles); + + // Add stop markers if available + if (typeof editStops !== 'undefined' && editStops.length > 0) { + var group = []; + + editStops.forEach(function (stop, index) { + if (stop.lat && stop.lng) { + var marker = L.marker([stop.lat, stop.lng]).addTo(map); + marker.bindPopup('' + (index + 1) + '. ' + stop.Name + ''); + group.push(marker); + } + }); + + if (group.length > 0) { + var bounds = L.featureGroup(group).getBounds(); + map.fitBounds(bounds, { padding: [50, 50] }); + } + } + } +}); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.index.js b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.index.js new file mode 100644 index 00000000..9b603b5e --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.index.js @@ -0,0 +1,6 @@ +$(document).ready(function () { + $('#routePlansTable').DataTable({ + order: [[4, 'desc']], + pageLength: 25 + }); +}); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.instancedetail.js b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.instancedetail.js new file mode 100644 index 00000000..d3182388 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.instancedetail.js @@ -0,0 +1,34 @@ +$(document).ready(function () { + if (document.getElementById('instanceMap')) { + var tiles = L.tileLayer( + osmTileUrl, + { + maxZoom: 19, + attribution: osmTileAttribution + } + ); + + var map = L.map('instanceMap', { + scrollWheelZoom: false + }).setView([39.8283, -98.5795], 4).addLayer(tiles); + + // Add instance stop markers if available + if (typeof instanceStops !== 'undefined' && instanceStops.length > 0) { + var group = []; + + instanceStops.forEach(function (stop, index) { + if (stop.lat && stop.lng) { + var statusText = ['Pending', 'Checked In', 'Completed', 'Skipped'][stop.Status] || 'Unknown'; + var marker = L.marker([stop.lat, stop.lng]).addTo(map); + marker.bindPopup('Stop ' + (stop.StopOrder + 1) + '
' + statusText); + group.push(marker); + } + }); + + if (group.length > 0) { + var bounds = L.featureGroup(group).getBounds(); + map.fitBounds(bounds, { padding: [50, 50] }); + } + } + } +}); 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 new file mode 100644 index 00000000..4ebe09b3 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.new.js @@ -0,0 +1,15 @@ +$(document).ready(function () { + if (document.getElementById('routeMap')) { + var tiles = L.tileLayer( + osmTileUrl, + { + maxZoom: 19, + attribution: osmTileAttribution + } + ); + + var map = L.map('routeMap', { + scrollWheelZoom: false + }).setView([39.8283, -98.5795], 4).addLayer(tiles); + } +}); 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 new file mode 100644 index 00000000..a8b13a1f --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/routes/resgrid.routes.view.js @@ -0,0 +1,47 @@ +$(document).ready(function () { + if (document.getElementById('routeMap')) { + var tiles = L.tileLayer( + osmTileUrl, + { + maxZoom: 19, + attribution: osmTileAttribution + } + ); + + var map = L.map('routeMap', { + scrollWheelZoom: false + }).setView([39.8283, -98.5795], 4).addLayer(tiles); + + // Add route geometry if available + if (typeof routeGeometry !== 'undefined' && routeGeometry) { + try { + var geojson = JSON.parse(routeGeometry); + L.geoJSON(geojson, { + style: { + color: routeColor || '#3388ff', + weight: 4, + opacity: 0.8 + } + }).addTo(map); + } catch (e) { } + } + + // Add stop markers + if (typeof routeStops !== 'undefined' && routeStops.length > 0) { + var group = []; + + routeStops.forEach(function (stop, index) { + if (stop.lat && stop.lng) { + var marker = L.marker([stop.lat, stop.lng]).addTo(map); + marker.bindPopup('' + (index + 1) + '. ' + stop.Name + ''); + group.push(marker); + } + }); + + if (group.length > 0) { + var bounds = L.featureGroup(group).getBounds(); + map.fitBounds(bounds, { padding: [50, 50] }); + } + } + } +});