Implement missing UI button features, filters, search, and view toggles across all modules

This commit is contained in:
t.indorf
2026-05-21 10:44:23 +02:00
parent e7eb43462c
commit 84aae1d72e
18 changed files with 3268 additions and 958 deletions
@@ -1,5 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
@@ -10,7 +11,9 @@ import {
MapPin,
MoreHorizontal,
WashingMachine,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -22,9 +25,12 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog";
import { AdminTerminModal } from "./admin-termin-modal";
import { TerminRequestModal } from "./termin-request-modal";
import { MitbringselList } from "./mitbringsel-list";
import { deleteTerminAdmin } from "../actions";
export type TerminListItemDto = {
kind: "termin";
@@ -62,6 +68,7 @@ export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
export function TerminList({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
@@ -115,9 +122,9 @@ export function TerminList({
<div className="divide-y divide-[var(--hairline-soft)]">
{group.items.map((item) =>
item.kind === "duty" ? (
<DutyRow key={`duty-${item.id}`} duty={item} />
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
) : (
<TerminRow key={`termin-${item.id}`} termin={item} />
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
),
)}
</div>
@@ -128,8 +135,9 @@ export function TerminList({
);
}
function DutyRow({ duty }: { duty: DutyCalendarItemDto }) {
function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
const color = "var(--good)";
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
@@ -147,92 +155,224 @@ function DutyRow({ duty }: { duty: DutyCalendarItemDto }) {
{duty.familyName}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>Elterndienst</CategoryPill>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href="/dashboard/admin/dienste"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
onClick={() => setMenuOpen(false)}
>
Dienst im Planer bearbeiten
</a>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
</div>
);
}
function TerminRow({ termin }: { termin: TerminListItemDto }) {
function TerminRow({
termin,
userId,
isAdmin,
}: {
termin: TerminListItemDto;
userId: string;
isAdmin: boolean;
}) {
const color = getTypeColor(termin.type);
const [menuOpen, setMenuOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, startDeleteTransition] = useTransition();
const handleDeleteConfirm = () => {
startDeleteTransition(async () => {
const res = await deleteTerminAdmin(termin.id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Termin wurde erfolgreich gelöscht.");
setDeleteOpen(false);
}
});
};
return (
<Dialog>
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<DateTile date={termin.startDate} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{termin.title}
</h3>
{termin.status === TerminStatus.PENDING && (
<Badge variant="warning">Ausstehend</Badge>
<>
<Dialog>
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<DateTile date={termin.startDate} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{termin.title}
</h3>
{termin.status === TerminStatus.PENDING && (
<Badge variant="warning">Ausstehend</Badge>
)}
</div>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{formatTerminTime(termin)}
<MapPin className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
Kita
</p>
</div>
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
Details
</Button>
</DialogTrigger>
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-48 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<AdminTerminModal
existingTermin={termin}
triggerLabel="Bearbeiten"
triggerVariant="ghost"
triggerClassName="flex w-full items-center justify-start rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)] font-normal h-auto bg-transparent border-0"
/>
<button
type="button"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--danger)] hover:bg-[var(--danger-soft)] text-left font-normal"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDeleteOpen(true);
}}
>
Löschen
</button>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{formatTerminTime(termin)}
<MapPin className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
Kita
</p>
</div>
<div className="flex items-center gap-2">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
Details
</Button>
</DialogTrigger>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</div>
</div>
<DialogContent>
<DialogHeader>
<DialogTitle>{termin.title}</DialogTitle>
<DialogDescription>
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay &&
` · ${format(termin.startDate, "p", { locale: de })} - ${format(
termin.endDate,
"p",
{ locale: de },
)}`}
</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{termin.title}</DialogTitle>
<DialogDescription>
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay &&
` · ${format(termin.startDate, "p", { locale: de })} - ${format(
termin.endDate,
"p",
{ locale: de },
)}`}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
{termin.description ? (
<p className="whitespace-pre-wrap text-sm leading-6 text-[var(--ink-soft)]">
{termin.description}
</p>
) : (
<p className="text-sm italic text-[var(--ink-faint)]">
Keine Beschreibung hinterlegt.
</p>
)}
{termin.participantInfo && termin.showParticipantInfo && (
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<div className="mb-1 flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4 text-[var(--warn)] [stroke-width:1.7]" />
Wichtiger Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{termin.participantInfo}</p>
<div className="grid gap-4">
<div className="flex">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
</div>
)}
</div>
</DialogContent>
</Dialog>
{termin.description ? (
<p className="whitespace-pre-wrap text-sm leading-6 text-[var(--ink-soft)]">
{termin.description}
</p>
) : (
<p className="text-sm italic text-[var(--ink-faint)]">
Keine Beschreibung hinterlegt.
</p>
)}
{termin.participantInfo && termin.showParticipantInfo && (
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<div className="mb-1 flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4 text-[var(--warn)] [stroke-width:1.7]" />
Wichtiger Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{termin.participantInfo}</p>
</div>
)}
{termin.mitbringselListEnabled && (
<div className="mt-3 border-t border-[var(--hairline-soft)] pt-3">
<MitbringselList
terminId={termin.id}
items={termin.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{termin.title}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteOpen(false)}
disabled={isDeleting}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}