This commit is contained in:
2026-06-13 21:45:32 +02:00
parent 7e7edbaeda
commit a50a15efc6
10 changed files with 615 additions and 0 deletions

240
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@prisma/client": "^7.8.0",
"@tailwindcss/typography": "^0.5.20",
"@types/better-sqlite3": "^7.6.13",
"@xyflow/react": "^12.11.0",
"next": "16.2.9",
"qrcode": "^1.5.4",
"react": "19.2.4",
@@ -2082,6 +2083,55 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -2850,6 +2900,48 @@
"win32"
]
},
"node_modules/@xyflow/react": {
"version": "12.11.0",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz",
"integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.77",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"@types/react": ">=17",
"@types/react-dom": ">=17",
"react": ">=17",
"react-dom": ">=17"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.77",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz",
"integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@@ -3546,6 +3638,12 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -3645,6 +3743,111 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -9417,6 +9620,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -9754,6 +9966,34 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -13,6 +13,7 @@
"@prisma/client": "^7.8.0",
"@tailwindcss/typography": "^0.5.20",
"@types/better-sqlite3": "^7.6.13",
"@xyflow/react": "^12.11.0",
"next": "16.2.9",
"qrcode": "^1.5.4",
"react": "19.2.4",

View File

@@ -17,6 +17,8 @@ model Location {
id Int @id @default(autoincrement())
name String
description String? // Optional Markdown text
mapX Float?
mapY Float?
// A location can be the start OR end of many cables
startCables Cable[] @relation("StartLocation")

View File

@@ -85,4 +85,23 @@ export async function deleteLocation(id: number): Promise<void> {
await prisma.location.delete({ where: { id } });
revalidatePath("/locations");
revalidatePath("/cables");
}
export async function updateLocationPosition(
id: number,
x: number,
y: number
): Promise<void> {
try {
await prisma.location.update({
where: { id },
data: {
mapX: x,
mapY: y,
},
});
revalidatePath("/map");
} catch (error) {
console.error("updateLocationPosition failed:", error);
}
}

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "@xyflow/react/dist/style.css";
/* Push content below the fixed mobile header */
@media (max-width: 1023px) {

42
src/app/map/page.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { prisma } from "@/lib/prisma";
import CableMap from "@/components/map/CableMap";
export const dynamic = "force-dynamic";
export default async function MapPage() {
const locations = await prisma.location.findMany({
include: {
startCables: {
include: {
endLocation: true,
_count: { select: { cores: true } },
intermediateStops: {
orderBy: { order: "asc" },
include: { location: true },
},
},
},
endCables: {
include: {
_count: { select: { cores: true } },
},
},
},
orderBy: { name: "asc" },
});
return (
<div className="relative min-h-[calc(100vh-6rem)]">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900">Kabelplan</h1>
<p className="text-slate-500 mt-1 text-sm">
Standorte und Kabel als interaktive Knotenkarte.
</p>
</div>
<div className="w-full max-w-full">
<CableMap locations={locations} />
</div>
</div>
);
}

View File

@@ -16,6 +16,19 @@ const navItems = [
</svg>
),
},
{
href: "/map",
label: "Kabelplan",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="6" cy="7" r="1.5" />
<circle cx="12" cy="17" r="1.5" />
<circle cx="18" cy="7" r="1.5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
d="M6 7h4m4 10h4m-2-3L8 10" />
</svg>
),
},
{
href: "/locations",
label: "Orte verwalten",

View File

@@ -0,0 +1,186 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
Background,
Controls,
MiniMap,
Node,
ReactFlow,
useEdgesState,
useNodesState,
} from "@xyflow/react";
import CableMapNode from "./CableMapNode";
import CableMapEdge from "./CableMapEdge";
import { updateLocationPosition } from "@/actions/locationActions";
type LocationCable = {
id: number;
identifier: string;
startLocationId: number;
endLocationId: number;
_count: { cores: number };
endLocation: { id: number; name: string };
intermediateStops: { location: { id: number; name: string } }[];
};
type LocationWithCables = {
id: number;
name: string;
mapX: number | null;
mapY: number | null;
startCables: LocationCable[];
endCables: { id: number }[];
};
interface CableMapProps {
locations: LocationWithCables[];
}
const nodeTypes = { location: CableMapNode as any };
const edgeTypes = { cable: CableMapEdge as any };
export default function CableMap({ locations }: CableMapProps) {
const router = useRouter();
const [selectedEdgeId, setSelectedEdgeId] = useState<number | null>(null);
const initialNodes = useMemo(
() =>
locations.map((location, index) => ({
id: String(location.id),
type: "location",
position: {
x: location.mapX ?? (index % 4) * 220,
y: location.mapY ?? Math.floor(index / 4) * 160,
},
data: {
label: location.name,
cableCount: location.startCables.length + location.endCables.length,
locationId: location.id,
},
})),
[locations]
);
const initialEdges = useMemo(
() =>
locations.flatMap((location) =>
location.startCables.flatMap((cable) => {
const route = [
{ id: String(cable.startLocationId), name: location.name },
...cable.intermediateStops.map((stop) => ({
id: String(stop.location.id),
name: stop.location.name,
})),
{ id: String(cable.endLocationId), name: cable.endLocation.name },
];
return route.slice(1).map((target, index) => {
const source = route[index];
return {
id: `cable-${cable.id}-${index}`,
type: "cable",
source: source.id,
target: target.id,
data: {
label: cable.identifier,
coreCount: cable._count.cores,
cableId: cable.id,
sourceName: source.name,
targetName: target.name,
onEdgeClick: () => setSelectedEdgeId(cable.id),
},
};
});
})
),
[locations]
);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const selectedEdge = selectedEdgeId
? edges.find((edge) => edge.data?.cableId === selectedEdgeId)
: null;
const handleNodeDragStop = async (_event: unknown, node: Node) => {
const locationId = Number(node.data?.locationId);
if (!locationId || typeof node.position.x !== "number" || typeof node.position.y !== "number") {
return;
}
await updateLocationPosition(locationId, node.position.x, node.position.y);
};
return locations.length === 0 ? (
<div className="rounded-3xl border border-slate-200 bg-white p-10 shadow-sm">
<div className="text-slate-700 text-lg font-semibold">Noch keine Orte vorhanden</div>
<p className="mt-2 text-slate-500 text-sm leading-6">
Legen Sie zuerst einen Ort unter Orte verwalten an, um den Kabelplan zu nutzen.
</p>
</div>
) : (
<div className="relative h-[calc(100vh-9rem)] rounded-[32px] border border-slate-200 bg-white shadow-sm">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStop={handleNodeDragStop}
fitView
fitViewOptions={{ padding: 0.15 }}
minZoom={0.2}
maxZoom={2}
selectionOnDrag={false}
>
<Background gap={16} size={1} color="#e2e8f0" />
<Controls position="top-left" />
<MiniMap
nodeColor={(node) => {
const cableCount = typeof node.data?.cableCount === "number" ? node.data.cableCount : 0;
return cableCount > 0 ? "#2563eb" : "#94a3b8";
}}
/>
</ReactFlow>
{selectedEdge ? (
<div className="absolute top-4 right-4 z-20 w-72 rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-xl backdrop-blur-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">Kabeldetails</p>
<p className="text-xs text-slate-500 mt-1">
{selectedEdge.data.sourceName} {selectedEdge.data.targetName}
</p>
</div>
<button
type="button"
onClick={() => setSelectedEdgeId(null)}
className="text-slate-400 hover:text-slate-700"
>
</button>
</div>
<div className="mt-4">
<p className="text-base font-semibold text-slate-900">
{selectedEdge.data.label}
</p>
<p className="mt-2 text-sm text-slate-500">
{selectedEdge.data.coreCount} {selectedEdge.data.coreCount === 1 ? "Ader" : "Adern"}
</p>
</div>
<button
type="button"
onClick={() => router.push(`/cables/${selectedEdge.data.cableId}`)}
className="mt-4 w-full rounded-2xl bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-700"
>
Details öffnen
</button>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { EdgeProps, getBezierPath } from "@xyflow/react";
type CableEdgeData = {
label: string;
coreCount: number;
cableId: number;
sourceName?: string;
targetName?: string;
onEdgeClick?: () => void;
};
export default function CableMapEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
}: EdgeProps<any>) {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const edgeId = `edge-${id}`;
const strokeWidth = 1 + Math.min(data.coreCount, 8) * 0.4;
const strokeColor = selected ? "#1d4ed8" : "#64748b";
return (
<>
<path
id={edgeId}
d={edgePath}
stroke={strokeColor}
strokeWidth={strokeWidth}
fill="none"
className="cursor-pointer transition-colors"
onClick={() => data.onEdgeClick?.()}
/>
<text>
<textPath href={`#${edgeId}`} startOffset="50%" textAnchor="middle" className="text-xs fill-slate-700">
{data.label}
</textPath>
</text>
</>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { Handle, NodeProps, Position } from "@xyflow/react";
type LocationNodeData = {
label: string;
cableCount: number;
locationId: number;
};
export default function CableMapNode({ data }: NodeProps<any>) {
const borderColor = data.cableCount === 0
? "border-slate-300"
: data.cableCount >= 5
? "border-orange-500"
: "border-blue-500";
return (
<div className={`min-w-[180px] max-w-[240px] rounded-3xl border bg-white p-4 shadow-sm ${borderColor}`}>
<div className="flex items-center gap-3">
<div className="rounded-full bg-slate-100 p-2 text-base">📍</div>
<div>
<p className="text-sm font-semibold text-slate-900">{data.label}</p>
<p className="text-xs text-slate-500 mt-0.5">
{data.cableCount} {data.cableCount === 1 ? "Kabel" : "Kabel"}
</p>
</div>
</div>
<Handle
type="target"
position={Position.Top}
className="opacity-0"
isConnectable={false}
/>
<Handle
type="source"
position={Position.Bottom}
className="opacity-0"
isConnectable={false}
/>
<Handle
type="target"
position={Position.Left}
className="opacity-0"
isConnectable={false}
/>
<Handle
type="source"
position={Position.Right}
className="opacity-0"
isConnectable={false}
/>
</div>
);
}