Merge main with notdienst planning and notification improvements
This commit is contained in:
+223
-123
@@ -1,24 +1,33 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AbsenceReason, UserRole } from "@prisma/client";
|
||||
import { AbsenceReason, Prisma, UserRole } from "@prisma/client";
|
||||
import { dateOnlySchema } from "@/lib/date-schema";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
absenceDate,
|
||||
absenceToday,
|
||||
canManageKitaAbsences,
|
||||
} from "@/lib/absence-policy";
|
||||
|
||||
const reportAbsenceSchema = z.object({
|
||||
childId: z.string().min(1),
|
||||
startDate: dateOnlySchema,
|
||||
endDate: dateOnlySchema,
|
||||
const dateSchema = dateOnlySchema;
|
||||
const fieldsSchema = z.object({
|
||||
startDate: dateSchema,
|
||||
endDate: dateSchema,
|
||||
reason: z.nativeEnum(AbsenceReason),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
const dailyAbsenceSchema = z.object({
|
||||
date: dateOnlySchema,
|
||||
const reportSchema = fieldsSchema.extend({
|
||||
childId: z.string().min(1).max(128),
|
||||
});
|
||||
const identitySchema = z.object({
|
||||
id: z.string().min(1).max(128),
|
||||
updatedAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
const updateSchema = fieldsSchema.extend(identitySchema.shape);
|
||||
type MutationResult = { success?: boolean; error?: string };
|
||||
|
||||
export type DailyAbsenceDto = {
|
||||
id: string;
|
||||
@@ -28,112 +37,216 @@ export type DailyAbsenceDto = {
|
||||
note: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function parseDateOnly(value: string) {
|
||||
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error("Ungueltiges Datum.");
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function toDateKey(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function reportAbsence(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
const parsed = reportAbsenceSchema.safeParse(rawPayload);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
if (!session.user.familyId) {
|
||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||
}
|
||||
|
||||
const startDate = parseDateOnly(parsed.data.startDate);
|
||||
const endDate = parseDateOnly(parsed.data.endDate);
|
||||
|
||||
if (endDate < startDate) {
|
||||
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
|
||||
}
|
||||
|
||||
const child = await prisma.child.findFirst({
|
||||
where: {
|
||||
id: parsed.data.childId,
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId,
|
||||
active: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
|
||||
}
|
||||
|
||||
const overlappingAbsence = await prisma.absence.findFirst({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
childId: child.id,
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (overlappingAbsence) {
|
||||
return { error: "Für diesen Zeitraum existiert bereits eine Abmeldung." };
|
||||
}
|
||||
|
||||
await prisma.absence.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
childId: child.id,
|
||||
startDate,
|
||||
endDate,
|
||||
reason: parsed.data.reason,
|
||||
note: parsed.data.note || null,
|
||||
},
|
||||
});
|
||||
|
||||
function refreshAbsences() {
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/abwesenheiten");
|
||||
revalidatePath("/dashboard/admin/abwesenheiten");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteAbsence(id: string) {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
if (!session.user.familyId) {
|
||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||
}
|
||||
|
||||
const absence = await prisma.absence.findFirst({
|
||||
async function currentActor(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
kitaId: string,
|
||||
) {
|
||||
return tx.user.findFirst({
|
||||
where: {
|
||||
id,
|
||||
kitaId: session.user.kitaId,
|
||||
child: {
|
||||
familyId: session.user.familyId,
|
||||
kitaId,
|
||||
role: {
|
||||
in: [
|
||||
UserRole.ELTERN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.KOORDINATOR,
|
||||
UserRole.ERZIEHER,
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
select: { id: true, role: true, familyId: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (!absence) {
|
||||
return { error: "Diese Abmeldung gehört nicht zu deinem Haushalt." };
|
||||
}
|
||||
async function lockChild(
|
||||
tx: Prisma.TransactionClient,
|
||||
childId: string,
|
||||
kitaId: string,
|
||||
) {
|
||||
// Serializes overlapping create/edit/delete operations for this child.
|
||||
await tx.$queryRaw`SELECT "id" FROM "children" WHERE "id" = ${childId} AND "kitaId" = ${kitaId} FOR UPDATE`;
|
||||
}
|
||||
|
||||
await prisma.absence.delete({
|
||||
where: { id: absence.id },
|
||||
export async function reportAbsence(
|
||||
rawPayload: unknown,
|
||||
): Promise<MutationResult> {
|
||||
const session = await requireKitaSession();
|
||||
const parsed = reportSchema.safeParse(rawPayload);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
error:
|
||||
"Bitte gültige Daten und einen Grund angeben. Die Bemerkung darf höchstens 500 Zeichen haben.",
|
||||
};
|
||||
const { childId, startDate, endDate, reason, note } = parsed.data;
|
||||
if (endDate < startDate)
|
||||
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const actor = await currentActor(tx, session.user.id, session.user.kitaId);
|
||||
if (!actor?.familyId)
|
||||
return { error: "Dein Account ist keinem Haushalt zugeordnet." };
|
||||
if (!canManageKitaAbsences(actor.role) && startDate < absenceToday())
|
||||
return {
|
||||
error:
|
||||
"Vergangene Tage kann nur die Kita nachtragen. Bitte melde dich dort.",
|
||||
};
|
||||
await lockChild(tx, childId, session.user.kitaId);
|
||||
const child = await tx.child.findFirst({
|
||||
where: {
|
||||
id: childId,
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: actor.familyId,
|
||||
active: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!child)
|
||||
return {
|
||||
error:
|
||||
"Dieses Kind gehört nicht zu deinem Haushalt oder ist nicht mehr aktiv.",
|
||||
};
|
||||
const start = absenceDate(startDate),
|
||||
end = absenceDate(endDate);
|
||||
const overlap = await tx.absence.findFirst({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
childId,
|
||||
startDate: { lte: end },
|
||||
endDate: { gte: start },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (overlap)
|
||||
return { error: "Für diesen Zeitraum existiert bereits eine Abmeldung." };
|
||||
await tx.absence.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
childId,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
reason,
|
||||
note: note || null,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
if (result.success) refreshAbsences();
|
||||
return result;
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/admin/abwesenheiten");
|
||||
return { success: true };
|
||||
async function changeAbsence(
|
||||
rawPayload: unknown,
|
||||
remove: boolean,
|
||||
): Promise<MutationResult> {
|
||||
const session = await requireKitaSession();
|
||||
const parsed = remove
|
||||
? identitySchema.safeParse(rawPayload)
|
||||
: updateSchema.safeParse(rawPayload);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
error:
|
||||
"Ungültige Eingabedaten. Bitte die Ansicht aktualisieren und gültige Daten angeben.",
|
||||
};
|
||||
const input = parsed.data;
|
||||
const fields = remove ? null : updateSchema.parse(rawPayload);
|
||||
if (fields && fields.endDate < fields.startDate)
|
||||
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const actor = await currentActor(tx, session.user.id, session.user.kitaId);
|
||||
if (!actor) return { error: "Keine Berechtigung für diese Meldung." };
|
||||
const team = canManageKitaAbsences(actor.role);
|
||||
if (!team && !actor.familyId)
|
||||
return { error: "Dein Account ist keinem Haushalt zugeordnet." };
|
||||
const where: Prisma.AbsenceWhereInput = {
|
||||
id: input.id,
|
||||
kitaId: session.user.kitaId,
|
||||
child: {
|
||||
kitaId: session.user.kitaId,
|
||||
...(team ? {} : { familyId: actor.familyId! }),
|
||||
},
|
||||
};
|
||||
const candidate = await tx.absence.findFirst({
|
||||
where,
|
||||
select: { childId: true },
|
||||
});
|
||||
if (!candidate)
|
||||
return {
|
||||
error:
|
||||
"Meldung wurde nicht gefunden oder gehört nicht zu deinem Haushalt.",
|
||||
};
|
||||
await lockChild(tx, candidate.childId, session.user.kitaId);
|
||||
const absence = await tx.absence.findFirst({ where });
|
||||
if (!absence)
|
||||
return {
|
||||
error:
|
||||
"Die Meldung ist nicht mehr vorhanden. Bitte die Ansicht aktualisieren.",
|
||||
};
|
||||
if (
|
||||
absence.updatedAt.toISOString() !==
|
||||
new Date(input.updatedAt).toISOString()
|
||||
)
|
||||
return {
|
||||
error:
|
||||
"Die Meldung wurde inzwischen geändert. Bitte die Ansicht aktualisieren.",
|
||||
};
|
||||
const today = absenceToday();
|
||||
if (
|
||||
!team &&
|
||||
(absence.startDate.toISOString().slice(0, 10) <= today ||
|
||||
(fields && fields.startDate <= today))
|
||||
)
|
||||
return {
|
||||
error:
|
||||
"Bereits begonnene Meldungen und laufende Tage verwaltet die Kita. Bitte melde dich dort.",
|
||||
};
|
||||
if (remove) await tx.absence.delete({ where: { id: absence.id } });
|
||||
else if (fields) {
|
||||
const startDate = absenceDate(fields.startDate),
|
||||
endDate = absenceDate(fields.endDate);
|
||||
const overlap = await tx.absence.findFirst({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
childId: absence.childId,
|
||||
id: { not: absence.id },
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (overlap)
|
||||
return {
|
||||
error: "Für diesen Zeitraum existiert bereits eine andere Abmeldung.",
|
||||
};
|
||||
await tx.absence.update({
|
||||
where: { id: absence.id },
|
||||
data: {
|
||||
startDate,
|
||||
endDate,
|
||||
reason: fields.reason,
|
||||
note: fields.note || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
if (result.success) refreshAbsences();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateAbsence(rawPayload: unknown) {
|
||||
return changeAbsence(rawPayload, false);
|
||||
}
|
||||
export async function deleteAbsence(rawPayload: unknown) {
|
||||
return changeAbsence(rawPayload, true);
|
||||
}
|
||||
|
||||
export async function getDailyAbsences(
|
||||
@@ -144,17 +257,10 @@ export async function getDailyAbsences(
|
||||
UserRole.KOORDINATOR,
|
||||
UserRole.ERZIEHER,
|
||||
]);
|
||||
|
||||
if (!session.user.kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const parsed = dailyAbsenceSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültiges Datum." };
|
||||
}
|
||||
|
||||
const date = parseDateOnly(parsed.data.date);
|
||||
if (!session.user.kitaId) return { error: "Kein Mandant zugeordnet." };
|
||||
const parsed = z.object({ date: dateSchema }).safeParse(rawPayload);
|
||||
if (!parsed.success) return { error: "Ungültiges Datum." };
|
||||
const date = absenceDate(parsed.data.date);
|
||||
const rows = await prisma.absence.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
@@ -167,24 +273,17 @@ export async function getDailyAbsences(
|
||||
note: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
updatedAt: true,
|
||||
child: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
family: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ child: { lastName: "asc" } },
|
||||
{ child: { firstName: "asc" } },
|
||||
],
|
||||
orderBy: [{ child: { lastName: "asc" } }, { child: { firstName: "asc" } }],
|
||||
});
|
||||
|
||||
return {
|
||||
absences: rows.map((absence) => ({
|
||||
id: absence.id,
|
||||
@@ -192,8 +291,9 @@ export async function getDailyAbsences(
|
||||
familyName: absence.child.family.name,
|
||||
reason: absence.reason,
|
||||
note: absence.note,
|
||||
startDate: toDateKey(absence.startDate),
|
||||
endDate: toDateKey(absence.endDate),
|
||||
startDate: absence.startDate.toISOString().slice(0, 10),
|
||||
endDate: absence.endDate.toISOString().slice(0, 10),
|
||||
updatedAt: absence.updatedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -1,47 +1,56 @@
|
||||
"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";
|
||||
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
|
||||
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(),
|
||||
@@ -54,12 +63,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(
|
||||
@@ -85,12 +101,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>) {
|
||||
@@ -117,32 +128,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(
|
||||
@@ -187,11 +180,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 },
|
||||
@@ -213,12 +201,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) => {
|
||||
@@ -241,19 +231,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({
|
||||
@@ -276,26 +259,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;
|
||||
|
||||
@@ -318,9 +300,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);
|
||||
@@ -330,13 +316,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 },
|
||||
@@ -346,7 +325,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({
|
||||
@@ -364,8 +343,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." };
|
||||
}
|
||||
|
||||
@@ -373,7 +350,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))) {
|
||||
@@ -407,10 +386,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,19 +407,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) {
|
||||
@@ -478,41 +452,48 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
return { error: publicActionError(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, familyId: { not: null },
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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."] } };
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Pencil, Undo2 } from "lucide-react";
|
||||
import { AbsenceReason } from "@prisma/client";
|
||||
import { toast } from "sonner";
|
||||
import { deleteAbsence, updateAbsence } from "@/actions/absences";
|
||||
import { absenceDate, canEditOwnAbsence } from "@/lib/absence-policy";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export type ManagedAbsence = {
|
||||
id: string;
|
||||
childName: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
reason: AbsenceReason;
|
||||
note: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function AbsenceManagementActions({
|
||||
absence,
|
||||
today,
|
||||
team = false,
|
||||
onChanged,
|
||||
}: {
|
||||
absence: ManagedAbsence;
|
||||
today: string;
|
||||
team?: boolean;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<"edit" | "withdraw" | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [startDate, setStartDate] = useState(absence.startDate);
|
||||
const [endDate, setEndDate] = useState(absence.endDate);
|
||||
const router = useRouter();
|
||||
if (!team && !canEditOwnAbsence(absence.startDate, today))
|
||||
return (
|
||||
<p className="mt-2 text-xs text-[var(--ink-muted)]">
|
||||
Bereits begonnene Meldung: Änderungen über die Kita.
|
||||
</p>
|
||||
);
|
||||
const tomorrow = new Date(absenceDate(today).getTime() + 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
function open(next: "edit" | "withdraw") {
|
||||
setStartDate(absence.startDate);
|
||||
setEndDate(absence.endDate);
|
||||
setError(null);
|
||||
setMode(next);
|
||||
}
|
||||
function submit(data?: FormData) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const identity = { id: absence.id, updatedAt: absence.updatedAt };
|
||||
const result =
|
||||
mode === "withdraw"
|
||||
? await deleteAbsence(identity)
|
||||
: await updateAbsence({
|
||||
...identity,
|
||||
startDate,
|
||||
endDate,
|
||||
reason: String(data?.get("reason") ?? ""),
|
||||
note: String(data?.get("note") ?? ""),
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
mode === "withdraw"
|
||||
? "Abwesenheitsmeldung zurückgenommen."
|
||||
: "Abwesenheitsmeldung aktualisiert.",
|
||||
);
|
||||
setMode(null);
|
||||
onChanged?.();
|
||||
} catch {
|
||||
setError(
|
||||
"Die Meldung konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => open("edit")}
|
||||
aria-label={`Abwesenheit bearbeiten: ${absence.childName}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => open("withdraw")}
|
||||
aria-label={`Abwesenheit zurücknehmen: ${absence.childName}`}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" /> Zurücknehmen
|
||||
</Button>
|
||||
<Dialog
|
||||
open={mode !== null}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen && !pending) setMode(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "withdraw"
|
||||
? "Abwesenheit zurücknehmen"
|
||||
: "Abwesenheit bearbeiten"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{absence.childName}
|
||||
{mode === "withdraw"
|
||||
? ": Diese Meldung wird aus der Abwesenheitsübersicht entfernt."
|
||||
: ": Passe den Zeitraum, den Grund oder die Bemerkung an."}
|
||||
{!team && mode === "edit"
|
||||
? " Änderungen sind nur für zukünftige Tage möglich."
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit(new FormData(event.currentTarget));
|
||||
}}
|
||||
>
|
||||
{mode === "edit" && (
|
||||
<fieldset disabled={pending} className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`absence-start-${absence.id}`}>Von</Label>
|
||||
<Input
|
||||
id={`absence-start-${absence.id}`}
|
||||
type="date"
|
||||
required
|
||||
min={team ? undefined : tomorrow}
|
||||
value={startDate}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setStartDate(value);
|
||||
if (value > endDate) setEndDate(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`absence-end-${absence.id}`}>Bis</Label>
|
||||
<Input
|
||||
id={`absence-end-${absence.id}`}
|
||||
type="date"
|
||||
required
|
||||
min={startDate}
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`absence-reason-${absence.id}`}>Grund</Label>
|
||||
<select
|
||||
id={`absence-reason-${absence.id}`}
|
||||
name="reason"
|
||||
defaultValue={absence.reason}
|
||||
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm"
|
||||
required
|
||||
>
|
||||
<option value={AbsenceReason.ILLNESS}>Krank</option>
|
||||
<option value={AbsenceReason.VACATION}>Urlaub</option>
|
||||
<option value={AbsenceReason.OTHER}>Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`absence-note-${absence.id}`}>
|
||||
Bemerkung (optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`absence-note-${absence.id}`}
|
||||
name="note"
|
||||
maxLength={500}
|
||||
defaultValue={absence.note ?? ""}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="my-4 rounded-lg bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]"
|
||||
>
|
||||
{error}
|
||||
{error.includes("aktualisieren") && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMode(null);
|
||||
onChanged?.();
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
Ansicht aktualisieren
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => setMode(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
variant={mode === "withdraw" ? "destructive" : "default"}
|
||||
>
|
||||
{pending
|
||||
? "Wird gespeichert…"
|
||||
: mode === "withdraw"
|
||||
? "Meldung zurücknehmen"
|
||||
: "Änderungen speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,14 @@ const navItems = [
|
||||
icon: Megaphone,
|
||||
exact: false,
|
||||
},
|
||||
{
|
||||
section: "Heute",
|
||||
href: "/dashboard/abwesenheiten",
|
||||
label: "Meine Abwesenheiten",
|
||||
icon: Stethoscope,
|
||||
exact: true,
|
||||
allowedRoles: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR],
|
||||
},
|
||||
{
|
||||
section: "Heute",
|
||||
href: "/dashboard/notdienst",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Absences are calendar dates stored as UTC dates, independent of the server timezone.
|
||||
export function absenceDate(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("Ungültiges Datum.");
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value)
|
||||
throw new Error("Ungültiges Datum.");
|
||||
return date;
|
||||
}
|
||||
|
||||
export function isAbsenceDate(value: string) {
|
||||
try {
|
||||
absenceDate(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function absenceToday(now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(now);
|
||||
const part = (type: string) =>
|
||||
parts.find((item) => item.type === type)!.value;
|
||||
return `${part("year")}-${part("month")}-${part("day")}`;
|
||||
}
|
||||
|
||||
export function canManageKitaAbsences(role: string) {
|
||||
return ["ADMIN", "KOORDINATOR", "ERZIEHER"].includes(role);
|
||||
}
|
||||
|
||||
export function canEditOwnAbsence(startDate: string, today: string) {
|
||||
return startDate > today;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export type FamilyInvitationView = {
|
||||
status: "active" | "open" | "sending" | "sent" | "failed" | "expired";
|
||||
expiresAt: string | null;
|
||||
sentAt: string | null;
|
||||
};
|
||||
|
||||
export function familyInvitationView(
|
||||
emailVerifiedAt: Date | null,
|
||||
invitation: {
|
||||
expiresAt: Date;
|
||||
sentAt: Date | null;
|
||||
failed: boolean;
|
||||
leaseUntil: Date | null;
|
||||
status: string;
|
||||
} | null,
|
||||
now = new Date(),
|
||||
): FamilyInvitationView {
|
||||
let status: FamilyInvitationView["status"] = "open";
|
||||
if (emailVerifiedAt) status = "active";
|
||||
else if (invitation) {
|
||||
if (
|
||||
invitation.expiresAt <= now ||
|
||||
["EXPIRED", "REVOKED"].includes(invitation.status)
|
||||
)
|
||||
status = "expired";
|
||||
else if (invitation.leaseUntil && invitation.leaseUntil > now)
|
||||
status = "sending";
|
||||
else if (invitation.failed) status = "failed";
|
||||
else if (invitation.sentAt) status = "sent";
|
||||
}
|
||||
return {
|
||||
status,
|
||||
expiresAt: invitation?.expiresAt.toISOString() ?? null,
|
||||
sentAt: invitation?.sentAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
import { createElement } from "react";
|
||||
import { InvitationStatus, Prisma } from "@prisma/client";
|
||||
|
||||
import { InviteEmail } from "@/emails/InviteEmail";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const INVITE_LIFETIME_MS = 7 * 24 * 60 * 60_000;
|
||||
|
||||
// The account, activation token and delivery status commit together.
|
||||
export async function queueFamilyInvitation(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
invitedById: string,
|
||||
) {
|
||||
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
if (!user.kitaId || !user.familyId || user.emailVerifiedAt || user.passwordHash || user.disabledAt)
|
||||
throw new Error("Dieses Konto benötigt keine Familieneinladung.");
|
||||
const existing = await tx.invitation.findFirst({
|
||||
where: {
|
||||
OR: [{ userId }, { kitaId: user.kitaId, email: user.email }],
|
||||
},
|
||||
});
|
||||
if (existing?.userId && existing.userId !== userId)
|
||||
throw new Error("Die Einladung gehört zu einem anderen Konto.");
|
||||
if (existing?.leaseUntil && existing.leaseUntil > new Date())
|
||||
throw new Error("Eine Einladung wird gerade versendet. Bitte kurz warten.");
|
||||
const token = newToken();
|
||||
const expiresAt = new Date(Date.now() + INVITE_LIFETIME_MS);
|
||||
await tx.verificationToken.deleteMany({ where: { identifier: userId } });
|
||||
await tx.verificationToken.create({
|
||||
data: { identifier: userId, token: hashToken(token), expires: expiresAt },
|
||||
});
|
||||
const data = {
|
||||
userId,
|
||||
kitaId: user.kitaId,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token,
|
||||
expiresAt,
|
||||
invitedById,
|
||||
status: InvitationStatus.PENDING,
|
||||
acceptedAt: null,
|
||||
sentAt: null,
|
||||
lastAttemptAt: null,
|
||||
leaseUntil: null,
|
||||
attempts: 0,
|
||||
failed: false,
|
||||
};
|
||||
return existing
|
||||
? tx.invitation.update({ where: { id: existing.id }, data })
|
||||
: tx.invitation.create({ data });
|
||||
}
|
||||
|
||||
// Tests inject a transport. Every recipient is attempted despite individual failures.
|
||||
export async function deliverFamilyInvitations(
|
||||
ids: string[],
|
||||
send = sendAppEmail,
|
||||
) {
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const id of new Set(ids)) {
|
||||
const now = new Date();
|
||||
const leaseUntil = new Date(now.getTime() + 5 * 60_000);
|
||||
const claim = await prisma.invitation.updateMany({
|
||||
where: {
|
||||
id,
|
||||
userId: { not: null },
|
||||
status: InvitationStatus.PENDING,
|
||||
sentAt: null,
|
||||
expiresAt: { gt: now },
|
||||
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
|
||||
},
|
||||
data: { leaseUntil, lastAttemptAt: now, attempts: { increment: 1 } },
|
||||
});
|
||||
if (!claim.count) continue;
|
||||
const finish = (data: Prisma.InvitationUpdateManyMutationInput) =>
|
||||
prisma.invitation.updateMany({
|
||||
where: { id, token: jobToken, leaseUntil },
|
||||
data: { ...data, leaseUntil: null },
|
||||
});
|
||||
let jobToken: string | undefined;
|
||||
try {
|
||||
const job = await prisma.invitation.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { user: true, kita: { select: { name: true } } },
|
||||
});
|
||||
jobToken = job.token;
|
||||
const token = await prisma.verificationToken.findUnique({
|
||||
where: { token: hashToken(job.token) },
|
||||
});
|
||||
const user = job.user;
|
||||
if (
|
||||
!user ||
|
||||
user.kitaId !== job.kitaId ||
|
||||
!user.familyId ||
|
||||
user.email !== job.email ||
|
||||
user.emailVerifiedAt ||
|
||||
user.disabledAt ||
|
||||
user.passwordHash !== "" ||
|
||||
!token ||
|
||||
token.identifier !== user.id ||
|
||||
token.expires <= now
|
||||
) {
|
||||
await finish({
|
||||
status: user?.emailVerifiedAt
|
||||
? InvitationStatus.ACCEPTED
|
||||
: InvitationStatus.REVOKED,
|
||||
failed: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
process.env.AUTH_URL ||
|
||||
process.env.NEXTAUTH_URL;
|
||||
if (!base) throw new Error("App-Adresse für Einladungen fehlt.");
|
||||
const site = new URL(base);
|
||||
if (
|
||||
!["https:", "http:"].includes(site.protocol) ||
|
||||
site.username ||
|
||||
site.password
|
||||
)
|
||||
throw new Error("App-Adresse für Einladungen ist ungültig.");
|
||||
const result = await send({
|
||||
to: job.email,
|
||||
subject: `Einladung zu ${job.kita.name} im Kita-Planer`,
|
||||
idempotencyKey: `family-invitation/${job.token}`,
|
||||
react: createElement(InviteEmail, {
|
||||
parentName: `${user.firstName} ${user.lastName}`,
|
||||
kitaName: job.kita.name,
|
||||
inviteLink: new URL(`/invite/${job.token}`, site.origin).toString(),
|
||||
}),
|
||||
});
|
||||
if (result.success) {
|
||||
await finish({ sentAt: new Date(), failed: false });
|
||||
sent++;
|
||||
} else {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
}
|
||||
} catch {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { sent, failed };
|
||||
}
|
||||
|
||||
export async function resendFamilyInvitation(
|
||||
kitaId: string,
|
||||
userId: string,
|
||||
actorId: string,
|
||||
expectedEmail?: string,
|
||||
) {
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
const actor = await tx.user.findFirst({
|
||||
where: { id: actorId, kitaId, role: "ADMIN", disabledAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!actor)
|
||||
return {
|
||||
error: "Nur Kita-Admins dürfen Familieneinladungen erneut senden.",
|
||||
};
|
||||
// Serializes renewal with other renewals for this account.
|
||||
await tx.$queryRaw`SELECT "id" FROM "users" WHERE "id" = ${userId} AND "kitaId" = ${kitaId} FOR UPDATE`;
|
||||
const user = await tx.user.findFirst({
|
||||
where: { id: userId, kitaId, familyId: { not: null } },
|
||||
});
|
||||
if (!user) return { error: "Elternkonto wurde nicht gefunden." };
|
||||
if (user.emailVerifiedAt || user.passwordHash || user.disabledAt)
|
||||
return { error: "Keine offene Einladung gefunden." };
|
||||
if (expectedEmail && user.email !== expectedEmail)
|
||||
return { error: "Die Einladung hat sich geändert. Bitte lade die Seite neu." };
|
||||
const existing = await tx.invitation.findUnique({ where: { userId } });
|
||||
const now = new Date();
|
||||
if (existing?.leaseUntil && existing.leaseUntil > now)
|
||||
return {
|
||||
error: "Die Einladung wird gerade versendet. Bitte kurz warten.",
|
||||
};
|
||||
if (existing?.sentAt && now.getTime() - existing.sentAt.getTime() < 60_000)
|
||||
return {
|
||||
error:
|
||||
"Die Einladung wurde gerade versendet. Bitte nach einer Minute erneut versuchen.",
|
||||
};
|
||||
const token = existing
|
||||
? await tx.verificationToken.findUnique({
|
||||
where: { token: hashToken(existing.token) },
|
||||
})
|
||||
: null;
|
||||
if (
|
||||
existing &&
|
||||
existing.status === InvitationStatus.PENDING &&
|
||||
!existing.sentAt &&
|
||||
existing.email === user.email &&
|
||||
existing.expiresAt > now &&
|
||||
token?.identifier === user.id &&
|
||||
token.expires > now
|
||||
)
|
||||
return { id: existing.id };
|
||||
const invitation = await queueFamilyInvitation(tx, userId, actorId);
|
||||
return { id: invitation.id };
|
||||
});
|
||||
if (outcome.error || !outcome.id)
|
||||
return {
|
||||
error: outcome.error ?? "Einladung konnte nicht erstellt werden.",
|
||||
};
|
||||
const result = await deliverFamilyInvitations([outcome.id]);
|
||||
return {
|
||||
success: true,
|
||||
...(result.failed
|
||||
? {
|
||||
warning:
|
||||
"Einladung ist gespeichert, konnte aber nicht versendet werden. Du kannst den Versand erneut versuchen.",
|
||||
}
|
||||
: !result.sent
|
||||
? {
|
||||
warning:
|
||||
"Die Einladung ist vorbereitet. Bitte prüfe den aktuellen Versandstatus.",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user