Add non-destructive dev seed on startup
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { TerminType } from "@prisma/client";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { createTerminAdmin } from "../actions";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function AdminTerminModal() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleAction = (formData: FormData) => {
|
||||
const data = {
|
||||
title: formData.get("title") as string,
|
||||
description: formData.get("description") as string,
|
||||
type: formData.get("type") as TerminType,
|
||||
startDate: new Date(formData.get("startDate") as string).toISOString(),
|
||||
endDate: new Date(formData.get("endDate") as string).toISOString(),
|
||||
allDay: formData.get("allDay") === "on",
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await createTerminAdmin(data);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("Termin wurde direkt angelegt!");
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2 bg-primary">
|
||||
<Plus className="h-4 w-4" />
|
||||
Termin direkt anlegen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form action={handleAction}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin anlegen (Admin)</DialogTitle>
|
||||
<DialogDescription>
|
||||
Dieser Termin wird sofort freigegeben und ist für alle sichtbar.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Titel</Label>
|
||||
<Input id="title" name="title" required placeholder="z.B. Sommerfest" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Beschreibung (Optional)</Label>
|
||||
<Textarea id="description" name="description" placeholder="Details zum Termin..." />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">Kategorie</Label>
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
required
|
||||
>
|
||||
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
|
||||
<option value={TerminType.SCHLIESSTAG}>Schließtag</option>
|
||||
<option value={TerminType.TEAMTAG}>Teamtag (Kita geschlossen)</option>
|
||||
<option value={TerminType.MITMACH_TAG}>Mitmach-Tag</option>
|
||||
<option value={TerminType.ELTERNABEND}>Elternabend</option>
|
||||
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>Mitgliederversammlung</option>
|
||||
<option value={TerminType.ELTERNCAFE}>Elterncafe</option>
|
||||
<option value={TerminType.GEBURTSTAG_INTERN}>Geburtstag (Intern)</option>
|
||||
<option value={TerminType.GEBURTSTAG_EXTERN}>Raumanfrage (Extern)</option>
|
||||
<option value={TerminType.SONSTIGES}>Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="startDate">Start</Label>
|
||||
<Input id="startDate" name="startDate" type="datetime-local" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">Ende</Label>
|
||||
<Input id="endDate" name="endDate" type="datetime-local" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" />
|
||||
<Label htmlFor="allDay" className="font-normal cursor-pointer">
|
||||
Ganztägiger Termin
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Speichern..." : "Termin erstellen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { MitbringselItem } from "@prisma/client";
|
||||
import { Trash2, Plus, Utensils } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { addMitbringsel, deleteMitbringsel } from "../actions";
|
||||
|
||||
type ItemWithUser = MitbringselItem & {
|
||||
user: { firstName: string; lastName: string };
|
||||
};
|
||||
|
||||
export function MitbringselList({
|
||||
terminId,
|
||||
items,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
}: {
|
||||
terminId: string;
|
||||
items: ItemWithUser[];
|
||||
currentUserId: string;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const [content, setContent] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await addMitbringsel({ terminId, content });
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("Eintrag hinzugefügt.");
|
||||
setContent("");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
startTransition(async () => {
|
||||
const res = await deleteMitbringsel(id);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("Eintrag gelöscht.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-md bg-muted/50 p-3">
|
||||
<div className="flex items-center gap-2 font-medium text-sm mb-1">
|
||||
<Utensils className="h-4 w-4 text-muted-foreground" />
|
||||
Mitbring-Liste
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">Noch keine Einträge vorhanden.</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-2 rounded-sm bg-background p-2 text-sm shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-xs text-primary">
|
||||
{item.user.firstName} {item.user.lastName}
|
||||
</span>
|
||||
<span>{item.content}</span>
|
||||
</div>
|
||||
{(isAdmin || item.userId === currentUserId) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
placeholder="Was bringst du mit?"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
}}
|
||||
disabled={isPending}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={handleAdd}
|
||||
disabled={!content.trim() || isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Check, X, Clock, CalendarIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Termin, User } from "@prisma/client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { approveTermin, rejectTermin } from "../actions";
|
||||
|
||||
type PendingTermin = Termin & {
|
||||
createdBy: { firstName: string; lastName: string } | null;
|
||||
};
|
||||
|
||||
export function PendingAnfragen({ termine }: { termine: PendingTermin[] }) {
|
||||
if (termine.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
|
||||
<CalendarIcon className="h-10 w-10 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold">Keine ausstehenden Anfragen</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Es gibt aktuell keine Terminanfragen, die bestätigt werden müssen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{termine.map((termin) => (
|
||||
<PendingTerminCard key={termin.id} termin={termin} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingTerminCard({ termin }: { termin: PendingTermin }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleApprove = () => {
|
||||
startTransition(async () => {
|
||||
const res = await approveTermin(termin.id);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("Anfrage freigegeben.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
// In a real app, you might want to ask for a rejection reason in a prompt/modal.
|
||||
startTransition(async () => {
|
||||
const res = await rejectTermin(termin.id);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("Anfrage abgelehnt.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold">{termin.title}</div>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{format(termin.startDate, "PP", { locale: de })}
|
||||
{!termin.allDay && ` • ${format(termin.startDate, "p", { locale: de })}`}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-primary mt-1">
|
||||
Angefragt von: {termin.createdBy?.firstName} {termin.createdBy?.lastName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground border-destructive/20"
|
||||
onClick={handleReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
<X className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Ablehnen</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={handleApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Freigeben</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { Termin, MitbringselItem, TerminType, TerminStatus } from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Calendar, Clock, MapPin, AlignLeft } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MitbringselList } from "./mitbringsel-list";
|
||||
import { toggleMitbringselList } from "../actions";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type TerminWithItems = Termin & {
|
||||
mitbringselItems?: (MitbringselItem & {
|
||||
user: { firstName: string; lastName: string };
|
||||
})[];
|
||||
};
|
||||
|
||||
export function TerminList({
|
||||
termine,
|
||||
userId,
|
||||
isAdmin,
|
||||
}: {
|
||||
termine: TerminWithItems[];
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
if (termine.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<Calendar className="h-10 w-10 text-muted-foreground mb-4" />
|
||||
<h3 className="mt-4 text-lg font-semibold">Keine Termine</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
Es stehen aktuell keine Termine an.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{termine.map((termin) => (
|
||||
<TerminCard
|
||||
key={termin.id}
|
||||
termin={termin}
|
||||
userId={userId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminCard({
|
||||
termin,
|
||||
userId,
|
||||
isAdmin,
|
||||
}: {
|
||||
termin: TerminWithItems;
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleToggleMitbringsel = (enabled: boolean) => {
|
||||
startTransition(async () => {
|
||||
const result = await toggleMitbringselList(termin.id, enabled);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success(
|
||||
enabled
|
||||
? "Mitbring-Liste aktiviert"
|
||||
: "Mitbring-Liste deaktiviert"
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden relative">
|
||||
{termin.status === TerminStatus.PENDING && (
|
||||
<div className="absolute top-0 right-0 bg-yellow-500 text-white text-xs font-bold px-2 py-1 rounded-bl-lg z-10">
|
||||
Ausstehend
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start mb-2 gap-2">
|
||||
<Badge variant={getBadgeVariant(termin.type)} className={getBadgeColor(termin.type)}>
|
||||
{getTypeLabel(termin.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="line-clamp-2 leading-tight">{termin.title}</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1 mt-1 text-sm">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{format(termin.startDate, "PP", { locale: de })}
|
||||
{!termin.allDay && (
|
||||
<>
|
||||
{" • "}
|
||||
{format(termin.startDate, "p", { locale: de })} -{" "}
|
||||
{format(termin.endDate, "p", { locale: de })}
|
||||
</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 pb-4">
|
||||
{termin.description && (
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground mt-2">
|
||||
<AlignLeft className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<p className="line-clamp-3 whitespace-pre-wrap">{termin.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Toggle for Mitbringsel-List */}
|
||||
{isAdmin && termin.status === TerminStatus.CONFIRMED && (
|
||||
<div className="flex items-center space-x-2 mt-6 pt-4 border-t">
|
||||
<Switch
|
||||
id={`mitbringsel-${termin.id}`}
|
||||
checked={termin.mitbringselListEnabled}
|
||||
onCheckedChange={handleToggleMitbringsel}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Label htmlFor={`mitbringsel-${termin.id}`} className="text-xs">
|
||||
Mitbring-Liste aktiv
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mitbringsel-List UI */}
|
||||
{termin.mitbringselListEnabled && termin.status === TerminStatus.CONFIRMED && (
|
||||
<div className="mt-4">
|
||||
<MitbringselList
|
||||
terminId={termin.id}
|
||||
items={termin.mitbringselItems || []}
|
||||
currentUserId={userId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getBadgeVariant(type: TerminType): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (type) {
|
||||
case TerminType.KITA_FEST:
|
||||
case TerminType.MITMACH_TAG:
|
||||
return "default";
|
||||
case TerminType.SCHLIESSTAG:
|
||||
case TerminType.TEAMTAG:
|
||||
return "destructive";
|
||||
case TerminType.GEBURTSTAG_INTERN:
|
||||
case TerminType.GEBURTSTAG_EXTERN:
|
||||
case TerminType.ELTERNABEND:
|
||||
case TerminType.MITGLIEDERVERSAMMLUNG:
|
||||
case TerminType.ELTERNCAFE:
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeColor(type: TerminType): string {
|
||||
switch (type) {
|
||||
case TerminType.KITA_FEST:
|
||||
case TerminType.MITMACH_TAG:
|
||||
return "bg-amber-500 hover:bg-amber-600 text-white border-transparent";
|
||||
case TerminType.SCHLIESSTAG:
|
||||
case TerminType.TEAMTAG:
|
||||
return "bg-rose-500 hover:bg-rose-600 text-white border-transparent";
|
||||
case TerminType.GEBURTSTAG_INTERN:
|
||||
case TerminType.GEBURTSTAG_EXTERN:
|
||||
return "bg-blue-500 hover:bg-blue-600 text-white border-transparent";
|
||||
case TerminType.ELTERNABEND:
|
||||
case TerminType.MITGLIEDERVERSAMMLUNG:
|
||||
case TerminType.ELTERNCAFE:
|
||||
return "bg-purple-500 hover:bg-purple-600 text-white border-transparent";
|
||||
default:
|
||||
return "bg-slate-200 text-slate-800 border-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-700";
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type: TerminType): string {
|
||||
switch (type) {
|
||||
case TerminType.KITA_FEST:
|
||||
return "Kita-Fest";
|
||||
case TerminType.MITMACH_TAG:
|
||||
return "Mitmach-Tag";
|
||||
case TerminType.SCHLIESSTAG:
|
||||
return "Schließtag";
|
||||
case TerminType.TEAMTAG:
|
||||
return "Teamtag (Kita geschlossen)";
|
||||
case TerminType.ELTERNABEND:
|
||||
return "Elternabend";
|
||||
case TerminType.MITGLIEDERVERSAMMLUNG:
|
||||
return "Mitgliederversammlung";
|
||||
case TerminType.ELTERNCAFE:
|
||||
return "Elterncafe";
|
||||
case TerminType.GEBURTSTAG_INTERN:
|
||||
return "Geburtstag (Intern)";
|
||||
case TerminType.GEBURTSTAG_EXTERN:
|
||||
return "Raumanfrage (Extern)";
|
||||
default:
|
||||
return "Sonstiges";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { TerminType } from "@prisma/client";
|
||||
import { CalendarPlus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { createTerminRequest } from "../actions";
|
||||
|
||||
export function TerminRequestModal() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleAction = (formData: FormData) => {
|
||||
const data = {
|
||||
title: formData.get("title") as string,
|
||||
type: formData.get("type") as TerminType,
|
||||
startDate: new Date(formData.get("startDate") as string).toISOString(),
|
||||
endDate: new Date(formData.get("endDate") as string).toISOString(),
|
||||
allDay: formData.get("allDay") === "on",
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await createTerminRequest(data);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("Terminanfrage wurde erfolgreich gestellt!");
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
Termin anfragen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form action={handleAction}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin anfragen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Frage einen privaten Termin (z.B. Kindergeburtstag) in den Räumlichkeiten der Kita an.
|
||||
Die Anfrage muss von einem Admin bestätigt werden.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Titel</Label>
|
||||
<Input id="title" name="title" required placeholder="z.B. Geburtstag von Mia" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">Kategorie</Label>
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
required
|
||||
>
|
||||
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
|
||||
<option value={TerminType.SCHLIESSTAG}>Schließtag</option>
|
||||
<option value={TerminType.TEAMTAG}>Teamtag (Kita geschlossen)</option>
|
||||
<option value={TerminType.MITMACH_TAG}>Mitmach-Tag</option>
|
||||
<option value={TerminType.ELTERNABEND}>Elternabend</option>
|
||||
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>Mitgliederversammlung</option>
|
||||
<option value={TerminType.ELTERNCAFE}>Elterncafe</option>
|
||||
<option value={TerminType.GEBURTSTAG_INTERN}>Geburtstag (Intern)</option>
|
||||
<option value={TerminType.GEBURTSTAG_EXTERN}>Raumanfrage (Extern)</option>
|
||||
<option value={TerminType.SONSTIGES}>Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="startDate">Start</Label>
|
||||
<Input id="startDate" name="startDate" type="datetime-local" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">Ende</Label>
|
||||
<Input id="endDate" name="endDate" type="datetime-local" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" />
|
||||
<Label htmlFor="allDay" className="font-normal cursor-pointer">
|
||||
Ganztägiger Termin
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Wird gesendet..." : "Anfrage stellen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// =====================================================================
|
||||
// /dashboard/kalender · Server Actions
|
||||
// =====================================================================
|
||||
|
||||
const terminSchema = z.object({
|
||||
title: z.string().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
|
||||
description: z.string().optional(),
|
||||
type: z.nativeEnum(TerminType),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
allDay: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export async function createTerminRequest(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
const parsed = terminSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
try {
|
||||
await prisma.termin.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
createdById: session.user.id,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
startDate: new Date(data.startDate),
|
||||
endDate: new Date(data.endDate),
|
||||
allDay: data.allDay,
|
||||
status: TerminStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Erstellen der Terminanfrage:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTerminAdmin(rawPayload: unknown) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
|
||||
const parsed = terminSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
try {
|
||||
await prisma.termin.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
createdById: session.user.id,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
startDate: new Date(data.startDate),
|
||||
endDate: new Date(data.endDate),
|
||||
allDay: data.allDay,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
approvedById: session.user.id,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Erstellen des Termins:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function approveTermin(terminId: string) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
|
||||
try {
|
||||
await prisma.termin.update({
|
||||
where: {
|
||||
id: terminId,
|
||||
kitaId: session.user.kitaId,
|
||||
},
|
||||
data: {
|
||||
status: TerminStatus.CONFIRMED,
|
||||
approvedById: session.user.id,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Bestätigen des Termins:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectTermin(terminId: string, reason?: string) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
|
||||
try {
|
||||
await prisma.termin.update({
|
||||
where: {
|
||||
id: terminId,
|
||||
kitaId: session.user.kitaId,
|
||||
},
|
||||
data: {
|
||||
status: TerminStatus.REJECTED,
|
||||
rejectionReason: reason,
|
||||
approvedById: session.user.id,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Ablehnen des Termins:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
|
||||
try {
|
||||
await prisma.termin.update({
|
||||
where: {
|
||||
id: terminId,
|
||||
kitaId: session.user.kitaId,
|
||||
},
|
||||
data: {
|
||||
mitbringselListEnabled: enabled,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten." };
|
||||
}
|
||||
}
|
||||
|
||||
const mitbringselSchema = z.object({
|
||||
terminId: z.string(),
|
||||
content: z.string().min(1, "Eintrag darf nicht leer sein"),
|
||||
});
|
||||
|
||||
export async function addMitbringsel(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
const parsed = mitbringselSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
const { terminId, content } = parsed.data;
|
||||
|
||||
try {
|
||||
// Verify termin is active and list is enabled
|
||||
const termin = await prisma.termin.findUnique({
|
||||
where: { id: terminId, kitaId: session.user.kitaId },
|
||||
});
|
||||
|
||||
if (!termin || termin.status !== TerminStatus.CONFIRMED || !termin.mitbringselListEnabled) {
|
||||
return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." };
|
||||
}
|
||||
|
||||
await prisma.mitbringselItem.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
terminId: terminId,
|
||||
userId: session.user.id,
|
||||
content: content,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Hinzufügen des Eintrags:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMitbringsel(itemId: string) {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
try {
|
||||
const item = await prisma.mitbringselItem.findUnique({
|
||||
where: { id: itemId, kitaId: session.user.kitaId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return { error: "Eintrag nicht gefunden." };
|
||||
}
|
||||
|
||||
// Admins/Koords can delete any item. Users can only delete their own.
|
||||
if (
|
||||
session.user.role !== UserRole.ADMIN &&
|
||||
session.user.role !== UserRole.KOORDINATOR &&
|
||||
item.userId !== session.user.id
|
||||
) {
|
||||
return { error: "Keine Berechtigung diesen Eintrag zu löschen." };
|
||||
}
|
||||
|
||||
await prisma.mitbringselItem.delete({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Löschen des Eintrags:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { UserRole, TerminStatus } from "@prisma/client";
|
||||
import { TerminList } from "./_components/termin-list";
|
||||
import { PendingAnfragen } from "./_components/pending-anfragen";
|
||||
import { TerminRequestModal } from "./_components/termin-request-modal";
|
||||
import { AdminTerminModal } from "./_components/admin-termin-modal";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
|
||||
export default async function KalenderPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { tab?: string };
|
||||
}) {
|
||||
const session = await requireKitaSession();
|
||||
const isAdmin =
|
||||
session.user.role === UserRole.ADMIN || session.user.role === UserRole.KOORDINATOR;
|
||||
|
||||
const currentTab = searchParams.tab || "übersicht";
|
||||
|
||||
// Fetch confirmed events
|
||||
const confirmedTermine = await prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
},
|
||||
include: {
|
||||
mitbringselItems: {
|
||||
include: {
|
||||
user: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
|
||||
// Fetch user's own pending events
|
||||
const myPendingTermine = await prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: TerminStatus.PENDING,
|
||||
createdById: session.user.id,
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
|
||||
// Combine for general view
|
||||
const allUserTermine = [...confirmedTermine, ...myPendingTermine].sort(
|
||||
(a, b) => a.startDate.getTime() - b.startDate.getTime()
|
||||
);
|
||||
|
||||
// If admin, fetch all pending events
|
||||
let allPendingTermine: any[] = [];
|
||||
if (isAdmin) {
|
||||
allPendingTermine = await prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: TerminStatus.PENDING,
|
||||
},
|
||||
include: {
|
||||
createdBy: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Terminkalender</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Alle anstehenden Termine, Feste und Schließtage im Überblick.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<TerminRequestModal />
|
||||
{isAdmin && <AdminTerminModal />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex gap-4 border-b">
|
||||
<a
|
||||
href="/dashboard/kalender?tab=übersicht"
|
||||
className={`pb-2 text-sm font-medium transition-colors hover:text-primary ${
|
||||
currentTab === "übersicht"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Übersicht
|
||||
</a>
|
||||
<a
|
||||
href="/dashboard/kalender?tab=anfragen"
|
||||
className={`pb-2 text-sm font-medium transition-colors hover:text-primary flex items-center gap-2 ${
|
||||
currentTab === "anfragen"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Ausstehende Anfragen
|
||||
{allPendingTermine.length > 0 && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
|
||||
{allPendingTermine.length}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
{currentTab === "übersicht" ? (
|
||||
<TerminList
|
||||
termine={allUserTermine}
|
||||
userId={session.user.id}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
) : (
|
||||
<PendingAnfragen termine={allPendingTermine} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TerminList
|
||||
termine={allUserTermine}
|
||||
userId={session.user.id}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user