intermediate stops

This commit is contained in:
2026-06-13 17:02:58 +02:00
parent 5eaef4612d
commit 14cd49a8dd
7 changed files with 181 additions and 1 deletions

View File

@@ -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,
},
});

View File

@@ -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) ── */}

View File

@@ -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

View File

@@ -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[];
};