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