## 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
1239 lines
45 KiB
JavaScript
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();
|