infrastruktur karte

This commit is contained in:
2026-02-16 11:57:24 +01:00
parent d1d89dd4e3
commit 12141485ae
7 changed files with 752 additions and 422 deletions

View File

@@ -0,0 +1,108 @@
.floor-infra-edit {
padding: 25px;
max-width: 1200px;
}
.infra-edit-form {
display: flex;
flex-direction: column;
gap: 15px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.form-actions {
display: flex;
gap: 10px;
}
.info {
font-size: 0.9em;
color: #555;
}
.floor-plan-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.floor-plan-canvas {
position: relative;
width: 100%;
min-height: 560px;
border: 1px solid #d4d4d4;
border-radius: 8px;
background-color: #fff;
background-image:
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px);
background-size: 40px 40px;
cursor: crosshair;
overflow: hidden;
}
.floor-plan-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
z-index: 0;
opacity: 0.75;
border-radius: 6px;
}
.floor-plan-overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 2;
touch-action: none;
}
.floor-plan-overlay .active-marker {
cursor: move;
}
.floor-plan-overlay .panel-marker {
fill: rgba(13, 110, 253, 0.25);
stroke: #0d6efd;
stroke-width: 2;
}
.floor-plan-overlay .outlet-marker {
fill: rgba(25, 135, 84, 0.25);
stroke: #198754;
stroke-width: 2;
}
.floor-plan-overlay .reference-marker {
pointer-events: none;
opacity: 0.35;
}
.floor-plan-overlay .room-highlight {
pointer-events: none;
fill: rgba(255, 193, 7, 0.22);
stroke: #ff9800;
stroke-width: 2;
}
.floor-plan-overlay .reference-marker.panel-marker {
fill: rgba(13, 110, 253, 0.22);
stroke: rgba(13, 110, 253, 0.7);
stroke-width: 2;
}
.floor-plan-overlay .reference-marker.outlet-marker {
fill: rgba(25, 135, 84, 0.22);
stroke: rgba(25, 135, 84, 0.7);
stroke-width: 2;
}
.floor-plan-hint {
font-size: 0.85em;
color: #444;
margin: 0;
}
.floor-plan-position {
margin: 0;
font-size: 0.85em;
color: #666;
}

View File

@@ -0,0 +1,109 @@
.floor-infra {
padding: 25px;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.filter-form {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.filter-form select {
padding: 8px 10px;
border-radius: 4px;
border: 1px solid #ccc;
}
.infra-plan {
padding: 15px;
margin-bottom: 28px;
background: #f7f7f7;
border: 1px dashed #ccc;
border-radius: 6px;
}
.infra-floor-canvas {
position: relative;
margin-top: 12px;
width: 100%;
min-height: 560px;
border: 1px solid #d4d4d4;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.infra-floor-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
opacity: 0.85;
}
.infra-floor-overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 2;
}
.infra-overlay-marker {
pointer-events: auto;
}
.infra-overlay-marker.patchpanel {
fill: rgba(13, 110, 253, 0.25);
stroke: #0d6efd;
stroke-width: 2;
}
.infra-overlay-marker.outlet {
fill: rgba(25, 135, 84, 0.25);
stroke: #198754;
stroke-width: 2;
}
.infra-section {
margin-bottom: 30px;
}
.infra-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.infra-table th,
.infra-table td {
padding: 10px;
border-bottom: 1px solid #ddd;
}
.floor-plan-hint {
margin: 8px 0 0;
font-size: 0.85em;
color: #444;
}
.empty-state {
padding: 20px;
background: #fafafa;
border: 1px dashed #ccc;
border-radius: 6px;
}
.actions .button-small {
margin-right: 6px;
}

View File

@@ -1,8 +1,11 @@
document.addEventListener('DOMContentLoaded', () => {
const SVG_NS = 'http://www.w3.org/2000/svg';
const DEFAULT_PLAN_SIZE = { width: 2000, height: 1000 };
const canvas = document.getElementById('floor-plan-canvas');
const marker = document.getElementById('floor-plan-marker');
const overlay = document.getElementById('floor-plan-overlay');
const positionLabel = document.getElementById('floor-plan-position');
if (!canvas || !marker) {
if (!canvas || !overlay) {
return;
}
@@ -14,8 +17,8 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
const markerWidth = Math.max(1, Number(canvas.dataset.markerWidth) || marker.offsetWidth);
const markerHeight = Math.max(1, Number(canvas.dataset.markerHeight) || marker.offsetHeight);
const markerWidth = Math.max(1, Number(canvas.dataset.markerWidth) || 10);
const markerHeight = Math.max(1, Number(canvas.dataset.markerHeight) || 10);
const markerType = canvas.dataset.markerType || '';
const activeId = Number(canvas.dataset.activeId || 0);
const panelReferences = JSON.parse(canvas.dataset.referencePanels || '[]');
@@ -23,7 +26,27 @@ document.addEventListener('DOMContentLoaded', () => {
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
marker.classList.add('is-active');
let markerX = 0;
let markerY = 0;
let dragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
const activeMarker = document.createElementNS(SVG_NS, 'rect');
activeMarker.classList.add('active-marker');
if (markerType === 'patchpanel') {
activeMarker.classList.add('panel-marker');
} else {
activeMarker.classList.add('outlet-marker');
}
activeMarker.setAttribute('width', String(markerWidth));
activeMarker.setAttribute('height', String(markerHeight));
overlay.appendChild(activeMarker);
const planSize = { ...DEFAULT_PLAN_SIZE };
const updateOverlayViewBox = () => {
overlay.setAttribute('viewBox', `0 0 ${planSize.width} ${planSize.height}`);
};
const updatePositionLabel = (x, y) => {
if (positionLabel) {
@@ -31,81 +54,138 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
const paintActiveMarker = () => {
activeMarker.setAttribute('x', String(Math.round(markerX)));
activeMarker.setAttribute('y', String(Math.round(markerY)));
};
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 maxX = Math.max(0, planSize.width - markerWidth);
const maxY = Math.max(0, planSize.height - markerHeight);
markerX = clamp(rawX, 0, maxX);
markerY = clamp(rawY, 0, maxY);
paintActiveMarker();
xField.value = Math.round(markerX);
yField.value = Math.round(markerY);
updatePositionLabel(markerX, markerY);
};
const toOverlayPoint = (clientX, clientY) => {
const pt = overlay.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
const ctm = overlay.getScreenCTM();
if (!ctm) {
return null;
}
const transformed = pt.matrixTransform(ctm.inverse());
return { x: transformed.x, y: transformed.y };
};
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;
const clearReferenceMarkers = () => {
overlay.querySelectorAll('.reference-marker').forEach((node) => node.remove());
};
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);
}
const clearRoomHighlight = () => {
overlay.querySelectorAll('.room-highlight').forEach((node) => node.remove());
};
['pointerup', 'pointercancel', 'pointerleave'].forEach((evt) => {
marker.addEventListener(evt, stopDrag);
});
const getNumericCoord = (value) => {
if (value === null || value === undefined || value === '') {
return null;
}
const num = Number(value);
return Number.isFinite(num) ? num : null;
};
canvas.addEventListener('pointerdown', (event) => {
if (event.target !== canvas) {
const appendReference = (entry, cssClass, width, height) => {
const rawX = entry.x ?? entry.pos_x;
const rawY = entry.y ?? entry.pos_y;
const x = getNumericCoord(rawX);
const y = getNumericCoord(rawY);
if (x === null || y === null) {
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();
});
});
const ref = document.createElementNS(SVG_NS, 'rect');
ref.classList.add('reference-marker', cssClass);
ref.setAttribute('x', String(Math.round(x)));
ref.setAttribute('y', String(Math.round(y)));
ref.setAttribute('width', String(Math.max(1, Math.round(width))));
ref.setAttribute('height', String(Math.max(1, Math.round(height))));
if (entry.name) {
ref.setAttribute('aria-label', String(entry.name));
}
overlay.insertBefore(ref, activeMarker);
};
window.addEventListener('resize', () => {
updateFromInputs();
});
const appendRoomHighlight = () => {
if (!outletRoomSelect) {
return;
}
const selectedRoomOption = outletRoomSelect.selectedOptions?.[0];
if (!selectedRoomOption || !selectedRoomOption.value) {
return;
}
const currentFloorId = getCurrentFloorId();
const roomFloorId = Number(selectedRoomOption.dataset.floorId || 0);
if (!currentFloorId || roomFloorId !== currentFloorId) {
return;
}
const polygonRaw = String(selectedRoomOption.dataset.roomPolygon || '').trim();
if (polygonRaw) {
try {
const parsed = JSON.parse(polygonRaw);
if (Array.isArray(parsed)) {
const points = parsed
.map((point) => ({
x: Number(point && point.x),
y: Number(point && point.y)
}))
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y));
if (points.length >= 3) {
const polygon = document.createElementNS(SVG_NS, 'polygon');
polygon.classList.add('room-highlight');
polygon.setAttribute(
'points',
points.map((point) => `${Math.round(point.x)},${Math.round(point.y)}`).join(' ')
);
overlay.insertBefore(polygon, activeMarker);
return;
}
}
} catch (error) {
// ignore invalid room polygon json
}
}
const x = Number(selectedRoomOption.dataset.roomX || 0);
const y = Number(selectedRoomOption.dataset.roomY || 0);
const width = Number(selectedRoomOption.dataset.roomWidth || 0);
const height = Number(selectedRoomOption.dataset.roomHeight || 0);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(width) || !Number.isFinite(height)) {
return;
}
if (width <= 0 || height <= 0) {
return;
}
const rect = document.createElementNS(SVG_NS, 'rect');
rect.classList.add('room-highlight');
rect.setAttribute('x', String(Math.round(x)));
rect.setAttribute('y', String(Math.round(y)));
rect.setAttribute('width', String(Math.round(width)));
rect.setAttribute('height', String(Math.round(height)));
overlay.insertBefore(rect, activeMarker);
};
const panelLocationSelect = document.getElementById('panel-location-select');
const panelBuildingSelect = document.getElementById('panel-building-select');
@@ -129,26 +209,13 @@ document.addEventListener('DOMContentLoaded', () => {
};
const renderReferenceMarkers = () => {
canvas.querySelectorAll('.floor-plan-reference').forEach((node) => node.remove());
clearRoomHighlight();
clearReferenceMarkers();
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);
};
appendRoomHighlight();
panelReferences.forEach((entry) => {
if (Number(entry.floor_id) !== currentFloorId) {
@@ -157,7 +224,7 @@ document.addEventListener('DOMContentLoaded', () => {
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));
appendReference(entry, 'panel-marker', Math.max(1, Number(entry.width) || 20), Math.max(1, Number(entry.height) || 5));
});
outletReferences.forEach((entry) => {
@@ -183,9 +250,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (svgUrl) {
floorPlanSvg.src = svgUrl;
floorPlanSvg.hidden = false;
loadPlanDimensions(svgUrl);
} else {
floorPlanSvg.removeAttribute('src');
floorPlanSvg.hidden = true;
planSize.width = DEFAULT_PLAN_SIZE.width;
planSize.height = DEFAULT_PLAN_SIZE.height;
updateOverlayViewBox();
}
renderReferenceMarkers();
};
@@ -197,6 +268,57 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
const loadPlanDimensions = async (svgUrl) => {
if (!svgUrl) {
return;
}
try {
const response = await fetch(svgUrl, { credentials: 'same-origin' });
if (!response.ok) {
throw new Error('SVG not available');
}
const raw = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(raw, 'image/svg+xml');
const root = doc.documentElement;
if (!root || root.nodeName.toLowerCase() === 'parsererror') {
throw new Error('Invalid SVG');
}
const vb = String(root.getAttribute('viewBox') || '').trim();
if (vb) {
const parts = vb.split(/\s+/).map((value) => Number(value));
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();
renderReferenceMarkers();
updateFromInputs();
return;
}
}
const width = Number(root.getAttribute('width'));
const height = Number(root.getAttribute('height'));
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
planSize.width = width;
planSize.height = height;
} else {
planSize.width = DEFAULT_PLAN_SIZE.width;
planSize.height = DEFAULT_PLAN_SIZE.height;
}
updateOverlayViewBox();
renderReferenceMarkers();
updateFromInputs();
} catch (error) {
planSize.width = DEFAULT_PLAN_SIZE.width;
planSize.height = DEFAULT_PLAN_SIZE.height;
updateOverlayViewBox();
renderReferenceMarkers();
updateFromInputs();
}
};
const updatePanelPlacementVisibility = () => {
if (!panelFloorSelect || !panelPlacementFields || !panelFloorPlanGroup) {
return;
@@ -257,6 +379,65 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
activeMarker.addEventListener('pointerdown', (event) => {
event.preventDefault();
dragging = true;
const point = toOverlayPoint(event.clientX, event.clientY);
if (!point) {
return;
}
dragOffsetX = point.x - markerX;
dragOffsetY = point.y - markerY;
activeMarker.setPointerCapture(event.pointerId);
});
activeMarker.addEventListener('pointermove', (event) => {
if (!dragging) {
return;
}
const point = toOverlayPoint(event.clientX, event.clientY);
if (!point) {
return;
}
setMarkerPosition(point.x - dragOffsetX, point.y - dragOffsetY);
});
const stopDrag = (event) => {
if (!dragging) {
return;
}
dragging = false;
if (activeMarker.hasPointerCapture(event.pointerId)) {
activeMarker.releasePointerCapture(event.pointerId);
}
};
['pointerup', 'pointercancel', 'pointerleave'].forEach((evt) => {
activeMarker.addEventListener(evt, stopDrag);
});
overlay.addEventListener('pointerdown', (event) => {
if (event.target !== overlay) {
return;
}
const point = toOverlayPoint(event.clientX, event.clientY);
if (!point) {
return;
}
setMarkerPosition(point.x - markerWidth / 2, point.y - markerHeight / 2);
});
[xField, yField].forEach((input) => {
input.addEventListener('input', () => {
updateFromInputs();
});
});
window.addEventListener('resize', () => {
updateFromInputs();
renderReferenceMarkers();
});
if (panelLocationSelect) {
panelLocationSelect.addEventListener('change', () => {
filterBuildingOptions();
@@ -283,6 +464,9 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
updateOverlayViewBox();
updateFromInputs();
if (panelLocationSelect) {
filterBuildingOptions();
filterFloorOptions();

View File

@@ -0,0 +1,148 @@
document.addEventListener('DOMContentLoaded', () => {
const SVG_NS = 'http://www.w3.org/2000/svg';
const DEFAULT_PLAN_SIZE = { width: 2000, height: 1000 };
const filterForm = document.getElementById('infra-filter-form');
const floorSelect = document.getElementById('infra-floor-select');
if (filterForm && floorSelect) {
floorSelect.addEventListener('change', () => {
filterForm.submit();
});
}
const canvas = document.getElementById('infra-floor-canvas');
const overlay = document.getElementById('infra-floor-overlay');
const floorSvg = canvas ? canvas.querySelector('.infra-floor-svg') : null;
if (!canvas || !overlay || !floorSvg) {
return;
}
const patchPanels = safeJsonParse(canvas.dataset.patchpanels);
const outlets = safeJsonParse(canvas.dataset.outlets);
const planSize = { ...DEFAULT_PLAN_SIZE };
const updateOverlayViewBox = () => {
overlay.setAttribute('viewBox', `0 0 ${planSize.width} ${planSize.height}`);
};
const createMarker = (entry, type) => {
const x = Number(entry.x);
const y = Number(entry.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return;
}
const marker = document.createElementNS(SVG_NS, 'rect');
marker.classList.add('infra-overlay-marker', type);
marker.setAttribute('x', String(Math.round(x)));
marker.setAttribute('y', String(Math.round(y)));
if (type === 'patchpanel') {
marker.setAttribute('width', String(Math.max(1, Number(entry.width) || 20)));
marker.setAttribute('height', String(Math.max(1, Number(entry.height) || 5)));
} else {
marker.setAttribute('width', '10');
marker.setAttribute('height', '10');
}
const tooltipLines = buildTooltipLines(entry, type);
if (tooltipLines.length > 0) {
const titleNode = document.createElementNS(SVG_NS, 'title');
titleNode.textContent = tooltipLines.join('\n');
marker.appendChild(titleNode);
}
overlay.appendChild(marker);
};
patchPanels.forEach((entry) => createMarker(entry, 'patchpanel'));
outlets.forEach((entry) => createMarker(entry, 'outlet'));
const loadPlanDimensions = async (svgUrl) => {
if (!svgUrl) {
updateOverlayViewBox();
return;
}
try {
const response = await fetch(svgUrl, { credentials: 'same-origin' });
if (!response.ok) {
throw new Error('SVG not available');
}
const raw = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(raw, 'image/svg+xml');
const root = doc.documentElement;
if (!root || root.nodeName.toLowerCase() === 'parsererror') {
throw new Error('Invalid SVG');
}
const vb = String(root.getAttribute('viewBox') || '').trim();
if (vb) {
const parts = vb.split(/\s+/).map((value) => Number(value));
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();
return;
}
}
const width = Number(root.getAttribute('width'));
const height = Number(root.getAttribute('height'));
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
planSize.width = width;
planSize.height = height;
} else {
planSize.width = DEFAULT_PLAN_SIZE.width;
planSize.height = DEFAULT_PLAN_SIZE.height;
}
updateOverlayViewBox();
} catch (error) {
planSize.width = DEFAULT_PLAN_SIZE.width;
planSize.height = DEFAULT_PLAN_SIZE.height;
updateOverlayViewBox();
}
};
loadPlanDimensions(floorSvg.getAttribute('src') || '');
});
function safeJsonParse(value) {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
}
function buildTooltipLines(entry, type) {
if (type === 'patchpanel') {
const lines = [
`Patchpanel: ${entry.name || '-'}`,
`Ports: ${Number(entry.port_count) || 0}`,
`Position: ${Number(entry.x) || 0} x ${Number(entry.y) || 0}`
];
if (entry.comment) {
lines.push(`Kommentar: ${entry.comment}`);
}
return lines;
}
const roomLabel = `${entry.room_name || '-'}${entry.room_number ? ` (${entry.room_number})` : ''}`;
const lines = [
`Wandbuchse: ${entry.name || '-'}`,
`Raum: ${roomLabel}`,
`Position: ${Number(entry.x) || 0} x ${Number(entry.y) || 0}`
];
if (entry.port_names) {
lines.push(`Ports: ${entry.port_names}`);
}
if (entry.comment) {
lines.push(`Kommentar: ${entry.comment}`);
}
return lines;
}

View File

@@ -20,7 +20,8 @@ $floors = $sql->get(
[]
);
$rooms = $sql->get(
"SELECT r.id, r.name, r.floor_id, f.name AS floor_name, f.svg_path, b.id AS building_id, l.id AS location_id
"SELECT r.id, r.name, r.floor_id, r.x, r.y, r.width, r.height, r.polygon_points,
f.name AS floor_name, f.svg_path, b.id AS building_id, l.id AS location_id
FROM rooms r
LEFT JOIN floors f ON f.id = r.floor_id
LEFT JOIN buildings b ON b.id = f.building_id
@@ -92,13 +93,13 @@ if ($type === 'patchpanel') {
}
}
$defaultPanelSize = ['width' => 140, 'height' => 40];
$defaultPanelSize = ['width' => 20, 'height' => 5];
$defaultOutletSize = 10;
$showPanelPlacementFields = $type === 'patchpanel' && $selectedFloorId > 0;
if ($type === 'patchpanel') {
$panel['width'] = $panel['width'] ?? $defaultPanelSize['width'];
$panel['height'] = $panel['height'] ?? $defaultPanelSize['height'];
$panel['width'] = $defaultPanelSize['width'];
$panel['height'] = $defaultPanelSize['height'];
$panel['pos_x'] = $panel['pos_x'] ?? 30;
$panel['pos_y'] = $panel['pos_y'] ?? 30;
} else {
@@ -127,6 +128,7 @@ $mapOutlets = $sql->get(
?>
<div class="floor-infra-edit">
<link rel="stylesheet" href="/assets/css/floor-infrastructure-edit.css">
<h1><?php echo $pageTitle; ?></h1>
<form method="post" action="?module=floor_infrastructure&action=save" class="infra-edit-form">
@@ -204,8 +206,7 @@ $mapOutlets = $sql->get(
data-reference-panels="<?php echo htmlspecialchars(json_encode($mapPatchPanels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"
data-reference-outlets="<?php echo htmlspecialchars(json_encode($mapOutlets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>">
<img id="floor-plan-svg" class="floor-plan-svg" alt="Stockwerksplan">
<div id="floor-plan-marker" class="floor-plan-marker panel-marker"
style="--marker-width: <?php echo $markerWidth; ?>px; --marker-height: <?php echo $markerHeight; ?>px;"></div>
<svg id="floor-plan-overlay" class="floor-plan-overlay" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
</div>
<p class="floor-plan-hint">Nur das aktuell bearbeitete Patchpanel ist verschiebbar. Andere Objekte werden als Referenz halbtransparent angezeigt. Neue Objekte starten bei Position 30 x 30.</p>
<p class="floor-plan-position">Koordinate: <span id="floor-plan-position"></span></p>
@@ -242,6 +243,11 @@ $mapOutlets = $sql->get(
<option value="<?php echo $room['id']; ?>"
data-floor-id="<?php echo $room['floor_id'] ?? 0; ?>"
data-floor-svg-url="<?php echo htmlspecialchars($room['floor_svg_url']); ?>"
data-room-x="<?php echo (int)($room['x'] ?? 0); ?>"
data-room-y="<?php echo (int)($room['y'] ?? 0); ?>"
data-room-width="<?php echo (int)($room['width'] ?? 0); ?>"
data-room-height="<?php echo (int)($room['height'] ?? 0); ?>"
data-room-polygon="<?php echo htmlspecialchars((string)($room['polygon_points'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>"
<?php echo ($outlet['room_id'] ?? 0) === $room['id'] ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($room['floor_name'] . ' / ' . $room['name']); ?>
</option>
@@ -265,10 +271,9 @@ $mapOutlets = $sql->get(
data-reference-panels="<?php echo htmlspecialchars(json_encode($mapPatchPanels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"
data-reference-outlets="<?php echo htmlspecialchars(json_encode($mapOutlets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>">
<img id="floor-plan-svg" class="floor-plan-svg" alt="Stockwerksplan">
<div id="floor-plan-marker" class="floor-plan-marker outlet-marker"
style="--marker-width: <?php echo $markerWidth; ?>px; --marker-height: <?php echo $markerHeight; ?>px;"></div>
<svg id="floor-plan-overlay" class="floor-plan-overlay" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
</div>
<p class="floor-plan-hint">Nur die aktuell bearbeitete Wandbuchse ist verschiebbar. Andere Objekte werden als Referenz halbtransparent angezeigt. Netzwerkdosen sind immer 10 x 10.</p>
<p class="floor-plan-hint">Nur die aktuell bearbeitete Wandbuchse ist verschiebbar. Blau = Patchpanel, Gruen = Dosen-Referenz, Orange = gewaehlter Raum. Netzwerkdosen sind immer 10 x 10.</p>
<p class="floor-plan-position">Koordinate: <span id="floor-plan-position"></span></p>
</div>
</div>
@@ -292,119 +297,7 @@ $mapOutlets = $sql->get(
//TODO drag an drop auf der stockwerkskarte für die patchfelder und wandbuchsen. buchsen haben eine einheitliche größe, und sind quadratisch, patchfelder sind auch für sich einheitlich, sind rechteckig und breiter als hoch
//TODO style in css files einsortieren
?>
<style>
.floor-infra-edit {
padding: 25px;
max-width: 700px;
}
.infra-edit-form {
display: flex;
flex-direction: column;
gap: 15px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.form-actions {
display: flex;
gap: 10px;
}
.info {
font-size: 0.9em;
color: #555;
}
.floor-plan-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.floor-plan-canvas {
position: relative;
width: 100%;
min-height: 260px;
border: 1px solid #d4d4d4;
border-radius: 8px;
background-color: #fff;
background-image:
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px);
background-size: 40px 40px;
cursor: crosshair;
overflow: hidden;
}
.floor-plan-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
z-index: 0;
opacity: 0.75;
border-radius: 6px;
}
.floor-plan-marker {
position: absolute;
top: 0;
left: 0;
width: var(--marker-width, 32px);
height: var(--marker-height, 32px);
transition: left 0.1s ease, top 0.1s ease;
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;
border-radius: 6px;
}
.floor-plan-marker.outlet-marker {
background: rgba(25, 135, 84, 0.25);
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: 10px;
height: 10px;
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;
margin: 0;
}
.floor-plan-position {
margin: 0;
font-size: 0.85em;
color: #666;
}
</style>
<script src="/assets/js/floor-infrastructure-edit.js" defer></script>

View File

@@ -2,12 +2,18 @@
/**
* app/modules/floor_infrastructure/list.php
*
* Übersicht über Patchpanels und Netzwerkbuchsen auf Stockwerken
* Uebersicht ueber Patchpanels und Netzwerkbuchsen auf Stockwerken.
*/
$floorId = (int)($_GET['floor_id'] ?? 0);
$floors = $sql->get("SELECT id, name, svg_path FROM floors ORDER BY name", "", []);
$floors = $sql->get(
"SELECT id, name, svg_path
FROM floors
ORDER BY name",
"",
[]
);
$where = '';
$types = '';
@@ -15,7 +21,7 @@ $params = [];
if ($floorId > 0) {
$where = "WHERE p.floor_id = ?";
$types = "i";
$types = 'i';
$params[] = $floorId;
}
@@ -30,10 +36,15 @@ $patchPanels = $sql->get(
);
$networkOutlets = $sql->get(
"SELECT o.*, r.name AS room_name, f.name AS floor_name, f.id AS floor_id
"SELECT o.id, o.room_id, o.name, o.x, o.y, o.comment,
r.name AS room_name, r.number AS room_number,
f.name AS floor_name, f.id AS floor_id,
GROUP_CONCAT(nop.name ORDER BY nop.name SEPARATOR ', ') AS port_names
FROM network_outlets o
LEFT JOIN rooms r ON r.id = o.room_id
LEFT JOIN floors f ON f.id = r.floor_id
LEFT JOIN network_outlet_ports nop ON nop.outlet_id = o.id
GROUP BY o.id
ORDER BY f.name, r.name, o.name",
"",
[]
@@ -50,27 +61,18 @@ foreach ($floors as $floor) {
];
}
$editorFloorId = $floorId;
if ($editorFloorId <= 0) {
foreach ($floorMap as $candidate) {
if ($candidate['svg_url'] !== '') {
$editorFloorId = (int)$candidate['id'];
break;
}
}
}
$editorFloor = $floorMap[$editorFloorId] ?? null;
$editorFloor = ($floorId > 0 && isset($floorMap[$floorId])) ? $floorMap[$floorId] : null;
$editorPatchPanels = [];
$editorOutlets = [];
if ($editorFloorId > 0) {
if ($editorFloor) {
foreach ($patchPanels as $panel) {
if ((int)$panel['floor_id'] === $editorFloorId) {
if ((int)$panel['floor_id'] !== $floorId) {
continue;
}
$editorPatchPanels[] = [
'id' => (int)$panel['id'],
'name' => (string)$panel['name'],
'floor_id' => (int)$panel['floor_id'],
'x' => (int)$panel['pos_x'],
'y' => (int)$panel['pos_y'],
'width' => max(1, (int)$panel['width']),
@@ -79,52 +81,81 @@ if ($editorFloorId > 0) {
'comment' => (string)($panel['comment'] ?? '')
];
}
}
foreach ($networkOutlets as $outlet) {
if ((int)$outlet['floor_id'] === $editorFloorId) {
if ((int)$outlet['floor_id'] !== $floorId) {
continue;
}
$editorOutlets[] = [
'id' => (int)$outlet['id'],
'name' => (string)$outlet['name'],
'room_id' => (int)$outlet['room_id'],
'x' => (int)$outlet['x'],
'y' => (int)$outlet['y'],
'room_name' => (string)($outlet['room_name'] ?? ''),
'room_number' => (string)($outlet['room_number'] ?? ''),
'port_names' => (string)($outlet['port_names'] ?? ''),
'comment' => (string)($outlet['comment'] ?? '')
];
}
}
}
?>
<div class="floor-infra">
<link rel="stylesheet" href="/assets/css/floor-infrastructure-list.css">
<script src="/assets/js/floor-infrastructure-list.js" defer></script>
<h1>Stockwerksinfrastruktur</h1>
<div class="toolbar">
<a href="?module=floor_infrastructure&action=edit&type=patchpanel" class="button button-primary">
+ Patchpanel hinzufügen
+ Patchpanel hinzufuegen
</a>
<a href="?module=floor_infrastructure&action=edit&type=outlet" class="button">
+ Wandbuchse hinzufügen
+ Wandbuchse hinzufuegen
</a>
</div>
<form method="get" class="filter-form">
<form method="get" class="filter-form" id="infra-filter-form">
<input type="hidden" name="module" value="floor_infrastructure">
<input type="hidden" name="action" value="list">
<select name="floor_id">
<option value="">- Alle Stockwerke -</option>
<select name="floor_id" id="infra-floor-select">
<option value="">- Stockwerk waehlen -</option>
<?php foreach ($floors as $floor): ?>
<option value="<?php echo $floor['id']; ?>" <?php echo $floor['id'] === $floorId ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($floor['name']); ?>
<option value="<?php echo (int)$floor['id']; ?>" <?php echo ((int)$floor['id'] === $floorId) ? 'selected' : ''; ?>>
<?php echo htmlspecialchars((string)$floor['name']); ?>
</option>
<?php endforeach; ?>
</select>
<button class="button">Filter</button>
<a href="?module=floor_infrastructure&action=list" class="button">Zurücksetzen</a>
<button class="button" type="submit">Filter</button>
<a href="?module=floor_infrastructure&action=list" class="button">Zuruecksetzen</a>
</form>
<section class="infra-plan">
<h2>Stockwerkskarte</h2>
<?php if ($floorId <= 0): ?>
<p class="empty-state">Bitte ein Stockwerk auswaehlen, um die Karte anzuzeigen.</p>
<?php elseif (!$editorFloor): ?>
<p class="empty-state">Gewaehltes Stockwerk wurde nicht gefunden.</p>
<?php elseif (($editorFloor['svg_url'] ?? '') === ''): ?>
<p class="empty-state">Das gewaehlte Stockwerk hat keinen SVG-Plan hinterlegt.</p>
<?php else: ?>
<p>
Read-only Vorschau fuer <strong><?php echo htmlspecialchars((string)$editorFloor['name']); ?></strong>.
Alle Objekte werden angezeigt; Hover zeigt Details.
</p>
<div id="infra-floor-canvas"
class="infra-floor-canvas"
data-patchpanels="<?php echo htmlspecialchars(json_encode($editorPatchPanels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"
data-outlets="<?php echo htmlspecialchars(json_encode($editorOutlets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>">
<img src="<?php echo htmlspecialchars((string)$editorFloor['svg_url']); ?>" class="infra-floor-svg" alt="Stockwerksplan">
<svg id="infra-floor-overlay" class="infra-floor-overlay" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
</div>
<p class="floor-plan-hint">Blau: Patchpanel | Gruen: Wandbuchse. Hover zeigt Name, Raum und Ports (Browser-Tooltip).</p>
<?php endif; ?>
</section>
<section class="infra-section">
<h2>Patchpanels</h2>
<?php if (!empty($patchPanels)): ?>
@@ -134,7 +165,7 @@ if ($editorFloorId > 0) {
<th>Name</th>
<th>Stockwerk</th>
<th>Position</th>
<th>Größe</th>
<th>Groesse</th>
<th>Ports</th>
<th>Aktionen</th>
</tr>
@@ -142,13 +173,13 @@ if ($editorFloorId > 0) {
<tbody>
<?php foreach ($patchPanels as $panel): ?>
<tr>
<td><?php echo htmlspecialchars($panel['name']); ?></td>
<td><?php echo htmlspecialchars($panel['floor_name'] ?? '-'); ?></td>
<td><?php echo $panel['pos_x'] . ' x ' . $panel['pos_y']; ?></td>
<td><?php echo $panel['width'] . ' x ' . $panel['height']; ?></td>
<td><?php echo $panel['port_count']; ?></td>
<td><?php echo htmlspecialchars((string)$panel['name']); ?></td>
<td><?php echo htmlspecialchars((string)($panel['floor_name'] ?? '-')); ?></td>
<td><?php echo (int)$panel['pos_x'] . ' x ' . (int)$panel['pos_y']; ?></td>
<td><?php echo (int)$panel['width'] . ' x ' . (int)$panel['height']; ?></td>
<td><?php echo (int)$panel['port_count']; ?></td>
<td class="actions">
<a href="?module=floor_infrastructure&action=edit&type=patchpanel&id=<?php echo $panel['id']; ?>" class="button button-small">Bearbeiten</a>
<a href="?module=floor_infrastructure&action=edit&type=patchpanel&id=<?php echo (int)$panel['id']; ?>" class="button button-small">Bearbeiten</a>
</td>
</tr>
<?php endforeach; ?>
@@ -169,6 +200,7 @@ if ($editorFloorId > 0) {
<th>Stockwerk</th>
<th>Raum</th>
<th>Koordinaten</th>
<th>Ports</th>
<th>Kommentar</th>
<th>Aktionen</th>
</tr>
@@ -176,13 +208,23 @@ if ($editorFloorId > 0) {
<tbody>
<?php foreach ($networkOutlets as $outlet): ?>
<tr>
<td><?php echo htmlspecialchars($outlet['name']); ?></td>
<td><?php echo htmlspecialchars($outlet['floor_name'] ?? '-'); ?></td>
<td><?php echo htmlspecialchars($outlet['room_name'] ?? '-'); ?></td>
<td><?php echo $outlet['x'] . ' x ' . $outlet['y']; ?></td>
<td><?php echo htmlspecialchars($outlet['comment']); ?></td>
<td><?php echo htmlspecialchars((string)$outlet['name']); ?></td>
<td><?php echo htmlspecialchars((string)($outlet['floor_name'] ?? '-')); ?></td>
<td>
<?php
$roomLabel = (string)($outlet['room_name'] ?? '-');
$roomNumber = trim((string)($outlet['room_number'] ?? ''));
if ($roomNumber !== '') {
$roomLabel .= ' (' . $roomNumber . ')';
}
echo htmlspecialchars($roomLabel);
?>
</td>
<td><?php echo (int)$outlet['x'] . ' x ' . (int)$outlet['y']; ?></td>
<td><?php echo htmlspecialchars((string)($outlet['port_names'] ?? '-')); ?></td>
<td><?php echo htmlspecialchars((string)($outlet['comment'] ?? '')); ?></td>
<td class="actions">
<a href="?module=floor_infrastructure&action=edit&type=outlet&id=<?php echo $outlet['id']; ?>" class="button button-small">Bearbeiten</a>
<a href="?module=floor_infrastructure&action=edit&type=outlet&id=<?php echo (int)$outlet['id']; ?>" class="button button-small">Bearbeiten</a>
</td>
</tr>
<?php endforeach; ?>
@@ -192,154 +234,4 @@ if ($editorFloorId > 0) {
<p class="empty-state">Noch keine Wandbuchsen angelegt.</p>
<?php endif; ?>
</section>
<section class="infra-plan">
<h2>Stockwerksplan-Verortung</h2>
<?php if (!$editorFloor): ?>
<p class="empty-state">Kein Stockwerk für die Planansicht verfügbar.</p>
<?php elseif (($editorFloor['svg_url'] ?? '') === ''): ?>
<p class="empty-state">Das gewählte Stockwerk hat noch keinen SVG-Plan hinterlegt.</p>
<?php else: ?>
<p>Vorschau für <strong><?php echo htmlspecialchars($editorFloor['name']); ?></strong>. Positionen bearbeitest du über den jeweiligen „Bearbeiten“-Button.</p>
<div id="infra-floor-canvas"
class="infra-floor-canvas"
data-save-url="?module=floor_infrastructure&action=save"
data-floor-id="<?php echo (int)$editorFloor['id']; ?>">
<img src="<?php echo htmlspecialchars($editorFloor['svg_url']); ?>" class="infra-floor-svg" alt="Stockwerksplan">
</div>
<p class="floor-plan-hint">Patchpanels: blau, Wandbuchsen: grün. In dieser Ansicht sind Marker nicht verschiebbar.</p>
<?php endif; ?>
</section>
</div>
<style>
.floor-infra {
padding: 25px;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.filter-form {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 25px;
}
.filter-form select {
padding: 8px 10px;
border-radius: 4px;
border: 1px solid #ccc;
}
.infra-section {
margin-bottom: 30px;
}
.infra-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.infra-table th,
.infra-table td {
padding: 10px;
border-bottom: 1px solid #ddd;
}
.infra-plan {
padding: 15px;
background: #f7f7f7;
border: 1px dashed #ccc;
border-radius: 6px;
}
.infra-floor-canvas {
position: relative;
margin-top: 12px;
width: 100%;
min-height: 420px;
border: 1px solid #d4d4d4;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.infra-floor-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
opacity: 0.85;
}
.infra-marker {
position: absolute;
top: 0;
left: 0;
user-select: none;
touch-action: none;
cursor: default;
z-index: 2;
pointer-events: none;
}
.infra-marker.patchpanel {
background: rgba(13, 110, 253, 0.25);
border: 2px solid #0d6efd;
border-radius: 6px;
}
.infra-marker.outlet {
width: 10px;
height: 10px;
background: rgba(25, 135, 84, 0.25);
border: 2px solid #198754;
border-radius: 4px;
}
.floor-plan-hint {
margin: 8px 0 0;
font-size: 0.85em;
color: #444;
}
.empty-state {
padding: 20px;
background: #fafafa;
border: 1px dashed #ccc;
border-radius: 6px;
}
.actions .button-small {
margin-right: 6px;
}
</style>
<?php if ($editorFloor && ($editorFloor['svg_url'] ?? '') !== ''): ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
const canvas = document.getElementById('infra-floor-canvas');
if (!canvas) {
return;
}
const outletSize = 10;
const patchPanels = <?php echo json_encode($editorPatchPanels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
const outlets = <?php echo json_encode($editorOutlets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
const createMarker = (entry, type) => {
const marker = document.createElement('div');
marker.className = `infra-marker ${type}`;
marker.dataset.id = String(entry.id);
marker.dataset.type = type;
marker.title = entry.name || '';
if (type === 'patchpanel') {
marker.style.width = `${entry.width}px`;
marker.style.height = `${entry.height}px`;
}
marker.style.left = `${entry.x}px`;
marker.style.top = `${entry.y}px`;
canvas.appendChild(marker);
};
patchPanels.forEach((entry) => createMarker(entry, 'patchpanel'));
outlets.forEach((entry) => createMarker(entry, 'outlet'));
});
</script>
<?php endif; ?>

View File

@@ -9,22 +9,18 @@ $type = $_POST['type'] ?? '';
$id = (int)($_POST['id'] ?? 0);
if ($type === 'patchpanel') {
$fixedPanelWidth = 20;
$fixedPanelHeight = 5;
$name = trim($_POST['name'] ?? '');
$floorId = (int)($_POST['floor_id'] ?? 0);
$posX = (int)($_POST['pos_x'] ?? 0);
$posY = (int)($_POST['pos_y'] ?? 0);
$width = (int)($_POST['width'] ?? 0);
$height = (int)($_POST['height'] ?? 0);
$width = $fixedPanelWidth;
$height = $fixedPanelHeight;
$portCount = (int)($_POST['port_count'] ?? 0);
$comment = trim($_POST['comment'] ?? '');
if ($width <= 0) {
$width = 140;
}
if ($height <= 0) {
$height = 40;
}
if ($id > 0) {
$sql->set(
"UPDATE floor_patchpanels SET name = ?, floor_id = ?, pos_x = ?, pos_y = ?, width = ?, height = ?, port_count = ?, comment = ? WHERE id = ?",