// ==UserScript==
// @name * Personalzuweiser 2.0
// @namespace bos-ernie.leitstellenspiel.de
// @version 2.5.1
// @license BSD-3-Clause
// @author BOS-Ernie
// @description Weist benötigtes Personal einem Fahrzeug zu.
// @match https://*.leitstellenspiel.de/vehicles/*/zuweisung
// @icon https://www.google.com/s2/favicons?sz=64&domain=leitstellenspiel.de
// @run-at document-idle
// @grant none
// @resource https://forum.leitstellenspiel.de/index.php?thread/27234-script-personalzuweiser-2-0/
// ==/UserScript==
/* global $, I18n */
(async function () {
const assignButtonHotkey = "s";
const resetButtonHotkey = "x";
const buildingButtonHotkey = "w";
const previousVehicleButtonHotkey = "a";
const nextVehicleButtonHotkey = "d";
const assignMostSeniorPersonnelFirst = true;
/*
* Um die Personalzuweisung für einen Fahrzeugtyp zu überschreiben, entferne die Kommentare am entsprechenden Block
* oder füge einen neuen hinzu. Wenn du Fragen zur Konfiguration hast, melde dich im Forum.
*
* Erklärung der Felder:
* {
* id: 53, // ID des Fahrzeugtyps
* caption: "Dekon-P", // Name des Fahrzeugtyps
* maxStaff: 6, // Maximale Fahrzeugbesatzung
* training: [ // Benötigte Lehrgänge
* {
* key: "dekon_p", // Schlüssel des benötigten Lehrgangs
* number: 6, // Anzahl des benötigten Lehrgangs
* },
* ],
* }
*/
const vehiclesConfigurationOverride = [
// {
// id: 53,
// caption: "Dekon-P",
// maxStaff: 6,
// training: [
// {
// key: "dekon_p",
// number: 6,
// },
// ],
// },
// {
// id: 134,
// caption: "Pferdetransporter klein",
// maxStaff: 4,
// training: [
// {
// key: "police_horse",
// number: 4,
// },
// ],
// },
// {
// id: 135,
// caption: "Pferdetransporter groß",
// maxStaff: 2,
// training: [
// {
// key: "police_horse",
// number: 2,
// },
// ],
// },
// {
// id: 137,
// caption: "Zugfahrzeug Pferdetransport",
// maxStaff: 6,
// training: [
// {
// key: "police_horse",
// number: 6,
// },
// ],
// },
];
let vehiclesConfiguration = [];
const storageKey = "bos-ernie.personnel-allocator.vehicle-type-configurations";
const storageTtl = 24 * 60 * 60 * 1000;
function transformVehiclesData(data) {
return Object.entries(data)
.filter(([id, vehicle]) => !vehicle.isTrailer)
.map(([id, vehicle]) => {
const trainingMap = {};
if (vehicle.staff && vehicle.staff.training) {
for (const trainings of Object.values(vehicle.staff.training)) {
for (const [trainingKey, trainingInfo] of Object.entries(trainings)) {
if (trainingInfo.min !== 0) {
trainingMap[trainingKey] = trainingInfo.min ? trainingInfo.min : vehicle.maxPersonnel;
}
}
}
}
return {
id: Number(id),
caption: vehicle.caption,
maxStaff: vehicle.maxPersonnel,
training: Object.entries(trainingMap).map(([key, number]) => ({
key,
number,
})),
};
});
}
async function initVehiclesConfiguration() {
const storedVehiclesConfiguration = localStorage.getItem(storageKey);
if (storedVehiclesConfiguration) {
const cachedData = JSON.parse(storedVehiclesConfiguration);
if (cachedData.lastUpdate > new Date().getTime() - storageTtl) {
vehiclesConfiguration = applyVehicleConfigurationOverride(cachedData.data);
return;
}
}
try {
const response = await fetch("https://api.lss-manager.de/de_DE/vehicles");
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
vehiclesConfiguration = applyVehicleConfigurationOverride(transformVehiclesData(data));
localStorage.setItem(
storageKey,
JSON.stringify({
lastUpdate: new Date().getTime(),
data: vehiclesConfiguration,
}),
);
} catch (error) {
console.error("Error fetching and transforming vehicles data:", error);
}
}
function applyVehicleConfigurationOverride(vehiclesConfiguration) {
return vehiclesConfiguration.map(vehicle => {
const override = vehiclesConfigurationOverride.find(override => override.id === vehicle.id);
return override ? override : vehicle;
});
}
function observeNumberOfAssignedPersonnelMutations() {
const targetNode = document.getElementById("count_personal");
const config = { attributes: true, childList: true, subtree: true };
const callback = function (mutationsList, observer) {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
updateNumberOfAssignedPersonnelDecoration();
}
}
};
const observer = new MutationObserver(callback);
observer.observe(targetNode, config);
}
function updateNumberOfAssignedPersonnelDecoration() {
const assignedPersonsElement = getAssignedPersonsElement();
const vehicleCapacity = parseInt(assignedPersonsElement.parentElement.firstElementChild.innerText);
let numberOfAssignedPersonnel = parseInt(assignedPersonsElement.innerText);
let numberOfPersonnelToAssign = vehicleCapacity - numberOfAssignedPersonnel;
if (numberOfPersonnelToAssign <= 0) {
assignedPersonsElement.classList.remove("label-warning");
assignedPersonsElement.classList.add("label-success");
} else {
assignedPersonsElement.classList.remove("label-success");
assignedPersonsElement.classList.add("label-warning");
}
}
async function assign() {
const vehicleTypeId = getVehicleTypeId();
if (vehicleTypeId === null) {
return;
}
const vehicleConfiguration = vehiclesConfiguration.find(vehicle => vehicle.id === vehicleTypeId);
const vehicleCapacity = vehicleConfiguration.maxStaff;
const assignedPersonsElement = getAssignedPersonsElement();
let numberOfAssignedPersonnel = parseInt(assignedPersonsElement.innerText);
let numberOfPersonnelToAssign = vehicleCapacity - numberOfAssignedPersonnel;
if (numberOfPersonnelToAssign <= 0) {
return;
}
for (const training of vehicleConfiguration.training) {
if (numberOfPersonnelToAssign === 0) {
break;
}
numberOfPersonnelToAssign -= await assignPersonnel(training.key, training.number);
}
if (numberOfPersonnelToAssign > 0) {
await assignPersonnel(null, numberOfPersonnelToAssign);
}
}
function getAvailableWithTraining(identifier) {
const rows = document.querySelectorAll("tr[data-filterable-by]");
return Array.from(rows).filter(row => {
const filterData = row
.getAttribute("data-filterable-by")
.replace(/"/g, "")
.replace(/[\[\]]/g, "")
.split(",")
.map(item => item.trim());
const isInTraining = row.children[2].innerText.startsWith("Im Unterricht");
if (identifier === null) {
return filterData.length === 1 && filterData[0] === "" && !isInTraining;
}
return filterData.includes(identifier) && !isInTraining;
});
}
async function assignPersonnel(identifier, number) {
let numberOfPersonnelAssigned = 0;
if (number === 0) {
return numberOfPersonnelAssigned;
}
const rowsNotInTraining = getAvailableWithTraining(identifier);
if (assignMostSeniorPersonnelFirst) {
rowsNotInTraining.reverse();
}
for (const row of rowsNotInTraining) {
if (numberOfPersonnelAssigned === number) {
break;
}
const button = row.querySelector("a.btn-success");
if (!button) {
continue;
}
const personalId = button.getAttribute("personal_id");
const personalElement = document.getElementById(`personal_${personalId}`);
personalElement.innerHTML = `<td colspan="4">${I18n.t("common.loading")}</td>`;
const response = await fetch(button.href, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"x-csrf-token": document.querySelector("meta[name=csrf-token]").content,
"x-requested-with": "XMLHttpRequest",
},
});
if (!response.ok) {
throw new Error("HTTP Fehler! Statuscode: " + response.status);
}
personalElement.innerHTML = await response.text();
numberOfPersonnelAssigned++;
const assignedPersonsElement = getAssignedPersonsElement();
getAssignedPersonsElement().innerText = parseInt(assignedPersonsElement.innerText) + 1;
await new Promise(r => setTimeout(r, 50));
}
return numberOfPersonnelAssigned;
}
async function reset() {
const selectButtons = document.getElementsByClassName("btn btn-default btn-assigned");
// Since the click event removes the button from the DOM, only every second item would be clicked.
// To prevent this, the loop is executed backwards.
for (let i = selectButtons.length - 1; i >= 0; i--) {
selectButtons[i].click();
// Wait 250ms to prevent possible race conditions
await new Promise(r => setTimeout(r, 250));
}
}
function assignClickEvent(event) {
assign();
event.preventDefault();
}
function resetClickEvent(event) {
reset();
event.preventDefault();
}
function getAssignedPersonsElement() {
return document.getElementById("count_personal");
}
function addButtonGroup() {
let okIcon = document.createElement("span");
okIcon.className = "glyphicon glyphicon-ok";
let assignButton = document.createElement("button");
assignButton.type = "button";
assignButton.className = "btn btn-success";
assignButton.appendChild(okIcon);
assignButton.addEventListener("click", assignClickEvent);
let resetIcon = document.createElement("span");
resetIcon.className = "glyphicon glyphicon-trash";
let resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.className = "btn btn-danger";
resetButton.appendChild(resetIcon);
resetButton.addEventListener("click", resetClickEvent);
let buttonGroup = document.createElement("div");
buttonGroup.id = "vehicle-assigner-button-group";
buttonGroup.className = "btn-group";
buttonGroup.style = "margin-left: 5px";
buttonGroup.appendChild(assignButton);
buttonGroup.appendChild(resetButton);
// Append button group to element with class "vehicles-education-filter-box"
document.getElementsByClassName("vehicles-education-filter-box")[0].appendChild(buttonGroup);
}
function getVehicleId() {
return window.location.pathname.split("/")[2];
}
function getVehicleTypeId() {
const vehicleId = getVehicleId();
const request = new XMLHttpRequest();
request.open("GET", `/api/v2/vehicles/${vehicleId}`, false);
request.send(null);
if (request.status === 200) {
const vehicle = JSON.parse(request.responseText);
return vehicle.result.vehicle_type;
}
return null;
}
function removeEventListenersOfAssignButtons() {
const personalTable = document.getElementById("personal_table");
const buttons = personalTable.querySelectorAll("a.btn");
for (let button of buttons) {
button = button.cloneNode(true);
button.replaceWith(button);
}
}
async function main() {
await initVehiclesConfiguration();
observeNumberOfAssignedPersonnelMutations();
removeEventListenersOfAssignButtons();
addButtonGroup();
document.addEventListener("keydown", function (event) {
const activeElement = document.activeElement;
if (activeElement.tagName.toLowerCase() !== "body") {
return;
}
const key = event.key.toLocaleLowerCase();
const buildingElement = document.querySelector("#iframe-inside-container ol.breadcrumb a");
switch (key) {
case assignButtonHotkey:
assign();
break;
case resetButtonHotkey:
reset();
break;
case previousVehicleButtonHotkey:
document.querySelectorAll(".btn-group.pull-right a")[0].click();
break;
case nextVehicleButtonHotkey:
document.querySelectorAll(".btn-group.pull-right a")[1].click();
break;
case buildingButtonHotkey:
if (buildingElement) {
buildingElement.click();
}
break;
}
});
}
main();
})();