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 ``; }) .join(""); ui.templateSelect.innerHTML = options; } function renderSummary() { const rack = getCurrentRackTemplate(); if (!rack) { ui.rackSummary.innerHTML = ""; return; } ui.rackSummary.innerHTML = `
Projekt ${escapeHtml(state.projectName)}
Rack-Standard ${rack.rackStandard === "19_inch" ? "19 inch" : "10 inch"}
Kapazitaet ${rack.totalU}U / ${rack.usableDepthMm} mm
`; } 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 = `
Keine passenden Komponenten gefunden.
`; return; } ui.componentLibrary.innerHTML = filtered .map( (component) => `
${escapeHtml(component.name)}
${escapeHtml(component.partNumber)}
${component.heightU}U
${escapeHtml(component.category)} ${component.depthMm} mm tief ${formatCurrency(component.priceNet, component.currency)}
` ) .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(`
${u}U
`); } ui.rackGrid.innerHTML = `${slots.join("")}
`; 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 = `
${escapeHtml(component.name)}
${component.heightU}U · ${component.depthMm} mm · ${formatCurrency(component.priceNet, component.currency)}
${item.startU}U
`; 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 = `
Belegte U ${usedU} / ${rack.totalU}
Freie U ${rack.totalU - usedU}
Gewicht ${usedWeight.toFixed(1)} / ${rack.maxWeightKg} kg
Leistung ${usedPower} W
`; } 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 = `
Noch keine Komponenten platziert.
`; return; } ui.bomOutput.innerHTML = lines .map((line) => { const total = line.quantity * line.priceNet; return `
${escapeHtml(line.name)}
${escapeHtml(line.partNumber)} · ${escapeHtml(line.manufacturer)}
${line.quantity} × ${formatCurrency(total, line.currency)}
`; }) .join(""); } function renderCableSelectors() { const options = [''] .concat( state.placedItems.map((item) => { const component = getComponent(item.componentId); if (!component) { return ""; } return ``; }) ) .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: ${Math.round(rawLength)} mm
Mit Reserve: ${Math.round(withSlack)} mm
Bestelllaenge: ${recommended.toFixed(2)} m `; } 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) => `
  • ${escapeHtml(note.text)}
  • `).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("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function csvEscape(value) { const stringValue = String(value ?? ""); if (stringValue.includes(",") || stringValue.includes('"') || stringValue.includes("\n")) { return `"${stringValue.replaceAll('"', '""')}"`; } return stringValue; }