Merge family absence flows while preserving security hardening

This commit is contained in:
t.indorf
2026-10-10 07:08:41 +02:00
30 changed files with 2822 additions and 644 deletions
+128 -88
View File
@@ -1,13 +1,14 @@
"use client";
import Link from "next/link";
import { useMemo, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { AbsenceReason } from "@prisma/client";
import { Baby, CalendarX2, Check, ChevronRight, Loader2 } from "lucide-react";
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
import { toast } from "sonner";
import { reportAbsence } from "@/actions/absences";
import { AbsenceManagementActions } from "@/components/absence-management-actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -40,6 +41,7 @@ type AbsenceListItem = {
note: string | null;
startDate: string;
endDate: string;
updatedAt: string;
};
export function AbsenceCard({
@@ -47,10 +49,11 @@ export function AbsenceCard({
today,
}: {
childOptions: ChildOption[];
absences: AbsenceListItem[];
today: string;
}) {
const [isPending, startTransition] = useTransition();
const [startDate, setStartDate] = useState(today);
const [endDate, setEndDate] = useState(today);
const defaultChildId = childOptions[0]?.id;
const hasChildren = childOptions.length > 0;
@@ -92,92 +95,113 @@ export function AbsenceCard({
<CardContent>
{hasChildren ? (
<form action={handleSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<FieldLabel label="Welches Kind?">
<Select name="childId" defaultValue={defaultChildId} required>
<SelectTrigger>
<SelectValue placeholder="Kind auswählen" />
</SelectTrigger>
<SelectContent>
{childOptions.map((child) => (
<SelectItem key={child.id} value={child.id}>
{child.name}
<form
onReset={() => {
setStartDate(today);
setEndDate(today);
}}
onSubmit={(event) => {
event.preventDefault();
handleSubmit(new FormData(event.currentTarget));
}}
>
<fieldset disabled={isPending}>
<div className="grid gap-4 md:grid-cols-2">
<FieldLabel label="Welches Kind?">
<Select name="childId" defaultValue={defaultChildId} required>
<SelectTrigger>
<SelectValue placeholder="Kind auswählen" />
</SelectTrigger>
<SelectContent>
{childOptions.map((child) => (
<SelectItem key={child.id} value={child.id}>
{child.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldLabel>
<FieldLabel label="Grund">
<Select
name="reason"
defaultValue={AbsenceReason.ILLNESS}
required
>
<SelectTrigger>
<SelectValue placeholder="Grund auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value={AbsenceReason.ILLNESS}>
Krank
</SelectItem>
))}
</SelectContent>
</Select>
</FieldLabel>
<SelectItem value={AbsenceReason.VACATION}>
Urlaub
</SelectItem>
<SelectItem value={AbsenceReason.OTHER}>
Sonstiges
</SelectItem>
</SelectContent>
</Select>
</FieldLabel>
<FieldLabel label="Grund">
<Select
name="reason"
defaultValue={AbsenceReason.ILLNESS}
required
>
<SelectTrigger>
<SelectValue placeholder="Grund auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem>
<SelectItem value={AbsenceReason.VACATION}>
Urlaub
</SelectItem>
<SelectItem value={AbsenceReason.OTHER}>
Sonstiges
</SelectItem>
</SelectContent>
</Select>
</FieldLabel>
<FieldLabel label="Von" htmlFor="absence-startDate">
<Input
id="absence-startDate"
name="startDate"
type="date"
value={startDate}
min={today}
onChange={(event) => {
const date = event.target.value;
setStartDate(date);
if (date > endDate) setEndDate(date);
}}
required
/>
</FieldLabel>
<FieldLabel label="Von" htmlFor="absence-startDate">
<Input
id="absence-startDate"
name="startDate"
type="date"
defaultValue={today}
required
/>
</FieldLabel>
<FieldLabel label="Bis" htmlFor="absence-endDate">
<Input
id="absence-endDate"
name="endDate"
type="date"
value={endDate}
min={startDate}
onChange={(event) => setEndDate(event.target.value)}
required
/>
</FieldLabel>
<FieldLabel label="Bis" htmlFor="absence-endDate">
<Input
id="absence-endDate"
name="endDate"
type="date"
defaultValue={today}
required
/>
</FieldLabel>
<div className="grid gap-2 md:col-span-2">
<Label
htmlFor="absence-note"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Bemerkung{" "}
<span className="font-medium text-[var(--ink-faint)]">
optional
</span>
</Label>
<Textarea
id="absence-note"
name="note"
rows={4}
placeholder="z. B. voraussichtlich wieder da am Freitag."
/>
<div className="grid gap-2 md:col-span-2">
<Label
htmlFor="absence-note"
className="text-xs font-semibold text-[var(--ink-soft)]"
>
Bemerkung{" "}
<span className="font-medium text-[var(--ink-faint)]">
optional
</span>
</Label>
<Textarea
id="absence-note"
name="note"
rows={4}
placeholder="z. B. voraussichtlich wieder da am Freitag."
/>
</div>
</div>
</div>
<div className="mt-7 flex items-center justify-between border-t border-[var(--hairline-soft)] pt-5">
<Button type="reset" variant="ghost">
Zurücksetzen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? <Loader2 className="animate-spin" /> : <Check />}
Abwesenheit melden
</Button>
</div>
<div className="mt-7 flex items-center justify-between border-t border-[var(--hairline-soft)] pt-5">
<Button type="reset" variant="ghost">
Zurücksetzen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? <Loader2 className="animate-spin" /> : <Check />}
Abwesenheit melden
</Button>
</div>
</fieldset>
</form>
) : (
<div className="flex min-h-72 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
@@ -201,8 +225,12 @@ export function AbsenceCard({
export function UpcomingAbsencesCard({
absences,
today,
team = false,
}: {
absences: AbsenceListItem[];
today: string;
team?: boolean;
}) {
return (
<Card>
@@ -233,16 +261,21 @@ export function UpcomingAbsencesCard({
) : (
<div className="divide-y divide-[var(--hairline-soft)]">
{absences.map((absence) => (
<AbsenceRow key={absence.id} absence={absence} />
<AbsenceRow
key={`${absence.id}-${absence.updatedAt}`}
absence={absence}
today={today}
team={team}
/>
))}
</div>
)}
<Link
href="/dashboard/admin/abwesenheiten"
href="/dashboard/abwesenheiten"
className="mt-4 inline-flex items-center gap-1 text-[13px] font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
Alle Abwesenheiten
Meine Abwesenheiten
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</CardContent>
@@ -272,7 +305,15 @@ function FieldLabel({
);
}
function AbsenceRow({ absence }: { absence: AbsenceListItem }) {
function AbsenceRow({
absence,
today,
team,
}: {
absence: AbsenceListItem;
today: string;
team: boolean;
}) {
const { day, month, detail } = useMemo(() => {
const start = new Date(`${absence.startDate}T00:00:00`);
const end = new Date(`${absence.endDate}T00:00:00`);
@@ -327,9 +368,8 @@ function AbsenceRow({ absence }: { absence: AbsenceListItem }) {
{absence.note}
</p>
) : null}
<AbsenceManagementActions absence={absence} today={today} team={team} />
</div>
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
import Link from "next/link";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import {
absenceDate,
absenceToday,
canManageKitaAbsences,
} from "@/lib/absence-policy";
import { reasonLabel } from "@/lib/absence-utils";
import { AbsenceManagementActions } from "@/components/absence-management-actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export const metadata = { title: "Meine Abwesenheiten · Kita-Planer" };
export default async function OwnAbsencesPage({
searchParams,
}: {
searchParams: Promise<{ view?: string; page?: string }>;
}) {
const session = await requireKitaSession();
const actor = await prisma.user.findFirstOrThrow({
where: { id: session.user.id, kitaId: session.user.kitaId },
select: { familyId: true, role: true },
});
const query = await searchParams;
const past = query.view === "past";
const parsedPage = Number(query.page ?? 1);
const requestedPage =
Number.isSafeInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1;
const today = absenceToday();
const where = {
kitaId: session.user.kitaId,
child: { kitaId: session.user.kitaId, familyId: actor.familyId ?? "" },
endDate: past ? { lt: absenceDate(today) } : { gte: absenceDate(today) },
};
const count = actor.familyId ? await prisma.absence.count({ where }) : 0;
const pages = Math.max(1, Math.ceil(count / 20));
const page = Math.min(requestedPage, pages);
const absences = actor.familyId
? await prisma.absence.findMany({
where,
select: {
id: true,
startDate: true,
endDate: true,
reason: true,
note: true,
updatedAt: true,
child: { select: { firstName: true, lastName: true } },
},
orderBy: [{ startDate: past ? "desc" : "asc" }, { id: "asc" }],
take: 20,
skip: (page - 1) * 20,
})
: [];
const href = (nextPage: number) =>
`/dashboard/abwesenheiten?view=${past ? "past" : "current"}&page=${nextPage}`;
const dateLabel = (date: Date) =>
new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeZone: "UTC",
}).format(date);
return (
<div className="mx-auto max-w-[1100px] px-11 py-9 max-[760px]:px-5">
<header className="mb-7">
<p className="text-[11px] font-semibold uppercase tracking-widest text-[var(--accent-deep)]">
MEIN HAUSHALT
</p>
<h1 className="mt-2 text-4xl tracking-tight">
Meine{" "}
<em className="font-normal text-[var(--accent-deep)]">
Abwesenheiten
</em>
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--ink-soft)]">
Zukünftige Meldungen deiner Kinder kannst du ändern oder zurücknehmen.
Sobald eine Meldung begonnen hat, übernimmt die Kita die gesamte
Meldung.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/dashboard">Kind abmelden</Link>
</Button>
{canManageKitaAbsences(actor.role) && (
<Button asChild variant="outline" size="sm">
<Link href="/dashboard/admin/abwesenheiten">
Kita-Tagesübersicht
</Link>
</Button>
)}
</div>
</header>
{!actor.familyId ? (
<Card>
<CardContent className="py-6">
<p className="mb-3">
Deinem Konto ist noch kein Haushalt zugeordnet.
</p>
<Button asChild variant="outline">
<Link href="/dashboard/profil">Profil öffnen</Link>
</Button>
</CardContent>
</Card>
) : (
<>
<nav aria-label="Abwesenheitszeitraum" className="mb-5 flex gap-2">
<Button asChild size="sm" variant={!past ? "default" : "outline"}>
<Link href="/dashboard/abwesenheiten">Aktuell & zukünftig</Link>
</Button>
<Button asChild size="sm" variant={past ? "default" : "outline"}>
<Link href="/dashboard/abwesenheiten?view=past">Vergangen</Link>
</Button>
</nav>
<p className="mb-3 text-sm text-[var(--ink-muted)]">
{count} Meldung{count === 1 ? "" : "en"}
</p>
<div className="grid gap-4">
{absences.length === 0 && (
<Card>
<CardContent className="py-8 text-sm text-[var(--ink-soft)]">
Für diesen Zeitraum sind keine Abwesenheiten gemeldet.
</CardContent>
</Card>
)}
{absences.map((absence) => {
const startDate = absence.startDate.toISOString().slice(0, 10);
return (
<Card key={absence.id}>
<CardContent className="py-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-semibold">
{absence.child.firstName} {absence.child.lastName}
</h2>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
{dateLabel(absence.startDate)}
{absence.endDate.getTime() !==
absence.startDate.getTime()
? ` – ${dateLabel(absence.endDate)}`
: ""}{" "}
· {reasonLabel(absence.reason)}
</p>
</div>
<Badge variant="secondary">
{past
? "Vergangen"
: startDate <= today
? "Laufend"
: "Zukünftig"}
</Badge>
</div>
{absence.note && (
<p className="mt-3 whitespace-pre-wrap break-words text-sm text-[var(--ink-soft)]">
{absence.note}
</p>
)}
<AbsenceManagementActions
key={`${absence.id}-${absence.updatedAt.toISOString()}`}
today={today}
team={canManageKitaAbsences(actor.role)}
absence={{
id: absence.id,
reason: absence.reason,
note: absence.note,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
startDate,
endDate: absence.endDate.toISOString().slice(0, 10),
updatedAt: absence.updatedAt.toISOString(),
}}
/>
</CardContent>
</Card>
);
})}
</div>
{pages > 1 && (
<div className="mt-5 flex items-center justify-between gap-3">
{page > 1 ? (
<Button asChild variant="outline">
<Link href={href(page - 1)}>Zurück</Link>
</Button>
) : (
<span />
)}
<span className="text-sm text-[var(--ink-muted)]">
Seite {page} von {pages}
</span>
{page < pages ? (
<Button asChild variant="outline">
<Link href={href(page + 1)}>Weiter</Link>
</Button>
) : (
<span />
)}
</div>
)}
</>
)}
</div>
);
}
@@ -3,7 +3,7 @@
import { useState, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { addDays, format, isWeekend, parseISO } from "date-fns";
import { addDays, format, parseISO } from "date-fns";
import { de } from "date-fns/locale";
import {
ArrowLeft,
@@ -55,34 +55,23 @@ export function AbsencesClient({
const today = parseISO(todayStr);
// Filters State
const [selectedGroup, setSelectedGroup] = useState<"all" | "Krippe" | "Elementar">("all");
const [selectedReason, setSelectedReason] = useState<"all" | AbsenceReason>("all");
// Dropdown UI state
const [isGroupOpen, setIsGroupOpen] = useState(false);
const [isReasonOpen, setIsReasonOpen] = useState(false);
// Date input state
const [inputDate, setInputDate] = useState(dateKey);
// Group resolver function
const mockGroup = (childName: string) => {
const charCode = childName.charCodeAt(0);
return charCode % 2 === 0 ? "Krippe" : "Elementar";
};
// Filter computation
const filteredAbsences = useMemo(() => {
return initialAbsences.filter((item) => {
if (selectedGroup !== "all" && mockGroup(item.childName) !== selectedGroup) {
return false;
}
if (selectedReason !== "all" && item.reason !== selectedReason) {
return false;
}
return true;
});
}, [initialAbsences, selectedGroup, selectedReason]);
}, [initialAbsences, selectedReason]);
// Recalculate reasons count based on filtered absences
const countsByReason = useMemo(() => {
@@ -113,9 +102,9 @@ export function AbsencesClient({
TAGESÜBERSICHT
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Heute fehlen{" "}
{isToday ? "Heute" : "An diesem Tag"}{filteredAbsences.length === 1 ? " fehlt " : " fehlen "}
<em className="font-normal italic text-[var(--accent-deep)]">
{filteredAbsences.length} Kinder
{filteredAbsences.length} {filteredAbsences.length === 1 ? "Kind" : "Kinder"}
</em>
</h1>
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
@@ -126,7 +115,7 @@ export function AbsencesClient({
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{childCount - filteredAbsences.length} anwesend
{Math.max(0, childCount - initialAbsences.length)} anwesend
</span>
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<button
@@ -167,71 +156,6 @@ export function AbsencesClient({
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{/* Group Dropdown Filter */}
<div className="relative">
<button
type="button"
onClick={() => setIsGroupOpen(!isGroupOpen)}
className={`inline-flex h-9 items-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] ${
selectedGroup !== "all"
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
{selectedGroup === "all" ? "Alle Gruppen" : selectedGroup}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{isGroupOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsGroupOpen(false)} />
<div className="absolute left-0 mt-1.5 w-44 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] py-1.5 shadow-lg z-20">
<button
type="button"
onClick={() => {
setSelectedGroup("all");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "all"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Alle Gruppen
</button>
<button
type="button"
onClick={() => {
setSelectedGroup("Krippe");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "Krippe"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Krippe (Mock)
</button>
<button
type="button"
onClick={() => {
setSelectedGroup("Elementar");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "Elementar"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Elementar (Mock)
</button>
</div>
</>
)}
</div>
{/* Reason Dropdown Filter */}
<div className="relative">
<button
@@ -361,7 +285,7 @@ export function AbsencesClient({
</div>
</CardHeader>
<CardContent>
<AbsenceTable absences={filteredAbsences} />
<AbsenceTable absences={filteredAbsences} today={todayStr} />
</CardContent>
</Card>
) : isTooFarFuture ? (
@@ -4,6 +4,8 @@ import { useState } from "react";
import { AbsenceReason } from "@prisma/client";
import { CalendarDays, Circle, MessageSquareText, UserRound } from "lucide-react";
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
import { AbsenceManagementActions } from "@/components/absence-management-actions";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -30,12 +32,12 @@ export type AbsenceTableItem = {
note: string | null;
startDate: string;
endDate: string;
updatedAt: string;
};
export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
const [activeAbsence, setActiveAbsence] = useState<AbsenceTableItem | null>(
null,
);
export function AbsenceTable({ absences, today }: { absences: AbsenceTableItem[]; today: string }) {
const [activeId, setActiveId] = useState<string | null>(null);
const activeAbsence = absences.find((absence) => absence.id === activeId) ?? null;
return (
<>
@@ -47,6 +49,7 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
<TableHead>Grund</TableHead>
<TableHead>Bemerkung</TableHead>
<TableHead>Status</TableHead>
<TableHead><span className="sr-only">Aktionen</span></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -54,7 +57,7 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
<TableRow
key={absence.id}
className="cursor-pointer border-[var(--hairline-soft)] hover:bg-[var(--surface-2)]"
onClick={() => setActiveAbsence(absence)}
onClick={() => setActiveId(absence.id)}
>
<TableCell>
<div className="flex items-center gap-3">
@@ -89,6 +92,9 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
<TableCell>
<Badge variant="success">Bestätigt</Badge>
</TableCell>
<TableCell>
<Button type="button" variant="ghost" size="sm" onClick={(event) => { event.stopPropagation(); setActiveId(absence.id); }} aria-label={`Abwesenheit öffnen: ${absence.childName}`}>Details & bearbeiten</Button>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -96,9 +102,9 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
<Dialog
open={!!activeAbsence}
onOpenChange={(open) => !open && setActiveAbsence(null)}
onOpenChange={(open) => !open && setActiveId(null)}
>
<DialogContent className="left-auto right-0 top-0 h-full max-w-md translate-x-0 translate-y-0 rounded-none border-y-0 border-r-0 border-[var(--hairline)] bg-[var(--surface)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right">
<DialogContent className="left-auto right-0 top-0 h-full max-w-md overflow-y-auto translate-x-0 translate-y-0 rounded-none border-y-0 border-r-0 border-[var(--hairline)] bg-[var(--surface)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right">
{activeAbsence ? (
<>
<DialogHeader>
@@ -129,6 +135,7 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
value={activeAbsence.note || "Keine Bemerkung hinterlegt."}
/>
<DetailLine icon={UserRound} label="Status" value="Bestätigt" />
<AbsenceManagementActions key={activeAbsence.updatedAt} absence={activeAbsence} today={today} team onChanged={() => setActiveId(null)} />
</div>
</>
) : null}
+11 -17
View File
@@ -1,9 +1,9 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns";
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
import { getDailyAbsences } from "@/actions/absences";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { absenceDate, absenceToday, isAbsenceDate } from "@/lib/absence-policy";
import { AbsencesClient } from "./_components/absences-client";
import { type AbsenceTableItem } from "./absence-table";
@@ -21,9 +21,10 @@ export default async function AdminAbwesenheitenPage({
]);
const resolvedSearchParams = await searchParams;
const kitaId = session.user.kitaId!;
const today = startOfLocalDay(new Date());
const todayKey = absenceToday();
const today = absenceDate(todayKey);
const selectedDate = parseDateParam(resolvedSearchParams.date, today);
const dateKey = format(selectedDate, "yyyy-MM-dd");
const dateKey = selectedDate.toISOString().slice(0, 10);
const result = await getDailyAbsences({ date: dateKey });
const absences = result.absences ?? [];
const childCount = await prisma.child.count({
@@ -46,9 +47,9 @@ export default async function AdminAbwesenheitenPage({
select: { title: true, type: true },
orderBy: { startDate: "asc" },
});
const isToday = dateKey === format(today, "yyyy-MM-dd");
const isTooFarFuture = differenceInCalendarDays(selectedDate, today) > 30;
const isClosedDay = isWeekend(selectedDate) || !!closure;
const isToday = dateKey === todayKey;
const isTooFarFuture = (selectedDate.getTime() - today.getTime()) / 86_400_000 > 30;
const isClosedDay = [0, 6].includes(selectedDate.getUTCDay()) || !!closure;
const tableItems: AbsenceTableItem[] = absences.map((absence) => ({
id: absence.id,
childName: absence.childName,
@@ -57,6 +58,7 @@ export default async function AdminAbwesenheitenPage({
note: absence.note,
startDate: absence.startDate,
endDate: absence.endDate,
updatedAt: absence.updatedAt,
}));
return (
@@ -67,11 +69,11 @@ export default async function AdminAbwesenheitenPage({
upcomingAbsenceCount={upcomingAbsenceCount}
closure={closure}
dateKey={dateKey}
selectedDateStr={selectedDate.toISOString()}
selectedDateStr={dateKey}
isToday={isToday}
isTooFarFuture={isTooFarFuture}
isClosedDay={isClosedDay}
todayStr={today.toISOString()}
todayStr={todayKey}
error={result.error ?? null}
/>
</div>
@@ -79,13 +81,5 @@ export default async function AdminAbwesenheitenPage({
}
function parseDateParam(value: string | undefined, fallback: Date) {
if (!value) return fallback;
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
}
function startOfLocalDay(date: Date) {
const nextDate = new Date(date);
nextDate.setHours(0, 0, 0, 0);
return nextDate;
return value && isAbsenceDate(value) ? absenceDate(value) : fallback;
}
@@ -19,6 +19,8 @@ import {
import { AddFamilyDialog } from "../add-family-dialog";
import { DutyManager } from "../duty-manager";
import { EditFamilyDialog } from "../edit-family-dialog";
import { InvitationStatus } from "../invitation-status";
import type { FamilyInvitationView } from "@/lib/family-invitation-view";
interface FamilyUser {
id: string;
@@ -26,6 +28,7 @@ interface FamilyUser {
lastName: string;
email: string;
emailVerifiedAt: Date | null;
invitation: FamilyInvitationView;
dutyAssignments: {
id: string;
dutyId: string;
@@ -250,6 +253,7 @@ export function FamiliesListClient({
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
</span>
</div>
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} />
</div>
))}
</div>
@@ -288,6 +292,7 @@ export function FamiliesListClient({
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
invitation: parent.invitation,
})),
children: family.children,
}}
@@ -357,6 +362,7 @@ export function FamiliesListClient({
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
</span>
</div>
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} />
</div>
))}
</div>
@@ -393,6 +399,7 @@ export function FamiliesListClient({
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
invitation: parent.invitation,
})),
children: family.children,
}}
+125 -144
View File
@@ -1,45 +1,54 @@
"use server";
import { hashToken, newToken } from "@/lib/tokens";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod";
import { InviteEmail } from "@/emails/InviteEmail";
import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
import {
deliverFamilyInvitations,
queueFamilyInvitation,
resendFamilyInvitation,
} from "@/lib/family-invitations";
import { prisma } from "@/lib/prisma";
const INVITE_TOKEN_TTL_DAYS = 7;
const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const childSchema = z.object({
firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(),
lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(),
firstName: z.string().trim().min(1, "Vorname des Kindes fehlt.").max(100),
lastName: z.string().trim().min(1, "Nachname des Kindes fehlt.").max(100),
});
const parentInputSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100),
lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100),
email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.trim()
.toLowerCase()
.trim(),
.email("Bitte eine gültige E-Mail-Adresse angeben."),
});
const addFamilySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
parent1FirstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
parent1LastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
familyName: z
.string()
.trim()
.min(1, "Familienname ist erforderlich.")
.max(120),
parent1FirstName: z
.string()
.trim()
.min(1, "Vorname ist erforderlich.")
.max(100),
parent1LastName: z
.string()
.trim()
.min(1, "Nachname ist erforderlich.")
.max(100),
parent1Email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.trim()
.toLowerCase()
.trim(),
.email("Bitte eine gültige E-Mail-Adresse angeben."),
parent2FirstName: z.string().max(100).trim().optional(),
parent2LastName: z.string().max(100).trim().optional(),
parent2Email: z.string().trim().optional(),
@@ -52,12 +61,19 @@ const addFamilySchema = z.object({
const updateFamilySchema = z.object({
familyId: z.string().min(1),
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
parents: z.array(
parentInputSchema.extend({
id: z.string().min(1),
}),
).min(1).max(2),
familyName: z
.string()
.trim()
.min(1, "Familienname ist erforderlich.")
.max(120),
parents: z
.array(
parentInputSchema.extend({
id: z.string().min(1),
}),
)
.min(1)
.max(2),
newParents: z.array(parentInputSchema).max(1).default([]),
children: z
.array(
@@ -83,12 +99,7 @@ export type AddFamilyState = {
_form?: string[];
};
success?: boolean;
};
type InviteJob = {
to: string;
parentName: string;
inviteUrl: string;
warning?: string;
};
function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
@@ -115,32 +126,14 @@ function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
return { ok: true as const, parent: parsed.data };
}
function createInviteToken() {
const token = newToken();
const expires = new Date(
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
);
return { token, expires, inviteUrl: `${BASE_URL}/invite/${token}` };
}
async function sendInviteJobs(kitaName: string, jobs: InviteJob[]) {
for (const job of jobs) {
const result = await sendAppEmail({
to: job.to,
subject: `Einladung zu ${kitaName} im Kita-Planer`,
react: createElement(InviteEmail, {
parentName: job.parentName,
kitaName,
inviteLink: job.inviteUrl,
}),
});
if (!result.success) {
return result.error;
}
async function sendInviteJobs(ids: string[]) {
try {
const result = await deliverFamilyInvitations(ids);
if (!result.failed) return undefined;
} catch {
// A committed family remains successful even if delivery/status storage fails.
}
return null;
return "Familie gespeichert. Mindestens eine Einladung konnte nicht versendet werden. Den Versand kannst du beim jeweiligen Elternteil erneut versuchen.";
}
export async function addFamilyAction(
@@ -185,11 +178,6 @@ export async function addFamilyAction(
childrenRaw.push(parsed.data);
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { errors: { _form: [mailConfigError] } };
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
@@ -211,12 +199,14 @@ export async function addFamilyAction(
if (emailSet.size !== parents.length) {
return {
errors: {
_form: ["Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein."],
_form: [
"Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein.",
],
},
};
}
const inviteJobs: InviteJob[] = [];
const inviteJobs: string[] = [];
try {
await prisma.$transaction(async (tx) => {
@@ -239,19 +229,12 @@ export async function addFamilyAction(
role: UserRole.ELTERN,
},
});
const invite = createInviteToken();
inviteJobs.push({
to: parent.email,
parentName: `${parent.firstName} ${parent.lastName}`,
inviteUrl: invite.inviteUrl,
});
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: hashToken(invite.token),
expires: invite.expires,
},
});
const invite = await queueFamilyInvitation(
tx,
createdParent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
await tx.child.createMany({
@@ -274,26 +257,25 @@ export async function addFamilyAction(
},
};
}
throw err;
}
const emailError = await sendInviteJobs(kita.name, inviteJobs);
if (emailError) {
console.error("Familie konnte nicht angelegt werden:", err);
return {
errors: {
_form: [
`Familie wurde angelegt, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
"Familie konnte nicht angelegt werden. Bitte erneut versuchen.",
],
},
};
}
const warning = await sendInviteJobs(inviteJobs);
revalidatePath("/dashboard/families");
revalidatePath("/dashboard");
return { success: true };
return { success: true, warning };
}
export async function updateFamilyAction(rawPayload: unknown) {
export async function updateFamilyAction(
rawPayload: unknown,
): Promise<{ success?: boolean; error?: string; warning?: string }> {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
@@ -316,9 +298,13 @@ export async function updateFamilyAction(rawPayload: unknown) {
removedChildIds,
} = parsed.data;
const parentEmails = [...parents, ...newParents].map((parent) => parent.email);
const parentEmails = [...parents, ...newParents].map(
(parent) => parent.email,
);
if (new Set(parentEmails).size !== parentEmails.length) {
return { error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein." };
return {
error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein.",
};
}
const removedChildIdSet = new Set(removedChildIds);
@@ -328,13 +314,6 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Eine Familie benötigt mindestens ein Kind." };
}
if (newParents.length > 0) {
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
@@ -344,7 +323,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Kita wurde nicht gefunden." };
}
const inviteJobs: InviteJob[] = [];
const inviteJobs: string[] = [];
try {
const existingFamily = await prisma.family.findFirst({
@@ -362,8 +341,6 @@ export async function updateFamilyAction(rawPayload: unknown) {
const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email);
if (needsEmail) {
const configError = getAppEmailConfigError();
if (configError) return { error: configError };
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." };
}
@@ -371,7 +348,9 @@ export async function updateFamilyAction(rawPayload: unknown) {
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
return { error: "Mindestens ein Elternteil gehört nicht zu dieser Familie." };
return {
error: "Mindestens ein Elternteil gehört nicht zu dieser Familie.",
};
}
if (!children.every((child) => existingChildIds.has(child.id))) {
@@ -402,10 +381,12 @@ export async function updateFamilyAction(rawPayload: unknown) {
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
});
if (emailChanged) {
const invite = createInviteToken();
await tx.verificationToken.deleteMany({ where: { identifier: parent.id } });
await tx.verificationToken.create({ data: { identifier: parent.id, token: hashToken(invite.token), expires: invite.expires } });
inviteJobs.push({ to: parent.email, parentName: `${parent.firstName} ${parent.lastName}`, inviteUrl: invite.inviteUrl });
const invite = await queueFamilyInvitation(
tx,
parent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
}
@@ -421,19 +402,12 @@ export async function updateFamilyAction(rawPayload: unknown) {
role: UserRole.ELTERN,
},
});
const invite = createInviteToken();
inviteJobs.push({
to: parent.email,
parentName: `${parent.firstName} ${parent.lastName}`,
inviteUrl: invite.inviteUrl,
});
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: hashToken(invite.token),
expires: invite.expires,
},
});
const invite = await queueFamilyInvitation(
tx,
createdParent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
for (const child of activeChildren) {
@@ -473,41 +447,48 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Familie konnte nicht aktualisiert werden." };
}
const emailError = await sendInviteJobs(kita.name, inviteJobs);
if (emailError) {
return {
error: `Familie wurde aktualisiert, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
};
}
const warning = await sendInviteJobs(inviteJobs);
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard");
return { success: true };
return { success: true, warning };
}
export async function resendFamilyInvitationAction(
userId: string,
): Promise<{ success?: boolean; error?: string; warning?: string }> {
const session = await requireRole([UserRole.ADMIN]);
if (!session.user.kitaId || typeof userId !== "string" || !userId || userId.length > 64)
return { error: "Ungültiges Elternkonto." };
if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed)
return { error: "Bitte versuche es später erneut." };
if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed)
return { error: "Bitte versuche es später erneut." };
const recipient = await prisma.user.findFirst({
where: { id: userId, kitaId: session.user.kitaId, role: UserRole.ELTERN,
emailVerifiedAt: null, passwordHash: "", disabledAt: null },
select: { email: true },
});
if (!recipient) return { error: "Keine offene Einladung gefunden." };
let result;
try {
result = await resendFamilyInvitation(
session.user.kitaId,
userId,
session.user.id,
recipient.email,
);
} catch {
result = {
error:
"Einladung konnte nicht erneut versendet werden. Bitte den Status prüfen und erneut versuchen.",
};
}
revalidatePath("/dashboard/families");
return result;
}
export async function resendParentInvite(userId: string) {
const session = await requireRole([UserRole.ADMIN]);
if (typeof userId !== "string" || !userId || userId.length > 64) return { error: "Ungültige Eingabedaten." };
if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
const user = await prisma.user.findFirst({ where: { id: userId, kitaId: session.user.kitaId, emailVerifiedAt: null, passwordHash: "", disabledAt: null },
select: { id: true, email: true, firstName: true, lastName: true, kita: { select: { name: true } } } });
if (!user?.kita) return { error: "Keine offene Einladung gefunden." };
const configError = getAppEmailConfigError(); if (configError) return { error: configError };
const invite = createInviteToken();
const renewed = await prisma.$transaction(async tx => {
// Lock and re-check the account before issuing a link for the selected mailbox.
// A concurrent address change/activation must invalidate this resend.
const eligible = await tx.user.updateMany({ where: { id: user.id, email: user.email, kitaId: session.user.kitaId,
emailVerifiedAt: null, passwordHash: "", disabledAt: null }, data: { sessionVersion: { increment: 1 } } });
if (eligible.count !== 1) return false;
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
await tx.verificationToken.create({ data: { identifier: user.id, token: hashToken(invite.token), expires: invite.expires } });
return true;
});
if (!renewed) return { error: "Die Einladung hat sich geändert. Bitte lade die Seite neu." };
const error = await sendInviteJobs(user.kita.name, [{ to: user.email, parentName: `${user.firstName} ${user.lastName}`, inviteUrl: invite.inviteUrl }]);
return error ? { error } : { success: true };
return resendFamilyInvitationAction(userId);
}
+176 -160
View File
@@ -1,7 +1,8 @@
"use client";
import { useActionState, useState } from "react";
import { useState, useTransition } from "react";
import { Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -26,28 +27,24 @@ const initialState: AddFamilyState = {};
// • Dialog-Open/Close-State
// • Dynamische Kinderliste (min. 1, max. 10)
// • Ein oder zwei Elternteile pro Haushalt
// • useActionState → Server-Action-Fehler anzeigen
// • Bei Erfolg (state.success) Dialog automatisch schließen
// • Eingaben bei Validierungsfehlern behalten
// • Nach erfolgreichem Speichern schließen, auch bei einer Versandwarnung
// =====================================================================
export function AddFamilyDialog() {
const [open, setOpen] = useState(false);
const [childCount, setChildCount] = useState(1);
const [state, formAction, pending] = useActionState(
addFamilyAction,
initialState,
);
// Dialog schließen, wenn die Action erfolgreich war
if (state.success && open) {
setOpen(false);
}
const [childIds, setChildIds] = useState(["initial"]);
const childCount = childIds.length;
const [state, setState] = useState<AddFamilyState>(initialState);
const [pending, startTransition] = useTransition();
function handleOpenChange(nextOpen: boolean) {
if (pending) return;
if (nextOpen) {
setState(initialState);
setChildIds([crypto.randomUUID()]);
}
setOpen(nextOpen);
// State beim Schließen zurücksetzen würde useActionState erfordern —
// stattdessen schließen wir einfach und der State bleibt bis zur nächsten
// Aktion erhalten (unkritisch).
}
return (
@@ -63,166 +60,185 @@ export function AddFamilyDialog() {
<DialogHeader>
<DialogTitle>Familie hinzufügen</DialogTitle>
<DialogDescription>
Lege einen Haushalt mit Elternteilen und Kindern an. Die
Elternteile erhalten Einladungslinks per E-Mail.
Lege einen Haushalt mit Elternteilen und Kindern an. Die Elternteile
erhalten Einladungslinks per E-Mail.
</DialogDescription>
</DialogHeader>
<form action={formAction} className="space-y-6">
{/* Anzahl Kinder als verstecktes Feld für die Server Action */}
<input type="hidden" name="childCount" value={childCount} />
<FormField
id="familyName"
name="familyName"
label="Familienname"
placeholder="Familie Schmidt"
error={state.errors?.familyName?.[0]}
/>
{/* ── Elternteile ────────────────────────────────────────── */}
<fieldset className="space-y-4">
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteil 1
</legend>
<div className="grid grid-cols-2 gap-3">
<FormField
id="parent1FirstName"
name="parent1FirstName"
label="Vorname"
autoComplete="given-name"
error={state.errors?.parent1FirstName?.[0]}
/>
<FormField
id="parent1LastName"
name="parent1LastName"
label="Nachname"
autoComplete="family-name"
error={state.errors?.parent1LastName?.[0]}
/>
</div>
<form
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
startTransition(async () => {
const result = await addFamilyAction(initialState, data);
setState(result);
if (result.success) {
if (result.warning) toast.warning(result.warning);
else toast.success("Familie angelegt. Einladungen versendet.");
setOpen(false);
}
});
}}
className="space-y-6"
>
<fieldset disabled={pending} className="space-y-6">
{/* Anzahl Kinder als verstecktes Feld für die Server Action */}
<input type="hidden" name="childCount" value={childCount} />
<FormField
id="parent1Email"
name="parent1Email"
type="email"
label="E-Mail-Adresse"
autoComplete="email"
error={state.errors?.parent1Email?.[0]}
id="familyName"
name="familyName"
label="Familienname"
placeholder="Familie Schmidt"
error={state.errors?.familyName?.[0]}
/>
</fieldset>
<fieldset className="space-y-4">
<legend>
<span className="text-[11px] uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteil 2
</span>{" "}
<span className="font-normal italic text-[var(--ink-faint)]">
optional
</span>
</legend>
<div className="grid grid-cols-2 gap-3">
<FormField
id="parent2FirstName"
name="parent2FirstName"
label="Vorname"
autoComplete="given-name"
required={false}
error={state.errors?.parent2FirstName?.[0]}
/>
<FormField
id="parent2LastName"
name="parent2LastName"
label="Nachname"
autoComplete="family-name"
required={false}
error={state.errors?.parent2LastName?.[0]}
/>
</div>
<FormField
id="parent2Email"
name="parent2Email"
type="email"
label="E-Mail-Adresse"
autoComplete="email"
required={false}
error={state.errors?.parent2Email?.[0]}
/>
</fieldset>
{/* ── Kinder ─────────────────────────────────────────────── */}
<fieldset className="space-y-3">
<div className="flex items-center justify-between">
{/* ── Elternteile ────────────────────────────────────────── */}
<fieldset className="space-y-4">
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kinder ({childCount})
Elternteil 1
</legend>
{childCount < 10 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setChildCount((c) => c + 1)}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Kind hinzufügen
</Button>
)}
</div>
{Array.from({ length: childCount }).map((_, i) => (
<div
key={i}
className="relative rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
>
{childCount > 1 && (
<button
type="button"
onClick={() => {
// Kind entfernen — childCount reduzieren reicht, da die
// Felder nach Index benannt sind und der letzte entfernt wird.
setChildCount((c) => c - 1);
}}
className="absolute right-3 top-3 rounded-md p-1 text-[var(--ink-muted)] transition-colors hover:bg-[var(--danger-soft)] hover:text-[var(--danger)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)]"
aria-label={`Kind ${i + 1} entfernen`}
>
<Trash2 className="h-4 w-4" />
</button>
)}
<p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kind {i + 1}
</p>
<div className="grid grid-cols-2 gap-3">
<FormField
id={`childFirstName_${i}`}
name={`childFirstName_${i}`}
label="Vorname"
/>
<FormField
id={`childLastName_${i}`}
name={`childLastName_${i}`}
label="Nachname"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
id="parent1FirstName"
name="parent1FirstName"
label="Vorname"
autoComplete="given-name"
error={state.errors?.parent1FirstName?.[0]}
/>
<FormField
id="parent1LastName"
name="parent1LastName"
label="Nachname"
autoComplete="family-name"
error={state.errors?.parent1LastName?.[0]}
/>
</div>
))}
{state.errors?.children?.[0] && (
<p className="text-xs text-[var(--danger)]">
{state.errors.children[0]}
<FormField
id="parent1Email"
name="parent1Email"
type="email"
label="E-Mail-Adresse"
autoComplete="email"
error={state.errors?.parent1Email?.[0]}
/>
</fieldset>
<fieldset className="space-y-4">
<legend>
<span className="text-[11px] uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Elternteil 2
</span>{" "}
<span className="font-normal italic text-[var(--ink-faint)]">
optional
</span>
</legend>
<div className="grid grid-cols-2 gap-3">
<FormField
id="parent2FirstName"
name="parent2FirstName"
label="Vorname"
autoComplete="given-name"
required={false}
error={state.errors?.parent2FirstName?.[0]}
/>
<FormField
id="parent2LastName"
name="parent2LastName"
label="Nachname"
autoComplete="family-name"
required={false}
error={state.errors?.parent2LastName?.[0]}
/>
</div>
<FormField
id="parent2Email"
name="parent2Email"
type="email"
label="E-Mail-Adresse"
autoComplete="email"
required={false}
error={state.errors?.parent2Email?.[0]}
/>
</fieldset>
{/* ── Kinder ─────────────────────────────────────────────── */}
<fieldset className="space-y-3">
<div className="flex items-center justify-between">
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kinder ({childCount})
</legend>
{childCount < 10 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setChildIds((ids) => [...ids, crypto.randomUUID()])
}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Kind hinzufügen
</Button>
)}
</div>
{childIds.map((childId, i) => (
<div
key={childId}
className="relative rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
>
{childCount > 1 && (
<button
type="button"
onClick={() => {
setChildIds((ids) =>
ids.filter((id) => id !== childId),
);
}}
className="absolute right-3 top-3 rounded-md p-1 text-[var(--ink-muted)] transition-colors hover:bg-[var(--danger-soft)] hover:text-[var(--danger)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)]"
aria-label={`Kind ${i + 1} entfernen`}
>
<Trash2 className="h-4 w-4" />
</button>
)}
<p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Kind {i + 1}
</p>
<div className="grid grid-cols-2 gap-3">
<FormField
id={`childFirstName_${i}`}
name={`childFirstName_${i}`}
label="Vorname"
/>
<FormField
id={`childLastName_${i}`}
name={`childLastName_${i}`}
label="Nachname"
/>
</div>
</div>
))}
{state.errors?.children?.[0] && (
<p className="text-xs text-[var(--danger)]">
{state.errors.children[0]}
</p>
)}
</fieldset>
{/* Globaler Fehler */}
{state.errors?._form?.[0] && (
<p className="rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
{state.errors._form[0]}
</p>
)}
</fieldset>
{/* Globaler Fehler */}
{state.errors?._form?.[0] && (
<p className="rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
{state.errors._form[0]}
</p>
)}
<DialogFooter>
<Button
type="button"
@@ -1,6 +1,11 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
useMemo,
useState,
useTransition,
type ChangeEventHandler,
} from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
@@ -17,12 +22,15 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateFamilyAction } from "./actions";
import { InvitationStatus } from "./invitation-status";
import type { FamilyInvitationView } from "@/lib/family-invitation-view";
type EditableParent = {
id: string;
firstName: string;
lastName: string;
email: string;
invitation?: FamilyInvitationView;
};
type EditableChild = {
@@ -47,6 +55,7 @@ type EditableFamily = {
};
export function EditFamilyDialog({ family }: { family: EditableFamily }) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const initialParents = useMemo(
() =>
@@ -154,13 +163,22 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
return;
}
toast.success("Familie aktualisiert. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden.");
if (result.warning) toast.warning(result.warning);
else toast.success("Familie aktualisiert. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden.");
setOpen(false);
setRemovedChildIds([]);
});
}
return (
<Dialog onOpenChange={(open) => !open && resetState()}>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (isPending) return;
resetState();
setOpen(nextOpen);
}}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1">
<Pencil className="h-3.5 w-3.5" />
@@ -168,7 +186,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<form action={handleSubmit}>
<form
onSubmit={(event) => {
event.preventDefault();
handleSubmit(new FormData(event.currentTarget));
}}
>
<DialogHeader>
<DialogTitle>Familie bearbeiten</DialogTitle>
<DialogDescription>
@@ -176,7 +199,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-4">
<fieldset disabled={isPending} className="grid gap-6 py-4">
<div className="grid gap-2">
<Label htmlFor={`familyName-${family.id}`}>Familienname</Label>
<Input
@@ -251,8 +274,38 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
label="E-Mail-Adresse"
type="email"
defaultValue={parent.email}
onChange={(event) => {
const email = event.target.value;
setParents((current) =>
current.map((item) =>
item.id === parent.id ? { ...item, email } : item,
),
);
}}
/>
</div>
{parent.kind === "existing" && parent.invitation && (
<InvitationStatus
userId={parent.id}
name={`${parent.firstName} ${parent.lastName}`}
invitation={parent.invitation}
blocked={
parent.email !==
family.parents.find((item) => item.id === parent.id)
?.email
}
/>
)}
{parent.kind === "existing" &&
parent.invitation?.status !== "active" &&
parent.email !==
family.parents.find((item) => item.id === parent.id)
?.email && (
<p className="mt-2 text-xs text-[var(--ink-soft)]">
Beim Speichern wird eine neue Einladung an die geänderte
Adresse versendet.
</p>
)}
</div>
))}
</fieldset>
@@ -319,7 +372,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
))
)}
</fieldset>
</div>
</fieldset>
<DialogFooter>
<Button type="submit" disabled={isPending}>
@@ -338,12 +391,14 @@ function Field({
label,
type = "text",
defaultValue,
onChange,
}: {
id: string;
name: string;
label: string;
type?: string;
defaultValue: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
}) {
return (
<div className="grid gap-2">
@@ -353,6 +408,7 @@ function Field({
name={name}
type={type}
defaultValue={defaultValue}
onChange={onChange}
required
/>
</div>
@@ -0,0 +1,73 @@
"use client";
import { useTransition } from "react";
import { Mail } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { FamilyInvitationView } from "@/lib/family-invitation-view";
import { resendFamilyInvitationAction } from "./actions";
const labels = {
active: "Konto aktiviert",
open: "Aktivierung offen",
sending: "Versand läuft",
sent: "Einladung versendet",
failed: "Versand fehlgeschlagen",
expired: "Einladung abgelaufen",
};
export function InvitationStatus({
userId,
name,
invitation,
blocked = false,
}: {
userId: string;
name: string;
invitation: FamilyInvitationView;
blocked?: boolean;
}) {
const [pending, startTransition] = useTransition();
return (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<Badge variant={invitation.status === "active" ? "success" : "warning"}>
{labels[invitation.status]}
</Badge>
{invitation.status !== "active" && (
<>
{invitation.expiresAt && (
<span className="text-[var(--ink-muted)]">
{invitation.status === "expired"
? "Gültig gewesen bis"
: "Link gültig bis"}{" "}
{new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeZone: "Europe/Berlin",
}).format(new Date(invitation.expiresAt))}
</span>
)}
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={blocked || pending || invitation.status === "sending"}
aria-label={`Einladung erneut senden an ${name}`}
onClick={() =>
startTransition(async () => {
const result = await resendFamilyInvitationAction(userId);
if (result.error) toast.error(result.error);
else if (result.warning) toast.warning(result.warning);
else toast.success("Einladung versendet.");
})
}
>
<Mail className="h-3 w-3" />
{pending ? "Wird versendet…" : "Einladung erneut senden"}
</Button>
</>
)}
</div>
);
}
+11 -1
View File
@@ -2,6 +2,7 @@ import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { familyInvitationView } from "@/lib/family-invitation-view";
import { FamiliesListClient } from "./_components/families-list-client";
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
@@ -32,6 +33,9 @@ export default async function FamiliesPage() {
lastName: true,
email: true,
emailVerifiedAt: true,
familyInvitation: {
select: { expiresAt: true, sentAt: true, failed: true, leaseUntil: true, status: true },
},
dutyAssignments: {
select: {
id: true,
@@ -73,7 +77,13 @@ export default async function FamiliesPage() {
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<FamiliesListClient
initialFamilies={families}
initialFamilies={families.map((family) => ({
...family,
users: family.users.map(({ familyInvitation, ...parent }) => ({
...parent,
invitation: familyInvitationView(parent.emailVerifiedAt, familyInvitation),
})),
}))}
allDuties={allDuties}
canManageDuties={canManageDuties}
/>
+10 -13
View File
@@ -16,6 +16,7 @@ import { UserRole } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import { getTargetMonthData } from "@/lib/date-utils";
import { prisma } from "@/lib/prisma";
import { absenceDate, absenceToday, canManageKitaAbsences } from "@/lib/absence-policy";
import { Stat } from "@/components/ui/stat";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { NewsTicker } from "./news-ticker";
@@ -30,8 +31,8 @@ export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() {
const session = await requireKitaSession();
const now = new Date();
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const todayKey = absenceToday(now);
const today = absenceDate(todayKey);
const targetData = getTargetMonthData();
const targetMonthStart = new Date(
@@ -138,6 +139,7 @@ export default async function DashboardPage() {
note: true,
startDate: true,
endDate: true,
updatedAt: true,
child: {
select: {
firstName: true,
@@ -225,32 +227,27 @@ export default async function DashboardPage() {
)}
{canReportAbsences ? (
<AbsenceCard
today={format(today, "yyyy-MM-dd")}
today={todayKey}
childOptions={absenceChildren.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
}))}
absences={upcomingAbsences.map((absence) => ({
id: absence.id,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
reason: absence.reason,
note: absence.note,
startDate: format(absence.startDate, "yyyy-MM-dd"),
endDate: format(absence.endDate, "yyyy-MM-dd"),
}))}
/>
) : null}
</div>
<aside>
<UpcomingAbsencesCard
today={todayKey}
team={canManageKitaAbsences(session.user.role)}
absences={upcomingAbsences.map((absence) => ({
id: absence.id,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
reason: absence.reason,
note: absence.note,
startDate: format(absence.startDate, "yyyy-MM-dd"),
endDate: format(absence.endDate, "yyyy-MM-dd"),
startDate: absence.startDate.toISOString().slice(0, 10),
endDate: absence.endDate.toISOString().slice(0, 10),
updatedAt: absence.updatedAt.toISOString(),
}))}
/>
</aside>
+4
View File
@@ -153,6 +153,10 @@ export async function acceptInviteAction(
sessionVersion: { increment: 1 } },
});
if (activated.count !== 1) throw new Error("INVITE_USED");
await tx.invitation.updateMany({
where: { token, userId },
data: { status: "ACCEPTED", acceptedAt: now, leaseUntil: null },
});
});
} catch (error) {
if (error instanceof Error && error.message === "INVITE_USED") return { attempt, values, errors: { _form: ["Dieser Link kann nicht mehr verwendet werden."] } };