From a53c0848b810d013d3fcce8bc30118b203d080d9 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Wed, 25 Mar 2026 12:04:21 +1100 Subject: [PATCH] Add Ops/Radio log modals, autosuggest & submit UI Add quick-entry handlers and UI/VM enhancements for Ops and Radio logs. main.js: introduce openBlankOpsLogModal and openBlankRadioOpsLogModal to open a prefilled/blank modal, focus the text input and install hotkeys for radio logs. OpsLogModalVM: keep parentVM reference, add job/team autocomplete inputs and suggestion pickers, extend reset/init logic, add openForRadioLog (auto-selects Radio contact tag) and submitting state, and prefill job fields when opening. RadioLogModalVM: add submitting observable and toggle it during submit. tasking.html: add toolbar buttons for Ops/Radio logs, add job/team input UI and dropdowns inside the Ops Log modal, and make submit buttons show a disabled state and spinner while submitting. These changes improve quick log creation, radio-specific defaults, and overall UX/feedback. --- src/pages/tasking/main.js | 44 +++++- src/pages/tasking/viewmodels/OpsLogModalVM.js | 143 ++++++++++++++---- .../tasking/viewmodels/RadioLogModalVM.js | 9 +- static/pages/tasking.html | 83 +++++++++- 4 files changed, 244 insertions(+), 35 deletions(-) diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index f09dfc0b..16c2ac7b 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -321,8 +321,8 @@ ResizeDividers(map) esri.basemapLayer('Topographic', { ignoreDeprecationWarning: true }).addTo(map); function VM() { - const self = this; + const self = this; self.mapVM = new MapVM(map, self); @@ -1506,6 +1506,48 @@ function VM() { }); }; + + + self.openBlankOpsLogModal = function() { + const modalEl = document.getElementById('CreateOpsLogModal'); + const modal = new bootstrap.Modal(modalEl); + + const vm = self.CreateOpsLogModalVM; + + vm.modalInstance = modal; + + vm.openForNewJobLog({}); // pass empty object for blank log + modal.show(); + + modalEl.addEventListener('shown.bs.modal', function () { + document.getElementById('OpsLogTextInput')?.focus(); + }, { once: true }); + }; + + self.openBlankRadioOpsLogModal = function() { + const modalEl = document.getElementById('CreateOpsLogModal'); + const modal = new bootstrap.Modal(modalEl); + + const vm = self.CreateOpsLogModalVM; + + vm.modalInstance = modal; + + vm.openForRadioLog({}); // blank radio log with Radio tag auto-selected + modal.show(); + + modalEl.addEventListener('shown.bs.modal', function () { + document.getElementById('OpsLogTextInput')?.focus(); + }, { once: true }); + + installModalHotkeys({ + modalEl, + onSave: () => vm.submit?.(), + onClose: () => modal.hide(), + allowInInputs: true + }); + }; + + self.openTrackableAssetsModal = function (data, event) { // If called from a dropdown menu, close the dropdown if (event && event.target) { diff --git a/src/pages/tasking/viewmodels/OpsLogModalVM.js b/src/pages/tasking/viewmodels/OpsLogModalVM.js index c8da69bc..e4beb2f8 100644 --- a/src/pages/tasking/viewmodels/OpsLogModalVM.js +++ b/src/pages/tasking/viewmodels/OpsLogModalVM.js @@ -5,6 +5,9 @@ export function CreateOpsLogModalVM(parentVM) { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; + // Store parentVM reference for use in computeds + self.parentVM = parentVM; + self.entityId = ko.observable(null); self.jobId = ko.observable(null); self.eventId = ko.observable(null); @@ -60,11 +63,42 @@ export function CreateOpsLogModalVM(parentVM) { self.actionRequired(anyActionTagSelected); }); + // When opening, prefill job/team fields if present + // (removed unused origOpenForNewJobLog) self.openForNewJobLog = async (job) => { self.resetFields(); - self.jobId(job.id() || ""); + if (job && typeof job.id === 'function') { + self.jobId(job.id() || ""); + self.jobIdInput(job.identifier ? job.identifier() : ''); + self.headerLabel(`New Ops Log for ${job.identifier() || ""}`); + } else { + self.jobId(""); + self.jobIdInput(""); + self.headerLabel("New Ops Log"); + } + self.initTags(); + } + + // Open for a new radio log — auto-selects the "Radio" contact method tag + self.openForRadioLog = async (job) => { + self.resetFields(); + if (job && typeof job.id === 'function') { + self.jobId(job.id() || ""); + self.jobIdInput(job.identifier ? job.identifier() : ''); + self.headerLabel(`New Radio Log for ${job.identifier() || ""}`); + } else { + self.jobId(""); + self.jobIdInput(""); + self.headerLabel("New Radio Log"); + } self.initTags(); - self.headerLabel(`New Ops Log for ${job.identifier() || ""}`); + + // Auto-select the "Radio" tag in Contact Methods (group 3) + self.uiTags().forEach(t => { + if (t.tagGroupId() === 3 && t.name && t.name().toLowerCase().includes('radio')) { + t.selected(true); + } + }); } self.toPayload = function () { @@ -112,50 +146,57 @@ export function CreateOpsLogModalVM(parentVM) { return true; } + self.submitting = ko.observable(false); + self.submit = function () { if (!validate()) { return; } + self.submitting(true); const payload = self.toPayload(); parentVM.createOpsLogEntry(payload, function (result) { - if (!result) { console.error("Ops Log submit failed"); return; } - if (self.modalInstance) { self.modalInstance.hide(); } + self.submitting(false); }); }; self.resetFields = function () { - self.entityId(null); - self.jobId(null); - self.eventId(null); - self.talkgroupId(null); - self.talkgroupRequestId(null); - - self.subject(""); - self.text(""); - self.position(null); - self.personFromId(null); - self.personTold(null); - - self.important(false); - self.restricted(false); - self.actionRequired(false); - self.actionReminder(null); - - self.timeLogged(null); - - self.uiTags([]); - self.headerLabel(""); - - self.showError(false); - self.errorMessage(""); + self.entityId(null); + self.jobId(null); + self.eventId(null); + self.talkgroupId(null); + self.talkgroupRequestId(null); + + self.subject(""); + self.text(""); + self.position(null); + self.personFromId(null); + self.personTold(null); + + self.important(false); + self.restricted(false); + self.actionRequired(false); + self.actionReminder(null); + + self.timeLogged(null); + + self.uiTags([]); + self.headerLabel(""); + + self.showError(false); + self.errorMessage(""); + + self.jobIdInput(""); + self.jobIdInputHasFocus(false); + self.teamInput(""); + self.teamInputHasFocus(false); }; function uiTag(tag) { @@ -181,4 +222,50 @@ export function CreateOpsLogModalVM(parentVM) { }); } + // Job and Team Autocomplete + self.jobIdInput = ko.observable(""); + self.jobIdInputHasFocus = ko.observable(false); + self.jobIdSuggestions = ko.pureComputed(() => { + const input = self.jobIdInput().toLowerCase(); + if (!input) return []; + return ko.unwrap(self.parentVM.jobs) + .filter(j => (j.identifier && j.identifier().toLowerCase().includes(input)) || (j.id && String(j.id()).includes(input))) + .slice(0, 10) + .map(j => { + const id = j.identifier ? j.identifier() : ''; + const type = j.typeName ? j.typeName() : ''; + const addr = j.address && j.address.prettyAddress ? j.address.prettyAddress() : ''; + const detail = [type, addr].filter(Boolean).join(' — '); + return { id: id, detail: detail, label: id, job: j }; + }); + }); + self.showJobDropdown = ko.pureComputed(() => self.jobIdInputHasFocus() && self.jobIdSuggestions().length > 0); + self.pickJobSuggestion = function(suggestion, event) { + if (event && event.preventDefault) event.preventDefault(); + if (suggestion && suggestion.job) { + self.jobIdInput(suggestion.label); + self.jobId(suggestion.job.id()); + } + self.jobIdInputHasFocus(false); + }; + + self.teamInput = ko.observable(""); + self.teamInputHasFocus = ko.observable(false); + self.teamSuggestions = ko.pureComputed(() => { + const input = self.teamInput().toLowerCase(); + if (!input) return []; + return ko.unwrap(self.parentVM.trackableAssets) + .filter(a => (a.name && a.name().toLowerCase().includes(input))) + .slice(0, 10) + .map(a => ({ label: a.name ? a.name() : '', asset: a })); + }); + self.showTeamDropdown = ko.pureComputed(() => self.teamInputHasFocus() && self.teamSuggestions().length > 0); + self.pickTeamSuggestion = function(suggestion, event) { + if (event && event.preventDefault) event.preventDefault(); + if (suggestion && suggestion.asset) { + self.teamInput(suggestion.label); + self.subject(`${suggestion.label} - ${self.subject()}`); + } + self.teamInputHasFocus(false); + }; } diff --git a/src/pages/tasking/viewmodels/RadioLogModalVM.js b/src/pages/tasking/viewmodels/RadioLogModalVM.js index 27958ff6..202e871a 100644 --- a/src/pages/tasking/viewmodels/RadioLogModalVM.js +++ b/src/pages/tasking/viewmodels/RadioLogModalVM.js @@ -5,6 +5,10 @@ export function CreateRadioLogModalVM(parentVM) { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; + // Submitting state for UI feedback + self.submitting = ko.observable(false); + + self.entityId = ko.observable(null); self.jobId = ko.observable(null); self.eventId = ko.observable(null); @@ -150,18 +154,17 @@ export function CreateRadioLogModalVM(parentVM) { if (!validate()) { return; } + self.submitting(true); const payload = self.toPayload(); - parentVM.createOpsLogEntry(payload, function (result) { - if (!result) { console.error("Ops Log submit failed"); return; } - if (self.modalInstance) { self.modalInstance.hide(); } + self.submitting(false); }); }; diff --git a/static/pages/tasking.html b/static/pages/tasking.html index 402ed79b..8ac601ed 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -35,6 +35,14 @@ data-bind="click: togglePinnedTeamsOnly, css: { active: showPinnedTeamsOnly }, disable: !countPinnedTeams()"> + + + + + @@ -3420,6 +3443,7 @@
Contact Types
@@ -3462,6 +3486,50 @@
Action Items
Subject
+ + +
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+
+
Text
@@ -3470,7 +3538,16 @@
Text