diff --git a/NEXT.md b/NEXT.md index a6a985e..f36171d 100644 --- a/NEXT.md +++ b/NEXT.md @@ -1,10 +1,10 @@ # NEXT_STEPS ## Aktive Aufgaben (priorisiert) -- [ ] [#20] //TODO Gesamt-Topologie-Wand im dashboard ist schwarze -- [ ] [#19] //TODO gerät nicht löschbar wegen ports, ports sind aber nicht löschbar -- [ ] [#18] //TODO wandbuchsen direkt beim erstellen schon an patchpanel bindfen -- [ ] [#17] //TODO infrastruktur karten zoombar, um objekte besser positionieren zu können, steps soll aber immernoch 1 bleiben +- [x] [#20] Gesamt-Topologie-Wand im dashboard ist schwarze +- [x] [#19] gerät nicht löschbar wegen ports, ports sind aber nicht löschbar +- [x] [#18] wandbuchsen direkt beim erstellen schon an patchpanel bindfen +- [x] [#17] infrastruktur karten zoombar, um objekte besser positionieren zu können, steps soll aber immernoch 1 bleiben ## Verifikation (Status unklar, nicht als erledigt markieren ohne Reproduktion + Commit) - [x] [#15] Neue Verbindung: Netzwerkdose auswählbar (Regressionstest in UI durchgeführt) diff --git a/app/assets/css/floor-infrastructure-edit.css b/app/assets/css/floor-infrastructure-edit.css index 2b3a129..240cfdb 100644 --- a/app/assets/css/floor-infrastructure-edit.css +++ b/app/assets/css/floor-infrastructure-edit.css @@ -30,6 +30,10 @@ flex-direction: column; gap: 6px; } +.floor-plan-toolbar { + display: flex; + gap: 6px; +} .floor-plan-canvas { position: relative; width: 100%; diff --git a/app/assets/js/floor-infrastructure-edit.js b/app/assets/js/floor-infrastructure-edit.js index 8db1d3f..0802c15 100644 --- a/app/assets/js/floor-infrastructure-edit.js +++ b/app/assets/js/floor-infrastructure-edit.js @@ -29,8 +29,14 @@ document.addEventListener('DOMContentLoaded', () => { let markerX = 0; let markerY = 0; let dragging = false; + let panning = false; + let panStart = null; let dragOffsetX = 0; let dragOffsetY = 0; + let viewX = 0; + let viewY = 0; + let viewWidth = DEFAULT_PLAN_SIZE.width; + let viewHeight = DEFAULT_PLAN_SIZE.height; const activeMarker = document.createElementNS(SVG_NS, 'rect'); activeMarker.classList.add('active-marker'); @@ -45,7 +51,7 @@ document.addEventListener('DOMContentLoaded', () => { const planSize = { ...DEFAULT_PLAN_SIZE }; const updateOverlayViewBox = () => { - overlay.setAttribute('viewBox', `0 0 ${planSize.width} ${planSize.height}`); + overlay.setAttribute('viewBox', `${viewX} ${viewY} ${viewWidth} ${viewHeight}`); }; const updatePositionLabel = (x, y) => { @@ -72,17 +78,57 @@ document.addEventListener('DOMContentLoaded', () => { }; const toOverlayPoint = (clientX, clientY) => { - const pt = overlay.createSVGPoint(); - pt.x = clientX; - pt.y = clientY; - const ctm = overlay.getScreenCTM(); - if (!ctm) { + const rect = overlay.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { return null; } - const transformed = pt.matrixTransform(ctm.inverse()); + const ratioX = (clientX - rect.left) / rect.width; + const ratioY = (clientY - rect.top) / rect.height; + const transformed = { + x: viewX + (ratioX * viewWidth), + y: viewY + (ratioY * viewHeight) + }; return { x: transformed.x, y: transformed.y }; }; + const clampView = () => { + const minWidth = Math.max(30, planSize.width * 0.1); + const minHeight = Math.max(30, planSize.height * 0.1); + viewWidth = clamp(viewWidth, minWidth, planSize.width); + viewHeight = clamp(viewHeight, minHeight, planSize.height); + viewX = clamp(viewX, 0, Math.max(0, planSize.width - viewWidth)); + viewY = clamp(viewY, 0, Math.max(0, planSize.height - viewHeight)); + }; + + const applyView = () => { + clampView(); + updateOverlayViewBox(); + }; + + const zoomAt = (clientX, clientY, factor) => { + const point = toOverlayPoint(clientX, clientY); + if (!point) { + return; + } + const ratioX = (point.x - viewX) / viewWidth; + const ratioY = (point.y - viewY) / viewHeight; + const nextWidth = viewWidth * factor; + const nextHeight = viewHeight * factor; + viewX = point.x - (ratioX * nextWidth); + viewY = point.y - (ratioY * nextHeight); + viewWidth = nextWidth; + viewHeight = nextHeight; + applyView(); + }; + + const resetView = () => { + viewX = 0; + viewY = 0; + viewWidth = planSize.width; + viewHeight = planSize.height; + applyView(); + }; + const updateFromInputs = () => { setMarkerPosition(Number(xField.value) || 0, Number(yField.value) || 0); }; @@ -195,6 +241,7 @@ document.addEventListener('DOMContentLoaded', () => { const panelPlacementFields = document.getElementById('panel-placement-fields'); const panelFloorPlanGroup = document.getElementById('panel-floor-plan-group'); const panelFloorMissingHint = document.getElementById('panel-floor-missing-hint'); + const outletBindPatchpanelSelect = document.getElementById('outlet-bind-patchpanel-port-id'); const buildingOptions = panelBuildingSelect ? Array.from(panelBuildingSelect.options).filter((option) => option.value !== '') : []; const floorOptions = panelFloorSelect ? Array.from(panelFloorSelect.options).filter((option) => option.value !== '') : []; @@ -208,6 +255,32 @@ document.addEventListener('DOMContentLoaded', () => { return Number(roomOption?.dataset?.floorId || 0); }; + const filterPatchpanelBindOptions = () => { + if (!outletBindPatchpanelSelect) { + return; + } + const currentFloorId = getCurrentFloorId(); + const options = Array.from(outletBindPatchpanelSelect.options).filter((option) => option.value !== ''); + let firstMatch = ''; + let selectedStillVisible = false; + + options.forEach((option) => { + const optionFloorId = Number(option.dataset.floorId || 0); + const matchesFloor = !currentFloorId || optionFloorId === currentFloorId; + option.hidden = !matchesFloor; + if (matchesFloor && !option.disabled && !firstMatch) { + firstMatch = option.value; + } + if (matchesFloor && option.selected) { + selectedStillVisible = true; + } + }); + + if (!selectedStillVisible && firstMatch && !outletBindPatchpanelSelect.value) { + outletBindPatchpanelSelect.value = firstMatch; + } + }; + const renderReferenceMarkers = () => { clearRoomHighlight(); clearReferenceMarkers(); @@ -256,9 +329,10 @@ document.addEventListener('DOMContentLoaded', () => { floorPlanSvg.hidden = true; planSize.width = DEFAULT_PLAN_SIZE.width; planSize.height = DEFAULT_PLAN_SIZE.height; - updateOverlayViewBox(); + resetView(); } renderReferenceMarkers(); + filterPatchpanelBindOptions(); }; if (floorPlanSvg) { @@ -291,7 +365,7 @@ document.addEventListener('DOMContentLoaded', () => { if (parts.length === 4 && parts.every((value) => Number.isFinite(value))) { planSize.width = Math.max(1, parts[2]); planSize.height = Math.max(1, parts[3]); - updateOverlayViewBox(); + resetView(); renderReferenceMarkers(); updateFromInputs(); return; @@ -307,13 +381,13 @@ document.addEventListener('DOMContentLoaded', () => { planSize.width = DEFAULT_PLAN_SIZE.width; planSize.height = DEFAULT_PLAN_SIZE.height; } - updateOverlayViewBox(); + resetView(); renderReferenceMarkers(); updateFromInputs(); } catch (error) { planSize.width = DEFAULT_PLAN_SIZE.width; planSize.height = DEFAULT_PLAN_SIZE.height; - updateOverlayViewBox(); + resetView(); renderReferenceMarkers(); updateFromInputs(); } @@ -382,6 +456,7 @@ document.addEventListener('DOMContentLoaded', () => { activeMarker.addEventListener('pointerdown', (event) => { event.preventDefault(); dragging = true; + panning = false; const point = toOverlayPoint(event.clientX, event.clientY); if (!point) { return; @@ -403,12 +478,18 @@ document.addEventListener('DOMContentLoaded', () => { }); const stopDrag = (event) => { - if (!dragging) { - return; + if (dragging) { + dragging = false; + if (activeMarker.hasPointerCapture(event.pointerId)) { + activeMarker.releasePointerCapture(event.pointerId); + } } - dragging = false; - if (activeMarker.hasPointerCapture(event.pointerId)) { - activeMarker.releasePointerCapture(event.pointerId); + if (panning) { + panning = false; + panStart = null; + if (overlay.hasPointerCapture(event.pointerId)) { + overlay.releasePointerCapture(event.pointerId); + } } }; @@ -417,6 +498,19 @@ document.addEventListener('DOMContentLoaded', () => { }); overlay.addEventListener('pointerdown', (event) => { + if (event.shiftKey || event.button === 1) { + event.preventDefault(); + panning = true; + dragging = false; + panStart = { + clientX: event.clientX, + clientY: event.clientY, + viewX, + viewY + }; + overlay.setPointerCapture(event.pointerId); + return; + } if (event.target !== overlay) { return; } @@ -427,6 +521,32 @@ document.addEventListener('DOMContentLoaded', () => { setMarkerPosition(point.x - markerWidth / 2, point.y - markerHeight / 2); }); + overlay.addEventListener('pointermove', (event) => { + if (!panning || !panStart) { + return; + } + const rect = overlay.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return; + } + const scaleX = viewWidth / rect.width; + const scaleY = viewHeight / rect.height; + const dx = (event.clientX - panStart.clientX) * scaleX; + const dy = (event.clientY - panStart.clientY) * scaleY; + viewX = panStart.viewX - dx; + viewY = panStart.viewY - dy; + applyView(); + }); + + overlay.addEventListener('pointerup', stopDrag); + overlay.addEventListener('pointercancel', stopDrag); + + overlay.addEventListener('wheel', (event) => { + event.preventDefault(); + const factor = event.deltaY < 0 ? 0.9 : 1.1; + zoomAt(event.clientX, event.clientY, factor); + }, { passive: false }); + [xField, yField].forEach((input) => { input.addEventListener('input', () => { updateFromInputs(); @@ -464,8 +584,26 @@ document.addEventListener('DOMContentLoaded', () => { }); } + document.querySelectorAll('[data-floor-plan-zoom]').forEach((button) => { + button.addEventListener('click', () => { + const action = button.getAttribute('data-floor-plan-zoom'); + if (action === 'in') { + const rect = overlay.getBoundingClientRect(); + zoomAt(rect.left + (rect.width / 2), rect.top + (rect.height / 2), 0.85); + return; + } + if (action === 'out') { + const rect = overlay.getBoundingClientRect(); + zoomAt(rect.left + (rect.width / 2), rect.top + (rect.height / 2), 1.15); + return; + } + resetView(); + }); + }); + updateOverlayViewBox(); updateFromInputs(); + filterPatchpanelBindOptions(); if (panelLocationSelect) { filterBuildingOptions(); diff --git a/app/modules/dashboard/list.php b/app/modules/dashboard/list.php index 1ae9296..525f72f 100644 --- a/app/modules/dashboard/list.php +++ b/app/modules/dashboard/list.php @@ -245,7 +245,7 @@ foreach ($rackLinksByKey as $entry) {

Hierarchie: Standort → Gebaeude → Stockwerk → Rack → Geraet. Linien zeigen Rack-Verbindungen (dicker = mehr Links).

- + @@ -681,7 +681,7 @@ foreach ($rackLinksByKey as $entry) { overlayDeviceLink.innerHTML = `Geraet bearbeiten`; overlayActions.innerHTML = ` Editieren - Entfernen + Entfernen `; } diff --git a/app/modules/floor_infrastructure/edit.php b/app/modules/floor_infrastructure/edit.php index f6441cb..ee6e4fb 100644 --- a/app/modules/floor_infrastructure/edit.php +++ b/app/modules/floor_infrastructure/edit.php @@ -125,6 +125,58 @@ $mapOutlets = $sql->get( "", [] ); + +$patchpanelPortOptions = $sql->get( + "SELECT + fpp.id, + fpp.name, + fp.name AS patchpanel_name, + fp.floor_id, + f.name AS floor_name, + EXISTS( + SELECT 1 + FROM connections c + WHERE + ((c.port_a_type = 'patchpanel' OR c.port_a_type = 'floor_patchpanel_ports') AND c.port_a_id = fpp.id) + OR + ((c.port_b_type = 'patchpanel' OR c.port_b_type = 'floor_patchpanel_ports') AND c.port_b_id = fpp.id) + ) AS is_occupied + FROM floor_patchpanel_ports fpp + JOIN floor_patchpanels fp ON fp.id = fpp.patchpanel_id + LEFT JOIN floors f ON f.id = fp.floor_id + ORDER BY f.name, fp.name, fpp.name", + "", + [] +); + +$selectedBindPatchpanelPortId = 0; +if ($type === 'outlet' && $id > 0) { + $selectedBindPatchpanelPortId = (int)($sql->single( + "SELECT + CASE + WHEN (c.port_a_type = 'patchpanel' OR c.port_a_type = 'floor_patchpanel_ports') THEN c.port_a_id + WHEN (c.port_b_type = 'patchpanel' OR c.port_b_type = 'floor_patchpanel_ports') THEN c.port_b_id + ELSE 0 + END AS patchpanel_port_id + FROM connections c + JOIN network_outlet_ports nop + ON ( + ((c.port_a_type = 'outlet' OR c.port_a_type = 'network_outlet_ports') AND c.port_a_id = nop.id) + OR + ((c.port_b_type = 'outlet' OR c.port_b_type = 'network_outlet_ports') AND c.port_b_id = nop.id) + ) + WHERE nop.outlet_id = ? + AND ( + c.port_a_type = 'patchpanel' OR c.port_a_type = 'floor_patchpanel_ports' + OR + c.port_b_type = 'patchpanel' OR c.port_b_type = 'floor_patchpanel_ports' + ) + ORDER BY c.id + LIMIT 1", + "i", + [$id] + )['patchpanel_port_id'] ?? 0); +} ?>
@@ -196,6 +248,11 @@ $mapOutlets = $sql->get(
>
+
+ + + +
get( Stockwerksplan
-

Nur das aktuell bearbeitete Patchpanel ist verschiebbar. Andere Objekte werden als Referenz halbtransparent angezeigt. Neue Objekte starten bei Position 30 x 30.

+

Nur das aktuell bearbeitete Patchpanel ist verschiebbar. Andere Objekte werden als Referenz halbtransparent angezeigt. Neue Objekte starten bei Position 30 x 30. Zoom per Mausrad, verschieben mit Shift + Drag.

Koordinate:

@@ -255,12 +312,49 @@ $mapOutlets = $sql->get(
+
+ + + Nur Ports vom gewaehlten Stockwerk sind auswaehlbar. Beim Speichern wird die Verbindung automatisch erstellt. +
+
+
+ + + +
get( Stockwerksplan
-

Nur die aktuell bearbeitete Wandbuchse ist verschiebbar. Blau = Patchpanel, Gruen = Dosen-Referenz, Orange = gewaehlter Raum. Netzwerkdosen sind immer 10 x 10.

+

Nur die aktuell bearbeitete Wandbuchse ist verschiebbar. Blau = Patchpanel, Gruen = Dosen-Referenz, Orange = gewaehlter Raum. Netzwerkdosen sind immer 10 x 10. Zoom per Mausrad, verschieben mit Shift + Drag.

Koordinate:

diff --git a/app/modules/floor_infrastructure/save.php b/app/modules/floor_infrastructure/save.php index 0126dd8..e3fab18 100644 --- a/app/modules/floor_infrastructure/save.php +++ b/app/modules/floor_infrastructure/save.php @@ -80,6 +80,7 @@ if ($type === 'patchpanel') { $x = (int)($_POST['x'] ?? 0); $y = (int)($_POST['y'] ?? 0); $comment = trim($_POST['comment'] ?? ''); + $bindPatchpanelPortId = (int)($_POST['bind_patchpanel_port_id'] ?? 0); $outletId = $id; $errors = []; @@ -126,6 +127,132 @@ if ($type === 'patchpanel') { [$outletId] ); } + + if ($bindPatchpanelPortId > 0) { + $roomFloorId = (int)($sql->single( + "SELECT floor_id FROM rooms WHERE id = ?", + "i", + [$roomId] + )['floor_id'] ?? 0); + + $patchpanelPort = $sql->single( + "SELECT + fpp.id, + fp.floor_id + FROM floor_patchpanel_ports fpp + JOIN floor_patchpanels fp ON fp.id = fpp.patchpanel_id + WHERE fpp.id = ?", + "i", + [$bindPatchpanelPortId] + ); + + if (!$patchpanelPort) { + $_SESSION['error'] = 'Gewaehlter Patchpanel-Port existiert nicht'; + $_SESSION['validation_errors'] = ['Gewaehlter Patchpanel-Port existiert nicht']; + header('Location: ?module=floor_infrastructure&action=edit&type=outlet&id=' . $outletId); + exit; + } + + if ($roomFloorId <= 0 || (int)$patchpanelPort['floor_id'] !== $roomFloorId) { + $_SESSION['error'] = 'Patchpanel-Port und Raum muessen auf demselben Stockwerk liegen'; + $_SESSION['validation_errors'] = ['Patchpanel-Port und Raum muessen auf demselben Stockwerk liegen']; + header('Location: ?module=floor_infrastructure&action=edit&type=outlet&id=' . $outletId); + exit; + } + + $outletPortId = (int)($sql->single( + "SELECT id + FROM network_outlet_ports + WHERE outlet_id = ? + ORDER BY id + LIMIT 1", + "i", + [$outletId] + )['id'] ?? 0); + + if ($outletPortId <= 0) { + $_SESSION['error'] = 'Wandbuchsen-Port konnte nicht ermittelt werden'; + $_SESSION['validation_errors'] = ['Wandbuchsen-Port konnte nicht ermittelt werden']; + header('Location: ?module=floor_infrastructure&action=edit&type=outlet&id=' . $outletId); + exit; + } + + $existingPatchpanelUsage = $sql->single( + "SELECT + id, + port_a_type, + port_a_id, + port_b_type, + port_b_id + FROM connections + WHERE + ((port_a_type = 'patchpanel' OR port_a_type = 'floor_patchpanel_ports') AND port_a_id = ?) + OR + ((port_b_type = 'patchpanel' OR port_b_type = 'floor_patchpanel_ports') AND port_b_id = ?) + LIMIT 1", + "ii", + [$bindPatchpanelPortId, $bindPatchpanelPortId] + ); + + if ($existingPatchpanelUsage) { + $sameOutletConnection = ( + ( + (($existingPatchpanelUsage['port_a_type'] ?? '') === 'outlet' || ($existingPatchpanelUsage['port_a_type'] ?? '') === 'network_outlet_ports') + && (int)($existingPatchpanelUsage['port_a_id'] ?? 0) === $outletPortId + ) + || + ( + (($existingPatchpanelUsage['port_b_type'] ?? '') === 'outlet' || ($existingPatchpanelUsage['port_b_type'] ?? '') === 'network_outlet_ports') + && (int)($existingPatchpanelUsage['port_b_id'] ?? 0) === $outletPortId + ) + ); + + if (!$sameOutletConnection) { + $_SESSION['error'] = 'Gewaehlter Patchpanel-Port ist bereits verbunden'; + $_SESSION['validation_errors'] = ['Gewaehlter Patchpanel-Port ist bereits verbunden']; + header('Location: ?module=floor_infrastructure&action=edit&type=outlet&id=' . $outletId); + exit; + } + } + + $sql->set( + "DELETE FROM connections + WHERE + ((port_a_type = 'outlet' OR port_a_type = 'network_outlet_ports') AND port_a_id = ? AND (port_b_type = 'patchpanel' OR port_b_type = 'floor_patchpanel_ports')) + OR + ((port_b_type = 'outlet' OR port_b_type = 'network_outlet_ports') AND port_b_id = ? AND (port_a_type = 'patchpanel' OR port_a_type = 'floor_patchpanel_ports'))", + "ii", + [$outletPortId, $outletPortId] + ); + + $connectionTypeId = (int)($sql->single( + "SELECT id FROM connection_types ORDER BY id LIMIT 1", + "", + [] + )['id'] ?? 0); + if ($connectionTypeId <= 0) { + $connectionTypeId = (int)$sql->set( + "INSERT INTO connection_types (name, medium, duplex, line_style, comment) VALUES (?, ?, ?, ?, ?)", + "sssss", + ['Default', 'copper', 'custom', 'solid', 'Auto-created by floor_infrastructure/save'], + true + ); + } + + if ($connectionTypeId <= 0) { + $_SESSION['error'] = 'Kein Verbindungstyp fuer automatische Bindung verfuegbar'; + $_SESSION['validation_errors'] = ['Kein Verbindungstyp fuer automatische Bindung verfuegbar']; + header('Location: ?module=floor_infrastructure&action=edit&type=outlet&id=' . $outletId); + exit; + } + + $sql->set( + "INSERT INTO connections (connection_type_id, port_a_type, port_a_id, port_b_type, port_b_id, vlan_config, comment) + VALUES (?, 'outlet', ?, 'patchpanel', ?, NULL, ?)", + "iiis", + [$connectionTypeId, $outletPortId, $bindPatchpanelPortId, 'Auto-Link bei Wandbuchsen-Erstellung'] + ); + } } $_SESSION['success'] = $id > 0 ? 'Wandbuchse gespeichert' : 'Wandbuchse erstellt'; } else {