diff --git a/api/index.php b/api/index.php
new file mode 100644
index 0000000..5dfd6c5
--- /dev/null
+++ b/api/index.php
@@ -0,0 +1,20 @@
+ 'Unknown action',
+ 'action' => $action,
+], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+
diff --git a/config/config.php b/config/config.php
new file mode 100644
index 0000000..ff23c1e
--- /dev/null
+++ b/config/config.php
@@ -0,0 +1,13 @@
+ {
+ 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 `${escapeHtml(template.name)} `;
+ })
+ .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.category)}
+ ${component.depthMm} mm tief
+ ${formatCurrency(component.priceNet, component.currency)}
+
+
+ In Rack einfuegen
+
+
+ `
+ )
+ .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 = `
+
+
+ +1U
+ -1U
+ Entfernen
+
+ `;
+
+ 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 = ['Bitte waehlen ']
+ .concat(
+ state.placedItems.map((item) => {
+ const component = getComponent(item.componentId);
+ if (!component) {
+ return "";
+ }
+ return `${escapeHtml(component.name)} @ ${item.startU}U `;
+ })
+ )
+ .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;
+}
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..ec18e09
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+ = htmlspecialchars($pageTitle, ENT_QUOTES) ?>
+
+
+
+
+
+
+
+
code.it concept workspace
+
Rack Planner
+
+ Browserbasierter Arbeitsstand fuer die Planung von 10" und 19" Racks.
+ Die aktuelle Domain ist nur Konzeptionsumgebung, nicht die spaetere Produkt-Domain.
+
+
+
+
+
+
+
+
+
+
+
Rack-Editor
+
Komponenten koennen per Drag-and-Drop verschoben werden. Jede Position referenziert eine U-Einheit.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/data/components.json b/src/data/components.json
new file mode 100644
index 0000000..1f72314
--- /dev/null
+++ b/src/data/components.json
@@ -0,0 +1,151 @@
+[
+ {
+ "id": "patchpanel-24-cat6a",
+ "name": "Patch Panel 24 Port Cat6A",
+ "manufacturer": "Generic",
+ "partNumber": "PP-24-C6A-1U",
+ "category": "patch_panel",
+ "rackStandard": "19_inch",
+ "heightU": 1,
+ "depthMm": 95,
+ "weightKg": 1.2,
+ "priceNet": 79,
+ "currency": "EUR",
+ "powerW": 0,
+ "ports": [
+ { "name": "Front Ports", "side": "front", "offsetYmm": 10 }
+ ]
+ },
+ {
+ "id": "switch-24-gbe",
+ "name": "24 Port Gigabit Switch",
+ "manufacturer": "Generic",
+ "partNumber": "SW-24-GBE-1U",
+ "category": "switch",
+ "rackStandard": "19_inch",
+ "heightU": 1,
+ "depthMm": 220,
+ "weightKg": 2.4,
+ "priceNet": 320,
+ "currency": "EUR",
+ "powerW": 35,
+ "ports": [
+ { "name": "Uplink", "side": "front", "offsetYmm": 10 }
+ ]
+ },
+ {
+ "id": "pdu-8-way",
+ "name": "PDU 8-way 1U",
+ "manufacturer": "Generic",
+ "partNumber": "PDU-8-1U",
+ "category": "pdu",
+ "rackStandard": "19_inch",
+ "heightU": 1,
+ "depthMm": 60,
+ "weightKg": 1.6,
+ "priceNet": 119,
+ "currency": "EUR",
+ "powerW": 0,
+ "ports": [
+ { "name": "Power Out", "side": "rear", "offsetYmm": 10 }
+ ]
+ },
+ {
+ "id": "ups-lineinteractive-2u",
+ "name": "UPS Line-Interactive 2U",
+ "manufacturer": "Generic",
+ "partNumber": "UPS-LI-2U",
+ "category": "ups",
+ "rackStandard": "19_inch",
+ "heightU": 2,
+ "depthMm": 420,
+ "weightKg": 18,
+ "priceNet": 499,
+ "currency": "EUR",
+ "powerW": 900,
+ "ports": [
+ { "name": "Power In", "side": "rear", "offsetYmm": 45 }
+ ]
+ },
+ {
+ "id": "shelf-1u",
+ "name": "Rack Shelf 1U",
+ "manufacturer": "Generic",
+ "partNumber": "SHELF-1U",
+ "category": "shelf",
+ "rackStandard": "19_inch",
+ "heightU": 1,
+ "depthMm": 350,
+ "weightKg": 3.5,
+ "priceNet": 49,
+ "currency": "EUR",
+ "powerW": 0,
+ "ports": []
+ },
+ {
+ "id": "blank-panel-1u",
+ "name": "Blank Panel 1U",
+ "manufacturer": "Generic",
+ "partNumber": "BLANK-1U",
+ "category": "blank_panel",
+ "rackStandard": "10_inch",
+ "heightU": 1,
+ "depthMm": 20,
+ "weightKg": 0.2,
+ "priceNet": 9,
+ "currency": "EUR",
+ "powerW": 0,
+ "ports": []
+ },
+ {
+ "id": "switch-8-10inch",
+ "name": "8 Port Switch 10\"",
+ "manufacturer": "Generic",
+ "partNumber": "SW-8-10-1U",
+ "category": "switch",
+ "rackStandard": "10_inch",
+ "heightU": 1,
+ "depthMm": 140,
+ "weightKg": 1.1,
+ "priceNet": 139,
+ "currency": "EUR",
+ "powerW": 18,
+ "ports": [
+ { "name": "Front Ports", "side": "front", "offsetYmm": 10 }
+ ]
+ },
+ {
+ "id": "patchpanel-12-10inch",
+ "name": "Patch Panel 12 Port 10\"",
+ "manufacturer": "Generic",
+ "partNumber": "PP-12-10-1U",
+ "category": "patch_panel",
+ "rackStandard": "10_inch",
+ "heightU": 1,
+ "depthMm": 85,
+ "weightKg": 0.9,
+ "priceNet": 55,
+ "currency": "EUR",
+ "powerW": 0,
+ "ports": [
+ { "name": "Front Ports", "side": "front", "offsetYmm": 10 }
+ ]
+ },
+ {
+ "id": "mini-ups-10inch",
+ "name": "Mini UPS 10\" 2U",
+ "manufacturer": "Generic",
+ "partNumber": "UPS-10-2U",
+ "category": "ups",
+ "rackStandard": "10_inch",
+ "heightU": 2,
+ "depthMm": 240,
+ "weightKg": 9.8,
+ "priceNet": 269,
+ "currency": "EUR",
+ "powerW": 400,
+ "ports": [
+ { "name": "Power In", "side": "rear", "offsetYmm": 45 }
+ ]
+ }
+]
diff --git a/src/data/rack-templates.json b/src/data/rack-templates.json
new file mode 100644
index 0000000..2f7cbd3
--- /dev/null
+++ b/src/data/rack-templates.json
@@ -0,0 +1,34 @@
+[
+ {
+ "id": "rack-10-wall-9u",
+ "name": "10\" Wall Rack 9U",
+ "rackStandard": "10_inch",
+ "totalU": 9,
+ "usableDepthMm": 260,
+ "maxWeightKg": 35
+ },
+ {
+ "id": "rack-10-wall-12u",
+ "name": "10\" Wall Rack 12U",
+ "rackStandard": "10_inch",
+ "totalU": 12,
+ "usableDepthMm": 300,
+ "maxWeightKg": 45
+ },
+ {
+ "id": "rack-19-floor-24u",
+ "name": "19\" Floor Rack 24U",
+ "rackStandard": "19_inch",
+ "totalU": 24,
+ "usableDepthMm": 800,
+ "maxWeightKg": 600
+ },
+ {
+ "id": "rack-19-floor-42u",
+ "name": "19\" Floor Rack 42U",
+ "rackStandard": "19_inch",
+ "totalU": 42,
+ "usableDepthMm": 1000,
+ "maxWeightKg": 1000
+ }
+]
diff --git a/src/functions.php b/src/functions.php
new file mode 100644
index 0000000..afa7ac9
--- /dev/null
+++ b/src/functions.php
@@ -0,0 +1,63 @@
+ [
+ 'name' => 'code.it Rack Planner',
+ 'environment' => defined('APP_ENV') ? APP_ENV : 'prod',
+ 'baseUrl' => $GLOBALS['app_base_url'] ?? '',
+ 'apiBaseUrl' => $GLOBALS['app_api_base'] ?? '',
+ 'projectDomainNote' => 'Current domain is a concept workspace only, not the final product domain.',
+ ],
+ 'rackTemplates' => app_get_rack_templates(),
+ 'components' => app_get_catalog_components(),
+ 'pluginFormat' => [
+ 'version' => 1,
+ 'acceptedExtensions' => ['json'],
+ 'requiredKeys' => ['manifestVersion', 'name', 'version', 'components'],
+ ],
+ ];
+}
+