intermediate stops
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Location" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Cable" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"startLocationId" INTEGER NOT NULL,
|
||||
"endLocationId" INTEGER NOT NULL,
|
||||
CONSTRAINT "Cable_startLocationId_fkey" FOREIGN KEY ("startLocationId") REFERENCES "Location" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Cable_endLocationId_fkey" FOREIGN KEY ("endLocationId") REFERENCES "Location" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Core" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"color" TEXT NOT NULL,
|
||||
"notes" TEXT,
|
||||
"cableId" INTEGER NOT NULL,
|
||||
CONSTRAINT "Core_cableId_fkey" FOREIGN KEY ("cableId") REFERENCES "Cable" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CableStop" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"order" INTEGER NOT NULL,
|
||||
"cableId" INTEGER NOT NULL,
|
||||
"locationId" INTEGER NOT NULL,
|
||||
CONSTRAINT "CableStop_cableId_fkey" FOREIGN KEY ("cableId") REFERENCES "Cable" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "CableStop_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CableStop_cableId_order_key" ON "CableStop"("cableId", "order");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
@@ -21,6 +21,7 @@ model Location {
|
||||
// A location can be the start OR end of many cables
|
||||
startCables Cable[] @relation("StartLocation")
|
||||
endCables Cable[] @relation("EndLocation")
|
||||
cableStops CableStop[]
|
||||
}
|
||||
|
||||
// A single cable run documented in the system
|
||||
@@ -39,6 +40,7 @@ model Cable {
|
||||
endLocation Location @relation("EndLocation", fields: [endLocationId], references: [id])
|
||||
|
||||
cores Core[] // One cable can have many cores/wires
|
||||
intermediateStops CableStop[]
|
||||
}
|
||||
|
||||
// A single wire/core within a cable (e.g., "Blau", "Rot/Weiß")
|
||||
@@ -50,3 +52,14 @@ model Core {
|
||||
cableId Int
|
||||
cable Cable @relation(fields: [cableId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model CableStop {
|
||||
id Int @id @default(autoincrement())
|
||||
order Int
|
||||
cable Cable @relation(fields: [cableId], references: [id], onDelete: Cascade)
|
||||
cableId Int
|
||||
location Location @relation(fields: [locationId], references: [id])
|
||||
locationId Int
|
||||
|
||||
@@unique([cableId, order])
|
||||
}
|
||||
@@ -21,6 +21,10 @@ export async function createCable(
|
||||
const notes = (formData.get("notes") as string)?.trim();
|
||||
const startLocationId = Number(formData.get("startLocationId"));
|
||||
const endLocationId = Number(formData.get("endLocationId"));
|
||||
const intermediateLocationIds = formData
|
||||
.getAll("intermediateLocationIds")
|
||||
.map((value) => Number(value))
|
||||
.filter((id) => Boolean(id));
|
||||
|
||||
if (!identifier) {
|
||||
return { success: false, error: "Bezeichnung ist erforderlich." };
|
||||
@@ -28,6 +32,21 @@ export async function createCable(
|
||||
if (!startLocationId || !endLocationId) {
|
||||
return { success: false, error: "Start- und End-Ort müssen ausgewählt werden." };
|
||||
}
|
||||
if (intermediateLocationIds.length > 0) {
|
||||
if (intermediateLocationIds.some((id) => id === startLocationId || id === endLocationId)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Zwischenstationen dürfen nicht der Start- oder End-Ort sein.",
|
||||
};
|
||||
}
|
||||
const hasDuplicate = new Set(intermediateLocationIds).size !== intermediateLocationIds.length;
|
||||
if (hasDuplicate) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Zwischenstationen dürfen nicht doppelt vorkommen.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const cable = await prisma.cable.create({
|
||||
@@ -37,6 +56,14 @@ export async function createCable(
|
||||
notes: notes || null,
|
||||
startLocationId,
|
||||
endLocationId,
|
||||
intermediateStops: intermediateLocationIds.length
|
||||
? {
|
||||
create: intermediateLocationIds.map((locationId, index) => ({
|
||||
locationId,
|
||||
order: index,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export const dynamic = "force-dynamic";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { Location, Core, CableStop } from "@/generated/prisma/client";
|
||||
import MarkdownContent from "@/components/ui/MarkdownContent";
|
||||
import CoresList from "@/components/cores/CoresList";
|
||||
import NewCoreForm from "@/components/cores/NewCoreForm";
|
||||
@@ -26,9 +27,20 @@ export default async function CableDetailPage({
|
||||
include: {
|
||||
startLocation: true,
|
||||
endLocation: true,
|
||||
intermediateStops: {
|
||||
orderBy: { order: "asc" },
|
||||
include: { location: true },
|
||||
},
|
||||
cores: { orderBy: { id: "asc" } },
|
||||
},
|
||||
});
|
||||
}) as
|
||||
| (import("@/generated/prisma/client").Cable & {
|
||||
startLocation: Location;
|
||||
endLocation: Location;
|
||||
intermediateStops: (CableStop & { location: Location })[];
|
||||
cores: Core[];
|
||||
})
|
||||
| null;
|
||||
|
||||
if (!cable) notFound();
|
||||
|
||||
@@ -106,6 +118,28 @@ export default async function CableDetailPage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cable.intermediateStops.length > 0 && (
|
||||
<div className="mt-6 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-slate-900">
|
||||
Zwischenstationen
|
||||
</h2>
|
||||
<div className="grid gap-3">
|
||||
{cable.intermediateStops.map((stop) => (
|
||||
<div key={stop.id} className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-slate-900">{stop.location.name}</p>
|
||||
{stop.location.description && (
|
||||
<p className="text-slate-500 text-sm mt-1">{stop.location.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-slate-500 text-xs font-medium uppercase tracking-[0.22em]">
|
||||
{stop.order + 1}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Notes card (only shown if notes exist) ── */}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface NewCableFormProps {
|
||||
export default function NewCableForm({ locations }: NewCableFormProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stations, setStations] = useState<number[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -145,6 +146,58 @@ export default function NewCableForm({ locations }: NewCableFormProps) {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Intermediate stations */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Zwischenstationen
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStations((prev) => [...prev, 0])}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
+ hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{stations.map((stationId, index) => (
|
||||
<div key={index} className="grid grid-cols-[1fr_auto] gap-3 items-center">
|
||||
<select
|
||||
name="intermediateLocationIds"
|
||||
value={stationId}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
setStations((current) => {
|
||||
const next = [...current];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value={0} disabled>
|
||||
Ort auswählen…
|
||||
</option>
|
||||
{locations.map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
{loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStations((current) => current.filter((_, idx) => idx !== index))}
|
||||
className="inline-flex items-center justify-center rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-700 hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Short description */}
|
||||
<div className="md:col-span-2">
|
||||
<label
|
||||
|
||||
@@ -17,9 +17,17 @@ export type CableWithLocations = Cable & {
|
||||
};
|
||||
};
|
||||
|
||||
// A single intermediate stop with its linked location.
|
||||
export type CableStopWithLocation = {
|
||||
id: number;
|
||||
order: number;
|
||||
location: Location;
|
||||
};
|
||||
|
||||
// Cable with full detail: locations + all cores (used on the detail page)
|
||||
export type CableWithDetails = Cable & {
|
||||
startLocation: Location;
|
||||
endLocation: Location;
|
||||
intermediateStops: CableStopWithLocation[];
|
||||
cores: Core[];
|
||||
};
|
||||
Reference in New Issue
Block a user