diff --git a/docs/lad/images/radio-log-dialog.png b/docs/lad/images/radio-log-dialog.png
index b8fb55dd..5ab4fa39 100644
Binary files a/docs/lad/images/radio-log-dialog.png and b/docs/lad/images/radio-log-dialog.png differ
diff --git a/docs/lad/images/radio-log-status-change.png b/docs/lad/images/radio-log-status-change.png
new file mode 100644
index 00000000..47342bd7
Binary files /dev/null and b/docs/lad/images/radio-log-status-change.png differ
diff --git a/docs/lad/images/radio-log-task-team.png b/docs/lad/images/radio-log-task-team.png
new file mode 100644
index 00000000..f8eced47
Binary files /dev/null and b/docs/lad/images/radio-log-task-team.png differ
diff --git a/docs/lad/incident-register.md b/docs/lad/incident-register.md
index 19b97723..3bbf9d12 100644
--- a/docs/lad/incident-register.md
+++ b/docs/lad/incident-register.md
@@ -94,7 +94,7 @@ Each team in the expanded incident's taskings list has the same four actions as

-- **Radio Log** (against the incident): opens [New Radio Log](logs-and-messages.md#new-radio-log), logged against the incident and including the team's callsign.
+- **Radio Log** (against the incident): opens [New Radio Log](logs-and-messages.md#new-radio-log) with the incident and team filled in. You can also [change the team's status](logs-and-messages.md#changing-the-teams-status) when you submit.
- **Message Team** (against the incident): opens [Send SMS](logs-and-messages.md#send-sms) with the team members as recipients, associated with the incident.
- **Open in Remote Tab**: opens the team in the Beacon remote tab.
- **Focus on map**: focuses the Situation Map on the team's radio location, if matched.
diff --git a/docs/lad/logs-and-messages.md b/docs/lad/logs-and-messages.md
index 17d22133..71ab3f06 100644
--- a/docs/lad/logs-and-messages.md
+++ b/docs/lad/logs-and-messages.md
@@ -9,17 +9,55 @@ LAD can create radio logs and ops log entries, and send SMS to team members, wit
## New Radio Log
-Clicking a **Radio Log** button opens the New Radio Log dialog. The title shows the team's callsign, plus the incident ID if opened against an incident.
+Clicking a **Radio Log** button opens the New Radio Log dialog. It is the same dialog as [New Ops Log](#new-ops-log), with the *Radio* contact method already selected. The title shows the team's callsign, plus the incident ID if opened against an incident.
-- Clicking Radio Log against a **team on an incident** creates the log against that incident.
-- Clicking Radio Log against the **team** itself does not associate it with an incident.
+- Clicking Radio Log on a **tasking** (a team on an incident) fills in the **Incident** and **Team** fields, and the subject is the team's callsign. The log is created against that incident, and you can [change the team's status](#changing-the-teams-status) at the same time.
+- Clicking Radio Log on a **team** fills in the **Team** field and the subject, but does not associate the log with an incident. If you pick an incident that the team is tasked to, you can change the team's status too.
+- Clicking Radio Log in the [register controls](interface.md#register-controls) opens the dialog with nothing filled in.

-Clicking **Submit** creates a new ops log entry. The subject is the team's Beacon name followed by your message, and the entry has the *Radio* tag applied. If created against an incident, it is visible in that incident's *Notes* / *Ops Log Entries*.
+Add any other tags you need, enter your message in **Text**, then click **Submit**. The entry is created with the tags you selected. If created against an incident, it is visible in that incident's *Notes* / *Ops Log Entries*.

+The Spotlight [`radio`](spotlight.md#radio-log-command--radio) command opens the same dialog with the incident and team filled in, the same as clicking Radio Log on a tasking.
+
+### Changing the Team's Status
+
+When the **Incident** and **Team** fields point at a team that is tasked to that incident, the bottom of the dialog shows the team's current status for that incident, followed by **update to** and a status menu. This happens straight away when the dialog is opened from a tasking. It also happens when you fill the fields in yourself, for example after opening it from a team row or the register controls, and in the New Ops Log dialog:
+
+- **Incident**: pick the incident from the suggestions. Typing the ID without picking it doesn't attach the log to the incident, so the status menu doesn't appear.
+- **Team**: type the team's callsign, or pick a trackable asset that is matched to the team.
+
+The menu starts at **No change** and lists the same statuses as [Tasking Status](tasking.md#tasking-status). Statuses the team can't change to from its current status are greyed out.
+
+
+
+- Picking a status does not change anything straight away. The change is made when you click **Submit**, and the button shows the status, for example **Submit & On site**.
+- If the status needs more information (a time, ETA, ETC or reason), a **Status change** box appears at the bottom of the dialog, and the dialog scrolls down to it. It works the same way as in the tasking status dropdown.
+- **Complete** opens Beacon's *Team Completion* dialog in the remote tab after the log entry is saved.
+- If you change the **Incident** or **Team** field, the status menu follows: it switches to that team's tasking on that incident, or is hidden if there isn't one. Any status you'd picked is cleared.
+
+When you click **Submit**, LAD changes the status first and only saves the log entry if that worked:
+
+- If the status change fails, the dialog shows why and stays open. Nothing is logged, so you can fix it or pick **No change** and submit again.
+- If the team's status changed while the dialog was open (for example, someone else set it in Beacon) and the status you picked is no longer available, the dialog says so and nothing is submitted.
+- If the status changes but the log entry fails to save, the dialog tells you. Submitting again only retries the log entry; the status is not sent twice.
+
+### Tasking the Team from a Log
+
+If the **Incident** and **Team** fields point at a team that isn't tasked to that incident (or whose tasking there is complete, called off or removed), the bottom of the dialog says so and offers two buttons instead of the status menu:
+
+
+
+- **Task Team**: tasks the team to the incident when you click **Submit**.
+- **Task Team & SMS**: tasks the team when you click **Submit**, then opens [Send SMS](#send-sms) addressed to the team, pre-filled with the incident details, once the dialog has closed. This is the same message as [Instant Task](tasking.md#tasking-the-team)'s **Task & SMS Details**.
+
+Click a button again to unselect it. Nothing happens until you click **Submit**, and the button shows what will happen, for example **Submit, Task Team & SMS**. The team must be a known team: type its callsign or pick one of its trackable assets.
+
+As with a status change, LAD tasks the team first and only saves the log entry if that worked. If tasking fails, the dialog shows why and nothing is logged. If the team is tasked but the log entry fails to save, submitting again only retries the log entry and doesn't task the team twice, and Send SMS still opens once it saves. If someone else tasks the team to the incident while the dialog is open, the dialog says so and the status menu is shown instead.
+
## New Ops Log
Clicking an **Ops Log** button opens the New Ops Log dialog. It works the same way as Beacon notes: select tags, enter a subject and text. If opened from an incident it is automatically attached to that incident. You can also enter a team to pre-fill the subject with the team's name.
diff --git a/docs/lad/spotlight.md b/docs/lad/spotlight.md
index 331fffce..13faae1d 100644
--- a/docs/lad/spotlight.md
+++ b/docs/lad/spotlight.md
@@ -60,7 +60,7 @@ Creates a radio log against a team on an incident. Works the same way as `task`:

-4. Press Enter to open the [New Radio Log](logs-and-messages.md#new-radio-log) dialog.
+4. Press Enter to open the [New Radio Log](logs-and-messages.md#new-radio-log) dialog with the incident and team filled in. If the team is tasked to the incident you can [change its status](logs-and-messages.md#changing-the-teams-status); if not, you can [task it](logs-and-messages.md#tasking-the-team-from-a-log).
### Ops Log command — `log`
diff --git a/docs/lad/tasking.md b/docs/lad/tasking.md
index ba163414..a92fdd3a 100644
--- a/docs/lad/tasking.md
+++ b/docs/lad/tasking.md
@@ -5,11 +5,12 @@ nav_order: 8
# Tasking Teams
-There are three ways to task a team to an incident in LAD:
+There are four ways to task a team to an incident in LAD:
- **Instant Task**: click **Task** on an incident in the [Incident Register](incident-register.md#incident-actions), or **Task Team** in its [map popup](situation-map.md#incident-popup-actions).
- **Drag & drop**: drag an incident onto a team in the Team Register.
- **Spotlight**: type `task`, then a team and an incident. See [Task Team command](spotlight.md#task-team-command--task).
+- **From a radio or ops log**: fill in the log's **Incident** and **Team**, then choose **Task Team** or **Task Team & SMS**. The team is tasked when you submit the log. See [Tasking the Team from a Log](logs-and-messages.md#tasking-the-team-from-a-log).
Once tasked, the tasking shows under the team in the [Team Register](team-register.md#team-taskings) and under the incident in the [Incident Register](incident-register.md#incident-taskings-actions), where you can [update its status](#tasking-status).
@@ -67,4 +68,6 @@ Each tasking shows its status, in both registers and in the incident's map popup

+You can also change a tasking's status while logging a radio message: click the tasking's **Radio Log** button and pick the new status at the bottom of the dialog. See [Changing the Team's Status](logs-and-messages.md#changing-the-teams-status).
+
To change the order of a team's taskings, see [Reordering Taskings](team-register.md#reordering-taskings).
diff --git a/docs/lad/team-register.md b/docs/lad/team-register.md
index 45a35297..7b2bf725 100644
--- a/docs/lad/team-register.md
+++ b/docs/lad/team-register.md
@@ -35,7 +35,7 @@ Every team row has four action buttons, whether or not the team is expanded:

-- **Radio Log**: opens [New Radio Log](logs-and-messages.md#new-radio-log) against the team.
+- **Radio Log**: opens [New Radio Log](logs-and-messages.md#new-radio-log) with the team filled in. The log isn't attached to an incident.
- **Message Team**: opens [Send SMS](logs-and-messages.md#send-sms) with the team members as recipients. Greyed out if the team has no members.
- **Open in Remote Tab**: opens the team's edit page in the Beacon [remote tab](getting-started.md#remote-beacon-tab), e.g. to change the team's name or members.
- **Focus on map**: focuses the Situation Map on the team's radio location. Greyed out if no radios are matched.
@@ -114,7 +114,7 @@ Click **Refresh** to reload the team's tasking details.
Each tasking in the list has four actions:
-- **Radio Log** (against the incident): opens [New Radio Log](logs-and-messages.md#new-radio-log), logged against the incident and including the team's callsign.
+- **Radio Log** (against the incident): opens [New Radio Log](logs-and-messages.md#new-radio-log) with the incident and team filled in. You can also [change the team's status](logs-and-messages.md#changing-the-teams-status) when you submit.
- **Message Team** (against the incident): opens [Send SMS](logs-and-messages.md#send-sms) with the team members as recipients, associated with the incident.
- **Open Incident in Remote Tab**: opens the incident in the Beacon remote tab.
- **Focus on map**: focuses the Situation Map on the incident if it is geocoded and within your incident filters. Greyed out if the incident is outside your filters or has no GPS location (it won't be on the map).
diff --git a/docs/whats-new/index.md b/docs/whats-new/index.md
index f225c267..b96adf09 100644
--- a/docs/whats-new/index.md
+++ b/docs/whats-new/index.md
@@ -18,6 +18,16 @@ section of the LAD guide with a full https://lighthouse.ses.nsw.gov.au/guides/la
The home page shows the first three `##` entries, so keep the title short.
-->
+## LAD radio logs with status changes
+
+*15 October 2026*
+
+- **Change a team's status from a radio log.** When a radio or ops log's **Incident** and **Team** fields point at a team tasked to that incident (straight away when you open **Radio Log** from a tasking, or once you fill them in), the bottom of the dialog shows the team's current status with an **update to** menu. The new status is only set when you click **Submit**, and the log entry is only saved if the status change worked. If it doesn't, the dialog explains why and nothing is logged. See [Changing the Team's Status](https://lighthouse.ses.nsw.gov.au/guides/lad/logs-and-messages.html#changing-the-teams-status).
+- **Task a team from a radio or ops log.** If the team in the log isn't tasked to the incident, choose **Task Team** or **Task Team & SMS** at the bottom of the dialog. The team is tasked when you click **Submit**, before the log entry is saved, and **Task Team & SMS** opens Send SMS once the dialog closes. See [Tasking the Team from a Log](https://lighthouse.ses.nsw.gov.au/guides/lad/logs-and-messages.html#tasking-the-team-from-a-log).
+- **Radio Log on teams and taskings, and the Spotlight `radio` command, open the full dialog.** It now uses the same dialog as New Ops Log, with the *Radio* tag already selected, so you can add other tags. The incident, team and subject are filled in for you. See [New Radio Log](https://lighthouse.ses.nsw.gov.au/guides/lad/logs-and-messages.html#new-radio-log).
+- If a log entry fails to save, the **Submit** button no longer stays stuck on *Submitting…*.
+- In dark mode, selected tags in the New Ops Log and New Radio Log dialogs are readable.
+
## LAD map and dark mode fixes
*15 October 2026 · [v2026.10.15](https://github.com/NSWSESMembers/Lighthouse/releases/tag/v2026.10.15)*
diff --git a/scripts/lad-docs/capture.mjs b/scripts/lad-docs/capture.mjs
index a0e5df6d..851d07b5 100644
--- a/scripts/lad-docs/capture.mjs
+++ b/scripts/lad-docs/capture.mjs
@@ -74,6 +74,22 @@ async function openPhotos(page) {
await settle(page, 1200);
}
+/**
+ * Open New Radio Log from the Radio Log button on DEM01's tasking under
+ * 2610-1207 (70006), so the incident, team and status change are all filled.
+ */
+async function openTaskingRadioLog(page) {
+ await closeConfigAndLoad(page);
+ const row = page.locator('tr.job-row[data-job-id="70006"]');
+ await row.scrollIntoViewIfNeeded();
+ await row.locator('[data-bind*="toggleAndExpand"]').first().click();
+ await settle(page, 800);
+ await row.locator('button[title="Radio Log"]:visible').first().click();
+ await page.locator('#CreateOpsLogModal').waitFor({ state: 'visible' });
+ await page.locator('#OpsLogTextInput').fill('Team arrived on scene, starting roof assessment.');
+ await settle(page, 600);
+}
+
/** Open the Trackable Asset Library from the config modal's rail link (saves + loads, then opens it). */
async function openAssetLibrary(page) {
await page.locator('#configModal button:has-text("Trackable Asset Library")').click();
@@ -387,6 +403,50 @@ const SHOTS = {
}),
},
+ // New Radio Log opened from a tasking: Radio tag selected, incident and
+ // team filled, current status + "update to" in the footer.
+ 'radio-log-dialog': {
+ launch: { viewport: { width: 1400, height: 1000 } },
+ run: openTaskingRadioLog,
+ target: (page) => page.locator('#CreateOpsLogModal .modal-content'),
+ },
+
+ // ...with On site picked: the status change details and the footer.
+ 'radio-log-status-change': {
+ launch: { viewport: { width: 1400, height: 1000 } },
+ run: async (page) => {
+ await openTaskingRadioLog(page);
+ await page.locator('#CreateOpsLogModal .modal-footer .dropup .dropdown-toggle').click();
+ await page.locator('#CreateOpsLogModal .modal-footer .dropdown-item:has-text("On site")').click();
+ await page.locator('#CreateOpsLogModal .ops-log-status-details button:has-text("+30")').click();
+ // don't capture the +30 button in its hover/focus state
+ await page.mouse.move(0, 0);
+ await page.evaluate(() => document.activeElement?.blur());
+ await settle(page, 800);
+ },
+ target: (page) => unionOf(page, '#CreateOpsLogModal .ops-log-status-details, #CreateOpsLogModal .modal-footer', 0),
+ },
+
+ // Blank New Radio Log pointed at a team that isn't tasked to the incident
+ // (DEM02 on 2610-1207): the Task Team / Task Team & SMS choice in the footer.
+ 'radio-log-task-team': {
+ launch: { viewport: { width: 1400, height: 1000 } },
+ run: async (page) => {
+ await closeConfigAndLoad(page);
+ await page.locator('#paneTop button[title="Radio Log"]:visible').first().click();
+ await page.locator('#CreateOpsLogModal').waitFor({ state: 'visible' });
+ await settle(page, 600); // the dialog focuses Text once shown
+ await page.locator('#CreateOpsLogModal input[placeholder^="Search by incident"]').fill('2610-1207');
+ await page.locator('#CreateOpsLogModal ul[data-ops-log-dropdown="job"] button').first().dispatchEvent('mousedown');
+ await page.locator('#CreateOpsLogModal input[placeholder^="Search by team"]').fill('DEM02');
+ await page.locator('#OpsLogTextInput').fill('DEM02 available, tasking to this incident.');
+ await page.locator('#CreateOpsLogModal .modal-footer button:has-text("Task Team & SMS")').click();
+ await page.mouse.move(0, 0);
+ await settle(page, 600);
+ },
+ target: (page) => page.locator('#CreateOpsLogModal .modal-footer'),
+ },
+
spotlight: {
run: async (page) => {
await closeConfigAndLoad(page);
diff --git a/scripts/lad-docs/mockBeacon.mjs b/scripts/lad-docs/mockBeacon.mjs
index c783ab27..5575f197 100644
--- a/scripts/lad-docs/mockBeacon.mjs
+++ b/scripts/lad-docs/mockBeacon.mjs
@@ -44,9 +44,24 @@ export function createMockRouter(scenario, { log = () => {}, photoImages = null
const method = route.request().method();
let m;
+ // Task a team (JSON {TeamIds, JobIds}) -- creates the tasking, and returns
+ // the created rows as Beacon does (LAD treats an empty result as failure).
+ if (method === 'POST' && p === '/Api/v1/Tasking') {
+ const body = route.request().postDataJSON() || {};
+ const rows = (body.TeamIds || []).flatMap((teamId) => (body.JobIds || []).map((jobId) => scenario.createTasking(teamId, jobId))).filter(Boolean);
+ log(`[mock] POST ${p} -> ${rows.length} tasking(s) created`);
+ return json(route, rows);
+ }
+ // Create an ops log entry (form-encoded) -- returns the created entry.
+ if (method === 'POST' && p === '/Api/v1/OperationsLog') {
+ const entry = scenario.createOpsLogEntry(new URLSearchParams(route.request().postData() || ''));
+ log(`[mock] POST ${p} -> ops log ${entry.Id}`);
+ return json(route, entry);
+ }
+
if (method !== 'GET') {
- // Writes (task/untask, status changes, ops log, SMS...) succeed with
- // nothing changed -- enough for click-throughs not to error.
+ // Other writes (untask, status changes, SMS...) succeed with nothing
+ // changed -- enough for click-throughs not to error.
log(`[mock] ${method} ${p} (accepted, no-op)`);
return route.fulfill({ status: 200, contentType: 'application/json', body: 'null' });
}
diff --git a/scripts/lad-docs/scenario.mjs b/scripts/lad-docs/scenario.mjs
index 334621a2..2db84795 100644
--- a/scripts/lad-docs/scenario.mjs
+++ b/scripts/lad-docs/scenario.mjs
@@ -78,12 +78,44 @@ const TAGS = {
RoofLeak: { Id: 9136, Name: 'Roof Leak', TagGroupId: 6, Description: null },
};
-// Ops-log tags. Group 27 (OpsLogActionItems) are the action-required ones;
-// names follow Beacon's vocabulary, ids are fake.
+// Ops-log tag groups, as the Ops/Radio Log modal lists them: 2 Contact Types,
+// 3 Contact Methods, 4 Entry Purpose, 27 Action Items (the action-required
+// ones). Names follow Beacon's vocabulary; ids are fake, except the ones
+// tagFactory.js keys an icon off (TagIconsOverride) -- those keep that id so
+// the pills render with the same icon as in Beacon, and Radio (6) is what the
+// radio log auto-selects.
+const ICON_TAG_IDS = {
+ Ambulance: 11, FRNSW: 13, Police: 10, SES: 15,
+ ICEMS: 420, 'Phone Call': 2, Radio: 6,
+ Incoming: 256, Outgoing: 257,
+};
+const opsTagGroup = (groupId, firstId, names) =>
+ names.map((Name, i) => ({ Id: ICON_TAG_IDS[Name] ?? firstId + i, Name, TagGroupId: groupId, Description: null }));
+
+const OPS_TAG_GROUPS = {
+ 2: opsTagGroup(2, 9300, [
+ 'Ambulance', 'Arborist', 'BOM', 'Contractor', 'Council', 'Crane/EWP', 'Electricity', 'FRNSW',
+ 'Gas', 'HAZMAT', 'Incident Contact', 'NPWS', 'Other', 'Police', 'Recon', 'Resident',
+ 'RFS', 'RMS', 'SES', 'Telco', 'TMC', 'Water',
+ ]),
+ 3: opsTagGroup(3, 9230, ['Email', 'Face-to-Face', 'Fax', 'ICEMS', 'Phone Call', 'Printed', 'Radio', 'SMS']),
+ 4: opsTagGroup(4, 9400, [
+ 'All Capabilities', 'Backdated', 'CFR', 'Flood/Storm', 'FR In Water', 'FR On Water', 'GLR',
+ 'Incoming', 'Information', 'Outgoing', 'RCR', 'Support', 'Unavailability', 'Update', 'VR',
+ ]),
+ 27: opsTagGroup(27, 9270, [
+ 'Awaiting Update', 'Call Made', 'Call Needed', 'Further Action Required', 'Handover', 'Media',
+ 'Not SES Incident', 'Ready To Task', 'Referral Required', 'Reopened', 'Retrieve Equipment',
+ 'Teams Complete', 'Untasked', 'Welfare Check',
+ ]),
+};
+const opsTag = (groupId, name) => OPS_TAG_GROUPS[groupId].find((t) => t.Name === name);
+
+// The ops-log tags the fixture entries below use.
const OPS_TAGS = {
- FurtherAction: { Id: 9271, Name: 'Further Action Required', TagGroupId: 27, Description: null },
- CallMade: { Id: 9272, Name: 'Call Made', TagGroupId: 27, Description: null },
- PhoneCall: { Id: 9231, Name: 'Phone Call', TagGroupId: 3, Description: null },
+ FurtherAction: opsTag(27, 'Further Action Required'),
+ CallMade: opsTag(27, 'Call Made'),
+ PhoneCall: opsTag(3, 'Phone Call'),
};
// Ops log entries: [jobIndex, subject, text, tagKeys, hoursAgo, actionRequired]
@@ -296,36 +328,34 @@ export function buildScenario(now = new Date()) {
};
});
- // Ops log entries (OperationsLog/search shape).
- const opsLog = OPS_LOG_SPECS.map(([ji, subject, text, tagKeys, hoursAgo, actionRequired], i) => {
- const j = jobs[ji];
- const at = t(hoursAgo);
- return {
- Id: 41000 + i,
- JobLabel: j.Identifier,
- JobId: j.Id,
- EventLabel: j.Event.Identifier,
- EventId: j.Event.Id,
- Entity: { ...HQ },
- Important: false,
- Position: null,
- Subject: subject,
- Text: text,
- Restricted: false,
- ActionRequired: actionRequired,
- ActionReminder: null,
- ReadOnly: false,
- TalkgroupId: null,
- TalkgroupLabel: null,
- TalkgroupRequestId: null,
- TalkgroupRequestLabel: null,
- TimeLogged: at,
- Tags: tagKeys.map((k) => ({ ...OPS_TAGS[k], CreatedOn: '2015-01-01T00:00:00', CreatedBy: 1 })),
- CreatedOn: at,
- CreatedBy: OPERATOR,
- ICEMSIncidentIdentifier: null,
- };
+ // Ops log entries (OperationsLog/search shape). j may be null (no incident).
+ const opsLogRow = (id, j, subject, text, tags, at, actionRequired) => ({
+ Id: id,
+ JobLabel: j ? j.Identifier : null,
+ JobId: j ? j.Id : null,
+ EventLabel: j ? j.Event.Identifier : null,
+ EventId: j ? j.Event.Id : null,
+ Entity: { ...HQ },
+ Important: false,
+ Position: null,
+ Subject: subject,
+ Text: text,
+ Restricted: false,
+ ActionRequired: actionRequired,
+ ActionReminder: null,
+ ReadOnly: false,
+ TalkgroupId: null,
+ TalkgroupLabel: null,
+ TalkgroupRequestId: null,
+ TalkgroupRequestLabel: null,
+ TimeLogged: at,
+ Tags: tags.map((tag) => ({ ...tag, CreatedOn: '2015-01-01T00:00:00', CreatedBy: 1 })),
+ CreatedOn: at,
+ CreatedBy: OPERATOR,
+ ICEMSIncidentIdentifier: null,
});
+ const opsLog = OPS_LOG_SPECS.map(([ji, subject, text, tagKeys, hoursAgo, actionRequired], i) =>
+ opsLogRow(41000 + i, jobs[ji], subject, text, tagKeys.map((k) => OPS_TAGS[k]), t(hoursAgo), actionRequired));
// What Beacon reports as a job's outstanding action tags: the group-27
// tags on its unresolved action-required entries.
const actionTagsByJob = new Map();
@@ -400,53 +430,70 @@ export function buildScenario(now = new Date()) {
});
const taskings = [];
+ // A tasking row: `reached` = how many TASKING_STEPS are done, at stepTimes.
+ const taskingRow = (tm, j, sequence, reached, stepTimes) => {
+ const history = TASKING_STEPS.slice(0, reached).map(([id, name], k) => ({
+ Type: id, Name: name, Description: name, TimeLogged: stepTimes[k], EstimatedCompletion: null,
+ CreatedOn: stepTimes[k], CreatedBy: OPERATOR,
+ }));
+ const [curId, curName] = TASKING_STEPS[reached - 1];
+ const stamp = (k) => (k < reached ? stepTimes[k] : null);
+ return {
+ Id: 90000 + taskings.length,
+ CurrentStatus: curName,
+ CurrentStatusTime: stepTimes[reached - 1],
+ CurrentStatusId: curId,
+ EstimatedStatusEndTime: null,
+ Enroute: stamp(1),
+ EnrouteEstimatedCompletion: stamp(1),
+ Onsite: stamp(2),
+ OnsiteEstimatedCompletion: stamp(2),
+ Offsite: stamp(3),
+ Complete: null,
+ Team: taskingTeam(tm),
+ Job: taskingJob(j),
+ Sequence: sequence,
+ Manifest: [],
+ DistanceToScene: null,
+ InjuriesSustained: false,
+ AARCompleted: false,
+ CISPRequired: false,
+ RiskAssessmentCompleted: false,
+ VesselUsed: false,
+ PrimaryActivityType: null,
+ PrimaryTaskType: null,
+ ActionTaken: null,
+ EquipmentUsed: [],
+ EquipmentKeptByName: null,
+ EquipmentKeptByNumber: null,
+ Injuries: [],
+ SafetyManagementSheet: null,
+ JobTeamStatusTypeHistory: history,
+ };
+ };
TEAM_SPECS.forEach(([, , , jobIdx], ti) => {
jobIdx.forEach((ji, seq) => {
// First job in a team's queue is under way; the rest are just tasked.
const reached = seq === 0 ? (ti % 2 === 0 ? 3 : 2) : 1; // how many TASKING_STEPS are done
const startH = Math.max(0.15, JOB_SPECS[ji][4] - 0.1);
const stepTimes = TASKING_STEPS.slice(0, reached).map((_, k) => t(startH - k * 0.05));
- const history = TASKING_STEPS.slice(0, reached).map(([id, name], k) => ({
- Type: id, Name: name, Description: name, TimeLogged: stepTimes[k], EstimatedCompletion: null,
- CreatedOn: stepTimes[k], CreatedBy: OPERATOR,
- }));
- const [curId, curName] = TASKING_STEPS[reached - 1];
- const stamp = (k) => (k < reached ? stepTimes[k] : null);
- taskings.push({
- Id: 90000 + taskings.length,
- CurrentStatus: curName,
- CurrentStatusTime: stepTimes[reached - 1],
- CurrentStatusId: curId,
- EstimatedStatusEndTime: null,
- Enroute: stamp(1),
- EnrouteEstimatedCompletion: stamp(1),
- Onsite: stamp(2),
- OnsiteEstimatedCompletion: stamp(2),
- Offsite: stamp(3),
- Complete: null,
- Team: taskingTeam(teams[ti]),
- Job: taskingJob(jobs[ji]),
- Sequence: seq + 1,
- Manifest: [],
- DistanceToScene: null,
- InjuriesSustained: false,
- AARCompleted: false,
- CISPRequired: false,
- RiskAssessmentCompleted: false,
- VesselUsed: false,
- PrimaryActivityType: null,
- PrimaryTaskType: null,
- ActionTaken: null,
- EquipmentUsed: [],
- EquipmentKeptByName: null,
- EquipmentKeptByNumber: null,
- Injuries: [],
- SafetyManagementSheet: null,
- JobTeamStatusTypeHistory: history,
- });
+ taskings.push(taskingRow(teams[ti], jobs[ji], seq + 1, reached, stepTimes));
});
});
+ // What POST /Api/v1/Tasking creates (the mock calls this): a new "Tasked"
+ // tasking at the end of the team's queue, so a "Task Team" click-through
+ // shows the team on the incident after LAD refetches.
+ const createTasking = (teamId, jobId) => {
+ const tm = teams.find((x) => x.Id === Number(teamId));
+ const j = jobs.find((x) => x.Id === Number(jobId));
+ if (!tm || !j) return null;
+ const seq = taskings.filter((x) => x.Team.Id === tm.Id).length + 1;
+ const row = taskingRow(tm, j, seq, 1, [t(0)]);
+ taskings.push(row);
+ return row;
+ };
+
// PSN radio positions: a bare array of GeoJSON features (Beacon's
// ResourceLocations/Radio), see BeaconClient/asset.js.
const radio = TEAM_SPECS.filter((s) => s[4]).map(([, , , jobIdx, assetName, [dLat, dLng]], i) => {
@@ -551,9 +598,23 @@ export function buildScenario(now = new Date()) {
})),
};
+ const stampTag = (x) => ({ ...x, CreatedOn: '2015-01-01T00:00:00', CreatedBy: 1 });
const tagGroups = {
- 5: [TAGS.BranchDown, TAGS.TreeOnHouse].map((x) => ({ ...x, CreatedOn: '2015-01-01T00:00:00', CreatedBy: 1 })),
- 6: [TAGS.RoofLeak].map((x) => ({ ...x, CreatedOn: '2015-01-01T00:00:00', CreatedBy: 1 })),
+ 5: [TAGS.BranchDown, TAGS.TreeOnHouse].map(stampTag),
+ 6: [TAGS.RoofLeak].map(stampTag),
+ ...Object.fromEntries(Object.entries(OPS_TAG_GROUPS).map(([groupId, tags]) => [groupId, tags.map(stampTag)])),
+ };
+
+ // What POST /Api/v1/OperationsLog creates (the mock calls this with the
+ // form fields LAD sends), so a submitted log shows in the incident timeline.
+ const allTags = Object.values(tagGroups).flat();
+ const createOpsLogEntry = (form) => {
+ const j = jobs.find((x) => x.Id === Number(form.get('JobId'))) || null;
+ const tagIds = form.getAll('TagIds[]').map(Number);
+ const tags = allTags.filter((tag) => tagIds.includes(tag.Id)).map(({ Id, Name, TagGroupId, Description }) => ({ Id, Name, TagGroupId, Description }));
+ const row = opsLogRow(41000 + opsLog.length, j, form.get('Subject') || '', form.get('Text') || '', tags, t(0), form.get('ActionRequired') === 'true');
+ opsLog.push(row);
+ return row;
};
// Job history endpoint shape (Jobs/{id}/History).
@@ -568,5 +629,8 @@ export function buildScenario(now = new Date()) {
// Image/IncidentThumbnails per job.
const photos = new Map(Object.entries(PHOTO_COUNTS).map(([ji, n]) => [jobs[ji].Id, photoList(jobs[ji].Id, n)]));
- return { hq: HQ, jobs, teams, taskings, radio, telematics, tagGroups, jobHistory, opsLog, photos, icemsIncidents: ICEMS_INCIDENTS, timezone: TZ };
+ return {
+ hq: HQ, jobs, teams, taskings, radio, telematics, tagGroups, jobHistory, opsLog, photos, icemsIncidents: ICEMS_INCIDENTS, timezone: TZ,
+ createTasking, createOpsLogEntry,
+ };
}
diff --git a/src/pages/radio/lib/radioLogPayload.js b/src/pages/radio/lib/radioLogPayload.js
index dfafb9eb..62b01a50 100644
--- a/src/pages/radio/lib/radioLogPayload.js
+++ b/src/pages/radio/lib/radioLogPayload.js
@@ -1,7 +1,7 @@
/*
Pure payload/validation logic for a Radio Ops Console entry -- same field
- shape as RadioLogModalVM.js's toPayload()/validate() (src/pages/tasking/
- viewmodels/RadioLogModalVM.js), extracted so it's usable and testable
+ shape as OpsLogModalVM.js's toPayload()/validate() (src/pages/tasking/
+ viewmodels/OpsLogModalVM.js), extracted so it's usable and testable
without Knockout or a parent tasking view model.
*/
diff --git a/src/pages/radio/main.js b/src/pages/radio/main.js
index 35d512ef..0869dcfb 100644
--- a/src/pages/radio/main.js
+++ b/src/pages/radio/main.js
@@ -112,7 +112,7 @@ document.addEventListener('DOMContentLoaded', function () {
// data-bs-target -- see the "wires up" comment above), so there's no
// matching addEventListener here. What the view model needs is a
// reference to *close* it once confirmResolve() actually succeeds --
- // same modalInstance convention as RadioLogModalVM.js's own.
+ // same modalInstance convention as OpsLogModalVM.js's own.
var resolveModalEl = document.getElementById('radioResolveModal');
viewModel.resolveModalInstance = resolveModalEl ? new bootstrap.Modal(resolveModalEl) : null;
// Same Ctrl/Cmd+Enter-to-save, Esc-to-close convention as every other
@@ -127,7 +127,7 @@ document.addEventListener('DOMContentLoaded', function () {
// Opened declaratively from several Resolve buttons (data-bs-toggle),
// not a single JS .show() call to hang a one-off focus() off of -- so
// this listens for every open instead, same as tasking/main.js's own
- // RadioLogModalVM focus-on-shown (just not { once: true }, since this
+ // Ops/Radio Log dialog focus-on-shown (just not { once: true }, since this
// modal reopens many times across a session).
resolveModalEl?.addEventListener('shown.bs.modal', function () {
document.getElementById('radioResolveTextInput')?.focus();
diff --git a/src/pages/radio/viewmodels/RadioConsoleViewModel.js b/src/pages/radio/viewmodels/RadioConsoleViewModel.js
index f8975fb2..2ab47445 100644
--- a/src/pages/radio/viewmodels/RadioConsoleViewModel.js
+++ b/src/pages/radio/viewmodels/RadioConsoleViewModel.js
@@ -516,7 +516,7 @@ export function createRadioConsoleViewModel({ host, source, userId, personId, si
// reminder" option and made every card taller regardless of whether it
// was ever going to be resolved from here). `resolveModalInstance` is
// set externally by main.js once it creates the bootstrap.Modal -- same
- // convention as RadioLogModalVM.js's `modalInstance`. ----
+ // convention as OpsLogModalVM.js's `modalInstance`. ----
self.resolveModalInstance = null;
self.resolveModalAction = ko.observable(null); // the outstandingActions wrapper currently open, or null
self.resolveModalEntry = ko.pureComputed(() => self.resolveModalAction()?.entry ?? null);
@@ -1350,7 +1350,7 @@ export function createRadioConsoleViewModel({ host, source, userId, personId, si
self.submitOutcomeUnconfirmed = ko.observable(false);
self.submitNotInFilterNotice = ko.observable('');
// Each tag carries its own `selected` observable and no-argument
- // `toggle()` method (mirroring RadioLogModalVM.js's createUiTag) rather
+ // `toggle()` method (mirroring OpsLogModalVM.js's uiTag) rather
// than a shared selected-ids array checked via `.indexOf(id)` in the
// binding -- knockout-secure-binding's data-bind parser can't call a
// function with an argument, so a per-item bare `click: toggle` /
diff --git a/src/pages/tasking/components/spotlightSearch.js b/src/pages/tasking/components/spotlightSearch.js
index 9757fed4..7abcfea3 100644
--- a/src/pages/tasking/components/spotlightSearch.js
+++ b/src/pages/tasking/components/spotlightSearch.js
@@ -1162,7 +1162,9 @@ export function SpotlightSearchVM({ rootVm, getTeams, getJobs }) {
function executeRadio(teamVm, jobVm) {
if (!teamVm || !jobVm) return;
rootVm._closeSpotlight?.();
- rootVm.attachJobRadioLogModalByTeamAndIncident?.(jobVm.id?.(), safeStr(teamVm.callsign));
+ // The Ops/Radio Log dialog finds the tasking from the incident + team,
+ // so the status change / Task Team options work from here too.
+ rootVm.openRadioOpsLogModal?.({ job: jobVm, teamCallsign: safeStr(teamVm.callsign) });
}
self.openResult = (r) => {
diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js
index fc7a3e5d..7e0f04e7 100644
--- a/src/pages/tasking/main.js
+++ b/src/pages/tasking/main.js
@@ -23,7 +23,6 @@ import { MapVM } from './viewmodels/Map.js';
import { JobTimeline } from "./viewmodels/JobTimeline.js";
import { CreateOpsLogModalVM } from "./viewmodels/OpsLogModalVM.js";
-import { CreateRadioLogModalVM } from "./viewmodels/RadioLogModalVM.js";
import { SendSMSModalVM } from "./viewmodels/SMSTeamModalVM.js";
import { JobStatusConfirmModalVM } from "./viewmodels/JobStatusConfirmModalVM.js";
import { OpsLogResolveModalVM } from "./viewmodels/OpsLogResolveModalVM.js";
@@ -513,7 +512,6 @@ function VM() {
///opslog short cuts
self.CreateOpsLogModalVM = new CreateOpsLogModalVM(self);
- self.CreateRadioLogModalVM = new CreateRadioLogModalVM(self);
self.SendSMSModalVM = new SendSMSModalVM(self);
self.selectedJob = ko.observable(null);
@@ -1111,7 +1109,7 @@ function VM() {
},
openRadioLogModal: (tasking) => {
- self.attachJobRadioLogModal(tasking);
+ self.openRadioOpsLogModal({ job: tasking.job, teamCallsign: tasking.teamCallsign(), tasking });
},
openNewOpsLogModal: (job) => {
@@ -1216,7 +1214,7 @@ function VM() {
}),
openRadioLogModal: (team) => {
- self.attachTeamRadioLogModal(team);
+ self.openRadioOpsLogModal({ teamCallsign: team.callsign() });
},
teamTaskStatusFilter: () => self.config.teamTaskStatusFilter(),
@@ -1364,50 +1362,6 @@ function VM() {
modal.show();
}
- self.attachJobRadioLogModal = function (tasking) {
- const modalEl = document.getElementById('RadioLogModal');
- const modal = new bootstrap.Modal(modalEl);
-
- self.CreateRadioLogModalVM.modalInstance = modal;
-
- self.CreateRadioLogModalVM.openForTasking(tasking);
- modal.show();
-
- modalEl.addEventListener('shown.bs.modal', function () {
- document.getElementById('RadioLogTextInput')?.focus();
- }, { once: true });
-
- installModalHotkeys({
- modalEl,
- onSave: () => self.CreateRadioLogModalVM.submit?.(),
- onClose: () => modal.hide(),
- allowInInputs: true // text-heavy modal
- });
-
- };
-
- self.attachJobRadioLogModalByTeamAndIncident = function (jobId, teamCallsign) {
- const modalEl = document.getElementById('RadioLogModal');
- const modal = new bootstrap.Modal(modalEl);
-
- self.CreateRadioLogModalVM.modalInstance = modal;
-
- self.CreateRadioLogModalVM.openFromTeamPlusIncident(jobId, teamCallsign);
- modal.show();
-
- modalEl.addEventListener('shown.bs.modal', function () {
- document.getElementById('RadioLogTextInput')?.focus();
- }, { once: true });
-
- installModalHotkeys({
- modalEl,
- onSave: () => self.CreateRadioLogModalVM.submit?.(),
- onClose: () => modal.hide(),
- allowInInputs: true // text-heavy modal
- });
-
- };
-
// See utils/smsHelpers.js for the recipient/prefill-text building.
self.attachSendSMSModal = function (recipients, team = null, tasking = null, job = null) {
var msgRecipients = team ? buildSmsRecipientsFromTeam(team) : recipients;
@@ -1447,30 +1401,6 @@ function VM() {
}
- self.attachTeamRadioLogModal = function (team) {
- const modalEl = document.getElementById('RadioLogModal');
- const modal = new bootstrap.Modal(modalEl);
-
- const vm = self.CreateRadioLogModalVM;
-
- vm.modalInstance = modal;
-
- vm.openForTeam(team);
- modal.show();
-
- modalEl.addEventListener('shown.bs.modal', function () {
- document.getElementById('RadioLogTextInput')?.focus();
- }, { once: true });
-
- installModalHotkeys({
- modalEl,
- onSave: () => self.CreateRadioLogModalVM.submit?.(),
- onClose: () => modal.hide(),
- allowInInputs: true // text-heavy modal
- });
-
- };
-
self.attachNewOpsLogModal = function (job) {
const modalEl = document.getElementById('CreateOpsLogModal');
const modal = new bootstrap.Modal(modalEl);
@@ -1512,7 +1442,14 @@ function VM() {
}, { once: true });
};
+ // Toolbar button -- KO passes ($data, event), so don't forward them as opts.
self.openBlankRadioOpsLogModal = function() {
+ self.openRadioOpsLogModal();
+ };
+
+ // Radio log in the Ops Log modal with the Radio tag auto-selected.
+ // opts: { job, teamCallsign } -- both optional, used to prefill.
+ self.openRadioOpsLogModal = function(opts = {}) {
const modalEl = document.getElementById('CreateOpsLogModal');
const modal = new bootstrap.Modal(modalEl);
@@ -1520,7 +1457,7 @@ function VM() {
vm.modalInstance = modal;
- vm.openForRadioLog({}); // blank radio log with Radio tag auto-selected
+ vm.openForRadioLog(opts);
modal.show();
modalEl.addEventListener('shown.bs.modal', function () {
@@ -2393,6 +2330,50 @@ function VM() {
});
}
+ // Promise version of updateTeamStatus/callOffTeam/untaskTeam for callers that
+ // must know whether the change landed (the Ops/Radio Log modal only logs
+ // after a successful status change). Those three resolve null/[] on an HTTP
+ // error (nullOnError), so this asks the client to throw instead -- rejects
+ // with the BeaconApiError (or network error); no alert, the caller shows it.
+ // action/payload come from Tasking.buildStatusChange.
+ self.applyTaskingStatusChange = async function (tasking, action, payload) {
+ const t = await getToken();
+ const ctx = { ...beaconCtx(t), throwOnError: true };
+ let data;
+ if (action === "Enroute" || action === "Onsite" || action === "Offsite") {
+ data = await BeaconClient.tasking.updateTeamStatus(tasking.id(), action, payload, ctx);
+ } else if (action === "CallOff") {
+ data = await BeaconClient.tasking.callOffTeam(tasking.id(), payload, ctx);
+ } else if (action === "Untask") {
+ data = await BeaconClient.tasking.untaskTeam(tasking.id(), payload, ctx);
+ } else {
+ throw new Error(`Unknown status action: ${action}`);
+ }
+ tasking.job.fetchTasking({ force: true });
+ if (tasking.team?.isFilteredIn?.()) {
+ tasking.team.fetchTasking({ force: true });
+ tasking.team.refreshData();
+ }
+ return data;
+ }
+
+ // Promise version of assignJobToTeam for the Ops/Radio Log dialog's "Task
+ // Team": rejects (no alert -- the dialog shows it) unless Beacon created the
+ // tasking, so the dialog only saves the log after a successful tasking.
+ self.applyTaskTeam = async function (teamVm, jobVm) {
+ const t = await getToken();
+ const r = await BeaconClient.tasking.task(teamVm.id(), jobVm.id(), { ...beaconCtx(t), throwOnError: true });
+ if (!(r && r.length > 0)) {
+ const err = new Error("Beacon did not create the tasking");
+ err.userMessage = "Beacon didn't create the tasking";
+ throw err;
+ }
+ jobVm.fetchTasking({ force: true });
+ teamVm.fetchTasking({ force: true });
+ teamVm.refreshData();
+ return r;
+ }
+
self.fetchJobById = async function (jobId, cb) {
const t = await getToken(); // blocks here until token is ready
try {
diff --git a/src/pages/tasking/models/Tasking.js b/src/pages/tasking/models/Tasking.js
index d2c9c071..c1e6a8e1 100644
--- a/src/pages/tasking/models/Tasking.js
+++ b/src/pages/tasking/models/Tasking.js
@@ -16,6 +16,41 @@ import { Enum } from "../utils/enum.js";
const _tick = ko.observable(Date.now());
setInterval(() => _tick(Date.now()), 300000);
+// Pill colour for a tasking status name (Tasked, Enroute, Onsite, ...).
+export function statusColorClass(statusName) {
+ switch ((statusName || "").toLowerCase()) {
+ case "tasked":
+ return "bg-primary text-white";
+ case "enroute":
+ return "bg-info";
+ case "onsite":
+ return "bg-warning";
+ case "complete":
+ return "bg-success";
+ default:
+ return "bg-secondary text-white";
+ }
+}
+
+// Adds minutes to a datetime-local input value (or to now if empty) and returns
+// it in datetime-local format -- backs the ETA/ETC "+N" quick buttons.
+export function addMinutesToInputTime(existingValue, minutes) {
+ // Case 1: No existing time → start with current time
+ let base = existingValue ? new Date(existingValue) : new Date();
+
+ base.setMinutes(base.getMinutes() + minutes);
+
+ // Convert back to datetime-local format
+ const pad = (n) => n.toString().padStart(2, "0");
+ return (
+ base.getFullYear() + "-" +
+ pad(base.getMonth() + 1) + "-" +
+ pad(base.getDate()) + "T" +
+ pad(base.getHours()) + ":" +
+ pad(base.getMinutes())
+ );
+}
+
export function Tasking(data = {}) {
const self = this;
@@ -194,19 +229,7 @@ export function Tasking(data = {}) {
Need to get rid fo this.
*/
self.tagColorFromStatus = function () {
- const status = (self.currentStatus() || "").toLowerCase();
- switch (status) {
- case "tasked":
- return "bg-primary text-white";
- case "enroute":
- return "bg-info";
- case "onsite":
- return "bg-warning";
- case "complete":
- return "bg-success";
- default:
- return "bg-secondary text-white";
- }
+ return statusColorClass(self.currentStatus());
}
/*
@@ -365,13 +388,7 @@ export function Tasking(data = {}) {
}
if (option.key === "Complete") {
- //DO THE MAGIC LIGHTHOUSE STUFF OPENING HERE THINGO
- const url = self.taskCompletionLink()
- if (url) {
- console.log("Opening completion in Beacon:", url);
- openURLInBeacon(url);
- showAlert("Teams must be completed from Beacon. Your Beacon Remote tab has been opened to the team completion screen for the selected team", "warning", 5000);
- }
+ self.openCompletionInBeacon();
self.closeStatusDropdown();
}
@@ -380,21 +397,16 @@ export function Tasking(data = {}) {
if (event && event.stopPropagation) event.stopPropagation();
};
- function addMinutesToInputTime(existingValue, minutes) {
- // Case 1: No existing time → start with current time
- let base = existingValue ? new Date(existingValue) : new Date();
-
- base.setMinutes(base.getMinutes() + minutes);
-
- // Convert back to datetime-local format
- const pad = (n) => n.toString().padStart(2, "0");
- return (
- base.getFullYear() + "-" +
- pad(base.getMonth() + 1) + "-" +
- pad(base.getDate()) + "T" +
- pad(base.getHours()) + ":" +
- pad(base.getMinutes())
- );
+ // Teams are completed in Beacon, not LAD -- opens the Beacon Remote tab on
+ // this tasking's completion screen. Returns false if there's no source to open.
+ self.openCompletionInBeacon = function () {
+ //DO THE MAGIC LIGHTHOUSE STUFF OPENING HERE THINGO
+ const url = self.taskCompletionLink()
+ if (!url) return false;
+ console.log("Opening completion in Beacon:", url);
+ openURLInBeacon(url);
+ showAlert("Teams must be completed from Beacon. Your Beacon Remote tab has been opened to the team completion screen for the selected team", "warning", 5000);
+ return true;
}
self.goBackFromDetails = function () {
@@ -404,40 +416,44 @@ export function Tasking(data = {}) {
self.needsETC(false);
}
- self.updateTaskingStatus = function () {
- console.log("SUBMITTING");
- const formattedTime = moment(self.time()).format("YYYY-MM-DDTHH:mm:ssZ");
+ // Builds the Beacon request for a status change from this tasking's current
+ // state plus a draft ({ newStatus, time, eta, etc, reason }). Writes nothing --
+ // shared by the row dropdown below and the Ops/Radio Log modal, which holds
+ // its own draft and only sends on Submit. action is null for Complete (that's
+ // a Beacon handoff, see openCompletionInBeacon) or an unknown status.
+ self.buildStatusChange = function ({ newStatus, time, eta, etc, reason } = {}) {
+ const formattedTime = (time ? moment(time) : moment()).format("YYYY-MM-DDTHH:mm:ssZ");
let action = null; // our friendly UI name is different to the Beacon API backend name, so this value is for that backend value that will be used to form the URL later on.
let payload = { timeLogged: formattedTime, overrideFutureStatuses: 0, description: "", LighthouseFunction: "updatingTeamStatus" };
- if (self.newStatus() === "Enroute") {
+ if (newStatus === "Enroute") {
action = "Enroute";
- if (self.eta() && moment(self.eta()).isValid()) {
- payload.estimatedCompletion = moment(self.eta()).format("YYYY-MM-DDTHH:mm:ssZ");
+ if (eta && moment(eta).isValid()) {
+ payload.estimatedCompletion = moment(eta).format("YYYY-MM-DDTHH:mm:ssZ");
} else {
payload.estimatedCompletion = null;
}
}
// CONSTRUCT PAYLOD FOR ONSITE
- if (self.newStatus() === "Onsite") {
+ if (newStatus === "Onsite") {
action = "Onsite";
- if (self.etc() && moment(self.etc()).isValid()) {
- payload.estimatedCompletion = moment(self.etc()).format("YYYY-MM-DDTHH:mm:ssZ");
+ if (etc && moment(etc).isValid()) {
+ payload.estimatedCompletion = moment(etc).format("YYYY-MM-DDTHH:mm:ssZ");
} else {
payload.estimatedCompletion = null;
}
}
// CONSTRUCT PAYLOAD FOR OFFSITE
- if (self.newStatus() === "Offsite") {
+ if (newStatus === "Offsite") {
action = "Offsite";
payload.estimatedCompletion = null;
payload.taskingId = self.id();
}
// Construct Payload for Call Off
- if (self.newStatus() === "CallOff") {
+ if (newStatus === "CallOff") {
action = "CallOff";
payload.TaskingId = self.id();
payload.InjuriesSustained = false;
@@ -445,14 +461,14 @@ export function Tasking(data = {}) {
payload.CISPRequired = false;
payload.VesselUsed = false;
payload.RiskAssessmentCompleted = false;
- payload.ReasonForCallOff = self.callOffReason();
+ payload.ReasonForCallOff = reason;
payload.InjuredPeopleIds = [];
payload.CompleteJob = false;
payload.LighthouseFunction = "callOffTeamFromJob";
}
// Construct Payload for Untask
- if (self.newStatus() === "Untask") {
+ if (newStatus === "Untask") {
action = "Untask";
delete payload.timeLogged;
delete payload.description;
@@ -465,18 +481,31 @@ export function Tasking(data = {}) {
// Decide whether we are overriding tasking - and apply tasking override value if we are.
- if (self.newStatus() === "Enroute" && self.currentStatus() !== "Tasked") {
+ if (newStatus === "Enroute" && self.currentStatus() !== "Tasked") {
payload.overrideFutureStatuses = self.id();
}
- if (self.newStatus() === "Onsite" && self.currentStatus() !== "Enroute") {
+ if (newStatus === "Onsite" && self.currentStatus() !== "Enroute") {
payload.overrideFutureStatuses = self.id();
}
- if (self.newStatus() === "Offsite" && self.currentStatus() !== "Onsite") {
+ if (newStatus === "Offsite" && self.currentStatus() !== "Onsite") {
payload.overrideFutureStatuses = self.id();
}
+ return { action, payload };
+ }
+
+ self.updateTaskingStatus = function () {
+ console.log("SUBMITTING");
+ const { action, payload } = self.buildStatusChange({
+ newStatus: self.newStatus(),
+ time: self.time(),
+ eta: self.eta(),
+ etc: self.etc(),
+ reason: self.callOffReason(),
+ });
+
// Send to ParentVM to make API Call
// If it's a POST Request send it to the UpdateTeamStatus API - sends Tasking, Action (what status we are changing to), Payload and Callback.
const UpdateTeamStatusAPI = ['Enroute', 'Onsite', 'Offsite']
diff --git a/src/pages/tasking/viewmodels/OpsLogModalVM.js b/src/pages/tasking/viewmodels/OpsLogModalVM.js
index b0a29808..6d80a85b 100644
--- a/src/pages/tasking/viewmodels/OpsLogModalVM.js
+++ b/src/pages/tasking/viewmodels/OpsLogModalVM.js
@@ -1,5 +1,27 @@
/* eslint-disable @typescript-eslint/no-this-alias */
import ko from "knockout";
+import { addMinutesToInputTime, statusColorClass } from "../models/Tasking.js";
+
+// Turns a failed status change or tasking (BeaconApiError, network error, or an
+// error carrying a userMessage) into something readable for the error banner.
+function describeStatusError(err) {
+ if (err && err.userMessage) return err.userMessage;
+ if (err && err.name === "BeaconApiError") {
+ if (err.status === 401 || err.status === 403) {
+ return "Beacon refused the request (your session may have expired)";
+ }
+ let msg = "";
+ try {
+ const body = JSON.parse(err.body);
+ const modelState = body && body.ModelState ? Object.values(body.ModelState).flat()[0] : "";
+ msg = (body && (body.Message || body.message || body.ExceptionMessage)) || modelState || "";
+ } catch (_) { /* not JSON -- fall back to the status code */ }
+ return msg
+ ? `Beacon said "${msg}"`
+ : `Beacon returned an error (${err.status}${err.statusText ? " " + err.statusText : ""})`;
+ }
+ return "couldn't reach Beacon";
+}
export function CreateOpsLogModalVM(parentVM) {
const self = this;
@@ -32,6 +54,18 @@ export function CreateOpsLogModalVM(parentVM) {
self.errorMessage = ko.observable("");
self.showError = ko.observable(false);
+ // The alert sits at the top of the (scrollable) modal body, but Submit is in
+ // the footer and the body is often scrolled down by then (e.g. with the
+ // status-change panel open) -- scroll back up so the error is seen. Waits a
+ // frame so the deferred KO update has shown it.
+ self.showError.subscribe((shown) => {
+ if (!shown) return;
+ requestAnimationFrame(() => {
+ document.querySelector('#CreateOpsLogModal .modal-body')
+ ?.scrollTo({ top: 0, behavior: 'smooth' });
+ });
+ });
+
self.uiTags = ko.observableArray([]);
self.tagsByGroup = {};
@@ -78,18 +112,23 @@ export function CreateOpsLogModalVM(parentVM) {
self.initTags();
}
- // Open for a new radio log — auto-selects the "Radio" contact method tag
- self.openForRadioLog = async (job) => {
+ // Open for a new radio log — auto-selects the "Radio" contact method tag.
+ // Optionally prefills the incident (job) and the team field/subject (teamCallsign).
+ // Passing the tasking as well enables the team status change in the footer.
+ self.openForRadioLog = async ({ job = null, teamCallsign = "", tasking = null } = {}) => {
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");
+ const hasJob = job && typeof job.id === 'function';
+ const jobIdentifier = hasJob && job.identifier ? job.identifier() || "" : "";
+ self.jobId(hasJob ? job.id() || "" : "");
+ self.jobIdInput(jobIdentifier);
+ if (teamCallsign) {
+ self.teamInput(teamCallsign);
+ self.subject(teamCallsign);
}
+ const target = [teamCallsign, jobIdentifier].filter(Boolean).join(" on ");
+ self.headerLabel(target ? `New Radio Log for ${target}` : "New Radio Log");
+ // after the incident/team fields are set, so showStatusChange sees a match
+ self.tasking(tasking);
self.initTags();
// Auto-select the "Radio" tag in Contact Methods (group 3)
@@ -145,25 +184,290 @@ export function CreateOpsLogModalVM(parentVM) {
return true;
}
+ function showSubmitError(message) {
+ self.errorMessage(`Error: ${message}`);
+ self.showError(true);
+ }
+
+ // ── Team status change ──
+ // Shown whenever the Incident + Team fields point at a tasking -- whether the
+ // dialog was opened from a tasking row, or the user filled them in after
+ // opening it from a team row or blank. A draft only: nothing is sent until
+ // Submit, which changes the status first and only creates the log if that
+ // worked (see submit). The options and their enabled rules are read live from
+ // the Tasking, so they follow SignalR updates while the dialog is open.
+ self.tasking = ko.observable(null); // the tasking row it was opened from, if any
+ self.teamPickedAsset = ko.observable(null); // asset picked in the Team field
+ self.pendingStatusKey = ko.observable(null); // null = no change
+ self.statusTimeOverride = ko.observable(false);
+ self.statusTime = ko.observable(null);
+ self.statusEta = ko.observable(null);
+ self.statusEtc = ko.observable(null);
+ self.statusReason = ko.observable("");
+
+ const norm = (s) => String(s || "").trim().toLowerCase();
+
+ // The incident the log will be attached to: jobId is only set by picking a
+ // suggestion, and editing the text afterwards detaches it (jobIdInput no
+ // longer matches), so the status change follows what the log will record.
+ self.resolvedJob = ko.pureComputed(() => {
+ const id = self.jobId();
+ if (id == null || id === "") return null;
+ const opened = self.tasking()?.job;
+ const job = opened && String(opened.id()) === String(id)
+ ? opened
+ : ko.unwrap(self.parentVM.jobs).find(j => String(j.id()) === String(id));
+ if (!job || self.jobIdInput() !== (job.identifier() || "")) return null;
+ return job;
+ });
+
+ // Callsigns the Team field could mean: the typed text, plus -- if a trackable
+ // asset was picked and the text is still its name -- the callsigns of the
+ // teams that asset is matched to (an asset's name isn't always the callsign).
+ self.teamCallsignCandidates = ko.pureComputed(() => {
+ const text = norm(self.teamInput());
+ if (!text) return [];
+ const names = [text];
+ const asset = self.teamPickedAsset();
+ if (asset && norm(asset.name()) === text) {
+ (ko.unwrap(asset.matchingTeams) || []).forEach(t => names.push(norm(t.callsign())));
+ }
+ return names;
+ });
+
+ // That team's active tasking on that incident -- one whose status can still
+ // change. A finished one (complete, called off, untasked) doesn't count: the
+ // team can be tasked again instead (see canTaskTeam).
+ self.activeTasking = ko.pureComputed(() => {
+ const job = self.resolvedJob();
+ const names = self.teamCallsignCandidates();
+ if (!job || !names.length) return null;
+ return job.taskings().find(ts => names.includes(norm(ts.teamCallsign())) && !ts.cannotUpdateStatus()) || null;
+ });
+
+ self.showStatusChange = ko.pureComputed(() => !!self.activeTasking());
+
+ // ── Task Team (the team isn't tasked to the incident) ──
+ // Same idea as the status change: a choice only, sent on Submit before the
+ // log is saved. "Task Team & SMS" also opens Send SMS once the dialog closes.
+
+ // The Team model the Team field means: the picked asset's matched team, the
+ // team of the tasking row it was opened from, or a team with that callsign.
+ self.resolvedTeam = ko.pureComputed(() => {
+ const text = norm(self.teamInput());
+ if (!text) return null;
+ const asset = self.teamPickedAsset();
+ if (asset && norm(asset.name()) === text) {
+ const matched = (ko.unwrap(asset.matchingTeams) || [])[0];
+ if (matched) return matched;
+ }
+ const opened = self.tasking()?.team;
+ if (opened && norm(opened.callsign()) === text) return opened;
+ return (ko.unwrap(self.parentVM.teams) || []).find(t => norm(t.callsign()) === text) || null;
+ });
+ self.resolvedTeamCallsign = ko.pureComputed(() => self.resolvedTeam()?.callsign() || "");
+
+ self.pendingTask = ko.observable(null); // null | "task" | "taskSms"
+
+ // Offered once the incident's taskings have loaded (so an existing tasking
+ // isn't missed) and the incident can still be tasked.
+ self.canTaskTeam = ko.pureComputed(() => {
+ const job = self.resolvedJob();
+ if (!job || !self.resolvedTeam() || self.activeTasking()) return false;
+ if (job.taskingLoading?.()) return false;
+ return job.canTaskJob ? !!job.canTaskJob() : true;
+ });
+
+ self.toggleTaskTeam = () => self.pendingTask(self.pendingTask() === "task" ? null : "task");
+ self.toggleTaskTeamSms = () => self.pendingTask(self.pendingTask() === "taskSms" ? null : "taskSms");
+ self.taskTeamSelected = ko.pureComputed(() => self.pendingTask() === "task");
+ self.taskTeamSmsSelected = ko.pureComputed(() => self.pendingTask() === "taskSms");
+ self.taskTeamBtnCss = ko.pureComputed(() => self.taskTeamSelected() ? "btn-primary" : "btn-outline-primary");
+ self.taskTeamSmsBtnCss = ko.pureComputed(() => self.taskTeamSmsSelected() ? "btn-primary" : "btn-outline-primary");
+
+ // Send SMS waiting for the dialog to close: set once "Task Team & SMS" has
+ // tasked the team, so it still opens if the log needed a retry.
+ self._smsAfterSubmit = null;
+
+ self.currentStatusLabel = ko.pureComputed(() => self.activeTasking()?.currentStatus() || "");
+ self.currentStatusCss = ko.pureComputed(() => statusColorClass(self.currentStatusLabel()));
+ self.statusOptions = ko.pureComputed(() => self.activeTasking()?.statusOptions() || []);
+ self.pendingStatusOption = ko.pureComputed(() =>
+ self.statusOptions().find(o => o.key === self.pendingStatusKey()) || null
+ );
+ self.pendingStatusLabel = ko.pureComputed(() => self.pendingStatusOption()?.label || "No change");
+ self.pendingStatusCss = ko.pureComputed(() =>
+ self.pendingStatusKey() ? statusColorClass(self.pendingStatusKey()) : "bg-secondary text-white"
+ );
+ // the team's status moved on (e.g. SignalR) and the picked option no longer applies
+ self.pendingStatusUnavailable = ko.pureComputed(() => {
+ const option = self.pendingStatusOption();
+ return !!option && !option.enabled;
+ });
+
+ self.pendingNeedsTime = ko.pureComputed(() => !!self.pendingStatusOption()?.needsTimeSet);
+ self.pendingNeedsETA = ko.pureComputed(() => !!self.pendingStatusOption()?.needsETA);
+ self.pendingNeedsETC = ko.pureComputed(() => !!self.pendingStatusOption()?.needsETC);
+ self.pendingNeedsReason = ko.pureComputed(() => !!self.pendingStatusOption()?.needsReason);
+ self.showStatusDetails = ko.pureComputed(() => self.showStatusChange()
+ && (self.pendingNeedsTime() || self.pendingNeedsETA() || self.pendingNeedsETC() || self.pendingNeedsReason())
+ );
+
+ self.submitLabel = ko.pureComputed(() => {
+ if (self.showStatusChange() && self.pendingStatusOption()) return `Submit & ${self.pendingStatusLabel()}`;
+ if (self.canTaskTeam() && self.pendingTask() === "task") return "Submit & Task Team";
+ if (self.canTaskTeam() && self.pendingTask() === "taskSms") return "Submit, Task Team & SMS";
+ return "Submit";
+ });
+
+ self.clearPendingStatus = function () {
+ self.pendingStatusKey(null);
+ self.statusTimeOverride(false);
+ self.statusTime(null);
+ self.statusEta(null);
+ self.statusEtc(null);
+ self.statusReason("");
+ };
+
+ self.pickPendingStatus = function (option) {
+ if (!option || !option.enabled) return;
+ self.clearPendingStatus();
+ self.pendingStatusKey(option.key);
+ self.statusTime(addMinutesToInputTime(null, 0)); // prefill the override with now
+ self._scrollStatusDetailsIntoView();
+ };
+
+ // The details box renders at the bottom of the (scrollable) modal body, often
+ // below the fold -- bring it into view. Waits a frame so the deferred KO
+ // update has rendered it (tasking page sets ko.options.deferUpdates).
+ self._scrollStatusDetailsIntoView = function () {
+ requestAnimationFrame(() => {
+ if (!self.showStatusDetails()) return;
+ document.querySelector('#CreateOpsLogModal .ops-log-status-details')
+ ?.scrollIntoView({ behavior: 'smooth', block: 'end' });
+ });
+ };
+
+
+ self.statusQuickButtons = [1, 5, 10, 30, 60].map(minutes => ({ label: `+${minutes}`, minutes }));
+ self.onStatusEtaQuickButtonClick = (btn) => self.statusEta(addMinutesToInputTime(self.statusEta(), btn.minutes));
+ self.onStatusEtcQuickButtonClick = (btn) => self.statusEtc(addMinutesToInputTime(self.statusEtc(), btn.minutes));
+
self.submitting = ko.observable(false);
- self.submit = function () {
- if (!validate()) {
+ // Hide the dialog, then run fn once it has fully closed -- opening another
+ // Bootstrap modal mid-hide leaves body.modal-open in a bad state.
+ function hideThen(fn) {
+ const modalEl = document.getElementById('CreateOpsLogModal');
+ if (fn && modalEl && self.modalInstance) {
+ modalEl.addEventListener('hidden.bs.modal', () => fn(), { once: true });
+ self.modalInstance.hide();
+ return;
+ }
+ if (self.modalInstance) self.modalInstance.hide();
+ if (fn) fn();
+ }
+
+ // Order matters: the status change or tasking goes first and the log is only
+ // created if it worked, so the log never records a change that didn't
+ // happen. Complete is the exception -- it isn't an API call but a hand-off
+ // to Beacon's completion screen, which opens once the log has saved -- and so
+ // is the SMS from "Task Team & SMS", which opens after the dialog closes.
+ self.submit = async function () {
+ if (self.submitting() || !validate()) {
+ return;
+ }
+
+ const tasking = self.activeTasking();
+ const option = tasking ? self.pendingStatusOption() : null;
+ const callsign = tasking ? tasking.teamCallsign() : "";
+ const taskMode = self.pendingTask();
+ const job = self.resolvedJob();
+ const team = self.resolvedTeam();
+
+ // A status was picked but the tasking stopped being active (e.g. called
+ // off elsewhere) -- don't silently log without it.
+ if (self.pendingStatusKey() && !tasking) {
+ showSubmitError(`${self.resolvedTeamCallsign() || "The team"}'s tasking on this incident is no longer active, so the status can't be changed. Pick "No change" or update the Incident/Team. Nothing was submitted.`);
+ return;
+ }
+ if (option && !option.enabled) {
+ showSubmitError(`${callsign} is now ${tasking.currentStatus()}, so "${option.label}" isn't available any more. Pick another status or "No change". Nothing was submitted.`);
return;
}
+ if (option && option.key === "Complete" && !tasking.taskCompletionLink()) {
+ showSubmitError("Can't open the completion screen in Beacon from this page (no Beacon source). Nothing was submitted.");
+ return;
+ }
+ if (taskMode && !self.canTaskTeam()) {
+ showSubmitError(tasking
+ ? `${callsign} has just been tasked to this incident, so it won't be tasked again. Use the status menu instead. Nothing was submitted.`
+ : "The team can't be tasked to this incident any more. Nothing was submitted.");
+ self.pendingTask(null);
+ return;
+ }
+
self.submitting(true);
- const payload = self.toPayload();
+ try {
+ let tasked = false;
+ if (taskMode) {
+ try {
+ await parentVM.applyTaskTeam(team, job);
+ } catch (err) {
+ console.error("Task team failed:", err);
+ showSubmitError(`Couldn't task ${team.callsign()} to ${job.identifier()}: ${describeStatusError(err)}. The log entry was not saved.`);
+ return;
+ }
+ tasked = true;
+ // It landed -- a retry after a failed log mustn't task again, but the
+ // SMS still opens once the dialog closes.
+ self.pendingTask(null);
+ if (taskMode === "taskSms") self._smsAfterSubmit = { team, job };
+ }
+
+ let statusChanged = false;
+ if (option && option.key !== "Complete") {
+ const { action, payload } = tasking.buildStatusChange({
+ newStatus: option.key,
+ time: self.statusTimeOverride() ? self.statusTime() : null, // null = now
+ eta: self.statusEta(),
+ etc: self.statusEtc(),
+ reason: self.statusReason(),
+ });
+ try {
+ await parentVM.applyTaskingStatusChange(tasking, action, payload);
+ } catch (err) {
+ console.error("Team status change failed:", err);
+ showSubmitError(`Couldn't change ${callsign} to "${option.label}": ${describeStatusError(err)}. The log entry was not saved.`);
+ return;
+ }
+ statusChanged = true;
+ // It landed -- drop the draft so retrying a failed log doesn't send it again.
+ self.clearPendingStatus();
+ }
- parentVM.createOpsLogEntry(payload, function (result) {
+ const result = await new Promise(resolve => parentVM.createOpsLogEntry(self.toPayload(), resolve));
if (!result) {
console.error("Ops Log submit failed");
+ let message = "The log entry didn't save. Please try again.";
+ if (statusChanged) {
+ message = `${callsign} was changed to "${option.label}", but the log entry didn't save. Submit again to retry the log (the status won't be sent twice).`;
+ } else if (tasked) {
+ message = `${team.callsign()} was tasked to ${job.identifier()}, but the log entry didn't save. Submit again to retry the log (the team won't be tasked twice).`;
+ }
+ showSubmitError(message);
return;
}
- if (self.modalInstance) {
- self.modalInstance.hide();
+
+ if (option && option.key === "Complete") {
+ tasking.openCompletionInBeacon();
}
+ const sms = self._smsAfterSubmit;
+ self._smsAfterSubmit = null;
+ hideThen(sms ? () => parentVM.attachSendSMSModal([], sms.team, null, sms.job) : null);
+ } finally {
self.submitting(false);
- });
+ }
};
self.resetFields = function () {
@@ -198,6 +502,12 @@ export function CreateOpsLogModalVM(parentVM) {
self.teamInput("");
self.teamInputHasFocus(false);
self.teamActiveIndex(-1);
+
+ self.tasking(null);
+ self.teamPickedAsset(null);
+ self.clearPendingStatus();
+ self.pendingTask(null);
+ self._smsAfterSubmit = null;
};
function uiTag(tag) {
@@ -320,6 +630,7 @@ export function CreateOpsLogModalVM(parentVM) {
}
if (suggestion && suggestion.asset) {
self.teamInput(suggestion.label);
+ self.teamPickedAsset(suggestion.asset);
self.subject(`${suggestion.label} - ${self.subject()}`);
}
self.teamInputHasFocus(false);
@@ -399,4 +710,26 @@ export function CreateOpsLogModalVM(parentVM) {
self._scrollDropdownItemIntoView('team', newIndex);
}
});
+
+ // Status change subscriptions -- down here because subscribing wakes the
+ // computeds, which read the Incident/Team field observables declared above.
+
+ // Pointing the log at a different incident or team drops any picked status
+ // or Task Team. Keyed on the fields, not the tasking, so a tasking that
+ // changes under an open dialog (e.g. called off elsewhere) keeps the choice
+ // and submit can refuse it with a reason instead of silently dropping it.
+ const statusTargetKey = ko.pureComputed(() =>
+ `${self.resolvedJob()?.id() ?? ""}|${self.teamCallsignCandidates().join(",")}`
+ );
+ statusTargetKey.subscribe(() => {
+ self.clearPendingStatus();
+ self.pendingTask(null);
+ });
+
+ // A picked incident whose taskings haven't loaded yet (e.g. not expanded in
+ // the register) -- load them so the team's tasking can be found.
+ // fetchTasking has its own cooldown.
+ self.resolvedJob.subscribe(job => {
+ if (job && !job.taskings().length && !job.taskingLoading?.()) job.fetchTasking?.();
+ });
}
diff --git a/src/pages/tasking/viewmodels/OpsLogModalVM.test.js b/src/pages/tasking/viewmodels/OpsLogModalVM.test.js
new file mode 100644
index 00000000..d8718bae
--- /dev/null
+++ b/src/pages/tasking/viewmodels/OpsLogModalVM.test.js
@@ -0,0 +1,401 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import ko from 'knockout';
+
+vi.mock('../utils/chromeRunTime.js', () => ({ openURLInBeacon: vi.fn() }));
+vi.mock('../components/windowAlert.js', () => ({ showAlert: vi.fn(), closeAlert: vi.fn() }));
+
+import { openURLInBeacon } from '../utils/chromeRunTime.js';
+import { BeaconApiError } from '../../../shared/BeaconClient/core/request.js';
+import { Tasking } from '../models/Tasking.js';
+import { Tag } from '../models/Tag.js';
+import { CreateOpsLogModalVM } from './OpsLogModalVM.js';
+
+function makeJob(id = 'job1', identifier = '0015-0240') {
+ return {
+ id: ko.observable(id),
+ identifier: ko.observable(identifier),
+ taskings: ko.observableArray([]),
+ taskingLoading: ko.observable(false),
+ fetchTasking: vi.fn(),
+ };
+}
+
+function makeTasking(status = 'Tasked', { job = makeJob(), callsign = 'Par Air', id = 't1' } = {}) {
+ const t = new Tasking({ Id: id, CurrentStatus: status });
+ t.job = job;
+ t.team = makeTeam(callsign);
+ job.taskings.push(t);
+ return t;
+}
+
+function makeTeam(callsign, id = `team-${callsign}`) {
+ return { id: () => id, callsign: ko.observable(callsign) };
+}
+
+function makeParent(jobs = [], teams = []) {
+ const calls = [];
+ return {
+ calls,
+ allTags: ko.observableArray([new Tag({ Id: 6, Name: 'Radio', TagGroupId: 3 })]),
+ jobs: ko.observableArray(jobs),
+ teams: ko.observableArray(teams),
+ trackableAssets: ko.observableArray([]),
+ applyTaskingStatusChange: vi.fn(async (tasking, action, payload) => { calls.push(['status', action, payload]); return {}; }),
+ applyTaskTeam: vi.fn(async (team, job) => { calls.push(['task', team, job]); return [{ Id: 'new' }]; }),
+ createOpsLogEntry: vi.fn((payload, cb) => { calls.push(['log', payload]); cb({ Id: 1 }); }),
+ attachSendSMSModal: vi.fn((...args) => { calls.push(['sms', ...args]); }),
+ };
+}
+
+function option(vm, key) {
+ return vm.statusOptions().find(o => o.key === key);
+}
+
+describe('OpsLogModalVM team status change', () => {
+ let parent, vm, tasking, modal;
+
+ beforeEach(async () => {
+ vi.mocked(openURLInBeacon).mockReset();
+ window.history.replaceState({}, '', '/?source=https://source.example');
+ parent = makeParent();
+ vm = new CreateOpsLogModalVM(parent);
+ modal = { hide: vi.fn() };
+ vm.modalInstance = modal;
+ tasking = makeTasking();
+ await vm.openForRadioLog({ job: tasking.job, teamCallsign: 'Par Air', tasking });
+ vm.text('Team on scene');
+ });
+
+ it('is hidden when there is no tasking (blank / team radio log)', async () => {
+ await vm.openForRadioLog({ teamCallsign: 'Par Air' });
+ expect(vm.showStatusChange()).toBe(false);
+ });
+
+ it('shows the live current status and defaults to no change', () => {
+ expect(vm.showStatusChange()).toBe(true);
+ expect(vm.currentStatusLabel()).toBe('Tasked');
+ expect(vm.pendingStatusLabel()).toBe('No change');
+ expect(vm.submitLabel()).toBe('Submit');
+ });
+
+ it('sends nothing when a status is picked -- only on submit, status first then log', async () => {
+ vm.pickPendingStatus(option(vm, 'Onsite'));
+ vm.statusEtc('2026-01-01T14:30');
+ expect(parent.applyTaskingStatusChange).not.toHaveBeenCalled();
+ expect(vm.submitLabel()).toBe('Submit & On site');
+
+ await vm.submit();
+
+ expect(parent.calls.map(c => c[0])).toEqual(['status', 'log']);
+ const [, action, payload] = parent.calls[0];
+ expect(action).toBe('Onsite');
+ expect(payload.estimatedCompletion).toMatch(/^2026-01-01T14:30:00/);
+ expect(payload.overrideFutureStatuses).toBe('t1'); // Tasked -> Onsite skips Enroute
+ expect(modal.hide).toHaveBeenCalled();
+ });
+
+ it('does not log and shows an error when the status change fails', async () => {
+ parent.applyTaskingStatusChange.mockRejectedValueOnce(
+ new BeaconApiError(400, 'Bad Request', 'u', JSON.stringify({ Message: 'Tasking is already complete' }))
+ );
+ vm.pickPendingStatus(option(vm, 'Enroute'));
+
+ await vm.submit();
+
+ expect(parent.createOpsLogEntry).not.toHaveBeenCalled();
+ expect(vm.showError()).toBe(true);
+ expect(vm.errorMessage()).toContain('Couldn\'t change Par Air to "En route"');
+ expect(vm.errorMessage()).toContain('Tasking is already complete');
+ expect(vm.errorMessage()).toContain('was not saved');
+ expect(modal.hide).not.toHaveBeenCalled();
+ expect(vm.submitting()).toBe(false);
+ expect(vm.pendingStatusKey()).toBe('Enroute'); // kept so they can retry
+ });
+
+ it('if the log fails after the status landed, says so and does not resend the status on retry', async () => {
+ parent.createOpsLogEntry.mockImplementationOnce((payload, cb) => cb(null));
+ vm.pickPendingStatus(option(vm, 'CallOff'));
+ vm.statusReason('Not required');
+
+ await vm.submit();
+ expect(vm.errorMessage()).toContain('Par Air was changed to "Call off", but the log entry didn\'t save');
+ expect(vm.pendingStatusKey()).toBe(null);
+ expect(parent.calls[0][2].ReasonForCallOff).toBe('Not required');
+
+ await vm.submit();
+ expect(parent.applyTaskingStatusChange).toHaveBeenCalledTimes(1);
+ expect(parent.createOpsLogEntry).toHaveBeenCalledTimes(2);
+ expect(modal.hide).toHaveBeenCalled();
+ });
+
+ it('Complete opens Beacon only after the log saves, with no status API call', async () => {
+ vm.pickPendingStatus(option(vm, 'Complete'));
+ expect(openURLInBeacon).not.toHaveBeenCalled();
+
+ await vm.submit();
+
+ expect(parent.applyTaskingStatusChange).not.toHaveBeenCalled();
+ expect(parent.createOpsLogEntry).toHaveBeenCalled();
+ expect(openURLInBeacon).toHaveBeenCalledWith('https://source.example/Jobs/job1?lhquickComplete=t1');
+ });
+
+ it('Complete does not open Beacon when the log fails', async () => {
+ parent.createOpsLogEntry.mockImplementationOnce((payload, cb) => cb(null));
+ vm.pickPendingStatus(option(vm, 'Complete'));
+ await vm.submit();
+ expect(openURLInBeacon).not.toHaveBeenCalled();
+ });
+
+ it('refuses to submit a status that stopped being valid while the modal was open', async () => {
+ vm.pickPendingStatus(option(vm, 'Untask'));
+ tasking.currentStatus('Onsite'); // e.g. a SignalR update
+ expect(vm.pendingStatusUnavailable()).toBe(true);
+
+ await vm.submit();
+
+ expect(parent.applyTaskingStatusChange).not.toHaveBeenCalled();
+ expect(parent.createOpsLogEntry).not.toHaveBeenCalled();
+ expect(vm.errorMessage()).toContain('Par Air is now Onsite');
+ });
+
+ it('hides and clears the status change when the team or incident field is changed', () => {
+ vm.pickPendingStatus(option(vm, 'Enroute'));
+ vm.teamInput('Someone Else');
+ expect(vm.showStatusChange()).toBe(false);
+ expect(vm.pendingStatusKey()).toBe(null);
+ });
+
+ it('switching to another team on the same incident moves to its tasking and drops the draft', async () => {
+ const other = makeTasking('Enroute', { job: tasking.job, callsign: 'DEM02', id: 't2' });
+ vm.pickPendingStatus(option(vm, 'Untask'));
+ vm.teamInput('DEM02');
+ expect(vm.activeTasking()).toBe(other);
+ expect(vm.currentStatusLabel()).toBe('Enroute');
+ expect(vm.pendingStatusKey()).toBe(null);
+ });
+
+ it('validates the log before touching the status', async () => {
+ vm.text('');
+ vm.pickPendingStatus(option(vm, 'Enroute'));
+ await vm.submit();
+ expect(parent.applyTaskingStatusChange).not.toHaveBeenCalled();
+ expect(vm.errorMessage()).toContain('must enter text');
+ });
+});
+
+describe('OpsLogModalVM finds the tasking from the Incident + Team fields', () => {
+ let job, tasking, parent, vm;
+
+ beforeEach(() => {
+ job = makeJob('job7', '0015-5759');
+ tasking = makeTasking('Onsite', { job, callsign: 'PAR31' });
+ parent = makeParent([makeJob('job8', '0015-9999'), job]);
+ vm = new CreateOpsLogModalVM(parent);
+ vm.modalInstance = { hide: vi.fn() };
+ });
+
+ // what picking an incident suggestion does
+ const pickJob = (j) => vm.pickJobSuggestion({ label: j.identifier(), job: j });
+
+ it('blank dialog: picking the incident and typing the team shows the status change', async () => {
+ await vm.openForRadioLog();
+ expect(vm.showStatusChange()).toBe(false);
+ pickJob(job);
+ expect(vm.showStatusChange()).toBe(false);
+ vm.teamInput('par31'); // case-insensitive
+ expect(vm.activeTasking()).toBe(tasking);
+ expect(vm.currentStatusLabel()).toBe('Onsite');
+ });
+
+ it('team row: adding the incident shows the status change, and submit uses that tasking', async () => {
+ await vm.openForRadioLog({ teamCallsign: 'PAR31' });
+ pickJob(job);
+ expect(vm.showStatusChange()).toBe(true);
+
+ vm.text('Leaving scene');
+ vm.pickPendingStatus(option(vm, 'Offsite'));
+ await vm.submit();
+ expect(parent.applyTaskingStatusChange).toHaveBeenCalledWith(tasking, 'Offsite', expect.any(Object));
+ expect(parent.createOpsLogEntry).toHaveBeenCalled();
+ });
+
+ it('also works in the plain Ops Log dialog opened from an incident', async () => {
+ await vm.openForNewJobLog(job);
+ vm.teamInput('PAR31');
+ expect(vm.activeTasking()).toBe(tasking);
+ });
+
+ it('a picked asset counts as its matched team, even when its name differs from the callsign', async () => {
+ await vm.openForRadioLog();
+ pickJob(job);
+ const asset = { name: ko.observable('Parramatta 31 Radio'), matchingTeams: ko.observableArray([tasking.team]) };
+ vm.pickTeamSuggestion({ label: 'Parramatta 31 Radio', asset });
+ expect(vm.activeTasking()).toBe(tasking);
+ });
+
+ it('stays hidden when that team is not tasked to the incident', async () => {
+ await vm.openForRadioLog({ teamCallsign: 'DEM99' });
+ pickJob(job);
+ expect(vm.showStatusChange()).toBe(false);
+ });
+
+ it('typing the incident without picking it does not count (the log would not be attached to it)', async () => {
+ await vm.openForRadioLog({ teamCallsign: 'PAR31' });
+ vm.jobIdInput('0015-5759');
+ expect(vm.showStatusChange()).toBe(false);
+ });
+
+ it('prefers the tasking whose status can still change when the team has several on the incident', async () => {
+ const old = makeTasking('CalledOff', { job, callsign: 'PAR31', id: 'old' });
+ job.taskings([old, tasking]);
+ await vm.openForRadioLog({ teamCallsign: 'PAR31' });
+ pickJob(job);
+ expect(vm.activeTasking()).toBe(tasking);
+ });
+
+ it('loads the incident\'s taskings if they have not been fetched yet', async () => {
+ const unloaded = makeJob('job9', '0015-1111');
+ parent.jobs.push(unloaded);
+ await vm.openForRadioLog({ teamCallsign: 'PAR31' });
+ pickJob(unloaded);
+ expect(unloaded.fetchTasking).toHaveBeenCalled();
+ expect(job.fetchTasking).not.toHaveBeenCalled();
+ });
+});
+
+describe('OpsLogModalVM Task Team (team not tasked to the incident)', () => {
+ let job, team, parent, vm;
+
+ beforeEach(async () => {
+ job = makeJob('job7', '0015-5759');
+ job.canTaskJob = ko.observable(true);
+ team = makeTeam('DEM03');
+ parent = makeParent([job], [makeTeam('DEM01'), team]);
+ vm = new CreateOpsLogModalVM(parent);
+ vm.modalInstance = { hide: vi.fn() };
+ await vm.openForRadioLog({ teamCallsign: 'DEM03' });
+ vm.pickJobSuggestion({ label: job.identifier(), job });
+ vm.text('Tasking DEM03 to this one');
+ });
+
+ it('offers Task Team instead of the status change, and nothing is sent until Submit', () => {
+ expect(vm.showStatusChange()).toBe(false);
+ expect(vm.canTaskTeam()).toBe(true);
+ expect(vm.resolvedTeam()).toBe(team);
+ vm.toggleTaskTeam();
+ expect(vm.submitLabel()).toBe('Submit & Task Team');
+ expect(parent.applyTaskTeam).not.toHaveBeenCalled();
+ vm.toggleTaskTeam(); // clicking again un-selects
+ expect(vm.pendingTask()).toBe(null);
+ expect(vm.submitLabel()).toBe('Submit');
+ });
+
+ it('Task Team: tasks first, then logs, then closes; no SMS', async () => {
+ vm.toggleTaskTeam();
+ await vm.submit();
+ expect(parent.calls.map(c => c[0])).toEqual(['task', 'log']);
+ expect(parent.applyTaskTeam).toHaveBeenCalledWith(team, job);
+ expect(vm.modalInstance.hide).toHaveBeenCalled();
+ expect(parent.attachSendSMSModal).not.toHaveBeenCalled();
+ });
+
+ it('Task Team & SMS: opens Send SMS for the team only after the log saved', async () => {
+ vm.toggleTaskTeamSms();
+ expect(vm.submitLabel()).toBe('Submit, Task Team & SMS');
+ await vm.submit();
+ expect(parent.calls.map(c => c[0])).toEqual(['task', 'log', 'sms']);
+ expect(parent.attachSendSMSModal).toHaveBeenCalledWith([], team, null, job);
+ });
+
+ it('waits for the dialog\'s hidden event before opening Send SMS', async () => {
+ const el = document.createElement('div');
+ el.id = 'CreateOpsLogModal';
+ document.body.appendChild(el);
+ try {
+ vm.toggleTaskTeamSms();
+ await vm.submit();
+ expect(vm.modalInstance.hide).toHaveBeenCalled();
+ expect(parent.attachSendSMSModal).not.toHaveBeenCalled();
+ el.dispatchEvent(new Event('hidden.bs.modal'));
+ expect(parent.attachSendSMSModal).toHaveBeenCalledTimes(1);
+ } finally {
+ el.remove();
+ }
+ });
+
+ it('does not log (or SMS) and shows an error when tasking fails', async () => {
+ parent.applyTaskTeam.mockRejectedValueOnce(Object.assign(new Error('x'), { userMessage: "Beacon didn't create the tasking" }));
+ vm.toggleTaskTeamSms();
+ await vm.submit();
+ expect(parent.createOpsLogEntry).not.toHaveBeenCalled();
+ expect(parent.attachSendSMSModal).not.toHaveBeenCalled();
+ expect(vm.errorMessage()).toContain("Couldn't task DEM03 to 0015-5759: Beacon didn't create the tasking. The log entry was not saved.");
+ expect(vm.pendingTask()).toBe('taskSms'); // kept for a retry
+ expect(vm.modalInstance.hide).not.toHaveBeenCalled();
+ });
+
+ it('if the log fails after tasking, a retry does not task again and still opens the SMS', async () => {
+ parent.createOpsLogEntry.mockImplementationOnce((payload, cb) => cb(null));
+ vm.toggleTaskTeamSms();
+ await vm.submit();
+ expect(vm.errorMessage()).toContain("DEM03 was tasked to 0015-5759, but the log entry didn't save");
+ expect(parent.attachSendSMSModal).not.toHaveBeenCalled();
+
+ await vm.submit();
+ expect(parent.applyTaskTeam).toHaveBeenCalledTimes(1);
+ expect(parent.attachSendSMSModal).toHaveBeenCalledTimes(1);
+ });
+
+ it('refuses if the team got tasked elsewhere while the dialog was open', async () => {
+ vm.toggleTaskTeam();
+ makeTasking('Tasked', { job, callsign: 'DEM03', id: 'elsewhere' });
+ expect(vm.showStatusChange()).toBe(true);
+ await vm.submit();
+ expect(parent.applyTaskTeam).not.toHaveBeenCalled();
+ expect(parent.createOpsLogEntry).not.toHaveBeenCalled();
+ expect(vm.errorMessage()).toContain('has just been tasked to this incident');
+ });
+
+ it('a team whose only tasking here is finished can be tasked again', () => {
+ makeTasking('CalledOff', { job, callsign: 'DEM03', id: 'old' });
+ expect(vm.showStatusChange()).toBe(false);
+ expect(vm.canTaskTeam()).toBe(true);
+ });
+
+ it('is not offered while the incident\'s taskings are loading, or when the incident can\'t be tasked', () => {
+ job.taskingLoading(true);
+ expect(vm.canTaskTeam()).toBe(false);
+ job.taskingLoading(false);
+ job.canTaskJob(false);
+ expect(vm.canTaskTeam()).toBe(false);
+ });
+
+ it('is not offered when the Team field is not a known team', () => {
+ vm.teamInput('NOBODY');
+ expect(vm.canTaskTeam()).toBe(false);
+ });
+
+ it('changing the team drops the Task Team choice', () => {
+ vm.toggleTaskTeam();
+ vm.teamInput('DEM01');
+ expect(vm.pendingTask()).toBe(null);
+ });
+});
+
+describe('OpsLogModalVM picked status whose tasking stops being active', () => {
+ it('refuses with a reason instead of logging without the status', async () => {
+ const job = makeJob();
+ const tasking = makeTasking('Tasked', { job });
+ const parent = makeParent([job]);
+ const vm = new CreateOpsLogModalVM(parent);
+ vm.modalInstance = { hide: vi.fn() };
+ await vm.openForRadioLog({ job, teamCallsign: 'Par Air', tasking });
+ vm.text('x');
+ vm.pickPendingStatus(option(vm, 'Enroute'));
+ tasking.currentStatus('CalledOff'); // called off in Beacon
+ await vm.submit();
+ expect(parent.createOpsLogEntry).not.toHaveBeenCalled();
+ expect(vm.errorMessage()).toContain('no longer active');
+ });
+});
diff --git a/src/pages/tasking/viewmodels/RadioLogModalVM.js b/src/pages/tasking/viewmodels/RadioLogModalVM.js
deleted file mode 100644
index a39adf12..00000000
--- a/src/pages/tasking/viewmodels/RadioLogModalVM.js
+++ /dev/null
@@ -1,209 +0,0 @@
-/* eslint-disable @typescript-eslint/no-this-alias */
-import ko from "knockout";
-
-export function CreateRadioLogModalVM(parentVM) {
- 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);
- self.talkgroupId = ko.observable(null);
- self.talkgroupRequestId = ko.observable(null);
-
- self.subject = ko.observable("");
- self.text = ko.observable("");
- self.position = ko.observable(null);
- self.personFromId = ko.observable(null);
- self.personTold = ko.observable(null);
-
- self.important = ko.observable(false);
- self.restricted = ko.observable(false);
- self.actionRequired = ko.observable(false);
- self.actionReminder = ko.observable(null);
-
- self.tagIds = ko.observableArray([]);
- self.timeLogged = ko.observable(null);
-
- // UI
- self.headerLabel = ko.observable("");
- self.tagsByGroup = {};
- self.errorMessage = ko.observable("");
- self.showError = ko.observable(false);
-
- self.uiTags = ko.observableArray([]);
-
- self.initTags = () => {
- const tags = parentVM.allTags ? parentVM.allTags() : [];
- self.uiTags(tags.map(tag => createUiTag(tag)));
- };
-
- self.tagIds = ko.computed(() =>
- self.uiTags()
- .filter(t => t.selected())
- .map(t => t.id())
- );
-
- [2, 3, 4, 27].forEach(gid => {
- self.tagsByGroup[gid] = ko.pureComputed(() =>
- self.uiTags().filter(t => t.groupId() === gid)
- );
- });
-
- function preselectTag(idToSelect) {
- self.uiTags().forEach(t => {
- t.selected(t.id() === idToSelect);
- });
- }
-
- self.tagIds = ko.computed(() =>
- self.uiTags()
- .filter(t => t.selected())
- .map(t => t.id())
- );
-
- self.ActionRequiredCheck = ko.computed(() => {
- const anyActionTagSelected = self.uiTags().some(
- t => t.selected() && t.groupId() === 27
- );
-
- self.actionRequired(anyActionTagSelected);
- });
-
- self.openForTasking = async (tasking) => {
- self.resetFields();
- self.jobId(tasking.job.id() || "");
- self.subject(tasking.teamCallsign() || "");
- self.initTags();
- preselectTag(6);
-
- self.headerLabel(`New Radio Log for ${tasking.teamCallsign?.() || ""} on ${tasking.job.identifier() || ""}`);
- }
-
- self.openFromTeamPlusIncident = async (jobId, teamCallsign) => {
- self.resetFields();
- self.jobId(jobId || "");
- self.subject(teamCallsign || "");
- self.initTags();
- preselectTag(6);
-
- self.headerLabel(`New Radio Log for ${teamCallsign || ""} on ${jobId || ""}`);
- }
-
- self.openForTeam = async (team) => {
- // NEED TO COME BACK AND ADD HQ ONCE TIM HAS SETUP THE CONFIG PAGE TO USE IT.
- self.resetFields();
- self.subject(team.callsign() || "");
- self.initTags();
- preselectTag(6);
- self.showError(false);
- self.errorMessage("");
- self.headerLabel(`New Radio Log for ${team.callsign?.() || ""}`);
- }
-
- self.toPayload = function () {
- return {
- EntityId: self.entityId(),
- JobId: self.jobId(),
- EventId: self.eventId(),
- TalkgroupId: self.talkgroupId(),
- TalkgroupRequestId: self.talkgroupRequestId(),
-
- Subject: self.subject(),
- Text: self.text(),
- Position: self.position(),
- PersonFromId: self.personFromId(),
- PersonTold: self.personTold(),
-
- Important: self.important(),
- Restricted: self.restricted(),
- ActionRequired: self.actionRequired(),
- ActionReminder: self.actionReminder(),
-
- TagIds: self.tagIds(),
- TimeLogged: self.timeLogged()
- };
- };
-
- function validate() {
- self.showError(false);
- self.errorMessage("");
-
- // No text
- if (!self.text() || self.text().trim().length === 0) {
- self.errorMessage("Text is required.");
- self.showError(true);
- return false;
- }
-
- // No tags
- if (self.tagIds().length === 0) {
- self.errorMessage("You must select at least one tag.");
- self.showError(true);
- return false;
- }
-
- return true;
- }
-
- 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("");
- };
-
- function createUiTag(tag) {
- return {
- model: tag,
-
- id: tag.id,
- name: tag.name,
- groupId: tag.tagGroupId,
-
- selected: ko.observable(false),
-
- toggle() {
- this.selected(!this.selected());
- }
- };
- }
-}
\ No newline at end of file
diff --git a/src/shared/BeaconClient/tasking.js b/src/shared/BeaconClient/tasking.js
index 6724743b..5fc14470 100644
--- a/src/shared/BeaconClient/tasking.js
+++ b/src/shared/BeaconClient/tasking.js
@@ -9,8 +9,9 @@ import { request } from './core/request.js';
*
* @param {string|number} teamId
* @param {string|number} jobId
- * @param {{host: string, userId?: string, token: string, signal?: AbortSignal}} ctx
- * @returns {Promise} response body, or null on failure
+ * @param {{host: string, userId?: string, token: string, signal?: AbortSignal, throwOnError?: boolean}} ctx
+ * throwOnError: reject with BeaconApiError instead of resolving null
+ * @returns {Promise} response body, or null on failure (unless throwOnError)
*/
export function task(teamId, jobId, ctx = {}) {
const { host, userId = 'notPassed', token, signal } = ctx;
@@ -19,7 +20,7 @@ export function task(teamId, jobId, ctx = {}) {
token,
signal,
json: { TeamIds: [teamId], JobIds: [jobId], LighthouseFunction: 'client.TaskTeam', userId },
- nullOnError: true,
+ nullOnError: !ctx.throwOnError,
});
}
@@ -27,8 +28,9 @@ export function task(teamId, jobId, ctx = {}) {
* @param {string|number} taskingId
* @param {string} status status path segment (e.g. "OnRoute")
* @param {Record} payload
- * @param {{host: string, token: string, signal?: AbortSignal}} ctx
- * @returns {Promise} response body, or null on failure
+ * @param {{host: string, token: string, signal?: AbortSignal, throwOnError?: boolean}} ctx
+ * throwOnError: reject with BeaconApiError instead of resolving null
+ * @returns {Promise} response body, or null on failure (unless throwOnError)
*/
export function updateTeamStatus(taskingId, status, payload, ctx = {}) {
const { host, token, signal } = ctx;
@@ -37,15 +39,16 @@ export function updateTeamStatus(taskingId, status, payload, ctx = {}) {
token,
signal,
json: payload,
- nullOnError: true,
+ nullOnError: !ctx.throwOnError,
});
}
/**
* @param {string|number} taskingId
* @param {Record} payload
- * @param {{host: string, token: string, signal?: AbortSignal}} ctx
- * @returns {Promise} response body, or null on failure
+ * @param {{host: string, token: string, signal?: AbortSignal, throwOnError?: boolean}} ctx
+ * throwOnError: reject with BeaconApiError instead of resolving null
+ * @returns {Promise} response body, or null on failure (unless throwOnError)
*/
export function callOffTeam(taskingId, payload, ctx = {}) {
const { host, token, signal } = ctx;
@@ -54,15 +57,16 @@ export function callOffTeam(taskingId, payload, ctx = {}) {
token,
signal,
json: payload,
- nullOnError: true,
+ nullOnError: !ctx.throwOnError,
});
}
/**
* @param {string|number} taskingId
* @param {Record|string} payload form fields (or a pre-encoded string)
- * @param {{host: string, token: string, signal?: AbortSignal}} ctx
- * @returns {Promise} response body, or null on failure
+ * @param {{host: string, token: string, signal?: AbortSignal, throwOnError?: boolean}} ctx
+ * throwOnError: reject with BeaconApiError instead of resolving null
+ * @returns {Promise} response body, or null on failure (unless throwOnError)
*/
export function untaskTeam(taskingId, payload, ctx = {}) {
const { host, token, signal } = ctx;
@@ -71,7 +75,7 @@ export function untaskTeam(taskingId, payload, ctx = {}) {
token,
signal,
form: payload,
- nullOnError: true,
+ nullOnError: !ctx.throwOnError,
});
}
diff --git a/src/shared/BeaconClient/tasking.test.js b/src/shared/BeaconClient/tasking.test.js
index ea71c325..9ea8e014 100644
--- a/src/shared/BeaconClient/tasking.test.js
+++ b/src/shared/BeaconClient/tasking.test.js
@@ -71,6 +71,18 @@ describe('untaskTeam', () => {
});
});
+describe('throwOnError (status changes that must report failure)', () => {
+ it.each([
+ ['task', () => task('team1', 'job1', { ...ctx, throwOnError: true })],
+ ['updateTeamStatus', () => updateTeamStatus('t1', 'Onsite', {}, { ...ctx, throwOnError: true })],
+ ['callOffTeam', () => callOffTeam('t1', {}, { ...ctx, throwOnError: true })],
+ ['untaskTeam', () => untaskTeam('t1', {}, { ...ctx, throwOnError: true })],
+ ])('%s turns nullOnError off', async (_name, call) => {
+ await call();
+ expect(request).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ nullOnError: false }));
+ });
+});
+
describe('sequence', () => {
it('PUTs to /Sequences without nullOnError (rejects on failure)', async () => {
const body = { Sequences: [{ Id: 1, Order: 0 }] };
diff --git a/static/pages/tasking.html b/static/pages/tasking.html
index c9a84cff..85861a88 100644
--- a/static/pages/tasking.html
+++ b/static/pages/tasking.html
@@ -4501,41 +4501,6 @@