forge/arma/client/addons/cad/ui/src/sidepanel.js
Jacob Schmidt ff7ff0c4e5 Implement org credit line debt and bank repayment flow (#2)
## Summary

This finishes the org credit line workflow so it behaves like reserved treasury-backed credit instead of a simple member allowance.

## What changed

- reserve org funds immediately when a credit line is assigned
- track credit lines with:
  - approved amount
  - available amount
  - outstanding principal
  - interest rate
  - amount due
- consume reserved credit during store checkout without charging org funds a second time
- add credit line repayment through the bank app
- sync richer credit line state into org and bank payloads/UI
- keep legacy `amount` compatibility mapped to available credit for older consumers

## User-facing behavior

- assigning a credit line now reduces available org funds immediately
- spending on `credit_line` reduces available credit and creates debt with interest
- the bank app now shows outstanding credit debt and allows repayment from personal bank funds
- the org treasury view now shows reserved credit and outstanding due totals

## Validation

- `cargo fmt`
- `npm run build:webui`
- `cargo test -p forge-services --quiet`
- `cargo test -p forge-server --quiet`

## Follow-up checks

- validate in-game that assigning a credit line reduces org funds immediately
- validate store checkout with `credit_line` updates available credit and debt correctly
- validate bank repayment decreases player bank balance, increases org funds, and reduces amount due

Co-authored-by: Jacob Schmidt <innovativestudios@outlook.com>
Reviewed-on: #2
2026-04-02 16:50:38 -05:00

1239 lines
45 KiB
JavaScript

window.cadTasks = {
contracts: [],
requests: [],
groups: [],
activity: [],
session: {},
mode: "operations",
dispatchView: "board",
activeTab: "contracts",
selectedDispatchGroupId: "",
selectedDispatchTaskId: "",
selectedDispatchRequestId: "",
focusStatusTimer: null,
requestModalType: "",
statuses: [
"available",
"en_route",
"on_task",
"holding",
"danger",
"unavailable",
],
roles: ["infantry", "recon", "armor", "air", "logistics", "support"],
requestTypes: [
{
id: "medevac_9line",
label: "9-Line MEDEVAC",
defaultPriority: "emergency",
fields: [
{
id: "pickup_location",
label: "Line 1 Pickup Location",
type: "text",
defaultFromGroupPosition: true,
},
{
id: "radio_freq",
label: "Line 2 Radio / Call Sign",
type: "text",
},
{
id: "precedence",
label: "Line 3 Precedence",
type: "select",
options: [
"urgent",
"urgent_surgical",
"priority",
"routine",
"convenience",
],
},
{
id: "special_equipment",
label: "Line 4 Special Equipment",
type: "select",
options: ["none", "hoist", "extraction", "ventilator"],
},
{
id: "patient_type",
label: "Line 5 Patient Type",
type: "select",
options: ["litter", "ambulatory", "mixed"],
},
{
id: "security",
label: "Line 6 Security",
type: "select",
options: [
"secure",
"possible_enemy",
"enemy_in_area",
"hot",
],
},
{
id: "marking",
label: "Line 7 Marking",
type: "select",
options: ["panels", "smoke", "ir", "none", "other"],
},
{
id: "patient_nationality",
label: "Line 8 Patient Nationality",
type: "select",
options: ["coalition", "civilian", "enemy", "epw", "mixed"],
},
{
id: "terrain",
label: "Line 9 Terrain",
type: "select",
options: [
"flat",
"restricted",
"slope",
"rooftop",
"wooded",
],
},
],
},
{
id: "ace_lace",
label: "ACE/LACE",
defaultPriority: "routine",
fields: [
{ id: "ammo", label: "Ammo", type: "textarea" },
{ id: "casualties", label: "Casualties", type: "textarea" },
{ id: "equipment", label: "Equipment", type: "textarea" },
{ id: "notes", label: "Notes", type: "textarea" },
],
},
{
id: "fire_support",
label: "Fire Support",
defaultPriority: "priority",
fields: [
{
id: "target_location",
label: "Target Location",
type: "text",
defaultFromGroupPosition: true,
},
{
id: "target_description",
label: "Target Description",
type: "textarea",
},
{
id: "requested_effect",
label: "Requested Effect",
type: "select",
options: [
"suppress",
"destroy",
"illum",
"smoke",
"screen",
],
},
{ id: "ordnance", label: "Requested Ordnance", type: "text" },
{
id: "danger_close",
label: "Danger Close",
type: "select",
options: ["no", "yes"],
},
{ id: "remarks", label: "Remarks", type: "textarea" },
],
},
{
id: "air_support",
label: "Air Support",
defaultPriority: "priority",
fields: [
{
id: "target_location",
label: "Target Location",
type: "text",
defaultFromGroupPosition: true,
},
{
id: "target_description",
label: "Target Description",
type: "textarea",
},
{
id: "target_marking",
label: "Target Marking",
type: "select",
options: ["smoke", "ir", "laser", "grid", "visual"],
},
{
id: "requested_effect",
label: "Requested Effect",
type: "select",
options: [
"show_of_force",
"escort",
"suppress",
"destroy",
"recon",
],
},
{ id: "remarks", label: "Remarks", type: "textarea" },
],
},
{
id: "logreq",
label: "LOGREQ",
defaultPriority: "priority",
fields: [
{
id: "category",
label: "Category",
type: "select",
options: [
"ammo",
"medical",
"fuel",
"repair",
"vehicle",
"equipment",
"weapons",
"mixed",
],
},
{
id: "delivery_method",
label: "Delivery Method",
type: "select",
options: [
"ground",
"airdrop",
"pickup",
"dispatch_discretion",
],
},
{
id: "delivery_location",
label: "Delivery Location",
type: "text",
defaultFromGroupPosition: true,
},
{
id: "requested_items",
label: "Requested Items",
type: "textarea",
},
{
id: "quantity",
label: "Quantity / Package",
type: "text",
},
{
id: "remarks",
label: "Remarks",
type: "textarea",
},
],
},
],
init() {
document.querySelectorAll(".cad-tab").forEach((tab) => {
tab.addEventListener("click", () => {
this.setActiveTab(tab.dataset.tab || "contracts");
});
});
document
.getElementById("cadRequestModalCloseBtn")
.addEventListener("click", () => {
this.closeRequestModal();
});
document
.getElementById("cadRequestModalSaveBtn")
.addEventListener("click", () => {
this.submitSupportRequest();
});
document
.querySelector("#cadRequestModal .cad-modal-backdrop")
.addEventListener("click", () => {
this.closeRequestModal();
});
window.ForgeBridge.on("cad::hydrate", (payload) => {
this.setHydratePayload(payload || {});
});
window.ForgeBridge.on("cad::assignment::response", (payload) => {
this.handleServerResponse(!!payload.success, payload.message || "");
});
window.ForgeBridge.on("cad::group::response", (payload) => {
this.handleServerResponse(!!payload.success, payload.message || "");
});
window.ForgeBridge.on("cad::request::response", (payload) => {
this.handleServerResponse(!!payload.success, payload.message || "");
});
window.ForgeBridge.ready({ loaded: true });
},
setActiveTab(tabName) {
this.activeTab = tabName || "contracts";
document.querySelectorAll(".cad-tab").forEach((tab) => {
tab.classList.toggle(
"is-active",
tab.dataset.tab === this.activeTab,
);
});
document.querySelectorAll("[data-panel]").forEach((panel) => {
panel.classList.toggle(
"is-active",
panel.dataset.panel === this.activeTab,
);
});
},
syncLayoutState() {
const tabsEl = document.querySelector(".cad-tabs");
const contractsTab = document.getElementById("tabContractsBtn");
const rosterTab = document.getElementById("tabRosterBtn");
const requestsTab = document.getElementById("tabRequestsBtn");
const activityTab = document.getElementById("tabActivityBtn");
const contractsPanel = document.getElementById("contractsPanel");
const rosterPanel = document.getElementById("rosterPanel");
const requestsPanel = document.getElementById("requestsPanel");
const activityPanel = document.getElementById("activityPanel");
const contractsHeader = contractsPanel?.querySelector(
".cad-section-header",
);
const rosterHeader = rosterPanel?.querySelector(".cad-section-header");
if (this.isDispatchMapMode()) {
if (tabsEl) {
tabsEl.style.display = "";
tabsEl.classList.remove("is-two-col");
tabsEl.classList.add("is-three-col");
}
if (contractsTab) {
contractsTab.style.display = "";
}
if (rosterTab) {
rosterTab.textContent = "Groups";
}
if (activityTab) {
activityTab.style.display = "none";
}
if (requestsTab) {
requestsTab.style.display = "";
}
if (activityPanel) {
activityPanel.classList.remove("is-active");
activityPanel.style.display = "none";
}
if (requestsPanel) {
requestsPanel.style.display = "";
}
if (rosterPanel) {
rosterPanel.style.display = "";
}
if (rosterHeader) {
rosterHeader.textContent = "Active Groups";
}
if (contractsPanel) {
contractsPanel.style.display = "";
}
if (contractsHeader) {
contractsHeader.textContent = "Contracts";
}
if (!["contracts", "roster", "requests"].includes(this.activeTab)) {
this.activeTab = "contracts";
}
return;
}
if (tabsEl) {
tabsEl.style.display = "";
tabsEl.classList.remove("is-three-col");
tabsEl.classList.remove("is-two-col");
}
if (contractsTab) {
contractsTab.style.display = "";
}
if (rosterTab) {
rosterTab.textContent = "Roster";
}
if (activityTab) {
activityTab.style.display = "";
}
if (requestsTab) {
requestsTab.style.display = "";
}
if (contractsPanel) {
contractsPanel.style.display = "";
}
if (activityPanel) {
activityPanel.style.display = "";
}
if (requestsPanel) {
requestsPanel.style.display = "";
}
if (rosterPanel) {
rosterPanel.style.display = "";
}
if (rosterHeader) {
rosterHeader.textContent = "Roster";
}
if (contractsHeader) {
contractsHeader.textContent = "Contracts";
}
},
setHydratePayload(payload) {
this.contracts = Array.isArray(payload.contracts)
? payload.contracts
: [];
this.requests = Array.isArray(payload.requests) ? payload.requests : [];
this.groups = Array.isArray(payload.groups) ? payload.groups : [];
this.activity = Array.isArray(payload.activity) ? payload.activity : [];
this.session =
payload.session && typeof payload.session === "object"
? payload.session
: {};
this.mode =
payload && typeof payload.mode === "string"
? payload.mode
: "operations";
this.dispatchView =
payload && typeof payload.dispatchView === "string"
? payload.dispatchView
: "board";
const statusEl = document.getElementById("cadStatusMessage");
if (
statusEl &&
(!statusEl.dataset.type || statusEl.dataset.type === "info")
) {
this.setStatus("", "");
}
if (
this.selectedDispatchGroupId &&
!this.groups.some(
(group) => group.groupId === this.selectedDispatchGroupId,
)
) {
this.selectedDispatchGroupId = "";
}
if (
this.selectedDispatchTaskId &&
!this.contracts.some((task) => {
const taskId = task.taskId || task.taskID || "";
return taskId === this.selectedDispatchTaskId;
})
) {
this.selectedDispatchTaskId = "";
}
if (
this.selectedDispatchRequestId &&
!this.requests.some(
(request) =>
(request.requestId || "") ===
this.selectedDispatchRequestId,
)
) {
this.selectedDispatchRequestId = "";
}
if (
this.mode === "dispatch" &&
this.dispatchView === "map" &&
!["contracts", "roster", "requests"].includes(this.activeTab)
) {
this.activeTab = "contracts";
}
this.render();
},
setStatus(message, type) {
const statusEl = document.getElementById("cadStatusMessage");
if (!statusEl) {
return;
}
statusEl.textContent = message || "";
statusEl.dataset.type = type || "";
},
getDangerGroups() {
return this.groups.filter((group) => (group.status || "") === "danger");
},
getSupportAlertRequests() {
return this.requests.filter((request) =>
["medevac_9line", "fire_support", "air_support"].includes(
request.type || "",
),
);
},
buildSupportAlertMessage() {
const alertRequests = this.getSupportAlertRequests();
if (!alertRequests.length) {
return "";
}
const labels = alertRequests.map((request) => {
const groupLabel =
request.groupCallsign || request.groupId || "Unknown Group";
const typeLabel = this.getRequestTypeLabel(
request.type || "request",
);
return `${groupLabel} ${typeLabel}`;
});
return `Support request alert: ${labels.join(", ")}`;
},
getCurrentGroupCoordinates() {
const currentGroup = this.getCurrentGroup();
const position = Array.isArray(currentGroup?.position)
? currentGroup.position
: [0, 0, 0];
return window.mapUI.formatPosition(position);
},
getSortedGroups() {
return this.groups.slice().sort((left, right) => {
const leftDanger = (left.status || "") === "danger" ? 0 : 1;
const rightDanger = (right.status || "") === "danger" ? 0 : 1;
if (leftDanger !== rightDanger) {
return leftDanger - rightDanger;
}
const leftCallsign = left.callsign || left.groupId || "";
const rightCallsign = right.callsign || right.groupId || "";
return leftCallsign.localeCompare(rightCallsign);
});
},
isDispatchOrder(entry) {
return (
!!entry.isDispatchOrder || (entry.type || "") === "dispatch_order"
);
},
formatTypeLabel(entry) {
const typeLabel = (entry.type || "task").replaceAll("_", " ");
return this.isDispatchOrder(entry) ? "dispatch order" : typeLabel;
},
getRequestDefinition(typeID) {
return this.requestTypes.find((entry) => entry.id === typeID) || null;
},
getRequestTypeLabel(typeID) {
return this.getRequestDefinition(typeID)?.label || typeID;
},
canSubmitSupportRequest() {
return this.mode === "operations" && this.isLeader();
},
openRequestModal(typeID) {
const definition = this.getRequestDefinition(typeID);
if (!definition) {
return;
}
this.requestModalType = typeID;
document.getElementById("cadRequestModalTitle").textContent =
definition.label;
document.getElementById("cadRequestPrioritySelect").value =
definition.defaultPriority || "priority";
this.renderRequestFields(definition);
document
.getElementById("cadRequestModal")
.classList.remove("is-hidden");
},
closeRequestModal() {
this.requestModalType = "";
document.getElementById("cadRequestFields").innerHTML = "";
document.getElementById("cadRequestModal").classList.add("is-hidden");
},
renderRequestFields(definition) {
const container = document.getElementById("cadRequestFields");
if (!container || !definition) {
return;
}
const coords = this.getCurrentGroupCoordinates();
container.innerHTML = definition.fields
.map((field) => {
const defaultValue = field.defaultFromGroupPosition
? coords
: "";
if (field.type === "select") {
return `
<label class="cad-field">
<span>${field.label}</span>
<select id="cadRequestField_${field.id}" class="cad-select">
${field.options
.map(
(option) =>
`<option value="${option}">${option.replaceAll("_", " ")}</option>`,
)
.join("")}
</select>
</label>
`;
}
if (field.type === "textarea") {
return `
<label class="cad-field">
<span>${field.label}</span>
<textarea id="cadRequestField_${field.id}" class="cad-textarea" rows="3">${defaultValue}</textarea>
</label>
`;
}
return `
<label class="cad-field">
<span>${field.label}</span>
<input id="cadRequestField_${field.id}" class="cad-input" type="text" value="${defaultValue}" />
</label>
`;
})
.join("");
},
submitSupportRequest() {
const definition = this.getRequestDefinition(this.requestModalType);
if (!definition) {
return;
}
const fields = {};
definition.fields.forEach((field) => {
const input = document.getElementById(
`cadRequestField_${field.id}`,
);
fields[field.id] = input ? String(input.value || "").trim() : "";
});
const priority = document.getElementById(
"cadRequestPrioritySelect",
).value;
this.setStatus("Submitting support request...", "info");
window.mapUI.sendEvent("cad::supportRequest::submit", {
type: definition.id,
fields: fields,
priority: priority,
});
this.closeRequestModal();
},
closeSupportRequest(requestID) {
if (!requestID) {
return;
}
this.setStatus(
this.isDispatchMode()
? "Closing support request..."
: "Cancelling support request...",
"info",
);
window.mapUI.sendEvent("cad::supportRequest::close", {
requestID: requestID,
});
},
renderRequests() {
const listEl = document.getElementById("requestList");
if (!listEl) {
return;
}
if (this.isDispatchMapMode()) {
const dispatchRequests = this.requests
.slice()
.sort((left, right) => {
const leftTitle = left.title || left.requestId || "";
const rightTitle = right.title || right.requestId || "";
return leftTitle.localeCompare(rightTitle);
});
if (!dispatchRequests.length) {
listEl.innerHTML =
'<div class="placeholder-message"><p>No support requests are currently active.</p></div>';
return;
}
listEl.innerHTML = dispatchRequests
.map((request) => {
const requestID = request.requestId || "";
const position = Array.isArray(request.position)
? request.position
: [0, 0, 0];
const isSelected =
requestID === this.selectedDispatchRequestId;
const isWarning = [
"medevac_9line",
"fire_support",
"air_support",
].includes(request.type || "");
return `
<button
type="button"
class="task-card dispatch-map-card ${isSelected ? "is-selected" : ""} ${isWarning ? "is-warning" : ""}"
data-request-id="${requestID}"
onclick="window.cadTasks.focusRequest('${requestID}')"
>
<div class="task-card-header">
<strong>${request.title || requestID || "Support Request"}</strong>
<span class="task-type">${this.getRequestTypeLabel(request.type || "request")}</span>
</div>
<p class="task-description">${request.summary || ""}</p>
<div class="task-meta">
<span>Group: ${request.groupCallsign || request.groupId || "Unknown"}</span>
<span>${(request.priority || "priority").replaceAll("_", " ")}</span>
</div>
<div class="task-meta">
<span>${window.mapUI.formatPosition(position)}</span>
<span>${requestID || "request"}</span>
</div>
</button>
`;
})
.join("");
return;
}
const requestButtons = this.canSubmitSupportRequest()
? `
<div class="cad-request-actions">
${this.requestTypes
.map(
(requestType) => `
<button
type="button"
class="task-secondary-btn cad-request-btn"
onclick="window.cadTasks.openRequestModal('${requestType.id}')"
>
${requestType.label}
</button>
`,
)
.join("")}
</div>
`
: "";
if (!this.requests.length) {
listEl.innerHTML = `
${requestButtons}
<div class="placeholder-message"><p>No support requests are currently active.</p></div>
`;
return;
}
listEl.innerHTML = `
${requestButtons}
${this.requests
.map((request) => {
const isOwnGroupLeader =
this.isLeader() &&
(request.groupId || "") === this.getPlayerGroupId();
const canClose = this.canDispatch() || isOwnGroupLeader;
const requestActionLabel = this.isDispatchMode()
? "Close"
: "Cancel";
return `
<div class="task-card cad-request-card">
<div class="task-card-header">
<strong>${request.title || this.getRequestTypeLabel(request.type || "")}</strong>
<span class="task-type">${(request.priority || "priority").replaceAll("_", " ")}</span>
</div>
<p class="task-description">${request.summary || ""}</p>
<div class="task-meta">
<span>Group: ${request.groupCallsign || request.groupId || "Unknown"}</span>
<span>${this.getRequestTypeLabel(request.type || "")}</span>
</div>
${
canClose
? `<div class="task-action-row">
<button type="button" class="task-secondary-btn" onclick="window.cadTasks.closeSupportRequest('${request.requestId || ""}')">${requestActionLabel}</button>
</div>`
: ""
}
</div>
`;
})
.join("")}
`;
},
updateDangerAlert() {
const alertEl = document.getElementById("cadDangerAlert");
if (!alertEl) {
return;
}
if (!this.isDispatchMapMode()) {
alertEl.textContent = "";
alertEl.classList.add("is-hidden");
return;
}
const dangerGroups = this.getDangerGroups();
if (!dangerGroups.length) {
alertEl.textContent = "";
alertEl.classList.add("is-hidden");
return;
}
const callsigns = dangerGroups.map(
(group) => group.callsign || group.groupId || "Unknown Group",
);
alertEl.textContent = `Danger alert active: ${callsigns.join(", ")}`;
alertEl.classList.remove("is-hidden");
},
updateRequestAlert() {
const alertEl = document.getElementById("cadRequestAlert");
if (!alertEl) {
return;
}
if (!this.isDispatchMapMode()) {
alertEl.textContent = "";
alertEl.classList.add("is-hidden");
return;
}
const alertMessage = this.buildSupportAlertMessage();
if (!alertMessage) {
alertEl.textContent = "";
alertEl.classList.add("is-hidden");
return;
}
alertEl.textContent = alertMessage;
alertEl.classList.remove("is-hidden");
},
clearFocusStatusSoon(message) {
if (this.focusStatusTimer) {
window.clearTimeout(this.focusStatusTimer);
}
this.focusStatusTimer = window.setTimeout(() => {
const statusEl = document.getElementById("cadStatusMessage");
if (!statusEl) {
return;
}
if (
statusEl.dataset.type === "info" &&
statusEl.textContent === message
) {
this.setStatus("", "");
}
}, 1800);
},
handleServerResponse(success, message) {
this.setStatus(
message ||
(success ? "CAD update succeeded." : "CAD update failed."),
success ? "success" : "error",
);
},
acknowledgeTask(taskID) {
this.setStatus("Acknowledging contract...", "info");
window.mapUI.sendEvent("cad::tasks::acknowledge", { taskID: taskID });
},
declineTask(taskID) {
this.setStatus("Declining contract...", "info");
window.mapUI.sendEvent("cad::tasks::decline", { taskID: taskID });
},
updateGroupStatus(groupID, status) {
this.setStatus("Updating group status...", "info");
window.mapUI.sendEvent("cad::groups::status", {
groupID: groupID,
status: status,
});
},
updateGroupRole(groupID, role) {
this.setStatus("Updating group role...", "info");
window.mapUI.sendEvent("cad::groups::role", {
groupID: groupID,
role: role,
});
},
focusGroup(groupID) {
const group = this.groups.find((entry) => entry.groupId === groupID);
if (!group) {
this.setStatus("Selected group is no longer available.", "error");
return;
}
this.selectedDispatchGroupId = groupID;
this.selectedDispatchTaskId = "";
this.selectedDispatchRequestId = "";
const statusMessage = `Centering map on ${group.callsign || group.groupId || "group"}...`;
this.setStatus(statusMessage, "info");
this.clearFocusStatusSoon(statusMessage);
window.mapUI.sendEvent("cad::groups::focus", {
groupID: groupID,
});
this.render();
},
focusTask(taskID) {
const task = this.contracts.find((entry) => {
const entryTaskID = entry.taskId || entry.taskID || "";
return entryTaskID === taskID;
});
if (!task) {
this.setStatus(
"Selected contract is no longer available.",
"error",
);
return;
}
this.selectedDispatchTaskId = taskID;
this.selectedDispatchGroupId = "";
this.selectedDispatchRequestId = "";
const statusMessage = `Centering map on ${task.title || taskID}...`;
this.setStatus(statusMessage, "info");
this.clearFocusStatusSoon(statusMessage);
window.mapUI.sendEvent("cad::tasks::focus", {
taskID: taskID,
});
this.render();
},
focusRequest(requestID) {
const request = this.requests.find(
(entry) => (entry.requestId || "") === requestID,
);
if (!request) {
this.setStatus("Selected request is no longer available.", "error");
return;
}
const position = Array.isArray(request.position)
? request.position
: [];
if (position.length < 2) {
this.setStatus("Selected request has no map position.", "error");
return;
}
this.selectedDispatchRequestId = requestID;
this.selectedDispatchGroupId = "";
this.selectedDispatchTaskId = "";
const statusMessage = `Centering map on ${request.title || requestID}...`;
this.setStatus(statusMessage, "info");
this.clearFocusStatusSoon(statusMessage);
window.mapUI.sendEvent("cad::requests::focus", {
requestID: requestID,
});
this.render();
},
getPlayerGroupId() {
return this.session.groupId || "";
},
getCurrentGroup() {
const currentGroupId = this.getPlayerGroupId();
return (
this.groups.find((group) => group.groupId === currentGroupId) ||
null
);
},
normalizeCollection(value) {
if (Array.isArray(value)) {
return value;
}
if (value && typeof value === "object") {
return Object.values(value);
}
return [];
},
canDispatch() {
return !!this.session.isDispatcher;
},
isDispatchMode() {
return this.mode === "dispatch";
},
isDispatchMapMode() {
return this.mode === "dispatch" && this.dispatchView === "map";
},
isLeader() {
return !!this.session.isLeader;
},
renderContracts() {
const listEl = document.getElementById("taskList");
if (!listEl) {
return;
}
if (this.isDispatchMapMode()) {
if (!this.contracts.length) {
listEl.innerHTML =
'<div class="placeholder-message"><p>No contracts are currently available.</p></div>';
return;
}
const dispatchContracts = this.contracts
.slice()
.sort((left, right) => {
const leftAssigned =
(left.assignmentState || "unassigned") === "unassigned"
? 0
: 1;
const rightAssigned =
(right.assignmentState || "unassigned") === "unassigned"
? 0
: 1;
if (leftAssigned !== rightAssigned) {
return leftAssigned - rightAssigned;
}
const leftId = left.taskId || left.taskID || "";
const rightId = right.taskId || right.taskID || "";
return leftId.localeCompare(rightId);
});
listEl.innerHTML = dispatchContracts
.map((task) => {
const taskId = task.taskId || task.taskID || "";
const position = Array.isArray(task.position)
? task.position
: [0, 0, 0];
const assignedGroupId = task.assignedGroupId || "";
const assignmentState =
task.assignmentState || "unassigned";
const assignedGroup = this.groups.find(
(group) => group.groupId === assignedGroupId,
);
const isSelected = taskId === this.selectedDispatchTaskId;
const stateLabel =
assignmentState === "unassigned"
? "Unassigned"
: `${assignmentState}: ${assignedGroup ? assignedGroup.callsign : assignedGroupId || "Unknown"}`;
return `
<button
type="button"
class="task-card dispatch-map-card ${isSelected ? "is-selected" : ""}"
data-task-id="${taskId}"
onclick="window.cadTasks.focusTask('${taskId}')"
>
<div class="task-card-header">
<strong>${task.title || taskId}</strong>
<span class="task-type">${this.formatTypeLabel(task)}</span>
</div>
<p class="task-description">${task.description || ""}</p>
<div class="task-meta">
<span>${stateLabel}</span>
<span>${window.mapUI.formatPosition(position)}</span>
</div>
</button>
`;
})
.join("");
return;
}
const currentGroupId = this.getPlayerGroupId();
const visibleContracts = this.contracts.filter(
(task) => (task.assignedGroupId || "") === currentGroupId,
);
if (!visibleContracts.length) {
listEl.innerHTML =
'<div class="placeholder-message"><p>No contract is currently assigned to your group.</p></div>';
return;
}
listEl.innerHTML = visibleContracts
.map((task) => {
const taskId = task.taskId || task.taskID || "";
const position = Array.isArray(task.position)
? task.position
: [0, 0, 0];
const assignedGroupId = task.assignedGroupId || "";
const assignmentState = task.assignmentState || "unassigned";
const assignedGroup = this.groups.find(
(group) => group.groupId === assignedGroupId,
);
const isAssignedToLeader =
this.isLeader() && assignedGroupId === currentGroupId;
return `
<div class="task-card" data-task-id="${taskId}">
<div class="task-card-header">
<strong>${task.title || taskId}</strong>
<span class="task-type">${this.formatTypeLabel(task)}</span>
</div>
<p class="task-description">${task.description || ""}</p>
<div class="task-meta">
<span>${assignmentState === "unassigned" ? "Available" : `${assignmentState}: ${assignedGroup ? assignedGroup.callsign : assignedGroupId}`}</span>
<span>${window.mapUI.formatPosition(position)}</span>
</div>
${
isAssignedToLeader && assignmentState === "assigned"
? `<div class="task-action-row">
<button type="button" class="task-accept-btn" onclick="window.cadTasks.acknowledgeTask('${taskId}')">Acknowledge</button>
<button type="button" class="task-secondary-btn" onclick="window.cadTasks.declineTask('${taskId}')">Decline</button>
</div>`
: ""
}
</div>
`;
})
.join("");
},
renderRoster() {
const listEl = document.getElementById("rosterList");
if (!listEl) {
return;
}
if (this.isDispatchMapMode()) {
if (!this.groups.length) {
listEl.innerHTML =
'<div class="placeholder-message"><p>No active groups are currently available.</p></div>';
return;
}
listEl.innerHTML = this.getSortedGroups()
.map((group) => {
const isSelected =
(group.groupId || "") === this.selectedDispatchGroupId;
const isDanger = (group.status || "") === "danger";
return `
<button
type="button"
class="task-card roster-member-card dispatch-map-group-card ${isSelected ? "is-selected" : ""} ${isDanger ? "is-danger" : ""}"
data-group-id="${group.groupId || ""}"
onclick="window.cadTasks.focusGroup('${group.groupId || ""}')"
>
<div class="task-card-header">
<strong>${group.callsign || group.groupId || "Unknown Group"}</strong>
<span class="task-type">${group.role || "group"}</span>
${isDanger ? '<span class="task-alert-badge">Danger</span>' : ""}
</div>
<div class="task-meta">
<span>Leader: ${group.leaderName || "Unknown"}</span>
<span>Status: ${group.status || "unknown"}</span>
</div>
<div class="task-meta">
<span>Members: ${this.normalizeCollection(group.members).length}</span>
<span>Task: ${group.currentTaskId || "None"}</span>
</div>
</button>
`;
})
.join("");
return;
}
const currentGroup = this.getCurrentGroup();
if (!currentGroup) {
listEl.innerHTML =
'<div class="placeholder-message"><p>Your group is not currently available.</p></div>';
return;
}
const roster = this.normalizeCollection(currentGroup.members);
const isDanger = (currentGroup.status || "") === "danger";
if (!roster.length) {
listEl.innerHTML =
'<div class="placeholder-message"><p>No roster members are currently available.</p></div>';
return;
}
listEl.innerHTML = `
<div class="roster-summary-card ${isDanger ? "is-danger" : ""}">
<div class="task-card-header">
<strong>${currentGroup.callsign || currentGroup.groupId || "Current Group"}</strong>
<span class="task-type">${roster.length} member${roster.length === 1 ? "" : "s"}</span>
${isDanger ? '<span class="task-alert-badge">Danger</span>' : ""}
</div>
<div class="task-meta">
<span>Leader: ${currentGroup.leaderName || "Unknown"}</span>
<span>Status: ${currentGroup.status || "unknown"}</span>
</div>
<div class="task-meta">
<span>Role: ${currentGroup.role || "unassigned"}</span>
<span>Task: ${currentGroup.currentTaskId || "None"}</span>
</div>
</div>
${roster
.map((member) => {
const lifeState = (
member.lifeState || "unknown"
).replaceAll("_", " ");
const leaderBadge = member.isLeader
? '<span class="roster-leader-badge">Leader</span>'
: "";
return `
<div class="task-card roster-member-card" data-member-id="${member.uid || ""}">
<div class="task-card-header">
<strong>${member.name || "Unknown Operator"}</strong>
<span class="task-type">${lifeState}</span>
</div>
<div class="task-meta">
<span>${member.uid || "No UID"}</span>
<span>${leaderBadge}</span>
</div>
</div>
`;
})
.join("")}
`;
},
renderActivity() {
const listEl = document.getElementById("activityList");
if (!listEl) {
return;
}
if (!this.activity.length) {
listEl.innerHTML =
'<div class="placeholder-message"><p>No recent activity.</p></div>';
return;
}
listEl.innerHTML = this.activity
.slice()
.reverse()
.slice(0, 8)
.map(
(entry) => `
<div class="task-card">
<div class="task-card-header">
<strong>${entry.type || "activity"}</strong>
<span class="task-type">${Math.round(entry.timestamp || 0)}s</span>
</div>
<p class="task-description">${entry.message || ""}</p>
</div>
`,
)
.join("");
},
render() {
this.updateDangerAlert();
this.updateRequestAlert();
this.syncLayoutState();
this.renderContracts();
this.renderRoster();
this.renderRequests();
this.renderActivity();
this.setActiveTab(this.activeTab);
},
};
window.cadTasks.init();