Files
c0de.it/public/assets/app.js
Lars Gebhardt-Kusche 752d33e38a
All checks were successful
Deploy / deploy (push) Successful in 15s
code it los
2026-05-15 22:27:49 +02:00

653 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
bootstrap: null,
rackTemplateId: null,
projectName: "Neues Rack-Projekt",
components: [],
placedItems: [],
nextPlacementId: 1,
dragPlacementId: null,
};
const ui = {};
document.addEventListener("DOMContentLoaded", async () => {
cacheDom();
bindEvents();
await loadBootstrap();
});
function cacheDom() {
ui.templateSelect = document.getElementById("rack-template-select");
ui.projectName = document.getElementById("project-name");
ui.componentFilter = document.getElementById("component-filter");
ui.pluginInput = document.getElementById("plugin-input");
ui.componentLibrary = document.getElementById("component-library");
ui.rackSummary = document.getElementById("rack-summary");
ui.rackGrid = document.getElementById("rack-grid");
ui.projectStats = document.getElementById("project-stats");
ui.bomOutput = document.getElementById("bom-output");
ui.cableFrom = document.getElementById("cable-from");
ui.cableTo = document.getElementById("cable-to");
ui.cableSlack = document.getElementById("cable-slack");
ui.cableOutput = document.getElementById("cable-output");
ui.validationOutput = document.getElementById("validation-output");
ui.exportBom = document.getElementById("export-bom");
}
function bindEvents() {
ui.templateSelect.addEventListener("change", () => {
state.rackTemplateId = ui.templateSelect.value;
state.placedItems = [];
renderAll();
});
ui.projectName.addEventListener("input", () => {
state.projectName = ui.projectName.value.trim() || "Neues Rack-Projekt";
renderSummary();
});
ui.componentFilter.addEventListener("input", renderLibrary);
ui.cableSlack.addEventListener("input", renderCableEstimate);
ui.cableFrom.addEventListener("change", renderCableEstimate);
ui.cableTo.addEventListener("change", renderCableEstimate);
ui.pluginInput.addEventListener("change", importPluginPack);
ui.exportBom.addEventListener("click", copyBomCsv);
}
async function loadBootstrap() {
const response = await fetch(window.APP_CONFIG.apiBootstrapUrl, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Bootstrap request failed: ${response.status}`);
}
state.bootstrap = await response.json();
state.components = [...state.bootstrap.components];
state.rackTemplateId = state.bootstrap.rackTemplates[0]?.id ?? null;
renderTemplateOptions();
renderAll();
}
function renderAll() {
renderSummary();
renderLibrary();
renderRack();
renderStats();
renderBom();
renderCableSelectors();
renderCableEstimate();
renderValidation();
}
function renderTemplateOptions() {
const options = state.bootstrap.rackTemplates
.map((template) => {
const selected = template.id === state.rackTemplateId ? " selected" : "";
return `<option value="${escapeHtml(template.id)}"${selected}>${escapeHtml(template.name)}</option>`;
})
.join("");
ui.templateSelect.innerHTML = options;
}
function renderSummary() {
const rack = getCurrentRackTemplate();
if (!rack) {
ui.rackSummary.innerHTML = "";
return;
}
ui.rackSummary.innerHTML = `
<div class="summary-card">
<span>Projekt</span>
<strong>${escapeHtml(state.projectName)}</strong>
</div>
<div class="summary-card">
<span>Rack-Standard</span>
<strong>${rack.rackStandard === "19_inch" ? "19 inch" : "10 inch"}</strong>
</div>
<div class="summary-card">
<span>Kapazitaet</span>
<strong>${rack.totalU}U / ${rack.usableDepthMm} mm</strong>
</div>
`;
}
function renderLibrary() {
const rack = getCurrentRackTemplate();
if (!rack) {
return;
}
const needle = ui.componentFilter.value.trim().toLowerCase();
const filtered = state.components.filter((component) => {
const compatible = component.rackStandard === rack.rackStandard;
const haystack = `${component.name} ${component.category} ${component.partNumber}`.toLowerCase();
return compatible && (!needle || haystack.includes(needle));
});
if (filtered.length === 0) {
ui.componentLibrary.innerHTML = `<div class="empty-state">Keine passenden Komponenten gefunden.</div>`;
return;
}
ui.componentLibrary.innerHTML = filtered
.map(
(component) => `
<article class="component-card">
<div class="component-card__header">
<div>
<strong>${escapeHtml(component.name)}</strong>
<div>${escapeHtml(component.partNumber)}</div>
</div>
<span class="chip">${component.heightU}U</span>
</div>
<div class="component-card__meta">
<span>${escapeHtml(component.category)}</span>
<span>${component.depthMm} mm tief</span>
<span>${formatCurrency(component.priceNet, component.currency)}</span>
</div>
<button type="button" data-action="add-component" data-component-id="${escapeHtml(component.id)}">
In Rack einfuegen
</button>
</article>
`
)
.join("");
ui.componentLibrary.querySelectorAll("[data-action='add-component']").forEach((button) => {
button.addEventListener("click", () => addComponentToRack(button.dataset.componentId));
});
}
function addComponentToRack(componentId) {
const component = state.components.find((entry) => entry.id === componentId);
const rack = getCurrentRackTemplate();
if (!component || !rack) {
return;
}
const position = findFirstFreePosition(component.heightU);
if (position === null) {
alert("Keine freie Position fuer diese Komponente im aktuellen Rack.");
return;
}
state.placedItems.push({
placementId: `p${state.nextPlacementId++}`,
componentId: component.id,
startU: position,
});
renderAll();
}
function renderRack() {
const rack = getCurrentRackTemplate();
if (!rack) {
return;
}
ui.rackGrid.style.setProperty("--rack-unit-height", "38px");
const slots = [];
for (let u = rack.totalU; u >= 1; u -= 1) {
slots.push(`
<div class="rack-slot" data-slot-u="${u}">
<span class="rack-slot__label">${u}U</span>
</div>
`);
}
ui.rackGrid.innerHTML = `${slots.join("")}<div class="rack-items-layer" id="rack-items-layer"></div>`;
ui.rackGrid.querySelectorAll(".rack-slot").forEach((slot) => {
slot.addEventListener("dragover", (event) => {
event.preventDefault();
slot.classList.add("is-drop-target");
});
slot.addEventListener("dragleave", () => slot.classList.remove("is-drop-target"));
slot.addEventListener("drop", (event) => {
event.preventDefault();
slot.classList.remove("is-drop-target");
if (!state.dragPlacementId) {
return;
}
movePlacedItem(state.dragPlacementId, Number(slot.dataset.slotU));
});
});
const layer = document.getElementById("rack-items-layer");
const rackHeight = rack.totalU * 38;
layer.style.height = `${rackHeight}px`;
state.placedItems.forEach((item) => {
const component = getComponent(item.componentId);
if (!component) {
return;
}
const top = (rack.totalU - (item.startU + component.heightU) + 1) * 38;
const height = component.heightU * 38 - 2;
const element = document.createElement("article");
element.className = "rack-item";
element.dataset.standard = component.rackStandard;
element.draggable = true;
element.style.top = `${top}px`;
element.style.height = `${height}px`;
element.innerHTML = `
<div class="rack-item__header">
<div>
<strong>${escapeHtml(component.name)}</strong>
<div class="rack-item__meta">${component.heightU}U · ${component.depthMm} mm · ${formatCurrency(component.priceNet, component.currency)}</div>
</div>
<span class="chip">${item.startU}U</span>
</div>
<div class="rack-item__actions">
<button type="button" data-action="move-up">+1U</button>
<button type="button" data-action="move-down">-1U</button>
<button type="button" data-action="remove">Entfernen</button>
</div>
`;
element.addEventListener("dragstart", () => {
state.dragPlacementId = item.placementId;
element.classList.add("is-dragging");
});
element.addEventListener("dragend", () => {
state.dragPlacementId = null;
element.classList.remove("is-dragging");
});
element.querySelector("[data-action='move-up']").addEventListener("click", () => movePlacedItem(item.placementId, item.startU + 1));
element.querySelector("[data-action='move-down']").addEventListener("click", () => movePlacedItem(item.placementId, item.startU - 1));
element.querySelector("[data-action='remove']").addEventListener("click", () => {
state.placedItems = state.placedItems.filter((entry) => entry.placementId !== item.placementId);
renderAll();
});
layer.appendChild(element);
});
}
function movePlacedItem(placementId, requestedStartU) {
const rack = getCurrentRackTemplate();
const item = state.placedItems.find((entry) => entry.placementId === placementId);
const component = item ? getComponent(item.componentId) : null;
if (!rack || !item || !component) {
return;
}
const normalizedStart = Math.max(1, Math.min(requestedStartU, rack.totalU - component.heightU + 1));
const occupied = state.placedItems.some((entry) => {
if (entry.placementId === placementId) {
return false;
}
const other = getComponent(entry.componentId);
if (!other) {
return false;
}
return rangesOverlap(normalizedStart, component.heightU, entry.startU, other.heightU);
});
if (occupied) {
return;
}
item.startU = normalizedStart;
renderAll();
}
function renderStats() {
const rack = getCurrentRackTemplate();
if (!rack) {
return;
}
const usedU = state.placedItems.reduce((sum, item) => {
const component = getComponent(item.componentId);
return sum + (component?.heightU ?? 0);
}, 0);
const usedWeight = state.placedItems.reduce((sum, item) => {
const component = getComponent(item.componentId);
return sum + (component?.weightKg ?? 0);
}, 0);
const usedPower = state.placedItems.reduce((sum, item) => {
const component = getComponent(item.componentId);
return sum + (component?.powerW ?? 0);
}, 0);
ui.projectStats.innerHTML = `
<div class="stat-card">
<span>Belegte U</span>
<strong>${usedU} / ${rack.totalU}</strong>
</div>
<div class="stat-card">
<span>Freie U</span>
<strong>${rack.totalU - usedU}</strong>
</div>
<div class="stat-card">
<span>Gewicht</span>
<strong>${usedWeight.toFixed(1)} / ${rack.maxWeightKg} kg</strong>
</div>
<div class="stat-card">
<span>Leistung</span>
<strong>${usedPower} W</strong>
</div>
`;
}
function renderBom() {
const aggregated = new Map();
state.placedItems.forEach((item) => {
const component = getComponent(item.componentId);
if (!component) {
return;
}
const key = component.partNumber;
if (!aggregated.has(key)) {
aggregated.set(key, {
quantity: 0,
name: component.name,
manufacturer: component.manufacturer,
partNumber: component.partNumber,
currency: component.currency,
priceNet: component.priceNet,
});
}
aggregated.get(key).quantity += 1;
});
const lines = Array.from(aggregated.values());
if (lines.length === 0) {
ui.bomOutput.innerHTML = `<div class="empty-state">Noch keine Komponenten platziert.</div>`;
return;
}
ui.bomOutput.innerHTML = lines
.map((line) => {
const total = line.quantity * line.priceNet;
return `
<div class="bom-line">
<div>
<strong>${escapeHtml(line.name)}</strong>
<div>${escapeHtml(line.partNumber)} · ${escapeHtml(line.manufacturer)}</div>
</div>
<div>${line.quantity} × ${formatCurrency(total, line.currency)}</div>
</div>
`;
})
.join("");
}
function renderCableSelectors() {
const options = ['<option value="">Bitte waehlen</option>']
.concat(
state.placedItems.map((item) => {
const component = getComponent(item.componentId);
if (!component) {
return "";
}
return `<option value="${escapeHtml(item.placementId)}">${escapeHtml(component.name)} @ ${item.startU}U</option>`;
})
)
.join("");
ui.cableFrom.innerHTML = options;
ui.cableTo.innerHTML = options;
}
function renderCableEstimate() {
const fromId = ui.cableFrom.value;
const toId = ui.cableTo.value;
const slackPercent = Number(ui.cableSlack.value || 0);
if (!fromId || !toId || fromId === toId) {
ui.cableOutput.textContent = "Fuer eine Schaetzung zwei verschiedene Komponenten auswaehlen.";
return;
}
const from = state.placedItems.find((item) => item.placementId === fromId);
const to = state.placedItems.find((item) => item.placementId === toId);
const rack = getCurrentRackTemplate();
const fromComponent = from ? getComponent(from.componentId) : null;
const toComponent = to ? getComponent(to.componentId) : null;
if (!from || !to || !rack || !fromComponent || !toComponent) {
ui.cableOutput.textContent = "Kabelschaetzung momentan nicht verfuegbar.";
return;
}
const verticalMm = Math.abs(from.startU - to.startU) * 44.45;
const depthAllowance = Math.min(rack.usableDepthMm * 0.35, 280);
const sideAllowance = estimateSideAllowance(fromComponent, toComponent);
const rawLength = verticalMm + depthAllowance + sideAllowance;
const withSlack = rawLength * (1 + slackPercent / 100);
const recommended = recommendCableLength(withSlack);
ui.cableOutput.innerHTML = `
Geschaetzt: <strong>${Math.round(rawLength)} mm</strong><br>
Mit Reserve: <strong>${Math.round(withSlack)} mm</strong><br>
Bestelllaenge: <strong>${recommended.toFixed(2)} m</strong>
`;
}
function renderValidation() {
const rack = getCurrentRackTemplate();
if (!rack) {
return;
}
const notes = [];
const usedU = state.placedItems.reduce((sum, item) => {
const component = getComponent(item.componentId);
return sum + (component?.heightU ?? 0);
}, 0);
const usedWeight = state.placedItems.reduce((sum, item) => {
const component = getComponent(item.componentId);
return sum + (component?.weightKg ?? 0);
}, 0);
if (usedU <= rack.totalU) {
notes.push({ className: "ok", text: `U-Belegung ist gueltig. ${rack.totalU - usedU}U frei.` });
} else {
notes.push({ className: "warn", text: "Rack ist ueberbelegt." });
}
if (usedWeight <= rack.maxWeightKg) {
notes.push({ className: "ok", text: `Gewicht innerhalb der Rack-Grenze (${usedWeight.toFixed(1)} kg).` });
} else {
notes.push({ className: "warn", text: `Gewicht ueberschreitet die Rack-Grenze von ${rack.maxWeightKg} kg.` });
}
const tooDeep = state.placedItems
.map((item) => ({ item, component: getComponent(item.componentId) }))
.filter(({ component }) => component && component.depthMm > rack.usableDepthMm);
if (tooDeep.length === 0) {
notes.push({ className: "ok", text: "Keine Tiefenkonflikte erkannt." });
} else {
tooDeep.forEach(({ component }) => {
notes.push({ className: "warn", text: `${component.name} ist tiefer als das Rack erlaubt.` });
});
}
ui.validationOutput.innerHTML = notes.map((note) => `<li class="${note.className}">${escapeHtml(note.text)}</li>`).join("");
}
function importPluginPack(event) {
const file = event.target.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const payload = JSON.parse(String(reader.result || "{}"));
validatePluginPack(payload);
const imported = payload.components.map((component, index) => ({
...component,
id: component.id || `plugin-${Date.now()}-${index}`,
}));
state.components = deduplicateComponents([...state.components, ...imported]);
renderAll();
ui.pluginInput.value = "";
alert(`Plugin-Pack "${payload.name}" importiert.`);
} catch (error) {
alert(`Plugin-Import fehlgeschlagen: ${error.message}`);
}
};
reader.readAsText(file);
}
function validatePluginPack(payload) {
if (!payload || typeof payload !== "object") {
throw new Error("Ungueltiges JSON.");
}
["manifestVersion", "name", "version", "components"].forEach((key) => {
if (!(key in payload)) {
throw new Error(`Pflichtfeld fehlt: ${key}`);
}
});
if (!Array.isArray(payload.components) || payload.components.length === 0) {
throw new Error("Plugin enthaelt keine Komponenten.");
}
payload.components.forEach((component) => {
["name", "partNumber", "rackStandard", "heightU", "depthMm", "priceNet", "currency"].forEach((key) => {
if (!(key in component)) {
throw new Error(`Komponente unvollstaendig: ${component.name || "unbekannt"} (${key} fehlt)`);
}
});
});
}
async function copyBomCsv() {
const rows = [["quantity", "manufacturer", "partNumber", "name", "unitPrice", "currency", "totalPrice"]];
const aggregated = new Map();
state.placedItems.forEach((item) => {
const component = getComponent(item.componentId);
if (!component) {
return;
}
const key = component.partNumber;
if (!aggregated.has(key)) {
aggregated.set(key, { quantity: 0, component });
}
aggregated.get(key).quantity += 1;
});
aggregated.forEach(({ quantity, component }) => {
rows.push([
String(quantity),
component.manufacturer,
component.partNumber,
component.name,
String(component.priceNet),
component.currency,
String(quantity * component.priceNet),
]);
});
const csv = rows.map((row) => row.map(csvEscape).join(",")).join("\n");
try {
await navigator.clipboard.writeText(csv);
ui.exportBom.textContent = "CSV kopiert";
window.setTimeout(() => {
ui.exportBom.textContent = "CSV kopieren";
}, 1600);
} catch (error) {
console.error(error);
alert("CSV konnte nicht in die Zwischenablage kopiert werden.");
}
}
function findFirstFreePosition(heightU) {
const rack = getCurrentRackTemplate();
if (!rack) {
return null;
}
for (let start = 1; start <= rack.totalU - heightU + 1; start += 1) {
const blocked = state.placedItems.some((item) => {
const component = getComponent(item.componentId);
return component && rangesOverlap(start, heightU, item.startU, component.heightU);
});
if (!blocked) {
return start;
}
}
return null;
}
function rangesOverlap(startA, heightA, startB, heightB) {
const endA = startA + heightA - 1;
const endB = startB + heightB - 1;
return startA <= endB && startB <= endA;
}
function getCurrentRackTemplate() {
return state.bootstrap?.rackTemplates.find((template) => template.id === state.rackTemplateId) ?? null;
}
function getComponent(componentId) {
return state.components.find((component) => component.id === componentId) ?? null;
}
function deduplicateComponents(components) {
const byId = new Map();
components.forEach((component) => {
byId.set(component.id, component);
});
return Array.from(byId.values());
}
function estimateSideAllowance(fromComponent, toComponent) {
const fromSide = fromComponent.ports?.[0]?.side ?? "front";
const toSide = toComponent.ports?.[0]?.side ?? "front";
return fromSide === toSide ? 160 : 420;
}
function recommendCableLength(lengthMm) {
const options = [0.25, 0.5, 1, 1.5, 2, 3, 5, 7.5, 10];
const meters = lengthMm / 1000;
return options.find((option) => option >= meters) ?? Math.ceil(meters);
}
function formatCurrency(value, currency) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: currency || "EUR",
maximumFractionDigits: 0,
}).format(value);
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function csvEscape(value) {
const stringValue = String(value ?? "");
if (stringValue.includes(",") || stringValue.includes('"') || stringValue.includes("\n")) {
return `"${stringValue.replaceAll('"', '""')}"`;
}
return stringValue;
}