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
@@ -0,0 +1,132 @@
"use client";
import { useState, useTransition, type FormEvent } from "react";
import { SlidersHorizontal } 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 { updateKitaNotdienstSettingsAction } from "../actions";
interface SettingsModalProps {
initialMinPerChild: number;
initialReminderDays: number;
}
export function SettingsModal({
initialMinPerChild,
initialReminderDays,
}: SettingsModalProps) {
const [open, setOpen] = useState(false);
const [minPerChild, setMinPerChild] = useState(initialMinPerChild);
const [reminderDays, setReminderDays] = useState(initialReminderDays);
const [isPending, startTransition] = useTransition();
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (minPerChild < 0 || reminderDays < 0) {
toast.error("Werte dürfen nicht negativ sein.");
return;
}
startTransition(async () => {
const res = await updateKitaNotdienstSettingsAction(
minPerChild,
reminderDays
);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Regeln erfolgreich gespeichert!");
setOpen(false);
}
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Einstellungen
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Notdienst-Einstellungen</DialogTitle>
<DialogDescription>
Passe die standardmäßigen Regeln für die Notdienst-Verteilung in deiner Kita an.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="minPerChild">
Mindesteinteilungen pro Kind/Monat
</Label>
<Input
id="minPerChild"
name="minPerChild"
type="number"
min="0"
required
value={minPerChild}
onChange={(e) => setMinPerChild(parseInt(e.target.value, 10))}
/>
<p className="text-xs text-[var(--ink-muted)]">
Bestimmt das Richtmaß der automatischen Verteilung für Kinder im jeweiligen Monat.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="reminderDays">
Erinnerungsfrist (Tage vor Monatsende)
</Label>
<Input
id="reminderDays"
name="reminderDays"
type="number"
min="0"
required
value={reminderDays}
onChange={(e) => setReminderDays(parseInt(e.target.value, 10))}
/>
<p className="text-xs text-[var(--ink-muted)]">
Frist, nach der Eltern automatisch per Mail an offene Verfügbarkeiten erinnert werden.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => {
setMinPerChild(initialMinPerChild);
setReminderDays(initialReminderDays);
setOpen(false);
}}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Regeln speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+187 -2
View File
@@ -1,21 +1,35 @@
"use server";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
// =====================================================================
export async function generatePlanAction() {
export async function generatePlanAction(year?: number, month?: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const { targetYear, targetMonth } = getTargetMonthData();
let targetYear: number;
let targetMonth: number;
if (year !== undefined && month !== undefined) {
targetYear = year;
targetMonth = month;
} else {
const data = getTargetMonthData();
targetYear = data.targetYear;
targetMonth = data.targetMonth;
}
// Prüfen, ob schon ein Plan existiert
const existingPlan = await prisma.notdienstPlan.findUnique({
@@ -193,3 +207,174 @@ export async function updateAssignmentAction(
};
}
}
export async function updateKitaNotdienstSettingsAction(
minPerChild: number,
reminderDays: number,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.kita.update({
where: { id: kitaId },
data: {
notdienstMinPerChildPerMonth: minPerChild,
notdienstReminderDaysBefore: reminderDays,
},
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler beim Speichern der Einstellungen." };
}
}
export async function sendNotdienstRemindersAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
try {
// 1. Get Kita Name
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { error: "Kita nicht gefunden." };
}
// 2. Fetch all families in this Kita with at least one active child and retrieve parents
const families = await prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
include: {
users: {
where: { role: UserRole.ELTERN },
},
},
});
// 3. For target month, find availability entries
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 1);
const respondedFamilies = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
child: {
select: {
familyId: true,
},
},
},
});
const respondedFamilyIds = new Set(respondedFamilies.map((a) => a.child.familyId));
// 4. Find open families (not responded yet)
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
if (openFamilies.length === 0) {
return { success: true, count: 0 };
}
// 5. Build emails list and send
const monthDate = new Date(year, month - 1, 1);
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
let sentCount = 0;
for (const family of openFamilies) {
for (const parent of family.users) {
if (!parent.email) continue;
const result = await sendAppEmail({
to: parent.email,
subject: `Erinnerung: Bitte Verfügbarkeiten für den Notdienst ${monthLabel} eintragen`,
react: createElement(NotdienstAvailabilityReminderEmail, {
parentName: `${parent.firstName} ${parent.lastName}`,
kitaName: kita.name,
monthLabel,
link,
}),
});
if (result.success) {
sentCount++;
} else {
console.error(`E-Mail konnte nicht an ${parent.email} gesendet werden:`, result.error);
}
}
}
return { success: true, count: sentCount };
} catch (error) {
console.error("Fehler beim Versenden der Erinnerungen:", error);
return { error: "Fehler beim Versenden der Erinnerungen." };
}
}
export async function resetPlanAction(planId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.$transaction(async (tx) => {
// Ensure the plan belongs to this kita and is draft
const plan = await tx.notdienstPlan.findFirst({
where: { id: planId, kitaId },
});
if (!plan) {
throw new Error("Plan nicht gefunden.");
}
if (plan.status !== NotdienstPlanStatus.DRAFT) {
throw new Error("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
}
// Delete all assignments
await tx.notdienstAssignment.deleteMany({
where: { planId },
});
// Delete the plan itself
await tx.notdienstPlan.delete({
where: { id: planId },
});
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error("Fehler beim Zurücksetzen des Plans:", error);
return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." };
}
}
+45 -16
View File
@@ -1,25 +1,39 @@
import Link from "next/link";
import { addMonths, getYear, getMonth, format } from "date-fns";
import { de } from "date-fns/locale";
import { TerminType, UserRole } from "@prisma/client";
import { ChevronLeft, ChevronRight, SlidersHorizontal } from "lucide-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
import { PlanView } from "./plan-view";
import { SettingsModal } from "./_components/settings-modal";
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
export default async function PlanungsZentralePage() {
export default async function PlanungsZentralePage({
searchParams,
}: {
searchParams: Promise<{ year?: string; month?: string }>;
}) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const { targetYear, targetMonth, monthName } = getTargetMonthData();
const resolvedSearchParams = await searchParams;
const now = new Date();
const nextMonth = addMonths(now, 1);
const targetYear = resolvedSearchParams.year ? parseInt(resolvedSearchParams.year, 10) : getYear(nextMonth);
const targetMonth = resolvedSearchParams.month ? parseInt(resolvedSearchParams.month, 10) : (getMonth(nextMonth) + 1);
const monthDate = new Date(targetYear, targetMonth - 1, 1);
const monthName = format(monthDate, "MMMM", { locale: de });
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
const monthStart = new Date(targetYear, targetMonth - 1, 1);
const monthEnd = new Date(targetYear, targetMonth, 1);
const [plan, families, availabilities, closureCount] = await Promise.all([
const [plan, families, availabilities, closureCount, kita] = await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
@@ -79,6 +93,13 @@ export default async function PlanungsZentralePage() {
},
},
}),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
notdienstMinPerChildPerMonth: true,
notdienstReminderDaysBefore: true,
},
}),
]);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
@@ -181,22 +202,28 @@ export default async function PlanungsZentralePage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="print:hidden flex shrink-0 items-center gap-2">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-none border-r border-[var(--hairline-soft)]">
<Link
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, -1))}&month=${getMonth(addMonths(monthDate, -1)) + 1}`}
className="inline-flex h-9 w-9 items-center justify-center border-r border-[var(--hairline-soft)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)] transition"
>
<ChevronLeft className="h-4 w-4 [stroke-width:1.7]" />
</Button>
<div className="flex h-9 min-w-32 items-center justify-center px-4 text-[13px] font-semibold text-[var(--ink)]">
</Link>
<div className="flex h-9 min-w-36 items-center justify-center px-4 text-[13px] font-semibold text-[var(--ink)]">
{monthName} {targetYear}
</div>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-none border-l border-[var(--hairline-soft)]">
<Link
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, 1))}&month=${getMonth(addMonths(monthDate, 1)) + 1}`}
className="inline-flex h-9 w-9 items-center justify-center border-l border-[var(--hairline-soft)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)] transition"
>
<ChevronRight className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</Link>
</div>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Einstellungen
</Button>
<SettingsModal
initialMinPerChild={kita.notdienstMinPerChildPerMonth}
initialReminderDays={kita.notdienstReminderDaysBefore}
/>
</div>
</header>
@@ -214,6 +241,8 @@ export default async function PlanungsZentralePage() {
coverageFactor={coverageFactor}
closureCount={closureCount}
monthName={monthName}
year={targetYear}
month={targetMonth}
/>
</div>
);
+90 -8
View File
@@ -18,10 +18,20 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
generatePlanAction,
publishPlanAction,
updateAssignmentAction,
sendNotdienstRemindersAction,
resetPlanAction,
} from "./actions";
interface PlanViewProps {
@@ -54,6 +64,8 @@ interface PlanViewProps {
coverageFactor: number;
closureCount: number;
monthName: string;
year: number;
month: number;
}
export function PlanView({
@@ -70,16 +82,19 @@ export function PlanView({
coverageFactor,
closureCount,
monthName,
year,
month,
}: PlanViewProps) {
const [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState<"preview" | "manual">("preview");
const [resetDialogOpen, setResetDialogOpen] = useState(false);
const responsePercent =
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
const handleGenerate = () => {
startTransition(async () => {
const result = await generatePlanAction();
const result = await generatePlanAction(year, month);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
@@ -100,6 +115,30 @@ export function PlanView({
});
};
const handleSendReminders = () => {
startTransition(async () => {
const result = await sendNotdienstRemindersAction(year, month);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else if (result.success) {
toast.success(`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`);
}
});
};
const handleReset = () => {
if (!planId) return;
startTransition(async () => {
const result = await resetPlanAction(planId);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Plan wurde erfolgreich zurückgesetzt.");
setResetDialogOpen(false);
}
});
};
const handleAssignmentChange = (date: string, newChildId: string) => {
if (!planId || status !== "DRAFT") return;
const valueToSet = newChildId === "empty" ? null : newChildId;
@@ -132,8 +171,12 @@ export function PlanView({
<Button disabled={isPending} onClick={handleGenerate}>
Trotzdem generieren
</Button>
<Button variant="ghost">
<Send className="h-4 w-4 [stroke-width:1.7]" />
<Button variant="ghost" onClick={handleSendReminders} disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
<Send className="h-4 w-4 [stroke-width:1.7]" />
)}
Erinnerungen verschicken
</Button>
</div>
@@ -199,8 +242,16 @@ export function PlanView({
</div>
<div className="border-t border-[var(--hairline-soft)] px-6 py-4">
<Button variant="ghost" disabled={openFamilies === 0}>
<Send className="h-4 w-4 [stroke-width:1.7]" />
<Button
variant="ghost"
onClick={handleSendReminders}
disabled={isPending || openFamilies === 0}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
<Send className="h-4 w-4 [stroke-width:1.7]" />
)}
Erinnerung an offene senden ({openFamilies})
</Button>
</div>
@@ -282,7 +333,7 @@ export function PlanView({
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="print:hidden flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<TabButton
active={activeTab === "preview"}
@@ -309,13 +360,15 @@ export function PlanView({
Plan veröffentlichen
</Button>
)}
<Button variant="ghost">
<Button variant="ghost" onClick={() => window.print()}>
<Download className="h-4 w-4 [stroke-width:1.7]" />
Als PDF exportieren
</Button>
<Button
variant="ghost"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => setResetDialogOpen(true)}
disabled={isPending}
>
<RotateCcw className="h-4 w-4 [stroke-width:1.7]" />
Plan zurücksetzen
@@ -323,7 +376,7 @@ export function PlanView({
</div>
</div>
<Card className="flex items-center justify-between p-4">
<Card className="print:hidden flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<Badge variant={isDraft ? "warning" : "success"}>
{isDraft ? "Entwurf" : "Veröffentlicht"}
@@ -347,6 +400,35 @@ export function PlanView({
onAssignmentChange={handleAssignmentChange}
/>
)}
<Dialog open={resetDialogOpen} onOpenChange={setResetDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Plan zurücksetzen?</DialogTitle>
<DialogDescription>
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für diesen Monat gehen unwiderruflich verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setResetDialogOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
type="button"
variant="destructive"
onClick={handleReset}
disabled={isPending}
>
{isPending ? "Zurücksetzen..." : "Ja, Plan zurücksetzen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}