first working release

This commit is contained in:
2026-06-13 15:11:49 +02:00
parent b97d574697
commit 550adb5114
29 changed files with 4438 additions and 131 deletions

View File

@@ -0,0 +1,188 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
const navItems = [
{
href: "/cables",
label: "Kabel-Übersicht",
icon: (
// Simple cable/plug icon via inline SVG
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
},
{
href: "/locations",
label: "Orte verwalten",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
export default function Navigation() {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
return (
<>
{/* ── Mobile top bar ── */}
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 flex items-center justify-between
bg-slate-900 px-4 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-blue-500 rounded flex items-center justify-center">
<svg className="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span className="text-white font-semibold text-sm">Kabel-Manager</span>
</div>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="text-slate-300 hover:text-white p-1 rounded"
aria-label="Navigation öffnen"
>
{mobileOpen ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
{/* ── Desktop sidebar ── */}
<aside className="hidden lg:flex fixed inset-y-0 left-0 w-64 bg-slate-900 flex-col z-40">
<div className="flex flex-col h-full">
{/* Brand / Logo area */}
<div className="px-5 py-5 border-b border-slate-700/50">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p className="text-white font-semibold text-sm leading-tight">Kabel-Manager</p>
<p className="text-slate-400 text-xs">Hausverkabelung</p>
</div>
</div>
</div>
{/* Navigation links */}
<nav className="flex-1 px-3 py-4 space-y-0.5">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={`
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
transition-colors duration-150
${isActive
? "bg-blue-600 text-white"
: "text-slate-300 hover:bg-slate-700/60 hover:text-white"
}
`}
>
{item.icon}
{item.label}
</Link>
);
})}
</nav>
{/* Footer note */}
<div className="px-5 py-4 border-t border-slate-700/50">
<p className="text-slate-500 text-xs">
Kabel, Adern & Orte dokumentieren
</p>
</div>
</div>
</aside>
{/* ── Mobile drawer ── */}
<aside
className={`
lg:hidden fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40
transform transition-transform duration-200 ease-in-out
${mobileOpen ? "translate-x-0" : "-translate-x-full"}
`}
>
<div className="pt-14"> {/* offset mobile top bar */}
{/* Brand / Logo area */}
<div className="px-5 py-5 border-b border-slate-700/50">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p className="text-white font-semibold text-sm leading-tight">Kabel-Manager</p>
<p className="text-slate-400 text-xs">Hausverkabelung</p>
</div>
</div>
</div>
{/* Navigation links */}
<nav className="flex-1 px-3 py-4 space-y-0.5">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={`
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
transition-colors duration-150
${isActive
? "bg-blue-600 text-white"
: "text-slate-300 hover:bg-slate-700/60 hover:text-white"
}
`}
>
{item.icon}
{item.label}
</Link>
);
})}
</nav>
{/* Footer note */}
<div className="px-5 py-4 border-t border-slate-700/50">
<p className="text-slate-500 text-xs">
Kabel, Adern & Orte dokumentieren
</p>
</div>
</div>
</aside>
{/* ── Mobile backdrop overlay ── */}
{mobileOpen && (
<div
className="lg:hidden fixed inset-0 z-30 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileOpen(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import type { CableWithLocations } from "@/lib/types";
interface CablesTableProps {
cables: CableWithLocations[];
}
export default function CablesTable({ cables }: CablesTableProps) {
const [query, setQuery] = useState("");
const filtered = query.trim()
? cables.filter((cable) => {
const q = query.toLowerCase();
return (
cable.identifier.toLowerCase().includes(q) ||
(cable.description ?? "").toLowerCase().includes(q) ||
(cable.notes ?? "").toLowerCase().includes(q) ||
cable.startLocation.name.toLowerCase().includes(q) ||
cable.endLocation.name.toLowerCase().includes(q)
);
})
: cables;
if (cables.length === 0) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-14 text-center shadow-sm">
<div className="text-4xl mb-4">🔌</div>
<p className="text-slate-600 font-medium">
Noch keine Kabel dokumentiert.
</p>
<p className="text-slate-400 text-sm mt-1">
Legen Sie oben Ihr erstes Kabel an.
</p>
</div>
);
}
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Header with search */}
<div className="px-6 py-4 border-b border-slate-100 flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-2 flex-1">
<h2 className="text-base font-semibold text-slate-900">Alle Kabel</h2>
<span className="text-xs font-medium bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">
{filtered.length}{query.trim() ? ` / ${cables.length}` : ""}
</span>
</div>
{/* Search input */}
<div className="relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Kabel suchen…"
className="
pl-9 pr-3 py-1.5 text-sm
border border-slate-200 rounded-lg
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400 w-full sm:w-56
"
/>
</div>
</div>
{/* No results */}
{filtered.length === 0 ? (
<div className="px-6 py-12 text-center text-slate-400 text-sm">
Keine Kabel für {query}&quot; gefunden.
</div>
) : (
/* Responsive table */
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-100">
<tr>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
Bezeichnung
</th>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
Start-Ort
</th>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
End-Ort
</th>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide hidden sm:table-cell">
Adern
</th>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide hidden md:table-cell">
Angelegt
</th>
<th className="px-6 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((cable) => (
<tr key={cable.id} className="hover:bg-slate-50/60 group">
<td className="px-6 py-4">
<Link
href={`/cables/${cable.id}`}
className="font-medium text-blue-600 hover:text-blue-800 transition-colors"
>
{cable.identifier}
</Link>
{cable.description && (
<p className="text-slate-400 text-xs mt-0.5">
{cable.description}
</p>
)}
</td>
<td className="px-6 py-4">
<span className="
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
bg-blue-50 text-blue-700
">
{cable.startLocation.name}
</span>
</td>
<td className="px-6 py-4">
<span className="
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
bg-emerald-50 text-emerald-700
">
{cable.endLocation.name}
</span>
</td>
<td className="px-6 py-4 hidden sm:table-cell">
<span className="
inline-flex items-center px-2 py-0.5 rounded text-xs
bg-slate-100 text-slate-600 tabular-nums
">
{cable._count.cores}{" "}
{cable._count.cores === 1 ? "Ader" : "Adern"}
</span>
</td>
<td className="px-6 py-4 text-slate-400 text-xs hidden md:table-cell tabular-nums">
{new Date(cable.createdAt).toLocaleDateString("de-DE")}
</td>
<td className="px-6 py-4 text-right">
<Link
href={`/cables/${cable.id}`}
className="
text-xs text-slate-400 hover:text-slate-700
opacity-0 group-hover:opacity-100 transition-opacity duration-150
"
>
Details
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteCable } from "@/actions/cableActions";
interface DeleteCableButtonProps {
id: number;
identifier: string;
}
export default function DeleteCableButton({
id,
identifier,
}: DeleteCableButtonProps) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleDelete = () => {
if (
!confirm(
`Kabel „${identifier}" wirklich löschen?\n\nAlle zugehörigen Adern werden ebenfalls gelöscht.`
)
) {
return;
}
startTransition(async () => {
await deleteCable(id);
// Navigation is handled client-side (server action does not redirect)
router.push("/cables");
});
};
return (
<button
onClick={handleDelete}
disabled={isPending}
className="
inline-flex items-center gap-1.5 px-3 py-1.5
border border-red-200 text-red-600
hover:bg-red-50 hover:border-red-300
disabled:opacity-50 disabled:cursor-not-allowed
text-sm font-medium rounded-lg transition-colors duration-150
flex-shrink-0
"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{isPending ? "Wird gelöscht…" : "Kabel löschen"}
</button>
);
}

View File

@@ -0,0 +1,228 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { Location } from "@/generated/prisma/client";
import { createCable } from "@/actions/cableActions";
interface NewCableFormProps {
locations: Location[];
}
export default function NewCableForm({ locations }: NewCableFormProps) {
const [isOpen, setIsOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
setError(null);
startTransition(async () => {
const result = await createCable(formData);
if (!result.success) {
setError(result.error);
} else {
router.push(`/cables/${result.cableId}`);
}
});
}
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="
inline-flex items-center gap-2 px-4 py-2.5
bg-blue-600 hover:bg-blue-700 text-white
text-sm font-medium rounded-lg transition-colors duration-150
"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neues Kabel anlegen
</button>
);
}
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
{/* Form header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<h2 className="text-base font-semibold text-slate-900">
Neues Kabel anlegen
</h2>
<button
onClick={() => { setIsOpen(false); setError(null); }}
className="text-slate-400 hover:text-slate-600 transition-colors"
aria-label="Formular schließen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Form fields */}
<form onSubmit={handleSubmit} className="p-6 space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Identifier */}
<div className="md:col-span-2">
<label
htmlFor="cable-identifier"
className="block text-sm font-medium text-slate-700 mb-1.5"
>
Bezeichnung <span className="text-red-500">*</span>
</label>
<input
id="cable-identifier"
name="identifier"
type="text"
placeholder="z. B. Kabel 01, LAN-01, Strom-KG-01"
required
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400
"
/>
</div>
{/* Start location */}
<div>
<label
htmlFor="cable-start"
className="block text-sm font-medium text-slate-700 mb-1.5"
>
Start-Ort <span className="text-red-500">*</span>
</label>
<select
id="cable-start"
name="startLocationId"
required
defaultValue=""
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="" disabled>Ort auswählen</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>
{loc.name}
</option>
))}
</select>
</div>
{/* End location */}
<div>
<label
htmlFor="cable-end"
className="block text-sm font-medium text-slate-700 mb-1.5"
>
End-Ort <span className="text-red-500">*</span>
</label>
<select
id="cable-end"
name="endLocationId"
required
defaultValue=""
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="" disabled>Ort auswählen</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>
{loc.name}
</option>
))}
</select>
</div>
{/* Short description */}
<div className="md:col-span-2">
<label
htmlFor="cable-description"
className="block text-sm font-medium text-slate-700 mb-1.5"
>
Kurzbeschreibung{" "}
<span className="text-slate-400 font-normal">(optional)</span>
</label>
<input
id="cable-description"
name="description"
type="text"
placeholder="z. B. Netzwerkkabel, CAT 7"
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400
"
/>
</div>
{/* Notes (Markdown) */}
<div className="md:col-span-2">
<label
htmlFor="cable-notes"
className="block text-sm font-medium text-slate-700 mb-1.5"
>
Notizen{" "}
<span className="text-slate-400 font-normal">(optional, Markdown)</span>
</label>
<textarea
id="cable-notes"
name="notes"
rows={3}
placeholder="Verlegeweg, Besonderheiten, Querschnitt…"
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400 resize-y
"
/>
</div>
</div>
{/* Error message */}
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-100 px-3 py-2 rounded-lg">
{error}
</p>
)}
{/* Action buttons */}
<div className="flex items-center gap-3 pt-1">
<button
type="submit"
disabled={isPending}
className="
px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400
text-white text-sm font-medium rounded-lg
transition-colors duration-150 disabled:cursor-not-allowed
"
>
{isPending ? "Wird gespeichert…" : "Kabel anlegen"}
</button>
<button
type="button"
onClick={() => { setIsOpen(false); setError(null); }}
className="
px-5 py-2 bg-slate-100 hover:bg-slate-200
text-slate-700 text-sm font-medium rounded-lg
transition-colors duration-150
"
>
Abbrechen
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import type { Core } from "@prisma/client";
import DeleteCoreButton from "./DeleteCoreButton";
import MarkdownContent from "@/components/ui/MarkdownContent";
interface CoresListProps {
cores: Core[];
cableId: number;
}
// Maps German color names to Tailwind background colors for the indicator dot.
// Supports "Farbe1/Farbe2" notation (uses the first color).
const COLOR_MAP: Record<string, string> = {
blau: "bg-blue-500",
rot: "bg-red-500",
grün: "bg-green-500",
gelb: "bg-yellow-400",
braun: "bg-amber-800",
orange: "bg-orange-500",
grau: "bg-slate-400",
schwarz: "bg-slate-900",
weiß: "bg-white border border-slate-300",
violett: "bg-violet-500",
lila: "bg-purple-500",
pink: "bg-pink-400",
türkis: "bg-cyan-400",
beige: "bg-amber-200",
};
function getColorClass(color: string): string {
// Handle "Rot/Weiß" or "Blau-Weiß" by taking the first color segment
const primary = color.toLowerCase().split(/[/\-,]/)[0].trim();
return COLOR_MAP[primary] ?? "bg-slate-300";
}
export default function CoresList({ cores, cableId }: CoresListProps) {
if (cores.length === 0) {
return (
<p className="text-slate-400 text-sm text-center py-4">
Noch keine Adern dokumentiert.
</p>
);
}
return (
<div className="space-y-2">
{cores.map((core) => (
<div
key={core.id}
className="
flex items-start gap-3 px-4 py-3
rounded-lg border border-slate-100
hover:bg-slate-50/80 transition-colors duration-100
"
>
{/* Color indicator dot */}
<div
className={`w-4 h-4 rounded-full flex-shrink-0 mt-0.5 ${getColorClass(core.color)}`}
title={core.color}
/>
{/* Color name + optional notes */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900">{core.color}</p>
{core.notes && (
<div className="mt-1 text-slate-500">
<MarkdownContent content={core.notes} size="compact" />
</div>
)}
</div>
{/* Delete button */}
<DeleteCoreButton id={core.id} cableId={cableId} />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useTransition } from "react";
import { deleteCore } from "@/actions/coreActions";
interface DeleteCoreButtonProps {
id: number;
cableId: number;
}
export default function DeleteCoreButton({ id, cableId }: DeleteCoreButtonProps) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (!confirm("Diese Ader wirklich löschen?")) return;
startTransition(async () => {
await deleteCore(id, cableId);
});
};
return (
<button
onClick={handleDelete}
disabled={isPending}
aria-label="Ader löschen"
className="
text-slate-300 hover:text-red-500
disabled:text-slate-200 disabled:cursor-not-allowed
transition-colors duration-150 flex-shrink-0 p-0.5
"
>
{isPending ? (
<span className="text-xs"></span>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</button>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { useActionState, useEffect, useRef } from "react";
import { useFormStatus } from "react-dom";
import { createCore } from "@/actions/coreActions";
import type { FormState } from "@/actions/coreActions";
const initialState: FormState = {};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="
inline-flex items-center gap-2
px-4 py-2 bg-blue-600 hover:bg-blue-700
disabled:bg-blue-400 disabled:cursor-not-allowed
text-white text-sm font-medium rounded-lg
transition-colors duration-150
"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{pending ? "Wird gespeichert…" : "Ader hinzufügen"}
</button>
);
}
interface NewCoreFormProps {
cableId: number;
}
export default function NewCoreForm({ cableId }: NewCoreFormProps) {
const [state, formAction] = useActionState(createCore, initialState);
const formRef = useRef<HTMLFormElement>(null);
// Auto-reset the form fields on success so the user can add the next core quickly
useEffect(() => {
if (state.success) {
formRef.current?.reset();
// Re-focus the color input for rapid data entry
formRef.current?.querySelector<HTMLInputElement>('input[name="color"]')?.focus();
}
}, [state.success]);
return (
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-3">
Ader hinzufügen
</h3>
<form ref={formRef} action={formAction} className="space-y-3">
{/* Hidden cable ID */}
<input type="hidden" name="cableId" value={cableId} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Color field */}
<div>
<label
htmlFor="core-color"
className="block text-xs font-medium text-slate-600 mb-1"
>
Farbe <span className="text-red-500">*</span>
</label>
<input
id="core-color"
name="color"
type="text"
placeholder="z. B. Blau, Rot/Weiß, Braun"
required
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400
"
/>
</div>
{/* Notes field */}
<div>
<label
htmlFor="core-notes"
className="block text-xs font-medium text-slate-600 mb-1"
>
Notizen{" "}
<span className="text-slate-400 font-normal">(optional)</span>
</label>
<input
id="core-notes"
name="notes"
type="text"
placeholder="z. B. Phase L1, Neutral, Erdung"
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400
"
/>
</div>
</div>
{/* Error feedback */}
{state.error && (
<p className="text-xs text-red-600 bg-red-50 border border-red-100 px-3 py-2 rounded-lg">
{state.error}
</p>
)}
<SubmitButton />
</form>
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useTransition } from "react";
import { deleteLocation } from "@/actions/locationActions";
interface DeleteLocationButtonProps {
id: number;
/** Number of cables referencing this location — disables deletion if > 0 */
cableCount: number;
}
export default function DeleteLocationButton({
id,
cableCount,
}: DeleteLocationButtonProps) {
const [isPending, startTransition] = useTransition();
const inUse = cableCount > 0;
const handleDelete = () => {
if (inUse) return; // Safety guard (UI also disables the button)
if (!confirm("Diesen Ort wirklich löschen?")) return;
startTransition(async () => {
await deleteLocation(id);
});
};
return (
<button
onClick={handleDelete}
disabled={isPending || inUse}
title={
inUse
? `Nicht löschbar — wird von ${cableCount} Kabel(n) verwendet`
: "Ort löschen"
}
className="
text-xs font-medium transition-colors duration-150
text-red-500 hover:text-red-700
disabled:text-slate-300 disabled:cursor-not-allowed
"
>
{isPending ? "…" : "Löschen"}
</button>
);
}

View File

@@ -0,0 +1,89 @@
import type { LocationWithCounts } from "@/lib/types";
import DeleteLocationButton from "./DeleteLocationButton";
interface LocationsTableProps {
locations: LocationWithCounts[];
}
export default function LocationsTable({ locations }: LocationsTableProps) {
if (locations.length === 0) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center shadow-sm">
<p className="text-3xl mb-3">📍</p>
<p className="text-slate-500 font-medium">Noch keine Orte angelegt.</p>
<p className="text-slate-400 text-sm mt-1">
Legen Sie links Ihren ersten Ort an.
</p>
</div>
);
}
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Table header */}
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<h2 className="text-base font-semibold text-slate-900">
Alle Orte
</h2>
<span className="text-xs font-medium bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">
{locations.length}
</span>
</div>
{/* Responsive table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-100">
<tr>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
Name
</th>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide hidden md:table-cell">
Beschreibung
</th>
<th className="text-left px-6 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">
Kabel
</th>
<th className="px-6 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{locations.map((location) => {
const totalCables =
location._count.startCables + location._count.endCables;
return (
<tr key={location.id} className="hover:bg-slate-50/60">
<td className="px-6 py-4">
<span className="font-medium text-slate-900">
{location.name}
</span>
</td>
<td className="px-6 py-4 text-slate-500 hidden md:table-cell">
{location.description ? (
<span className="truncate block max-w-xs">
{location.description}
</span>
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-6 py-4">
<span className="text-slate-500 tabular-nums">
{totalCables}
</span>
</td>
<td className="px-6 py-4 text-right">
<DeleteLocationButton
id={location.id}
cableCount={totalCables}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useActionState, useEffect, useRef } from "react";
import { useFormStatus } from "react-dom";
import { createLocation } from "@/actions/locationActions";
import type { FormState } from "@/actions/locationActions";
const initialState: FormState = {};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="
w-full py-2 px-4 bg-blue-600 hover:bg-blue-700
disabled:bg-blue-400 disabled:cursor-not-allowed
text-white text-sm font-medium rounded-lg
transition-colors duration-150
"
>
{pending ? "Wird gespeichert…" : "Ort anlegen"}
</button>
);
}
export default function NewLocationForm() {
const [state, formAction] = useActionState(createLocation, initialState);
const formRef = useRef<HTMLFormElement>(null);
// Reset the form after a successful submission
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state.success]);
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
<h2 className="text-base font-semibold text-slate-900 mb-4">
Neuen Ort anlegen
</h2>
<form ref={formRef} action={formAction} className="space-y-4">
{/* Name field */}
<div>
<label
htmlFor="loc-name"
className="block text-sm font-medium text-slate-700 mb-1"
>
Name <span className="text-red-500">*</span>
</label>
<input
id="loc-name"
name="name"
type="text"
placeholder="z. B. Serverraum, Wohnzimmer"
required
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400
"
/>
</div>
{/* Description field */}
<div>
<label
htmlFor="loc-description"
className="block text-sm font-medium text-slate-700 mb-1"
>
Beschreibung{" "}
<span className="text-slate-400 font-normal">(optional, Markdown)</span>
</label>
<textarea
id="loc-description"
name="description"
rows={3}
placeholder="Kurze Beschreibung des Ortes…"
className="
w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
placeholder:text-slate-400 resize-y
"
/>
</div>
{/* Error / success feedback */}
{state.error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-100 px-3 py-2 rounded-lg">
{state.error}
</p>
)}
{state.success && (
<p className="text-sm text-green-700 bg-green-50 border border-green-100 px-3 py-2 rounded-lg">
Ort erfolgreich angelegt.
</p>
)}
<SubmitButton />
</form>
</div>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import ReactMarkdown from "react-markdown";
interface MarkdownContentProps {
content: string;
/** Use 'compact' for small spaces like core notes, 'full' for large areas */
size?: "compact" | "full";
}
export default function MarkdownContent({
content,
size = "full",
}: MarkdownContentProps) {
return (
<div
className={`
prose max-w-none text-slate-700
${size === "compact" ? "prose-xs" : "prose-sm"}
prose-headings:text-slate-900
prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline
prose-code:bg-slate-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded
prose-code:text-slate-800 prose-code:font-mono prose-code:text-xs
prose-pre:bg-slate-100 prose-pre:border prose-pre:border-slate-200
`}
>
<ReactMarkdown>{content}</ReactMarkdown>
</div>
);
}