first working release
This commit is contained in:
188
src/components/Navigation.tsx
Normal file
188
src/components/Navigation.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
172
src/components/cables/CablesTable.tsx
Normal file
172
src/components/cables/CablesTable.tsx
Normal 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}" 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>
|
||||
);
|
||||
}
|
||||
55
src/components/cables/DeleteCableButton.tsx
Normal file
55
src/components/cables/DeleteCableButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
src/components/cables/NewCableForm.tsx
Normal file
228
src/components/cables/NewCableForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/components/cores/CoresList.tsx
Normal file
77
src/components/cores/CoresList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/cores/DeleteCoreButton.tsx
Normal file
43
src/components/cores/DeleteCoreButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/components/cores/NewCoreForm.tsx
Normal file
115
src/components/cores/NewCoreForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/locations/DeleteLocationButton.tsx
Normal file
46
src/components/locations/DeleteLocationButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/locations/LocationsTabe.tsx
Normal file
89
src/components/locations/LocationsTabe.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/components/locations/NewLocationForm.tsx
Normal file
106
src/components/locations/NewLocationForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/MarkdownContent.tsx
Normal file
30
src/components/ui/MarkdownContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user