first working release
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user