diff --git a/app/assets/js/floor-infrastructure-edit.js b/app/assets/js/floor-infrastructure-edit.js new file mode 100644 index 0000000..ec91310 --- /dev/null +++ b/app/assets/js/floor-infrastructure-edit.js @@ -0,0 +1,294 @@ +document.addEventListener('DOMContentLoaded', () => { + const canvas = document.getElementById('floor-plan-canvas'); + const marker = document.getElementById('floor-plan-marker'); + const positionLabel = document.getElementById('floor-plan-position'); + if (!canvas || !marker) { + return; + } + + const xFieldName = canvas.dataset.xField; + const yFieldName = canvas.dataset.yField; + const xField = xFieldName ? document.querySelector(`input[name="${xFieldName}"]`) : null; + const yField = yFieldName ? document.querySelector(`input[name="${yFieldName}"]`) : null; + if (!xField || !yField) { + return; + } + + const markerWidth = Math.max(1, Number(canvas.dataset.markerWidth) || marker.offsetWidth); + const markerHeight = Math.max(1, Number(canvas.dataset.markerHeight) || marker.offsetHeight); + const markerType = canvas.dataset.markerType || ''; + const activeId = Number(canvas.dataset.activeId || 0); + const panelReferences = JSON.parse(canvas.dataset.referencePanels || '[]'); + const outletReferences = JSON.parse(canvas.dataset.referenceOutlets || '[]'); + + const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + + marker.classList.add('is-active'); + + const updatePositionLabel = (x, y) => { + if (positionLabel) { + positionLabel.textContent = `${Math.round(x)} x ${Math.round(y)}`; + } + }; + + const setMarkerPosition = (rawX, rawY) => { + const rect = canvas.getBoundingClientRect(); + const maxX = Math.max(0, rect.width - markerWidth); + const maxY = Math.max(0, rect.height - markerHeight); + const left = clamp(rawX, 0, maxX); + const top = clamp(rawY, 0, maxY); + marker.style.left = `${left}px`; + marker.style.top = `${top}px`; + xField.value = Math.round(left); + yField.value = Math.round(top); + updatePositionLabel(left, top); + }; + + const updateFromInputs = () => { + setMarkerPosition(Number(xField.value) || 0, Number(yField.value) || 0); + }; + + updateFromInputs(); + + let dragging = false; + let offsetX = 0; + let offsetY = 0; + + const startDrag = (clientX, clientY) => { + const markerRect = marker.getBoundingClientRect(); + offsetX = clientX - markerRect.left; + offsetY = clientY - markerRect.top; + }; + + marker.addEventListener('pointerdown', (event) => { + event.preventDefault(); + dragging = true; + startDrag(event.clientX, event.clientY); + marker.setPointerCapture(event.pointerId); + }); + + marker.addEventListener('pointermove', (event) => { + if (!dragging) { + return; + } + const rect = canvas.getBoundingClientRect(); + setMarkerPosition(event.clientX - rect.left - offsetX, event.clientY - rect.top - offsetY); + }); + + const stopDrag = (event) => { + if (!dragging) { + return; + } + dragging = false; + if (marker.hasPointerCapture(event.pointerId)) { + marker.releasePointerCapture(event.pointerId); + } + }; + + ['pointerup', 'pointercancel', 'pointerleave'].forEach((evt) => { + marker.addEventListener(evt, stopDrag); + }); + + canvas.addEventListener('pointerdown', (event) => { + if (event.target !== canvas) { + return; + } + const rect = canvas.getBoundingClientRect(); + setMarkerPosition(event.clientX - rect.left - markerWidth / 2, event.clientY - rect.top - markerHeight / 2); + }); + + [xField, yField].forEach((input) => { + input.addEventListener('input', () => { + updateFromInputs(); + }); + }); + + window.addEventListener('resize', () => { + updateFromInputs(); + }); + + const panelLocationSelect = document.getElementById('panel-location-select'); + const panelBuildingSelect = document.getElementById('panel-building-select'); + const panelFloorSelect = document.getElementById('panel-floor-select'); + const outletRoomSelect = document.getElementById('outlet-room-select'); + const floorPlanSvg = document.getElementById('floor-plan-svg'); + const panelPlacementFields = document.getElementById('panel-placement-fields'); + const panelFloorPlanGroup = document.getElementById('panel-floor-plan-group'); + const panelFloorMissingHint = document.getElementById('panel-floor-missing-hint'); + + const buildingOptions = panelBuildingSelect ? Array.from(panelBuildingSelect.options).filter((option) => option.value !== '') : []; + const floorOptions = panelFloorSelect ? Array.from(panelFloorSelect.options).filter((option) => option.value !== '') : []; + + const getCurrentFloorId = () => { + const floorOption = panelFloorSelect?.selectedOptions?.[0]; + if (floorOption?.value) { + return Number(floorOption.value); + } + const roomOption = outletRoomSelect?.selectedOptions?.[0]; + return Number(roomOption?.dataset?.floorId || 0); + }; + + const renderReferenceMarkers = () => { + canvas.querySelectorAll('.floor-plan-reference').forEach((node) => node.remove()); + const currentFloorId = getCurrentFloorId(); + if (!currentFloorId) { + return; + } + + const appendReference = (entry, cssClass, width, height) => { + const markerRef = document.createElement('div'); + markerRef.className = `floor-plan-reference ${cssClass}`; + markerRef.style.left = `${Number(entry.x || entry.pos_x || 0)}px`; + markerRef.style.top = `${Number(entry.y || entry.pos_y || 0)}px`; + if (width > 0) { + markerRef.style.width = `${width}px`; + } + if (height > 0) { + markerRef.style.height = `${height}px`; + } + markerRef.title = entry.name || ''; + canvas.appendChild(markerRef); + }; + + panelReferences.forEach((entry) => { + if (Number(entry.floor_id) !== currentFloorId) { + return; + } + if (markerType === 'patchpanel' && Number(entry.id) === activeId) { + return; + } + appendReference(entry, 'panel-marker', Math.max(1, Number(entry.width) || 140), Math.max(1, Number(entry.height) || 40)); + }); + + outletReferences.forEach((entry) => { + if (Number(entry.floor_id) !== currentFloorId) { + return; + } + if (markerType === 'outlet' && Number(entry.id) === activeId) { + return; + } + appendReference(entry, 'outlet-marker', 32, 32); + }); + }; + + const updateFloorPlanImage = () => { + if (!floorPlanSvg) { + return; + } + + const floorOption = panelFloorSelect?.selectedOptions?.[0]; + const roomOption = outletRoomSelect?.selectedOptions?.[0]; + const svgUrl = floorOption?.dataset?.svgUrl || roomOption?.dataset?.floorSvgUrl || ''; + + if (svgUrl) { + floorPlanSvg.src = svgUrl; + floorPlanSvg.hidden = false; + } else { + floorPlanSvg.removeAttribute('src'); + floorPlanSvg.hidden = true; + } + renderReferenceMarkers(); + }; + + if (floorPlanSvg) { + floorPlanSvg.addEventListener('error', () => { + floorPlanSvg.removeAttribute('src'); + floorPlanSvg.hidden = true; + }); + } + + const updatePanelPlacementVisibility = () => { + if (!panelFloorSelect || !panelPlacementFields || !panelFloorPlanGroup) { + return; + } + + const hasFloor = !!panelFloorSelect.value; + panelPlacementFields.hidden = !hasFloor; + panelFloorPlanGroup.hidden = !hasFloor; + if (panelFloorMissingHint) { + panelFloorMissingHint.hidden = hasFloor; + } + }; + + const filterFloorOptions = () => { + if (!panelFloorSelect) { + return; + } + const buildingValue = panelBuildingSelect?.value || ''; + let firstMatch = ''; + floorOptions.forEach((option) => { + const matches = !buildingValue || option.dataset.buildingId === buildingValue; + option.hidden = !matches; + option.disabled = !matches; + if (matches && !firstMatch) { + firstMatch = option.value; + } + }); + + const selectedOption = panelFloorSelect.querySelector(`option[value="${panelFloorSelect.value}"]`); + const isSelectedHidden = selectedOption ? selectedOption.hidden : true; + if (buildingValue && (!panelFloorSelect.value || isSelectedHidden) && firstMatch) { + panelFloorSelect.value = firstMatch; + } + + updatePanelPlacementVisibility(); + updateFloorPlanImage(); + }; + + const filterBuildingOptions = () => { + if (!panelBuildingSelect) { + return; + } + const locationValue = panelLocationSelect?.value || ''; + let firstMatch = ''; + buildingOptions.forEach((option) => { + const matches = !locationValue || option.dataset.locationId === locationValue; + option.hidden = !matches; + option.disabled = !matches; + if (matches && !firstMatch) { + firstMatch = option.value; + } + }); + + const selectedOption = panelBuildingSelect.querySelector(`option[value="${panelBuildingSelect.value}"]`); + const isSelectedHidden = selectedOption ? selectedOption.hidden : true; + if (locationValue && (!panelBuildingSelect.value || isSelectedHidden) && firstMatch) { + panelBuildingSelect.value = firstMatch; + } + }; + + if (panelLocationSelect) { + panelLocationSelect.addEventListener('change', () => { + filterBuildingOptions(); + filterFloorOptions(); + }); + } + + if (panelBuildingSelect) { + panelBuildingSelect.addEventListener('change', () => { + filterFloorOptions(); + }); + } + + if (panelFloorSelect) { + panelFloorSelect.addEventListener('change', () => { + updatePanelPlacementVisibility(); + updateFloorPlanImage(); + }); + } + + if (outletRoomSelect) { + outletRoomSelect.addEventListener('change', () => { + updateFloorPlanImage(); + }); + } + + if (panelLocationSelect) { + filterBuildingOptions(); + filterFloorOptions(); + } else { + updateFloorPlanImage(); + } + + updatePanelPlacementVisibility(); +}); diff --git a/app/modules/floor_infrastructure/edit.php b/app/modules/floor_infrastructure/edit.php index 709cbb5..ea66230 100644 --- a/app/modules/floor_infrastructure/edit.php +++ b/app/modules/floor_infrastructure/edit.php @@ -103,6 +103,22 @@ if ($type === 'patchpanel') { $markerWidth = $type === 'patchpanel' ? $panel['width'] : $defaultOutletSize; $markerHeight = $type === 'patchpanel' ? $panel['height'] : $defaultOutletSize; + +$mapPatchPanels = $sql->get( + "SELECT id, floor_id, name, pos_x, pos_y, width, height + FROM floor_patchpanels + ORDER BY floor_id, name", + "", + [] +); +$mapOutlets = $sql->get( + "SELECT o.id, r.floor_id, o.name, o.x, o.y + FROM network_outlets o + JOIN rooms r ON r.id = o.room_id + ORDER BY r.floor_id, o.name", + "", + [] +); ?>
@@ -188,12 +204,15 @@ $markerHeight = $type === 'patchpanel' ? $panel['height'] : $defaultOutletSize; data-marker-height="" data-marker-type="patchpanel" data-x-field="pos_x" - data-y-field="pos_y"> + data-y-field="pos_y" + data-active-id="" + data-reference-panels="" + data-reference-outlets=""> Stockwerksplan
-

Ziehe das Patchpanel oder klicke auf den Plan, um die Position zu setzen.

+

Nur das aktuell bearbeitete Patchpanel ist verschiebbar. Andere Objekte werden als Referenz halbtransparent angezeigt.

Koordinate:

@@ -204,7 +223,7 @@ $markerHeight = $type === 'patchpanel' ? $panel['height'] : $defaultOutletSize;

> - Position, Groesse und Stockwerkskarte werden erst angezeigt, sobald ein Stockwerk ausgewaehlt ist. + Position, Größe und Stockwerkskarte werden erst angezeigt, sobald ein Stockwerk ausgewählt ist.

@@ -253,12 +272,15 @@ $markerHeight = $type === 'patchpanel' ? $panel['height'] : $defaultOutletSize; data-marker-height="" data-marker-type="outlet" data-x-field="x" - data-y-field="y"> + data-y-field="y" + data-active-id="" + data-reference-panels="" + data-reference-outlets=""> Stockwerksplan
-

Klicke oder ziehe die Wandbuchse auf dem Plan. Die Größe bleibt quadratisch.

+

Nur die aktuell bearbeitete Wandbuchse ist verschiebbar. Andere Objekte werden als Referenz halbtransparent angezeigt.

Koordinate:

@@ -350,6 +372,10 @@ $markerHeight = $type === 'patchpanel' ? $panel['height'] : $defaultOutletSize; touch-action: none; z-index: 2; } +.floor-plan-marker.is-active { + cursor: move; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8), 0 0 0 4px rgba(13, 110, 253, 0.35); +} .floor-plan-marker.panel-marker { background: rgba(13, 110, 253, 0.25); border: 2px solid #0d6efd; @@ -360,6 +386,26 @@ $markerHeight = $type === 'patchpanel' ? $panel['height'] : $defaultOutletSize; border: 2px solid #198754; border-radius: 4px; } +.floor-plan-reference { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + opacity: 0.35; + z-index: 1; +} +.floor-plan-reference.panel-marker { + background: rgba(13, 110, 253, 0.22); + border: 2px solid rgba(13, 110, 253, 0.7); + border-radius: 6px; +} +.floor-plan-reference.outlet-marker { + width: 32px; + height: 32px; + background: rgba(25, 135, 84, 0.22); + border: 2px solid rgba(25, 135, 84, 0.7); + border-radius: 4px; +} .floor-plan-hint { font-size: 0.85em; color: #444; @@ -372,241 +418,5 @@ $markerHeight = $type === 'patchpanel' ? $panel['height'] : $defaultOutletSize; } - + diff --git a/app/modules/floor_infrastructure/list.php b/app/modules/floor_infrastructure/list.php index b4c1117..58a50c2 100644 --- a/app/modules/floor_infrastructure/list.php +++ b/app/modules/floor_infrastructure/list.php @@ -2,7 +2,7 @@ /** * app/modules/floor_infrastructure/list.php * - * Uebersicht ueber Patchpanels und Netzwerkbuchsen auf Stockwerken + * Übersicht über Patchpanels und Netzwerkbuchsen auf Stockwerken */ $floorId = (int)($_GET['floor_id'] ?? 0); @@ -101,10 +101,10 @@ if ($editorFloorId > 0) {
- + Patchpanel hinzufuegen + + Patchpanel hinzufügen - + Wandbuchse hinzufuegen + + Wandbuchse hinzufügen
@@ -122,7 +122,7 @@ if ($editorFloorId > 0) { - Zuruecksetzen + Zurücksetzen
@@ -134,7 +134,7 @@ if ($editorFloorId > 0) { Name Stockwerk Position - Groesse + Größe Ports Aktionen @@ -196,18 +196,18 @@ if ($editorFloorId > 0) {

Stockwerksplan-Verortung

-

Kein Stockwerk fuer die Planansicht verfuegbar.

+

Kein Stockwerk für die Planansicht verfügbar.

-

Das gewaehlte Stockwerk hat noch keinen SVG-Plan hinterlegt.

+

Das gewählte Stockwerk hat noch keinen SVG-Plan hinterlegt.

-

Drag & Drop ist aktiv fuer . Aenderungen werden direkt gespeichert.

+

Vorschau für . Positionen bearbeitest du über den jeweiligen „Bearbeiten“-Button.

Stockwerksplan
-

Patchpanels: blau, Wandbuchsen: gruen. Ziehen und loslassen zum Speichern.

+

Patchpanels: blau, Wandbuchsen: grün. In dieser Ansicht sind Marker nicht verschiebbar.

@@ -277,8 +277,9 @@ if ($editorFloorId > 0) { left: 0; user-select: none; touch-action: none; - cursor: move; + cursor: default; z-index: 2; + pointer-events: none; } .infra-marker.patchpanel { background: rgba(13, 110, 253, 0.25); @@ -292,9 +293,6 @@ if ($editorFloorId > 0) { border: 2px solid #198754; border-radius: 4px; } -.infra-marker.is-saving { - opacity: 0.65; -} .floor-plan-hint { margin: 8px 0 0; font-size: 0.85em; @@ -319,95 +317,10 @@ document.addEventListener('DOMContentLoaded', () => { return; } - const saveUrl = canvas.dataset.saveUrl; - const floorId = Number(canvas.dataset.floorId || 0); const outletSize = 32; const patchPanels = ; const outlets = ; - const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); - - const savePosition = async (entry, type, marker) => { - const form = new FormData(); - - if (type === 'patchpanel') { - form.append('type', 'patchpanel'); - form.append('id', String(entry.id)); - form.append('name', entry.name || ''); - form.append('floor_id', String(floorId)); - form.append('pos_x', String(entry.x)); - form.append('pos_y', String(entry.y)); - form.append('width', String(entry.width)); - form.append('height', String(entry.height)); - form.append('port_count', String(entry.port_count || 0)); - form.append('comment', entry.comment || ''); - } else { - form.append('type', 'outlet'); - form.append('id', String(entry.id)); - form.append('name', entry.name || ''); - form.append('room_id', String(entry.room_id || 0)); - form.append('x', String(entry.x)); - form.append('y', String(entry.y)); - form.append('comment', entry.comment || ''); - } - - marker.classList.add('is-saving'); - try { - await fetch(saveUrl, { - method: 'POST', - body: form, - credentials: 'same-origin' - }); - } finally { - marker.classList.remove('is-saving'); - } - }; - - const bindDrag = (marker, entry, type) => { - let dragging = false; - let offsetX = 0; - let offsetY = 0; - - marker.addEventListener('pointerdown', (event) => { - dragging = true; - const rect = marker.getBoundingClientRect(); - offsetX = event.clientX - rect.left; - offsetY = event.clientY - rect.top; - marker.setPointerCapture(event.pointerId); - }); - - marker.addEventListener('pointermove', (event) => { - if (!dragging) { - return; - } - - const rect = canvas.getBoundingClientRect(); - const width = type === 'patchpanel' ? entry.width : outletSize; - const height = type === 'patchpanel' ? entry.height : outletSize; - const maxX = Math.max(0, rect.width - width); - const maxY = Math.max(0, rect.height - height); - - entry.x = Math.round(clamp(event.clientX - rect.left - offsetX, 0, maxX)); - entry.y = Math.round(clamp(event.clientY - rect.top - offsetY, 0, maxY)); - marker.style.left = `${entry.x}px`; - marker.style.top = `${entry.y}px`; - }); - - const stopDrag = (event) => { - if (!dragging) { - return; - } - dragging = false; - if (marker.hasPointerCapture(event.pointerId)) { - marker.releasePointerCapture(event.pointerId); - } - savePosition(entry, type, marker); - }; - - marker.addEventListener('pointerup', stopDrag); - marker.addEventListener('pointercancel', stopDrag); - }; - const createMarker = (entry, type) => { const marker = document.createElement('div'); marker.className = `infra-marker ${type}`; @@ -423,8 +336,6 @@ document.addEventListener('DOMContentLoaded', () => { marker.style.left = `${entry.x}px`; marker.style.top = `${entry.y}px`; canvas.appendChild(marker); - - bindDrag(marker, entry, type); }; patchPanels.forEach((entry) => createMarker(entry, 'patchpanel'));