Merge current features with security hardening and automatic safe initialization

This commit is contained in:
t.indorf
2026-10-09 23:29:19 +02:00
85 changed files with 13522 additions and 1326 deletions
+7 -123
View File
@@ -1,126 +1,10 @@
"use server";
import { serializableTransaction } from "@/lib/transaction";
import { UserFacingError, publicActionError } from "@/lib/action-error";
import { hashToken, newToken } from "@/lib/tokens";
import { rateLimit } from "@/lib/rate-limit";
import { createElement } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { revalidatePath } from "next/cache";
import { NotdienstAlertStatus, UserRole } from "@prisma/client";
import { AlertEmail } from "@/emails/AlertEmail";
import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
import { alarmAction } from "./notdienst/alarm/actions";
// Existing callers use the shared household workflow and the same guards.
export async function triggerAlertAction(assignmentId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
if (!(await rateLimit(`alerts:${kitaId}:${session.user.id}`, 50, 3600)).allowed) return { error: "Zu viele Alarme. Bitte versuche es später erneut." };
try {
const [existing, assignment] = await Promise.all([
// Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt
prisma.notdienstAlert.findFirst({
where: { assignmentId, kitaId },
}),
prisma.notdienstAssignment.findFirst({
where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } },
include: {
child: {
select: {
id: true,
familyId: true,
firstName: true,
lastName: true,
family: {
select: {
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
},
},
},
},
},
}),
]);
if (existing) {
return { error: "Für diese Einteilung wurde bereits ein Alarm ausgelöst." };
}
if (!assignment) {
return { error: "Notdienst-Einteilung wurde nicht gefunden." };
}
const parents = assignment.child.family.users;
if (parents.length === 0) {
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
locale: de,
});
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
const alertJobs = parents.map(parent => ({ email: parent.email, token: newToken(), parentId: parent.id }));
await serializableTransaction(async tx => {
if (await tx.notdienstAlert.findFirst({ where: { assignmentId, kitaId }, select: { id: true } })) {
throw new UserFacingError("Für diese Einteilung wurde bereits ein Alarm ausgelöst.");
}
const current = await tx.notdienstAssignment.findFirst({ where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } }, select: { childId: true, date: true } });
const today = new Date(); today.setUTCHours(0, 0, 0, 0);
if (!current || current.childId !== assignment.child.id || current.date < today) throw new UserFacingError("Die Einteilung hat sich geändert oder liegt in der Vergangenheit. Bitte lade die Seite neu.");
for (const job of alertJobs) {
const parent = await tx.user.findFirst({ where: { id: job.parentId, kitaId, familyId: assignment.child.familyId,
email: job.email, disabledAt: null, emailVerifiedAt: { not: null }, privacyPolicyAcceptedAt: { not: null } }, select: { id: true } });
if (!parent) throw new UserFacingError("Die Empfänger haben sich geändert. Bitte lade die Seite neu.");
await tx.notdienstAlert.create({ data: { kitaId, assignmentId, parentUserId: parent.id,
triggeredById: session.user.id, status: NotdienstAlertStatus.PENDING, confirmationToken: hashToken(job.token) } });
}
});
for (const job of alertJobs) {
const emailResult = await sendAppEmail({
to: job.email,
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
react: createElement(AlertEmail, {
date: dateLabel,
childName,
confirmLink: `${BASE_URL}/alert/${job.token}`,
}),
});
if (!emailResult.success) {
revalidatePath("/dashboard");
return {
error: `Alarm wurde angelegt, aber mindestens eine E-Mail konnte nicht versendet werden: ${emailResult.error}`,
};
}
}
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error(error);
return { error: publicActionError(error, "Fehler beim Auslösen des Alarms.") };
}
return alarmAction("create", {
assignmentId,
wholeDay: true,
replyMinutes: 30,
});
}
@@ -3,6 +3,7 @@
import { useMemo, useState, useTransition } from "react";
import {
EventParticipationStatus,
RsvpMode,
TerminStatus,
TerminType,
} from "@prisma/client";
@@ -19,6 +20,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { suggestedRsvpMode } from "@/lib/rsvp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -48,10 +50,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
createTerminAdmin,
updateTerminAdmin,
} from "../../kalender/actions";
import { createTerminAdmin, updateTerminAdmin } from "../../kalender/actions";
type AdminTerminDto = {
id: string;
@@ -64,6 +63,12 @@ type AdminTerminDto = {
allDay: boolean;
mitbringselListEnabled: boolean;
requiresRsvp: boolean;
rsvpMode: RsvpMode;
familyParticipations: {
familyId: string;
status: EventParticipationStatus;
adultCount: number;
}[];
rsvpDeadline: string | null;
participantInfo: string | null;
participations: {
@@ -81,9 +86,11 @@ type ChildDto = {
export function AdminCalendarManager({
termine,
childRows,
familyRows,
}: {
termine: AdminTerminDto[];
childRows: ChildDto[];
familyRows: { id: string; name: string }[];
}) {
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
const pendingCount = termine.filter(
@@ -104,8 +111,8 @@ export function AdminCalendarManager({
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
verfolgen.
Termine verwalten, RSVP aktivieren und den Rücklauf pro Familie oder
Kind verfolgen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
@@ -134,7 +141,7 @@ export function AdminCalendarManager({
Noch keine Termine
</h2>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
</p>
</div>
</Card>
@@ -145,6 +152,7 @@ export function AdminCalendarManager({
key={termin.id}
termin={termin}
childRows={childRows}
familyRows={familyRows}
/>
))}
</div>
@@ -156,11 +164,25 @@ export function AdminCalendarManager({
function AdminTerminCard({
termin,
childRows,
familyRows,
}: {
termin: AdminTerminDto;
childRows: ChildDto[];
familyRows: { id: string; name: string }[];
}) {
const stats = useMemo(() => getStats(termin, childRows), [termin, childRows]);
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
const rows = useMemo(
() =>
isFamily
? familyRows.map((family) => ({
id: family.id,
name: family.name,
familyName: family.name,
}))
: childRows,
[isFamily, familyRows, childRows],
);
const stats = useMemo(() => getStats(termin, rows), [termin, rows]);
return (
<Card className="overflow-hidden">
@@ -175,7 +197,9 @@ function AdminTerminCard({
{getStatusLabel(termin.status)}
</Badge>
{termin.requiresRsvp && (
<Badge variant="warning">RSVP aktiv</Badge>
<Badge variant="warning">
Antwort pro {isFamily ? "Familie" : "Kind"}
</Badge>
)}
</div>
<CardTitle className="text-lg">{termin.title}</CardTitle>
@@ -217,14 +241,19 @@ function AdminTerminCard({
<div>
<p className="font-medium">
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
{stats.pending} Ausstehend
{stats.pending} Ausstehend{" "}
{isFamily ? `· ${stats.adultCount} Erwachsene` : ""}
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Frist:{" "}
{termin.rsvpDeadline
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
locale: de,
})
? format(
new Date(termin.rsvpDeadline),
"dd.MM.yyyy HH:mm",
{
locale: de,
},
)
: "bis Terminbeginn"}
</p>
</div>
@@ -241,18 +270,24 @@ function AdminTerminCard({
<Table>
<TableHeader>
<TableRow>
<TableHead>Kind</TableHead>
<TableHead>Familie</TableHead>
<TableHead>{isFamily ? "Familie" : "Kind"}</TableHead>
<TableHead>{isFamily ? "Erwachsene" : "Familie"}</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{childRows.map((child) => {
{rows.map((child) => {
const status = stats.statusByChildId.get(child.id) ?? null;
return (
<TableRow key={child.id}>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell>{child.familyName}</TableCell>
<TableCell className="font-medium">
{child.name}
</TableCell>
<TableCell>
{isFamily
? (stats.adultCounts.get(child.id) ?? "–")
: child.familyName}
</TableCell>
<TableCell>
<RsvpStatusBadge status={status} />
</TableCell>
@@ -283,6 +318,13 @@ function TerminFormDialog({
const [requiresRsvp, setRequiresRsvp] = useState(
termin?.requiresRsvp ?? false,
);
const [rsvpMode, setRsvpMode] = useState(
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
);
const [modeTouched, setModeTouched] = useState(false);
const modeLocked =
!!termin &&
termin.participations.length + termin.familyParticipations.length > 0;
const [isPending, startTransition] = useTransition();
function handleAction(formData: FormData) {
@@ -300,6 +342,7 @@ function TerminFormDialog({
allDay: formData.get("allDay") === "on",
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
requiresRsvp: hasRsvp,
rsvpMode,
rsvpDeadline:
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
participantInfo: String(formData.get("participantInfo") ?? ""),
@@ -330,6 +373,10 @@ function TerminFormDialog({
setOpen(nextOpen);
if (nextOpen) {
setRequiresRsvp(termin?.requiresRsvp ?? false);
setRsvpMode(
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
);
setModeTouched(false);
}
}}
>
@@ -380,6 +427,12 @@ function TerminFormDialog({
<select
id={`${mode}-type`}
name="type"
onChange={(event) => {
if (!termin && !modeLocked && !modeTouched)
setRsvpMode(
suggestedRsvpMode(event.target.value as TerminType),
);
}}
defaultValue={termin?.type ?? TerminType.KITA_FEST}
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
required
@@ -453,9 +506,33 @@ function TerminFormDialog({
{requiresRsvp && (
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>
Rückmeldefrist
</Label>
<Label htmlFor={`${mode}-rsvpMode`}>Antwortart</Label>
<select
id={`${mode}-rsvpMode`}
value={rsvpMode}
disabled={modeLocked}
onChange={(e) => {
setRsvpMode(e.target.value as RsvpMode);
setModeTouched(true);
}}
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm disabled:opacity-60"
>
<option value="FAMILY">
Pro Familie · mit Anzahl Erwachsener
</option>
<option value="CHILD">
Pro Kind · einzelne Zu-/Absage
</option>
</select>
{modeLocked ? (
<p className="text-xs text-[var(--ink-soft)]">
Die Antwortart ist gesperrt, da bereits Rückmeldungen
vorliegen.
</p>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>Rückmeldefrist</Label>
<Input
id={`${mode}-rsvpDeadline`}
name="rsvpDeadline"
@@ -532,28 +609,34 @@ function RsvpStatusBadge({
);
}
function getStats(termin: AdminTerminDto, children: ChildDto[]) {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
function getStats(termin: AdminTerminDto, rows: ChildDto[]) {
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
const statusByChildId = new Map<string, EventParticipationStatus>(
isFamily
? termin.familyParticipations.map((row) => [row.familyId, row.status])
: termin.participations.map((row) => [row.childId, row.status]),
);
const adultCounts = new Map(
termin.familyParticipations.map((row) => [row.familyId, row.adultCount]),
);
let attending = 0;
let notAttending = 0;
children.forEach((child) => {
const status = statusByChildId.get(child.id);
if (status === EventParticipationStatus.ATTENDING) attending += 1;
let adultCount = 0;
for (const row of rows) {
const status = statusByChildId.get(row.id);
if (status === EventParticipationStatus.ATTENDING) {
attending += 1;
adultCount += adultCounts.get(row.id) ?? 0;
}
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
});
}
return {
attending,
notAttending,
pending: Math.max(children.length - attending - notAttending, 0),
pending: rows.length - attending - notAttending,
statusByChildId,
adultCounts,
adultCount,
};
}
+11 -1
View File
@@ -14,7 +14,7 @@ export default async function AdminKalenderPage() {
return <div className="p-8">Kein Mandant zugeordnet.</div>;
}
const [termine, children] = await Promise.all([
const [termine, children, families] = await Promise.all([
prisma.termin.findMany({
where: { kitaId },
select: {
@@ -28,6 +28,10 @@ export default async function AdminKalenderPage() {
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
rsvpMode: true,
familyParticipations: {
select: { familyId: true, status: true, adultCount: true },
},
rsvpDeadline: true,
participantInfo: true,
participations: {
@@ -60,6 +64,11 @@ export default async function AdminKalenderPage() {
{ firstName: "asc" },
],
}),
prisma.family.findMany({
where: { kitaId },
select: { id: true, name: true },
orderBy: { name: "asc" },
}),
]);
return (
@@ -70,6 +79,7 @@ export default async function AdminKalenderPage() {
endDate: termin.endDate.toISOString(),
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
}))}
familyRows={families}
childRows={children.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
+20 -16
View File
@@ -64,7 +64,9 @@ export function NewsForm() {
rows={10}
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
placeholder={
"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."
}
required
/>
<p className="text-xs text-[var(--ink-muted)]">
@@ -110,21 +112,23 @@ export function NewsForm() {
</Label>
</div>
{sendEmail && (
<div className="mt-4 grid gap-2">
<Label>Empfänger</Label>
<Select name="audience" defaultValue="ELTERN">
<SelectTrigger>
<SelectValue placeholder="Empfänger auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ELTERN">Eltern</SelectItem>
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
<SelectItem value="ALLE">Alle</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="mt-4 grid gap-2">
<Label htmlFor="news-audience">Empfängergruppe</Label>
<p className="text-xs text-[var(--ink-soft)]">
Bestimmt, wer die Meldung und Anhänge in der App sehen darf und
optional die E-Mail erhält.
</p>
<Select name="audience" defaultValue="ELTERN">
<SelectTrigger id="news-audience">
<SelectValue placeholder="Empfänger auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ELTERN">Eltern</SelectItem>
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
<SelectItem value="ALLE">Eltern und ErzieherInnen</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end">
+7 -2
View File
@@ -13,6 +13,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { announcementAudienceLabels } from "@/lib/announcement-access";
import { NewsForm } from "./news-form";
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
@@ -30,6 +31,7 @@ export default async function AdminNewsPage() {
select: {
id: true,
title: true,
audience: true,
createdAt: true,
attachments: {
select: {
@@ -63,8 +65,8 @@ export default async function AdminNewsPage() {
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
werden geschützt gespeichert und nur für eingeloggte Nutzer
derselben Kita ausgeliefert.
werden geschützt gespeichert und nur an die gewählte Empfängergruppe
deiner Kita ausgeliefert.
</p>
</div>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
@@ -127,6 +129,9 @@ export default async function AdminNewsPage() {
</Badge>
)}
</div>
<Badge variant="outline" className="mb-2">
{announcementAudienceLabels[announcement.audience]}
</Badge>
<p className="text-xs text-[var(--ink-muted)]">
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
locale: de,
@@ -1,6 +1,8 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Plus, Pencil, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
@@ -29,18 +31,112 @@ import {
TableRow,
} from "@/components/ui/table";
import { createEducator, updateEducator, deleteEducator } from "../actions";
import {
createEducator,
updateEducator,
deleteEducator,
resendEducatorInvitation,
} from "../actions";
type EducatorDto = {
id: string;
firstName: string;
lastName: string;
active: boolean;
hasProfile: boolean;
email: string | null;
verified: boolean;
inviteFailed: boolean;
};
export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
function AccessFields({
prefix,
today,
educator,
}: {
prefix: string;
today: string;
educator?: EducatorDto;
}) {
return (
<>
<div className="grid gap-2">
<Label htmlFor={`${prefix}-email`}>E-Mail-Adresse</Label>
<Input
id={`${prefix}-email`}
name="email"
type="email"
autoComplete="email"
defaultValue={educator?.email ?? ""}
readOnly={!!educator?.email}
required
maxLength={254}
/>
<p className="text-xs leading-5 text-[var(--ink-soft)]">
{educator?.email
? "Diese Adresse gehört zum verknüpften Account."
: "An diese Adresse senden wir den persönlichen Aktivierungslink."}
</p>
</div>
{!educator?.hasProfile && (
<div className="grid gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`${prefix}-employmentStart`}>
Beschäftigungsbeginn
</Label>
<Input
id={`${prefix}-employmentStart`}
name="employmentStart"
type="date"
required
min="2000-01-01"
max="2100-12-31"
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${prefix}-trackingStart`}>
Start der Zeiterfassung
</Label>
<Input
id={`${prefix}-trackingStart`}
name="trackingStart"
type="date"
defaultValue={today}
required
min="2000-01-01"
max="2100-12-31"
/>
</div>
<p className="text-xs leading-5 text-[var(--ink-soft)] sm:col-span-2">
Ab diesem Datum werden Arbeitszeiten im Kita-Planer erfasst.
Arbeitstage, Urlaub und vorhandene Überstunden richtest du
anschließend im Personalprofil ein.
</p>
</div>
)}
</>
);
}
export function ErzieherList({
educators,
today,
}: {
educators: EducatorDto[];
today: string;
}) {
const router = useRouter();
const [search, setSearch] = useState("");
const [formError, setFormError] = useState("");
const filtered = educators.filter((ed) =>
`${ed.firstName} ${ed.lastName} ${ed.email ?? ""}`
.toLocaleLowerCase("de")
.includes(search.toLocaleLowerCase("de")),
);
const [openCreate, setOpenCreate] = useState(false);
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null);
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(
null,
);
const [educatorToDelete, setEducatorToDelete] = useState<EducatorDto | null>(
null,
);
@@ -51,15 +147,24 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
firstName: formData.get("firstName") as string,
lastName: formData.get("lastName") as string,
active: formData.get("active") === "on",
email: formData.get("email"),
employmentStart: formData.get("employmentStart"),
trackingStart: formData.get("trackingStart"),
};
setFormError("");
startTransition(async () => {
const res = await createEducator(data);
if (res.error) {
toast.error(res.error);
setFormError(res.error);
} else {
toast.success("ErzieherIn angelegt.");
toast.success(res.message ?? "Fachkraft und Personalprofil angelegt.");
if (res.warning) toast.warning(res.warning, { duration: 12000 });
setOpenCreate(false);
if (res.educatorId)
router.push(
`/dashboard/personal?tab=verwaltung&educator=${res.educatorId}`,
);
}
});
};
@@ -70,19 +175,35 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
firstName: formData.get("firstName") as string,
lastName: formData.get("lastName") as string,
active: formData.get("active") === "on",
email: formData.get("email"),
employmentStart: formData.get("employmentStart"),
trackingStart: formData.get("trackingStart"),
};
setFormError("");
startTransition(async () => {
const res = await updateEducator(editingEducator.id, data);
if (res.error) {
toast.error(res.error);
setFormError(res.error);
} else {
toast.success("Daten aktualisiert.");
toast.success(res.message ?? "Daten aktualisiert.");
if (res.warning) toast.warning(res.warning, { duration: 12000 });
setEditingEducator(null);
router.refresh();
}
});
};
const handleResend = (educator: EducatorDto) => {
startTransition(async () => {
const res = await resendEducatorInvitation(educator.id);
if (res.error) toast.error(res.error);
else if (res.warning) toast.warning(res.warning, { duration: 12000 });
else toast.success(res.message ?? "Einladung erneuert.");
router.refresh();
});
};
const handleDelete = (educator: EducatorDto) => {
startTransition(async () => {
const res = await deleteEducator(educator.id);
@@ -100,24 +221,44 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input placeholder="Name suchen" className="pl-9" />
<Input
placeholder="Name oder E-Mail suchen"
aria-label="Team durchsuchen"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Dialog open={openCreate} onOpenChange={setOpenCreate}>
<Dialog
open={openCreate}
onOpenChange={(open) => {
if (!isPending) {
setOpenCreate(open);
setFormError("");
}
}}
>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4 [stroke-width:1.7]" />
ErzieherIn anlegen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleCreate}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<form
onSubmit={(event) => {
event.preventDefault();
handleCreate(new FormData(event.currentTarget));
}}
>
<DialogHeader>
<DialogTitle>Neue(n) ErzieherIn anlegen</DialogTitle>
<DialogDescription>
Erfasse die Stammdaten des Kita-Personals.
Lege Stammdaten, Personalprofil und App-Zugang gemeinsam an.
Die Fachkraft erhält ihren Aktivierungslink per E-Mail.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<fieldset disabled={isPending} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="firstName">Vorname</Label>
<Input id="firstName" name="firstName" required />
@@ -126,14 +267,31 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
<Label htmlFor="lastName">Nachname</Label>
<Input id="lastName" name="lastName" required />
</div>
<AccessFields prefix="create" today={today} />
<div className="flex items-center gap-2 mt-2">
<Switch id="active" name="active" defaultChecked />
<Label htmlFor="active">Aktiv (Arbeitet aktuell hier)</Label>
</div>
</div>
</fieldset>
{formError && (
<p role="alert" className="mb-4 text-sm text-[var(--danger)]">
{formError}
</p>
)}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpenCreate(false)}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>Speichern</Button>
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => setOpenCreate(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending
? "Wird angelegt …"
: "Fachkraft anlegen und einladen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
@@ -153,91 +311,188 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
</Badge>
</CardHeader>
<CardContent className="px-0 pb-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Vorname</TableHead>
<TableHead>Nachname</TableHead>
<TableHead>Status</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{educators.length === 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={4} className="h-32 text-center">
<div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Plus className="h-6 w-6 [stroke-width:1.7]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Keine Einträge gefunden
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege die erste Person im Team an.
</p>
</div>
</TableCell>
<TableHead className="pl-6">Vorname</TableHead>
<TableHead>Nachname</TableHead>
<TableHead>E-Mail / App-Zugang</TableHead>
<TableHead>Status</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
) : (
educators.map((ed) => (
<TableRow key={ed.id}>
<TableCell className="pl-6 font-medium">{ed.firstName}</TableCell>
<TableCell>{ed.lastName}</TableCell>
<TableCell>
<Badge variant={ed.active ? "success" : "secondary"}>
{ed.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="pr-6 text-right">
<Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}>
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => setEducatorToDelete(ed)}
disabled={isPending}
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
>
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-32 text-center">
<div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Plus className="h-6 w-6 [stroke-width:1.7]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Keine Einträge gefunden
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege die erste Person im Team an.
</p>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
) : (
filtered.map((ed) => (
<TableRow key={ed.id}>
<TableCell className="pl-6 font-medium">
{ed.firstName}
</TableCell>
<TableCell>{ed.lastName}</TableCell>
<TableCell>
<p className="mb-1 text-sm">
{ed.email ?? "Noch keine E-Mail hinterlegt"}
</p>
<Badge variant={ed.verified ? "success" : "secondary"}>
{ed.verified
? "Account aktiviert"
: ed.inviteFailed
? "Mailversand fehlgeschlagen"
: ed.email
? "Einladung ausstehend"
: "Zugang fehlt"}
</Badge>
{ed.email && !ed.verified && (
<Button
variant="ghost"
size="sm"
disabled={isPending}
onClick={() => handleResend(ed)}
aria-label={`Einladung für ${ed.firstName} ${ed.lastName} erneut senden`}
className="mt-1 block"
>
Einladung erneut senden
</Button>
)}
</TableCell>
<TableCell>
<Badge variant={ed.active ? "success" : "secondary"}>
{ed.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="pr-6 text-right">
<Button
variant="outline"
size="sm"
asChild
className="mr-2"
>
<Link
href={`/dashboard/personal?tab=verwaltung&educator=${ed.id}`}
>
Personalprofil
</Link>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
setFormError("");
setEditingEducator(ed);
}}
disabled={isPending}
aria-label={`${ed.firstName} ${ed.lastName} bearbeiten`}
>
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => setEducatorToDelete(ed)}
disabled={isPending}
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
>
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}>
<DialogContent>
<form action={handleUpdate}>
<Dialog
open={!!editingEducator}
onOpenChange={(open) => {
if (!open && !isPending) setEditingEducator(null);
}}
>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<form
onSubmit={(event) => {
event.preventDefault();
handleUpdate(new FormData(event.currentTarget));
}}
>
<DialogHeader>
<DialogTitle>ErzieherIn bearbeiten</DialogTitle>
</DialogHeader>
{editingEducator && (
<div className="grid gap-4 py-4">
<fieldset disabled={isPending} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-firstName">Vorname</Label>
<Input id="edit-firstName" name="firstName" defaultValue={editingEducator.firstName} required />
<Input
id="edit-firstName"
name="firstName"
defaultValue={editingEducator.firstName}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-lastName">Nachname</Label>
<Input id="edit-lastName" name="lastName" defaultValue={editingEducator.lastName} required />
<Input
id="edit-lastName"
name="lastName"
defaultValue={editingEducator.lastName}
required
/>
</div>
<AccessFields
prefix="edit"
today={today}
educator={editingEducator}
/>
<div className="flex items-center gap-2 mt-2">
<Switch id="edit-active" name="active" defaultChecked={editingEducator.active} />
<Switch
id="edit-active"
name="active"
defaultChecked={editingEducator.active}
/>
<Label htmlFor="edit-active">Aktiv</Label>
</div>
</div>
</fieldset>
)}
{formError && (
<p role="alert" className="mb-4 text-sm text-[var(--danger)]">
{formError}
</p>
)}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setEditingEducator(null)}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>Aktualisieren</Button>
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => setEditingEducator(null)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending
? "Wird gespeichert …"
: editingEducator?.email
? "Aktualisieren"
: "Speichern und einladen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
+22 -65
View File
@@ -6,74 +6,23 @@ import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { staffAction } from "../personal/actions";
const educatorSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich").max(100).trim(),
active: z.boolean().default(true),
});
// The staff service creates the person, profile, account and mail job in one transaction.
export async function createEducator(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.educator.create({
data: {
kitaId,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
active: parsed.data.active,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload);
if (!parsed.success) return { error: "Ungültige Eingabedaten." };
return staffAction("educatorCreate", parsed.data);
}
export async function updateEducator(id: string, rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.educator.update({
where: {
id,
kitaId,
},
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
active: parsed.data.active,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload);
if (!parsed.success) return { error: "Ungültige Eingabedaten." };
return staffAction("educatorUpdate", { ...parsed.data, educatorId: id });
}
export async function resendEducatorInvitation(educatorId: string) {
return staffAction("educatorInvite", { educatorId });
}
export async function deleteEducator(id: string) {
@@ -82,15 +31,23 @@ export async function deleteEducator(id: string) {
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
const profile = await prisma.staffProfile.findFirst({
where: { educatorId: id, kitaId },
});
if (profile)
return {
error:
"Diese Fachkraft hat ein Personalprofil. Bitte auf inaktiv setzen, damit Zeiten und Konten erhalten bleiben.",
};
await prisma.educator.delete({
where: {
id,
kitaId,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
+25 -3
View File
@@ -3,6 +3,7 @@ import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { todayKey } from "@/lib/staff/calculations";
import { ErzieherList } from "./_components/erzieher-list";
export const metadata = { title: "ErzieherInnen · Kita-Planer" };
@@ -17,6 +18,18 @@ export default async function ErzieherPage() {
firstName: true,
lastName: true,
active: true,
staffProfile: {
select: {
id: true,
user: { select: { email: true, emailVerifiedAt: true } },
emails: {
where: { inviteToken: { not: null }, skippedAt: null },
orderBy: { createdAt: "desc" },
take: 1,
select: { failed: true },
},
},
},
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
});
@@ -36,8 +49,8 @@ export default async function ErzieherPage() {
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Stammdaten des Kita-Personals für interne Planung und spätere
Notdienst-Alarmierung.
Stammdaten des Teams und Zugang zu Beschäftigungsprofilen,
Urlaubsansprüchen und Arbeitszeitmodellen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
@@ -51,7 +64,16 @@ export default async function ErzieherPage() {
</div>
</header>
<ErzieherList educators={educators} />
<ErzieherList
today={todayKey()}
educators={educators.map(({ staffProfile, ...educator }) => ({
...educator,
hasProfile: !!staffProfile,
email: staffProfile?.user?.email ?? null,
verified: !!staffProfile?.user?.emailVerifiedAt,
inviteFailed: !!staffProfile?.emails[0]?.failed,
}))}
/>
</div>
);
}
@@ -2,10 +2,11 @@
import type { ComponentProps, FormEvent } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { TerminType } from "@prisma/client";
import { RsvpMode, TerminType } from "@prisma/client";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { suggestedRsvpMode } from "@/lib/rsvp";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -28,6 +29,11 @@ type AdminTerminFormState = {
startDate: string;
endDate: string;
allDay: boolean;
requiresRsvp: boolean;
rsvpMode: RsvpMode;
rsvpDeadline: string;
participantInfo: string;
mitbringselListEnabled: boolean;
};
const initialFormState: AdminTerminFormState = {
@@ -37,6 +43,11 @@ const initialFormState: AdminTerminFormState = {
startDate: "",
endDate: "",
allDay: false,
requiresRsvp: false,
rsvpMode: RsvpMode.FAMILY,
rsvpDeadline: "",
participantInfo: "",
mitbringselListEnabled: false,
};
type AdminTerminModalProps = {
@@ -51,6 +62,12 @@ type AdminTerminModalProps = {
startDate: Date;
endDate: Date;
allDay: boolean;
requiresRsvp: boolean;
rsvpMode: RsvpMode;
rsvpDeadline: Date | null;
participantInfo: string | null;
mitbringselListEnabled: boolean;
hasRsvpResponses: boolean;
};
};
@@ -61,10 +78,12 @@ export function AdminTerminModal({
existingTermin,
}: AdminTerminModalProps) {
const [open, setOpen] = useState(false);
const [formState, setFormState] = useState<AdminTerminFormState>(initialFormState);
const [formState, setFormState] =
useState<AdminTerminFormState>(initialFormState);
const [modeTouched, setModeTouched] = useState(false);
const modeLocked = existingTermin?.hasRsvpResponses ?? false;
const [isPending, startTransition] = useTransition();
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
const minEndDateTime = formState.startDate || minDateTime;
// Initialize and reset form fields when modal opens
useEffect(() => {
@@ -78,10 +97,18 @@ export function AdminTerminModal({
startDate: getLocalDateTimeInputValue(existingTermin.startDate),
endDate: getLocalDateTimeInputValue(existingTermin.endDate),
allDay: existingTermin.allDay,
requiresRsvp: existingTermin.requiresRsvp,
rsvpMode: existingTermin.rsvpMode,
rsvpDeadline: existingTermin.rsvpDeadline
? getLocalDateTimeInputValue(existingTermin.rsvpDeadline)
: "",
participantInfo: existingTermin.participantInfo ?? "",
mitbringselListEnabled: existingTermin.mitbringselListEnabled,
});
} else {
setFormState(initialFormState);
}
setModeTouched(false);
}
}, [open, existingTermin]);
@@ -91,7 +118,14 @@ export function AdminTerminModal({
) => {
setFormState((current) => {
const next = { ...current, [key]: value };
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
if (key === "type" && !existingTermin && !modeTouched && !modeLocked)
next.rsvpMode = suggestedRsvpMode(value as TerminType);
if (
key === "startDate" &&
typeof value === "string" &&
next.endDate &&
next.endDate < value
) {
next.endDate = value;
}
return next;
@@ -122,6 +156,13 @@ export function AdminTerminModal({
startDate: new Date(formState.startDate).toISOString(),
endDate: new Date(formState.endDate).toISOString(),
allDay: formState.allDay,
requiresRsvp: formState.requiresRsvp,
rsvpMode: formState.rsvpMode,
rsvpDeadline: formState.rsvpDeadline
? new Date(formState.rsvpDeadline).toISOString()
: null,
participantInfo: formState.participantInfo,
mitbringselListEnabled: formState.mitbringselListEnabled,
};
startTransition(async () => {
@@ -138,7 +179,7 @@ export function AdminTerminModal({
toast.success(
existingTermin
? "Termin wurde erfolgreich aktualisiert!"
: "Termin wurde direkt angelegt!"
: "Termin wurde direkt angelegt!",
);
if (!existingTermin) {
setFormState(initialFormState);
@@ -156,11 +197,13 @@ export function AdminTerminModal({
{triggerLabel}
</Button>
</DialogTrigger>
<DialogContent>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>
{existingTermin ? "Termin bearbeiten (Admin)" : "Termin anlegen (Admin)"}
{existingTermin
? "Termin bearbeiten (Admin)"
: "Termin anlegen (Admin)"}
</DialogTitle>
<DialogDescription>
{existingTermin
@@ -189,7 +232,9 @@ export function AdminTerminModal({
name="description"
placeholder="Details zum Termin..."
value={formState.description}
onChange={(event) => updateField("description", event.target.value)}
onChange={(event) =>
updateField("description", event.target.value)
}
/>
</div>
@@ -200,18 +245,28 @@ export function AdminTerminModal({
name="type"
value={formState.type}
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
onChange={(event) => updateField("type", event.target.value as TerminType)}
onChange={(event) =>
updateField("type", event.target.value as TerminType)
}
required
>
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
<option value={TerminType.SCHLIESSTAG}>Schließtag</option>
<option value={TerminType.TEAMTAG}>Teamtag (Kita geschlossen)</option>
<option value={TerminType.TEAMTAG}>
Teamtag (Kita geschlossen)
</option>
<option value={TerminType.MITMACH_TAG}>Mitmach-Tag</option>
<option value={TerminType.ELTERNABEND}>Elternabend</option>
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>Mitgliederversammlung</option>
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>
Mitgliederversammlung
</option>
<option value={TerminType.ELTERNCAFE}>Elterncafe</option>
<option value={TerminType.GEBURTSTAG_INTERN}>Geburtstag (Intern)</option>
<option value={TerminType.GEBURTSTAG_EXTERN}>Raumanfrage (Extern)</option>
<option value={TerminType.GEBURTSTAG_INTERN}>
Geburtstag (Intern)
</option>
<option value={TerminType.GEBURTSTAG_EXTERN}>
Raumanfrage (Extern)
</option>
<option value={TerminType.SONSTIGES}>Sonstiges</option>
</select>
</div>
@@ -225,7 +280,9 @@ export function AdminTerminModal({
type="datetime-local"
min={existingTermin ? undefined : minDateTime}
value={formState.startDate}
onChange={(event) => updateField("startDate", event.target.value)}
onChange={(event) =>
updateField("startDate", event.target.value)
}
required
/>
</div>
@@ -235,14 +292,82 @@ export function AdminTerminModal({
id="endDate"
name="endDate"
type="datetime-local"
min={formState.startDate || (existingTermin ? undefined : minDateTime)}
min={
formState.startDate ||
(existingTermin ? undefined : minDateTime)
}
value={formState.endDate}
onChange={(event) => updateField("endDate", event.target.value)}
onChange={(event) =>
updateField("endDate", event.target.value)
}
required
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={formState.mitbringselListEnabled}
onChange={(e) =>
updateField("mitbringselListEnabled", e.target.checked)
}
/>
Mitbring-Liste aktiv
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={formState.requiresRsvp}
onChange={(e) => updateField("requiresRsvp", e.target.checked)}
/>
Teilnahme-Abfrage aktivieren
</label>
{formState.requiresRsvp ? (
<div className="grid gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
<Label htmlFor="calendar-rsvp-mode">Antwortart</Label>
<select
id="calendar-rsvp-mode"
value={formState.rsvpMode}
disabled={modeLocked}
onChange={(e) => {
updateField("rsvpMode", e.target.value as RsvpMode);
setModeTouched(true);
}}
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm"
>
<option value="FAMILY">
Pro Familie · mit Anzahl Erwachsener
</option>
<option value="CHILD">Pro Kind · einzelne Zu-/Absage</option>
</select>
{modeLocked ? (
<p className="text-xs">
Die Antwortart ist wegen vorhandener Rückmeldungen gesperrt.
</p>
) : null}
<Label htmlFor="calendar-rsvp-deadline">
Rückmeldefrist (sonst Terminbeginn)
</Label>
<Input
id="calendar-rsvp-deadline"
type="datetime-local"
value={formState.rsvpDeadline}
onChange={(e) => updateField("rsvpDeadline", e.target.value)}
/>
<Label htmlFor="calendar-participant-info">
Hinweise für Teilnehmer
</Label>
<Textarea
id="calendar-participant-info"
value={formState.participantInfo}
onChange={(e) =>
updateField("participantInfo", e.target.value)
}
/>
</div>
) : null}
<div className="flex items-center gap-2 mt-2">
<input
type="checkbox"
@@ -250,7 +375,9 @@ export function AdminTerminModal({
name="allDay"
checked={formState.allDay}
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
onChange={(event) => updateField("allDay", event.target.checked)}
onChange={(event) =>
updateField("allDay", event.target.checked)
}
/>
<Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin
@@ -259,11 +386,19 @@ export function AdminTerminModal({
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : existingTermin ? "Änderungen speichern" : "Termin erstellen"}
{isPending
? "Speichern..."
: existingTermin
? "Änderungen speichern"
: "Termin erstellen"}
</Button>
</DialogFooter>
</form>
@@ -30,6 +30,7 @@ import {
WashingMachine,
Trash2,
} from "lucide-react";
import { RsvpDialog, RsvpForm } from "@/components/rsvp-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -522,6 +523,11 @@ export function CalendarOverviewClient({
</p>
)}
{item.rsvpEvent ? <section className="mt-4 border-t border-[var(--hairline)] pt-4">
<h5 className="mb-3 font-medium">Eure Rückmeldung</h5>
<RsvpForm key={JSON.stringify(item.rsvpEvent)} event={item.rsvpEvent} />
</section> : null}
{item.participantInfo && item.showParticipantInfo && (
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
<div className="mb-0.5 flex items-center gap-1.5 font-medium">
@@ -999,6 +1005,8 @@ function AgendaView({
</div>
</div>
{item.kind === "termin" && item.rsvpEvent ? <RsvpDialog event={item.rsvpEvent} /> : null}
{/* Actions for admins */}
{isAdmin && item.kind === "termin" && (
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -2,7 +2,12 @@
import type { ReactNode } from "react";
import { useState, useTransition } from "react";
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
import {
DutyAssignmentStatus,
RsvpMode,
TerminStatus,
TerminType,
} from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import {
@@ -15,6 +20,8 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { RsvpForm } from "@/components/rsvp-form";
import type { RsvpEventDto } from "@/lib/rsvp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -44,6 +51,10 @@ export type TerminListItemDto = {
allDay: boolean;
mitbringselListEnabled: boolean;
requiresRsvp: boolean;
rsvpMode: RsvpMode;
rsvpDeadline: Date | null;
hasRsvpResponses: boolean;
rsvpEvent?: RsvpEventDto;
participantInfo: string | null;
showParticipantInfo: boolean;
mitbringselItems?: {
@@ -88,7 +99,10 @@ export type NotdienstSwapOptionDto = {
familyName: string;
};
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
export type CalendarListItemDto =
| TerminListItemDto
| DutyCalendarItemDto
| NotdienstCalendarItemDto;
export function TerminList({
termine,
@@ -116,7 +130,10 @@ export function TerminList({
</p>
<div className="mt-5">
{isAdmin ? (
<AdminTerminModal triggerLabel="+ Termin anlegen" triggerClassName="" />
<AdminTerminModal
triggerLabel="+ Termin anlegen"
triggerClassName=""
/>
) : (
<TerminRequestModal
triggerLabel="+ Termin anlegen"
@@ -141,14 +158,19 @@ export function TerminList({
{group.label}
</h2>
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)] tabular-nums">
{group.items.length} {group.items.length === 1 ? "Termin" : "Termine"}
{group.items.length}{" "}
{group.items.length === 1 ? "Termin" : "Termine"}
</span>
</div>
<Card className="overflow-hidden p-0">
<div className="divide-y divide-[var(--hairline-soft)]">
{group.items.map((item) =>
item.kind === "duty" ? (
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
<DutyRow
key={`duty-${item.id}`}
duty={item}
isAdmin={isAdmin}
/>
) : item.kind === "notdienst" ? (
<NotdienstRow
key={`notdienst-${item.id}`}
@@ -157,7 +179,12 @@ export function TerminList({
notdienstSwapOptions={notdienstSwapOptions}
/>
) : (
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
<TerminRow
key={`termin-${item.id}`}
termin={item}
userId={userId}
isAdmin={isAdmin}
/>
),
)}
</div>
@@ -168,13 +195,22 @@ export function TerminList({
);
}
function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
function DutyRow({
duty,
isAdmin,
}: {
duty: DutyCalendarItemDto;
isAdmin: boolean;
}) {
const color = duty.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)";
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<div
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<DateTile date={duty.startDate} />
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
@@ -207,7 +243,10 @@ function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolea
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href="/dashboard/admin/dienste"
@@ -257,7 +296,10 @@ function TerminRow({
<>
<Dialog>
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<div
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<DateTile date={termin.startDate} />
<div className="min-w-0">
<div className="flex items-center gap-2">
@@ -276,7 +318,9 @@ function TerminRow({
</p>
</div>
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<CategoryPill color={color}>
{getTypeLabel(termin.type)}
</CategoryPill>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
Details
@@ -297,7 +341,10 @@ function TerminRow({
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-48 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<AdminTerminModal
existingTermin={termin}
@@ -342,7 +389,9 @@ function TerminRow({
<div className="grid gap-4">
<div className="flex">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<CategoryPill color={color}>
{getTypeLabel(termin.type)}
</CategoryPill>
</div>
{termin.description ? (
@@ -355,6 +404,16 @@ function TerminRow({
</p>
)}
{termin.rsvpEvent ? (
<section className="rounded-lg border border-[var(--hairline)] p-4">
<h3 className="mb-3 font-medium">Eure Rückmeldung</h3>
<RsvpForm
key={JSON.stringify(termin.rsvpEvent)}
event={termin.rsvpEvent}
/>
</section>
) : null}
{termin.participantInfo && termin.showParticipantInfo && (
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<div className="mb-1 flex items-center gap-2 font-medium">
@@ -385,8 +444,9 @@ function TerminRow({
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{termin.title}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
Möchtest du den Termin &ldquo;{termin.title}&rdquo; wirklich
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -436,14 +496,20 @@ function CategoryPill({
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)]"
style={{ color }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: color }} />
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: color }}
/>
{children}
</span>
);
}
function groupByMonth(items: CalendarListItemDto[]) {
const groups = new Map<string, { key: string; label: string; items: CalendarListItemDto[] }>();
const groups = new Map<
string,
{ key: string; label: string; items: CalendarListItemDto[] }
>();
items.forEach((item) => {
const key = format(item.startDate, "yyyy-MM");
@@ -559,7 +625,8 @@ export function NotdienstSwapDialog({
<DialogTitle>Notdienst tauschen</DialogTitle>
<DialogDescription>
Frage Familie {target.familyName}, ob ihr den Notdienst am{" "}
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen könnt.
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen
könnt.
</DialogDescription>
</DialogHeader>
@@ -575,11 +642,14 @@ export function NotdienstSwapDialog({
onChange={(event) => setOfferedAssignmentId(event.target.value)}
>
{selectableOptions.length === 0 ? (
<option value="">Kein eigener zukünftiger Notdienst verfügbar</option>
<option value="">
Kein eigener zukünftiger Notdienst verfügbar
</option>
) : (
selectableOptions.map((option) => (
<option key={option.id} value={option.id}>
{format(option.date, "dd.MM.yyyy", { locale: de })} - {option.childName}
{format(option.date, "dd.MM.yyyy", { locale: de })} -{" "}
{option.childName}
</option>
))
)}
@@ -603,7 +673,11 @@ export function NotdienstSwapDialog({
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={isPending}>
<Button
variant="ghost"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
@@ -632,7 +706,10 @@ function NotdienstRow({
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<div
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<DateTile date={notdienst.startDate} />
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
@@ -640,7 +717,8 @@ function NotdienstRow({
</h3>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })} (ganztägig)
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })}{" "}
(ganztägig)
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
Kind: {notdienst.childName}
@@ -666,7 +744,10 @@ function NotdienstRow({
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href={`/dashboard/notdienst/plan?year=${notdienst.planYear}&month=${notdienst.planMonth}`}
@@ -684,7 +765,11 @@ function NotdienstRow({
target={notdienst}
options={notdienstSwapOptions}
trigger={
<Button size="sm" variant="outline" className="h-8 px-2.5 text-xs">
<Button
size="sm"
variant="outline"
className="h-8 px-2.5 text-xs"
>
Tausch anfragen
</Button>
}
+125 -37
View File
@@ -8,6 +8,7 @@ import {
NotdienstAlertStatus,
NotdienstPlanStatus,
NotdienstSwapStatus,
RsvpMode,
TerminStatus,
TerminType,
UserRole,
@@ -22,6 +23,7 @@ import { sendAppEmail } from "@/lib/mail";
import { serializableTransaction } from "@/lib/transaction";
import { rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
import { lockEvent } from "@/lib/event-lock";
// =====================================================================
// /dashboard/kalender · Server Actions
@@ -34,8 +36,9 @@ const terminSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
allDay: z.boolean().default(false),
mitbringselListEnabled: z.boolean().default(false),
requiresRsvp: z.boolean().default(false),
mitbringselListEnabled: z.boolean().optional(),
requiresRsvp: z.boolean().optional(),
rsvpMode: z.nativeEnum(RsvpMode).optional(),
rsvpDeadline: z.string().datetime().nullable().optional(),
participantInfo: z.string().trim().max(1000).optional(),
});
@@ -55,11 +58,15 @@ const activeNotdienstAlertStatuses = [
function getTodayUtcDateOnly() {
const today = new Date();
return new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
return new Date(
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
);
}
function getBaseUrl() {
return process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
return (
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"
);
}
function formatNotdienstDate(date: Date) {
@@ -72,7 +79,7 @@ function revalidateCalendarAndNotdienst() {
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard");
} catch (e) {
} catch {
// Ignore if called outside Next.js context
}
}
@@ -98,6 +105,7 @@ type SwapAssignmentWithFamily = {
alerts: {
id: string;
}[];
alarms?: { id: string }[];
};
async function getSwapAssignment(
@@ -131,7 +139,11 @@ async function getSwapAssignment(
},
},
alerts: {
where: { status: { in: activeNotdienstAlertStatuses } },
where: { alarmId: null, status: { in: activeNotdienstAlertStatuses } },
select: { id: true },
},
alarms: {
where: { activeAssignmentId: { not: null } },
select: { id: true },
},
},
@@ -176,7 +188,12 @@ function validateSwapAssignmentPair({
return "Du kannst keinen Tausch mit deinem eigenen Haushalt anfragen.";
}
if (targetAssignment.alerts.length > 0 || offeredAssignment.alerts.length > 0) {
if (
targetAssignment.alerts.length > 0 ||
offeredAssignment.alerts.length > 0 ||
targetAssignment.alarms?.length ||
offeredAssignment.alarms?.length
) {
return "Notdienste mit aktiver Alarmierung können nicht getauscht werden.";
}
@@ -220,7 +237,10 @@ async function sendSwapMail({
});
if (!result.success) {
console.error("Notdienst-Tausch-Mail konnte nicht versendet werden:", result.error);
console.error(
"Notdienst-Tausch-Mail konnte nicht versendet werden:",
result.error,
);
}
}
@@ -262,20 +282,35 @@ async function notifyFamilyAboutSwap({
);
}
function buildTerminData(data: z.infer<typeof terminSchema>) {
function buildTerminData(
data: z.infer<typeof terminSchema>,
allowPast = false,
) {
const startDate = new Date(data.startDate);
const endDate = new Date(data.endDate);
const now = new Date();
now.setSeconds(0, 0);
if (startDate < now) {
return { error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen." };
if (!allowPast && startDate < now) {
return {
error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen.",
};
}
if (endDate < startDate) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
}
if (
data.requiresRsvp &&
data.rsvpDeadline &&
new Date(data.rsvpDeadline) > startDate
) {
return {
error: "Die Rückmeldefrist darf nicht nach dem Terminbeginn liegen.",
};
}
return {
data: {
title: data.title,
@@ -284,16 +319,15 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
startDate,
endDate,
allDay: data.allDay,
mitbringselListEnabled: data.mitbringselListEnabled,
requiresRsvp: data.requiresRsvp,
mitbringselListEnabled: data.mitbringselListEnabled ?? false,
requiresRsvp: data.requiresRsvp ?? false,
rsvpMode: data.rsvpMode ?? RsvpMode.CHILD,
rsvpDeadline:
data.requiresRsvp && data.rsvpDeadline
? new Date(data.rsvpDeadline)
: null,
participantInfo:
data.requiresRsvp && data.participantInfo
? data.participantInfo
: null,
data.requiresRsvp && data.participantInfo ? data.participantInfo : null,
},
};
}
@@ -357,6 +391,8 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
throw new UserFacingError("Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.");
}
if (await tx.notdienstAlarm.count({ where: { kitaId: session.user.kitaId, activeAssignmentId: { in: [targetAssignment.id, offeredAssignment.id] } } })) throw new UserFacingError("Notdienste mit aktiver Alarmierung können nicht getauscht werden.");
await tx.notdienstSwapRequest.create({
data: {
kitaId: session.user.kitaId,
@@ -388,7 +424,7 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
if (!(error instanceof Error && error.message === "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.")) {
console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
}
return { error: "Tauschanfrage konnte nicht erstellt werden." };
return { error: publicActionError(error, "Tauschanfrage konnte nicht erstellt werden.") };
}
}
@@ -491,6 +527,8 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
throw new UserFacingError(validationError);
}
if (await tx.notdienstAlarm.count({ where: { kitaId: session.user.kitaId, activeAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } } })) throw new UserFacingError("Notdienste mit aktiver Alarmierung können nicht getauscht werden.");
await tx.notdienstAssignment.update({
where: { id: request.targetAssignmentId },
data: { childId: request.offeredAssignment.childId },
@@ -632,7 +670,9 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
}
if (request.targetAssignment.child.familyId !== session.user.familyId) {
return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." };
return {
error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen.",
};
}
const responded = await prisma.notdienstSwapRequest.updateMany({
@@ -721,7 +761,10 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
revalidateCalendarAndNotdienst();
return { success: true };
} catch (error) {
console.error("Fehler beim Zurückziehen der Notdienst-Tauschanfrage:", error);
console.error(
"Fehler beim Zurückziehen der Notdienst-Tauschanfrage:",
error,
);
return { error: "Tauschanfrage konnte nicht zurückgezogen werden." };
}
}
@@ -734,7 +777,7 @@ export async function createTerminRequest(rawPayload: unknown) {
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const data = parsed.data;
const terminData = buildTerminData(data);
@@ -771,12 +814,12 @@ export async function createTerminAdmin(rawPayload: unknown) {
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const data = parsed.data;
const terminData = buildTerminData(data);
@@ -798,6 +841,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen des Termins:", error);
@@ -805,7 +849,10 @@ export async function createTerminAdmin(rawPayload: unknown) {
}
}
export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
export async function updateTerminAdmin(
terminId: string,
rawPayload: unknown,
): Promise<{ error?: string; success?: boolean }> {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
@@ -818,19 +865,50 @@ export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
return { error: "Ungültige Eingabedaten." };
}
const terminData = buildTerminData(parsed.data);
if ("error" in terminData) {
return { error: terminData.error };
}
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId,
},
data: terminData.data,
const result = await prisma.$transaction(async (tx) => {
await lockEvent(tx, terminId, kitaId);
const existing = await tx.termin.findFirst({
where: { id: terminId, kitaId },
include: {
_count: {
select: { participations: true, familyParticipations: true },
},
},
});
if (!existing) return { error: "Termin wurde nicht gefunden." };
const data = {
...parsed.data,
mitbringselListEnabled:
parsed.data.mitbringselListEnabled ?? existing.mitbringselListEnabled,
requiresRsvp: parsed.data.requiresRsvp ?? existing.requiresRsvp,
rsvpMode: parsed.data.rsvpMode ?? existing.rsvpMode,
rsvpDeadline:
parsed.data.rsvpDeadline === undefined
? (existing.rsvpDeadline?.toISOString() ?? null)
: parsed.data.rsvpDeadline,
participantInfo:
parsed.data.participantInfo ?? existing.participantInfo ?? "",
};
if (
data.rsvpMode !== existing.rsvpMode &&
existing._count.participations + existing._count.familyParticipations >
0
) {
return {
error:
"Die Antwortart kann nach der ersten Rückmeldung nicht mehr geändert werden.",
};
}
const terminData = buildTerminData(data, true);
if ("error" in terminData) return { error: terminData.error };
await tx.termin.update({
where: { id: terminId, kitaId },
data: terminData.data,
});
return { success: true };
});
if (result.error) return result;
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
@@ -864,6 +942,7 @@ export async function approveTermin(terminId: string) {
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Bestätigen des Termins:", error);
@@ -894,6 +973,7 @@ export async function rejectTermin(terminId: string, reason?: string) {
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Ablehnen des Termins:", error);
@@ -901,7 +981,10 @@ export async function rejectTermin(terminId: string, reason?: string) {
}
}
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
export async function toggleMitbringselList(
terminId: string,
enabled: boolean,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
@@ -921,6 +1004,7 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
@@ -941,7 +1025,7 @@ export async function addMitbringsel(rawPayload: unknown) {
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const { terminId, content } = parsed.data;
try {
@@ -950,7 +1034,11 @@ export async function addMitbringsel(rawPayload: unknown) {
where: { id: terminId, kitaId: session.user.kitaId },
});
if (!termin || termin.status !== TerminStatus.CONFIRMED || !termin.mitbringselListEnabled) {
if (
!termin ||
termin.status !== TerminStatus.CONFIRMED ||
!termin.mitbringselListEnabled
) {
return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." };
}
+95 -70
View File
@@ -9,9 +9,16 @@ import { HelpCircle } from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getRsvpEvents } from "@/lib/rsvp-events";
import { AdminTerminModal } from "./_components/admin-termin-modal";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
import { type CalendarListItemDto, type NotdienstSwapOptionDto } from "./_components/termin-list";
import {
PendingAnfragen,
type PendingTerminDto,
} from "./_components/pending-anfragen";
import {
type CalendarListItemDto,
type NotdienstSwapOptionDto,
} from "./_components/termin-list";
import { TerminRequestModal } from "./_components/termin-request-modal";
import {
CalendarOverviewClient,
@@ -36,50 +43,62 @@ export default async function KalenderPage({
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const confirmedTermineRows = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
},
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: {
select: {
id: true,
userId: true,
content: true,
user: {
select: {
firstName: true,
lastName: true,
const [rsvpEventRows, confirmedTermineRows] = await Promise.all([
getRsvpEvents(session),
prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
},
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
rsvpMode: true,
rsvpDeadline: true,
_count: {
select: { participations: true, familyParticipations: true },
},
familyParticipations: {
where: { familyId: familyIdForParticipation },
select: { status: true },
},
participantInfo: true,
mitbringselItems: {
select: {
id: true,
userId: true,
content: true,
user: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
participations: {
where: {
child: {
familyId: familyIdForParticipation,
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "asc" },
});
orderBy: { startDate: "asc" },
}),
]);
const myPendingTermineRows = await prisma.termin.findMany({
where: {
@@ -98,6 +117,13 @@ export default async function KalenderPage({
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
rsvpMode: true,
rsvpDeadline: true,
_count: { select: { participations: true, familyParticipations: true } },
familyParticipations: {
where: { familyId: familyIdForParticipation },
select: { status: true },
},
participantInfo: true,
mitbringselItems: {
select: {
@@ -236,31 +262,32 @@ export default async function KalenderPage({
})
: [];
const rsvpEvents = new Map(rsvpEventRows.map((event) => [event.id, event]));
const mapTermin = (
termin: (typeof confirmedTermineRows)[number],
): CalendarListItemDto => {
const showParticipantInfo =
isAdmin ||
termin.familyParticipations.some(
(row) => row.status === EventParticipationStatus.ATTENDING,
) ||
termin.participations.some(
(row) => row.status === EventParticipationStatus.ATTENDING,
);
const { _count, familyParticipations, ...data } = termin;
void familyParticipations;
return {
...data,
kind: "termin",
showParticipantInfo,
participantInfo: showParticipantInfo ? termin.participantInfo : null,
hasRsvpResponses: _count.participations + _count.familyParticipations > 0,
rsvpEvent: rsvpEvents.get(termin.id),
};
};
const allUserTermine: CalendarListItemDto[] = [
...confirmedTermineRows.map((termin) => ({
...termin,
participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING)
? termin.participantInfo : null,
kind: "termin" as const,
showParticipantInfo:
isAdmin ||
termin.participations.some(
(participation) =>
participation.status === EventParticipationStatus.ATTENDING,
),
})),
...myPendingTermineRows.map((termin) => ({
...termin,
participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING)
? termin.participantInfo : null,
kind: "termin" as const,
showParticipantInfo:
isAdmin ||
termin.participations.some(
(participation) =>
participation.status === EventParticipationStatus.ATTENDING,
),
})),
...confirmedTermineRows.map(mapTermin),
...myPendingTermineRows.map(mapTermin),
...dutyAssignmentRows.map((assignment) => {
const isOwn = assignment.familyId === familyIdForParticipation;
return {
@@ -320,7 +347,9 @@ export default async function KalenderPage({
(request) => ({
id: request.id,
direction:
request.requesterFamilyId === session.user.familyId ? "outgoing" : "incoming",
request.requesterFamilyId === session.user.familyId
? "outgoing"
: "incoming",
targetDate: request.targetAssignment.date,
targetFamilyName: request.targetAssignment.child.family.name,
targetChildName: `${request.targetAssignment.child.firstName} ${request.targetAssignment.child.lastName}`,
@@ -371,9 +400,7 @@ export default async function KalenderPage({
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Was steht{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
an
</em>
<em className="font-normal italic text-[var(--accent-deep)]">an</em>
?
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
@@ -437,8 +464,6 @@ export default async function KalenderPage({
);
}
function TabBar({
currentTab,
overviewCount,
+67
View File
@@ -0,0 +1,67 @@
import Link from "next/link";
import { requireKitaSession } from "@/lib/auth-utils";
import { getAnnouncements } from "@/lib/announcements";
import { NewsTicker } from "../news-ticker";
import { Button } from "@/components/ui/button";
export const metadata = { title: "Mitteilungen · Kita-Planer" };
export default async function MitteilungenPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const session = await requireKitaSession();
const params = await searchParams;
const parsed = Number(params.page ?? 1);
const page =
Number.isSafeInteger(parsed) && parsed > 0 ? Math.min(parsed, 100000) : 1;
const pageSize = 12;
const items = await getAnnouncements(session, {
take: pageSize + 1,
skip: (page - 1) * pageSize,
});
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
SCHWARZES BRETT
</p>
<h1 className="mt-2 text-[36px] tracking-[-0.025em]">
Alle{" "}
<em className="font-normal text-[var(--accent-deep)]">
Mitteilungen
</em>
</h1>
<p className="mt-2 text-[15px] text-[var(--ink-soft)]">
Aktuelle und ältere Meldungen für deine Empfängergruppe.
</p>
</header>
<NewsTicker items={items.slice(0, pageSize)} archive />
<nav
aria-label="Mitteilungen durchblättern"
className="flex items-center justify-between gap-3"
>
<div>
{page > 1 ? (
<Button asChild variant="outline">
<Link href={`/dashboard/mitteilungen?page=${page - 1}`}>
Neuere Meldungen
</Link>
</Button>
) : null}
</div>
<span className="text-sm text-[var(--ink-soft)]">Seite {page}</span>
<div>
{items.length > pageSize ? (
<Button asChild variant="outline">
<Link href={`/dashboard/mitteilungen?page=${page + 1}`}>
Ältere Meldungen
</Link>
</Button>
) : null}
</div>
</nav>
</div>
);
}
+48 -17
View File
@@ -1,5 +1,7 @@
"use client";
import Link from "next/link";
import { toast } from "sonner";
import { useState, useTransition } from "react";
import { Bell, FileText, Megaphone } from "lucide-react";
@@ -26,6 +28,7 @@ export type NewsTickerItemDto = {
id: string;
title: string;
content: string;
excerpt: string;
createdAt: string;
authorName: string;
isUnread: boolean;
@@ -37,22 +40,32 @@ export type NewsTickerItemDto = {
}[];
};
export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
export function NewsTicker({
items,
archive = false,
}: {
items: NewsTickerItemDto[];
archive?: boolean;
}) {
const [selected, setSelected] = useState<NewsTickerItemDto | null>(null);
const [readIds, setReadIds] = useState<Set<string>>(new Set());
const [, startTransition] = useTransition();
if (items.length === 0) {
return null;
}
function openAnnouncement(item: NewsTickerItemDto) {
setSelected(item);
if (!item.isUnread || readIds.has(item.id)) return;
setReadIds((current) => new Set(current).add(item.id));
startTransition(async () => {
await markAnnouncementRead(item.id);
try {
const result = await markAnnouncementRead(item.id);
if (result.error) {
toast.error(result.error);
return;
}
setReadIds((current) => new Set(current).add(item.id));
} catch {
toast.error("Lesestatus konnte nicht gespeichert werden.");
}
});
}
@@ -60,20 +73,33 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
<>
<Card className="mb-8 border-[var(--good)] bg-[var(--good-soft)]">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<Megaphone className="h-5 w-5 text-[var(--good)] [stroke-width:1.7]" />
Schwarzes Brett
{archive ? "Mitteilungen" : "Neues aus der Kita"}
</CardTitle>
<CardDescription>
Die neuesten offiziellen Ankündigungen deiner Kita.
{archive
? "Veröffentlichte Ankündigungen deiner Kita."
: "Die neuesten offiziellen Ankündigungen deiner Kita."}
</CardDescription>
</div>
<Badge variant="secondary">{items.length}</Badge>
{archive ? (
<Badge variant="secondary">{items.length} Meldungen</Badge>
) : (
<Button asChild variant="outline" size="sm">
<Link href="/dashboard/mitteilungen">Alle Mitteilungen</Link>
</Button>
)}
</div>
</CardHeader>
<CardContent className="grid gap-3 lg:grid-cols-3">
{items.length === 0 ? (
<p className="col-span-full py-4 text-sm text-[var(--ink-soft)]">
Noch keine Mitteilungen für deine Empfängergruppe.
</p>
) : null}
{items.map((item) => {
const isUnread = item.isUnread && !readIds.has(item.id);
return (
@@ -88,15 +114,13 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
<Bell className="h-3.5 w-3.5 [stroke-width:1.7]" />
{item.createdAt}
</div>
{isUnread && (
<Badge variant="success">NEU</Badge>
)}
{isUnread && <Badge variant="success">NEU</Badge>}
</div>
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
{item.title}
</h3>
<p className="mt-2 line-clamp-3 text-sm leading-6 text-[var(--ink-soft)]">
{item.content}
{item.excerpt}
</p>
{item.attachments.length > 0 && (
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-[var(--accent-deep)]">
@@ -111,7 +135,10 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
</CardContent>
</Card>
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<Dialog
open={!!selected}
onOpenChange={(open) => !open && setSelected(null)}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
{selected && (
<>
@@ -137,7 +164,11 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
variant="outline"
className="justify-start"
>
<a href={attachment.fileUrl} target="_blank" rel="noreferrer">
<a
href={attachment.fileUrl}
target="_blank"
rel="noreferrer"
>
<FileText className="h-4 w-4 [stroke-width:1.7]" />
{attachment.fileName}
</a>
@@ -0,0 +1,56 @@
"use server";
import { publicActionError } from "@/lib/action-error";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireAlarmActor } from "@/lib/alarms/auth";
import { executeAlarmCommand } from "@/lib/alarms/service";
import { deliverAlarmEmails } from "@/lib/alarms/email";
export type AlarmActionResult = {
error?: string;
warning?: string;
success?: boolean;
alarmId?: string;
};
export async function alarmAction(
command: string,
data: Record<string, unknown>,
): Promise<AlarmActionResult> {
const actor = await requireAlarmActor(command !== "readNotice");
try {
const result = await executeAlarmCommand(actor, command, data);
let warning: string | undefined;
if (result.emailIds.length) {
try {
const delivery = await deliverAlarmEmails(result.emailIds);
if (delivery.failed)
warning =
"Gespeichert. Einige E-Mails warten noch auf den Versand. Bitte den Versandstand im Alarm prüfen.";
else if (
process.env.NODE_ENV === "development" &&
!process.env.RESEND_API_KEY
)
warning = "Gespeichert. E-Mail-Versand lokal simuliert.";
} catch {
warning = "Gespeichert. Der E-Mail-Versand ist noch ausstehend.";
}
}
for (const path of [
"/dashboard",
"/dashboard/notdienst/alarm",
"/dashboard/notdienst/plan",
"/dashboard/kalender",
])
revalidatePath(path);
revalidatePath("/alert/[token]", "page");
return { success: true, alarmId: result.alarmId, warning };
} catch (error) {
if (error instanceof z.ZodError)
return {
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
};
const message = publicActionError(error, "");
if (message) return { error: message };
return { error: "Die Alarmaktion konnte nicht gespeichert werden." };
}
}
@@ -0,0 +1,51 @@
import Link from "next/link";
import { requireKitaSession } from "@/lib/auth-utils";
import { getAlarmFeed, managerRoles } from "@/lib/alarms/service";
import {
alarmLabel,
durationLabel,
dateKey,
needsClarification,
} from "@/lib/alarms/format";
import { AlarmRefresh } from "./forms";
export async function AlarmDashboardHints() {
const { user } = await requireKitaSession();
if (![...managerRoles, "ELTERN"].includes(user.role)) return null;
let alarms;
try {
alarms = await getAlarmFeed({
id: user.id!,
kitaId: user.kitaId,
role: user.role,
});
} catch {
return null;
}
if (!alarms.length) return null;
return (
<section className="mb-6 rounded-2xl border border-[var(--accent-mid)] bg-[var(--surface)] p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Aktuelle Notdienstalarme</h2>
<AlarmRefresh />
</div>
<div className="grid gap-3">
{alarms.map((a) => (
<Link
key={a.id}
href={`/dashboard/notdienst/alarm?alarm=${a.id}`}
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 text-sm ${needsClarification(a) ? "border-[var(--danger)] text-[var(--danger)]" : "border-[var(--hairline)]"}`}
>
<span>
{dateKey(a.date).split("-").reverse().join(".")} · {a.familyName}
<span className="mt-1 block text-xs text-[var(--ink-soft)]">
{durationLabel(a)}
</span>
</span>
<strong>{alarmLabel(a)} →</strong>
</Link>
))}
</div>
</section>
);
}
+383
View File
@@ -0,0 +1,383 @@
"use client";
import { useEffect, useId, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { BellRing } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { clockMinute } from "@/lib/staff/calculations";
import { alarmAction } from "./actions";
const control =
"w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-mid)]";
export function AlarmRefresh() {
const router = useRouter();
useEffect(() => {
const timer = setInterval(() => {
if (!document.hidden) router.refresh();
}, 30000);
return () => clearInterval(timer);
}, [router]);
return (
<Button variant="outline" size="sm" onClick={() => router.refresh()}>
Stand aktualisieren
</Button>
);
}
export type AlarmCandidate = { id: string; label: string; emails: string[] };
export function AlarmCreateForm({
candidates,
educators,
selected,
}: {
candidates: AlarmCandidate[];
educators: { id: string; name: string }[];
selected?: string;
}) {
const [open, setOpen] = useState(!!selected);
const [assignmentId, setAssignmentId] = useState(
candidates.find((c) => c.id === selected)?.id ?? candidates[0]?.id ?? "",
);
const [wholeDay, setWholeDay] = useState(true);
const [pending, start] = useTransition();
const [error, setError] = useState("");
const router = useRouter();
const prefix = useId();
const candidate = candidates.find((c) => c.id === assignmentId);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button disabled={!candidates.length}>
<BellRing className="mr-2 h-4 w-4" />
Notdienstalarm auslösen
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Notdienstalarm auslösen</DialogTitle>
<DialogDescription>
Die eingeteilte Familie wird per E-Mail und in der App informiert.
Eine Antwort gilt für den Haushalt.
</DialogDescription>
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
setError("");
start(async () => {
const result = await alarmAction("create", {
assignmentId,
wholeDay,
replyMinutes: Number(form.get("replyMinutes")),
...(wholeDay
? {}
: {
startMinute: clockMinute(String(form.get("start"))),
endMinute: clockMinute(String(form.get("end"))),
}),
...(form.get("educatorId")
? { educatorId: String(form.get("educatorId")) }
: {}),
notes: String(form.get("notes") ?? ""),
});
if (result.error) {
setError(result.error);
return;
}
if (result.warning) toast.warning(result.warning);
else toast.success("Alarm ausgelöst.");
setOpen(false);
router.push(`/dashboard/notdienst/alarm?alarm=${result.alarmId}`);
router.refresh();
});
}}
>
<fieldset disabled={pending} className="grid gap-4">
<label
className="grid gap-1.5 text-sm"
htmlFor={`${prefix}-assignment`}
>
Eingeteilte Familie
<select
id={`${prefix}-assignment`}
className={control}
required
value={assignmentId}
onChange={(event) => setAssignmentId(event.target.value)}
>
{candidates.map((c) => (
<option key={c.id} value={c.id}>
{c.label}
</option>
))}
</select>
</label>
<div className="rounded-lg bg-[var(--surface-2)] p-3 text-sm">
<p className="font-medium">Empfänger</p>
{candidate?.emails.length ? (
<ul className="mt-1 grid gap-1 break-all">
{candidate.emails.map((email) => (
<li key={email}>{email}</li>
))}
</ul>
) : (
<p className="mt-1 text-[var(--danger)]">
Keine Elternkonten hinterlegt. Bitte den Haushalt zuerst
vervollständigen.
</p>
)}
</div>
<label
className="grid gap-1.5 text-sm"
htmlFor={`${prefix}-duration`}
>
Einsatzdauer
<select
id={`${prefix}-duration`}
className={control}
value={wholeDay ? "whole" : "hours"}
onChange={(event) =>
setWholeDay(event.target.value === "whole")
}
>
<option value="whole">Ganzer Kita-Tag</option>
<option value="hours">Bestimmte Uhrzeiten</option>
</select>
</label>
{!wholeDay && (
<div className="grid grid-cols-2 gap-3">
<label
className="grid gap-1.5 text-sm"
htmlFor={`${prefix}-start`}
>
Beginn
<Input
id={`${prefix}-start`}
type="time"
name="start"
required
defaultValue="08:00"
/>
</label>
<label
className="grid gap-1.5 text-sm"
htmlFor={`${prefix}-end`}
>
Ende
<Input
id={`${prefix}-end`}
type="time"
name="end"
required
defaultValue="16:00"
/>
</label>
</div>
)}
<label
className="grid gap-1.5 text-sm"
htmlFor={`${prefix}-minutes`}
>
Antwortfrist in Minuten
<Input
id={`${prefix}-minutes`}
name="replyMinutes"
type="number"
min={1}
max={1440}
step={1}
defaultValue={30}
required
/>
</label>
<p className="text-xs leading-5 text-[var(--ink-soft)]">
Die Frist beginnt beim Auslösen und endet spätestens mit dem
Einsatz. Eine Absage erfordert sofort Klärung.
</p>
<label
className="grid gap-1.5 text-sm"
htmlFor={`${prefix}-educator`}
>
Ausgefallene Fachkraft (optional, intern)
<select
id={`${prefix}-educator`}
className={control}
name="educatorId"
>
<option value="">Ohne Zuordnung</option>
{educators.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
</label>
<label className="grid gap-1.5 text-sm" htmlFor={`${prefix}-notes`}>
Interne Notiz (optional)
<textarea
id={`${prefix}-notes`}
className={control}
name="notes"
maxLength={500}
rows={2}
/>
</label>
<Button type="submit" disabled={!candidate?.emails.length}>
{pending
? "Alarm wird gespeichert …"
: "Alarm auslösen und Familie informieren"}
</Button>
</fieldset>
{error && (
<p role="alert" className="text-sm text-[var(--danger)]">
{error}
</p>
)}
</form>
</DialogContent>
</Dialog>
);
}
export function AlarmCommand({
alarmId,
command,
label,
}: {
alarmId: string;
command: "cancel" | "recordResponse" | "approveLate";
label: string;
}) {
const [open, setOpen] = useState(false),
[pending, start] = useTransition(),
[error, setError] = useState("");
const router = useRouter(),
prefix = useId();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
{label}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{label}</DialogTitle>
<DialogDescription>
{command === "cancel"
? "Alle angeschriebenen Elternkonten erhalten eine Stornierung. Ihre Rückmeldelinks erlauben danach keine weitere Antwort."
: "Die Rückmeldung gilt für den gesamten Haushalt. Die Familie und die Koordination werden über den Stand informiert."}
</DialogDescription>
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
setError("");
start(async () => {
const result = await alarmAction(command, {
alarmId,
reason: String(data.get("reason")),
...(command === "recordResponse"
? { approve: data.get("response") === "yes" }
: {}),
});
if (result.error) {
setError(result.error);
return;
}
if (result.warning) toast.warning(result.warning);
else toast.success("Gespeichert.");
setOpen(false);
router.refresh();
});
}}
>
{command === "recordResponse" && (
<label
className="grid gap-1.5 text-sm"
htmlFor={`${prefix}-response`}
>
Telefonische Rückmeldung / Korrektur
<select
id={`${prefix}-response`}
name="response"
className={control}
>
<option value="yes">Haushalt übernimmt</option>
<option value="no">Haushalt kann nicht übernehmen</option>
</select>
</label>
)}
<label className="grid gap-1.5 text-sm" htmlFor={`${prefix}-reason`}>
Interne Begründung
<textarea
id={`${prefix}-reason`}
className={control}
name="reason"
required
minLength={3}
maxLength={500}
rows={3}
/>
</label>
<p className="text-xs text-[var(--ink-soft)]">
Die Begründung bleibt intern und wird nicht per E-Mail versendet.
</p>
{error && (
<p role="alert" className="text-sm text-[var(--danger)]">
{error}
</p>
)}
<Button disabled={pending} type="submit">
{pending
? "Wird gespeichert …"
: command === "cancel"
? "Alarm stornieren"
: "Rückmeldung speichern"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
export function AlarmSimpleAction({
command,
data,
children,
}: {
command: "retry" | "readNotice";
data: Record<string, unknown>;
children: React.ReactNode;
}) {
const [pending, start] = useTransition();
const router = useRouter();
return (
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() =>
start(async () => {
const result = await alarmAction(command, data);
if (result.error) toast.error(result.error);
else if (result.warning) toast.warning(result.warning);
else toast.success("Gespeichert.");
router.refresh();
})
}
>
{pending ? "Bitte warten …" : children}
</Button>
);
}
+408
View File
@@ -0,0 +1,408 @@
import Link from "next/link";
import { BellRing } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { requireAlarmActor } from "@/lib/alarms/auth";
import { managerRoles, importLegacyAlarms } from "@/lib/alarms/service";
import {
alarmLabel,
dateKey,
durationLabel,
instantLabel,
needsClarification,
todayKey,
} from "@/lib/alarms/format";
import { addDays, dateOnly } from "@/lib/staff/calculations";
import {
AlarmCreateForm,
AlarmCommand,
AlarmSimpleAction,
AlarmRefresh,
} from "./forms";
export const metadata = { title: "Notdienstalarm · Kita-Planer" };
const historyLabels: Record<string, string> = {
CONFIRMED: "Übernahme bestätigt",
DECLINED: "Absage",
LATE_CONFIRMATION: "Verspätete Zusage – Freigabe erforderlich",
};
export default async function AlarmPage({
searchParams,
}: {
searchParams: Promise<{ alarm?: string; assignment?: string }>;
}) {
const actor = await requireAlarmActor(false),
manager = managerRoles.includes(actor.role);
const params = await searchParams,
now = new Date(),
today = todayKey(now);
if (manager) await importLegacyAlarms(actor.kitaId);
const user = await prisma.user.findUniqueOrThrow({
where: { id: actor.id },
select: { familyId: true },
});
const [kita, alarms, assignments, educators, notifications] =
await Promise.all([
prisma.kita.findUniqueOrThrow({
where: { id: actor.kitaId },
select: { name: true, notdienstModuleEnabled: true },
}),
prisma.notdienstAlarm.findMany({
where: {
kitaId: actor.kitaId,
...(manager ? {} : { familyId: user.familyId ?? "" }),
...(params.alarm
? { id: params.alarm }
: { date: { gte: dateOnly(addDays(today, -30)) } }),
},
include: {
recipients: {
include: { user: { select: { email: true, firstName: true } } },
},
emails: { orderBy: { createdAt: "desc" } },
history: manager
? { orderBy: { createdAt: "desc" }, take: 20 }
: false,
},
orderBy: [{ date: "desc" }, { triggeredAt: "desc" }],
take: 100,
}),
manager
? prisma.notdienstAssignment.findMany({
where: {
kitaId: actor.kitaId,
date: { gte: dateOnly(today), lt: dateOnly(addDays(today, 93)) },
plan: { status: "PUBLISHED" },
alarms: { none: { activeAssignmentId: { not: null } } },
},
include: {
child: {
include: {
family: {
include: {
users: {
where: { role: "ELTERN", kitaId: actor.kitaId },
select: { email: true },
},
},
},
},
},
},
orderBy: { date: "asc" },
})
: Promise.resolve([]),
manager
? prisma.educator.findMany({
where: { kitaId: actor.kitaId, active: true },
select: { id: true, firstName: true, lastName: true },
orderBy: { lastName: "asc" },
})
: Promise.resolve([]),
prisma.notdienstAlarmNotification.findMany({
where: {
userId: actor.id,
alarm: {
kitaId: actor.kitaId,
...(manager ? {} : { familyId: user.familyId ?? "" }),
},
readAt: null,
},
orderBy: { createdAt: "desc" },
take: 20,
}),
]);
const candidates = assignments.map((a) => ({
id: a.id,
label: `${dateKey(a.date).split("-").reverse().join(".")} · ${a.child.family.name}`,
emails: a.child.family.users.map((u) => u.email),
}));
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex flex-wrap items-end justify-between gap-5">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
NOTDIENST · {kita.name}
</p>
<h1 className="mt-2 text-4xl tracking-tight text-[var(--ink)]">
Notdienst
<span className="italic text-[var(--accent-deep)]">alarm</span>
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--ink-soft)]">
{manager
? "Familien anfragen, Rückmeldungen verfolgen und offene Einsätze klären."
: "Eure Rückmeldung gilt für den gesamten Haushalt."}
</p>
</div>
<div className="flex flex-wrap gap-2">
<AlarmRefresh />
{manager && kita.notdienstModuleEnabled && (
<AlarmCreateForm
candidates={candidates}
educators={educators.map((e) => ({
id: e.id,
name: `${e.firstName} ${e.lastName}`,
}))}
selected={params.assignment}
/>
)}
</div>
</header>
{manager &&
(!process.env.NOTDIENST_JOB_SECRET ||
process.env.NOTDIENST_JOB_SECRET.length < 32) && (
<p className="mb-6 rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm leading-6">
Die automatische Fristprüfung ist noch nicht eingerichtet. Für
Erinnerungen bei geschlossener App muss die Hosting-Konfiguration
ergänzt werden. Fristabläufe sind hier bereits sichtbar.
</p>
)}
{!kita.notdienstModuleEnabled && (
<p className="mb-6 text-sm">
Das Notdienstmodul ist deaktiviert; vorhandene Alarme bleiben
einsehbar.
</p>
)}
{notifications.length > 0 && (
<details className="mb-6 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4">
<summary className="cursor-pointer text-sm font-semibold">
{notifications.length} neue Hinweise
</summary>
<div className="mt-3 grid gap-3">
{notifications.map((n) => (
<div
key={n.id}
className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3"
>
<Link
className="max-w-3xl text-sm underline decoration-[var(--accent-mid)] underline-offset-4"
href={`/dashboard/notdienst/alarm?alarm=${n.alarmId}`}
>
{n.message}
</Link>
<AlarmSimpleAction command="readNotice" data={{ id: n.id }}>
Gelesen
</AlarmSimpleAction>
</div>
))}
</div>
</details>
)}
{params.alarm && (
<Link
className="mb-5 inline-block text-sm text-[var(--accent-deep)] underline"
href="/dashboard/notdienst/alarm"
>
Alle Alarme anzeigen
</Link>
)}
{manager && !candidates.length && !params.alarm && (
<p className="mb-5 text-sm text-[var(--ink-soft)]">
Neue Alarme benötigen eine aktuelle oder zukünftige Einteilung in
einem veröffentlichten Plan.{" "}
<Link href="/dashboard/notdienst/plan" className="underline">
Notdienstplan öffnen
</Link>
</p>
)}
<div className="grid gap-5">
{alarms.length ? (
alarms.map((a) => {
const clarify = needsClarification(a, now),
ended = a.expiresAt <= now;
const pending = a.emails.filter((e) => !e.sentAt && !e.skippedAt);
const ownRecipient = a.recipients.find(
(r) => r.userId === actor.id,
);
return (
<article
key={a.id}
id={`alarm-${a.id}`}
className={`overflow-hidden rounded-2xl border bg-[var(--surface)] ${clarify ? "border-[var(--danger)]" : "border-[var(--hairline)]"}`}
>
<div
className={`flex flex-wrap items-start justify-between gap-4 border-b border-[var(--hairline-soft)] p-5 sm:p-6 ${clarify ? "bg-[var(--surface-2)]" : ""}`}
>
<div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-[var(--ink-soft)]">
<BellRing className="h-4 w-4" />
{dateKey(a.date) === today ? "Heute · " : ""}
{dateKey(a.date).split("-").reverse().join(".")}
</p>
<h2 className="mt-2 text-2xl">{a.familyName}</h2>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
{durationLabel(a)}
{ended ? " · Einsatz beendet" : ""}
</p>
</div>
<span
className={`max-w-full rounded-full border px-3 py-1.5 text-xs font-semibold ${clarify ? "border-[var(--danger)] text-[var(--danger)]" : "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"}`}
>
{alarmLabel(a, now)}
</span>
</div>
<div className="grid gap-5 p-5 sm:p-6">
<div className="flex flex-wrap gap-x-8 gap-y-3 text-sm">
<p>
<span className="text-[var(--ink-muted)]">
Antwortfrist
</span>
<br />
<strong>{instantLabel(a.deadline)} Uhr</strong>
</p>
<p>
<span className="text-[var(--ink-muted)]">Ausgelöst</span>
<br />
{instantLabel(a.triggeredAt)} Uhr
</p>
{a.respondedAt && (
<p>
<span className="text-[var(--ink-muted)]">
Rückmeldung
</span>
<br />
{instantLabel(a.respondedAt)} Uhr ·{" "}
{a.responseSource === "PHONE"
? "Telefonisch dokumentiert"
: a.responseSource === "COORDINATION"
? "Freigabe durch Koordination"
: a.responseSource === "LEGACY"
? "Bisherige Alarmierung"
: "Haushaltsantwort"}
</p>
)}
</div>
{manager ? (
<>
<div className="flex flex-wrap gap-2">
{a.status === "LATE_CONFIRMATION" && !ended && (
<AlarmCommand
command="approveLate"
alarmId={a.id}
label="Verspätete Zusage freigeben"
/>
)}
{a.status !== "CANCELLED" && !ended && (
<AlarmCommand
command="recordResponse"
alarmId={a.id}
label="Telefonische Rückmeldung / Korrektur"
/>
)}
{a.status !== "CANCELLED" && (
<AlarmCommand
command="cancel"
alarmId={a.id}
label="Alarm stornieren"
/>
)}
{pending.length > 0 && (
<AlarmSimpleAction
command="retry"
data={{ alarmId: a.id }}
>
Ausstehende E-Mails erneut senden ({pending.length})
</AlarmSimpleAction>
)}
</div>
{clarify && (
<p className="rounded-lg bg-[var(--surface-2)] p-3 text-sm leading-6">
Bitte den Haushalt kontaktieren oder Ersatz manuell
organisieren. Weitere Familien werden nicht
automatisch alarmiert.
</p>
)}
<details className="border-t border-[var(--hairline-soft)] pt-4">
<summary className="cursor-pointer text-sm font-medium">
Versandstand (
{a.emails.filter((e) => e.sentAt).length} angenommen,{" "}
{pending.length} ausstehend)
</summary>
<div className="mt-3 grid gap-2 text-sm">
{a.emails.map((e) => (
<p key={e.id} className="break-all">
{a.recipients.find((r) => r.userId === e.userId)
?.user.email ?? "Koordination / Admin"}{" "}
·{" "}
{e.kind === "REQUEST"
? "Anfrage"
: e.kind === "CANCEL"
? "Stornierung"
: "Hinweis"}
:{" "}
{e.sentAt
? "Vom Maildienst angenommen"
: e.skippedAt
? "Nicht mehr erforderlich / Empfänger geändert"
: e.failed
? "Versand fehlgeschlagen – wiederholbar"
: "Versand ausstehend"}
</p>
))}
</div>
</details>
<details className="border-t border-[var(--hairline-soft)] pt-4">
<summary className="cursor-pointer text-sm font-medium">
Interne Details und Verlauf
</summary>
<div className="mt-3 grid gap-2 text-sm">
{a.educatorId && (
<p>
Ausgefallene Fachkraft:{" "}
{educators.find((e) => e.id === a.educatorId)
?.firstName ??
"Ehemalige / inaktive Fachkraft"}{" "}
{educators.find((e) => e.id === a.educatorId)
?.lastName ?? ""}
</p>
)}
{a.notes && <p>Interne Notiz: {a.notes}</p>}
{a.cancelReason && (
<p>Stornierungsgrund: {a.cancelReason}</p>
)}
{a.history.map((h) => (
<p key={h.id}>
{instantLabel(h.createdAt)} · {h.action}:{" "}
{historyLabels[h.details] ?? h.details}
</p>
))}
</div>
</details>
</>
) : (
<>
{ownRecipient && a.status === "PENDING" && !ended && (
<Link
className="inline-flex w-fit rounded-lg bg-[var(--accent-deep)] px-4 py-2 text-sm font-semibold text-white"
href={`/alert/${ownRecipient.token}`}
>
Für unseren Haushalt antworten
</Link>
)}
<p className="text-sm text-[var(--ink-soft)]">
{a.status === "LATE_CONFIRMATION"
? "Eure verspätete Zusage wartet auf Freigabe durch die Koordination."
: a.status === "CONFIRMED"
? "Die Kita rechnet mit euch. Änderungen bitte mit der Koordination klären."
: a.status === "CANCELLED"
? "Ihr müsst für diesen Alarm nicht einspringen."
: a.status === "DECLINED"
? "Eure Absage ist gespeichert. Die Koordination organisiert Ersatz."
: "Eine Antwort eines Elternteils gilt für euren ganzen Haushalt."}
</p>
</>
)}
</div>
</article>
);
})
) : (
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface)] p-8 text-sm text-[var(--ink-soft)]">
{params.alarm
? "Kein zugänglicher Alarm gefunden."
: "Noch keine Notdienstalarme in diesem Zeitraum."}
</div>
)}
</div>
</div>
);
}
+195 -153
View File
@@ -1,20 +1,25 @@
"use server";
import { UserFacingError, publicActionError } from "@/lib/action-error";
import { dateOnlySchema, validPlanMonth } from "@/lib/date-schema";
import { rateLimit } from "@/lib/rate-limit";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { dateOnlySchema, validPlanMonth } from "@/lib/date-schema";
import { rateLimit } from "@/lib/rate-limit";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
import { requireAlarmActor } from "@/lib/alarms/auth";
import {
validateAlarmActor,
assertAssignmentNotAlarmed,
} from "@/lib/alarms/service";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
@@ -158,42 +163,76 @@ export async function updateAssignmentAction(
date: string,
newChildId: string | null,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const actor = await requireAlarmActor();
const kitaId = actor.kitaId;
if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." };
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
try {
const notifications = await prisma.$transaction(async (tx) => {
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
const notifications = await prisma.$transaction(
async (tx) => {
await validateAlarmActor(tx, actor);
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
if (!plan) {
throw new UserFacingError("Plan nicht gefunden.");
}
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
if (!isDraft && !isPublished) {
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
}
if (isPublished) {
const today = new Date();
const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
if (parsedDate < todayUtc) {
throw new UserFacingError("Vergangene Notdienste können nicht geändert werden.");
if (!plan) {
throw new UserFacingError("Plan nicht gefunden.");
}
}
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
include: {
child: {
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
if (!isDraft && !isPublished) {
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
}
if (isPublished) {
const today = new Date();
const todayUtc = new Date(
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
);
if (parsedDate < todayUtc) {
throw new UserFacingError(
"Vergangene Notdienste können nicht geändert werden.",
);
}
}
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
include: {
child: {
include: {
family: {
include: {
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
},
},
},
},
});
let newChild: {
id: string;
family: {
users: { email: string; firstName: string; lastName: string }[];
};
} | null = null;
if (newChildId) {
newChild = await tx.child.findFirst({
where: {
id: newChildId,
kitaId,
active: true,
},
include: {
family: {
include: {
@@ -204,133 +243,118 @@ export async function updateAssignmentAction(
},
},
},
},
},
});
});
let newChild:
| {
id: string;
family: {
users: { email: string; firstName: string; lastName: string }[];
};
if (!newChild) {
throw new UserFacingError("Kind gehört nicht zu dieser Kita.");
}
| null = null;
if (newChildId) {
newChild = await tx.child.findFirst({
where: {
id: newChildId,
kitaId,
active: true,
},
include: {
family: {
include: {
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
},
},
});
if (!newChild) {
throw new UserFacingError("Kind gehört nicht zu dieser Kita.");
}
}
if (existingAssignment?.childId === newChildId) {
return [];
}
if (existingAssignment?.childId === newChildId) {
return [];
}
if (!newChildId) {
if (existingAssignment) {
await tx.notdienstAssignment.delete({
if (existingAssignment)
await assertAssignmentNotAlarmed(tx, existingAssignment.id, kitaId);
if (!newChildId) {
if (existingAssignment) {
await tx.notdienstAssignment.delete({
where: { id: existingAssignment.id },
});
}
} else if (existingAssignment) {
await tx.notdienstAssignment.update({
where: { id: existingAssignment.id },
data: { childId: newChildId },
});
} else {
await tx.notdienstAssignment.create({
data: {
kitaId,
planId,
childId: newChildId,
date: parsedDate,
},
});
}
} else if (existingAssignment) {
await tx.notdienstAssignment.update({
where: { id: existingAssignment.id },
data: { childId: newChildId },
});
} else {
await tx.notdienstAssignment.create({
data: {
kitaId,
planId,
childId: newChildId,
date: parsedDate,
},
});
}
if (existingAssignment) {
await tx.notdienstSwapRequest.updateMany({
where: {
kitaId,
status: "PENDING",
OR: [
{ targetAssignmentId: existingAssignment.id },
{ offeredAssignmentId: existingAssignment.id },
],
},
data: {
status: "CANCELLED",
respondedAt: new Date(),
},
});
}
if (!isPublished) {
return [];
}
const dateLabel = format(new Date(yr, mo - 1, dy), "EEEE, dd. MMMM yyyy", { locale: de });
const messages: {
parentEmails: string[];
parentNames: string[];
subject: string;
heading: string;
preview: string;
body: string;
dateLabel: string;
}[] = [];
if (existingAssignment) {
const oldParents = existingAssignment.child.family.users.filter((u) => !!u.email);
if (oldParents.length > 0) {
messages.push({
parentEmails: oldParents.map((p) => p.email),
parentNames: oldParents.map((p) => `${p.firstName} ${p.lastName}`),
subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
heading: "Notdienst wurde geändert",
preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
dateLabel,
if (existingAssignment) {
await tx.notdienstSwapRequest.updateMany({
where: {
kitaId,
status: "PENDING",
OR: [
{ targetAssignmentId: existingAssignment.id },
{ offeredAssignmentId: existingAssignment.id },
],
},
data: {
status: "CANCELLED",
respondedAt: new Date(),
},
});
}
}
if (newChild && existingAssignment?.childId !== newChild.id) {
const newParents = newChild.family.users.filter((u) => !!u.email);
if (newParents.length > 0) {
messages.push({
parentEmails: newParents.map((p) => p.email),
parentNames: newParents.map((p) => `${p.firstName} ${p.lastName}`),
subject: `Notdienst-Einteilung am ${dateLabel}`,
heading: "Neuer Notdienst-Termin zugewiesen",
preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
dateLabel,
});
if (!isPublished) {
return [];
}
}
return messages;
});
const dateLabel = format(
new Date(yr, mo - 1, dy),
"EEEE, dd. MMMM yyyy",
{ locale: de },
);
const messages: {
parentEmails: string[];
parentNames: string[];
subject: string;
heading: string;
preview: string;
body: string;
dateLabel: string;
}[] = [];
if (existingAssignment) {
const oldParents = existingAssignment.child.family.users.filter(
(u) => !!u.email,
);
if (oldParents.length > 0) {
messages.push({
parentEmails: oldParents.map((p) => p.email),
parentNames: oldParents.map(
(p) => `${p.firstName} ${p.lastName}`,
),
subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
heading: "Notdienst wurde geändert",
preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
dateLabel,
});
}
}
if (newChild && existingAssignment?.childId !== newChild.id) {
const newParents = newChild.family.users.filter((u) => !!u.email);
if (newParents.length > 0) {
messages.push({
parentEmails: newParents.map((p) => p.email),
parentNames: newParents.map(
(p) => `${p.firstName} ${p.lastName}`,
),
subject: `Notdienst-Einteilung am ${dateLabel}`,
heading: "Neuer Notdienst-Termin zugewiesen",
preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
dateLabel,
});
}
}
return messages;
},
{ isolationLevel: "Serializable" },
);
// E-Mails außerhalb der DB-Transaktion senden
if (notifications.length > 0) {
@@ -340,7 +364,10 @@ export async function updateAssignmentAction(
});
if (kita) {
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const BASE_URL =
process.env.NEXTAUTH_URL ??
process.env.AUTH_URL ??
"http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
for (const notification of notifications) {
@@ -385,8 +412,8 @@ export async function updateKitaNotdienstSettingsAction(
return { error: "Kein Mandant zugeordnet." };
}
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 ||
!Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
try {
await prisma.kita.update({
where: { id: kitaId },
@@ -404,15 +431,18 @@ export async function updateKitaNotdienstSettingsAction(
}
}
export async function sendNotdienstRemindersAction(year: number, month: number) {
export async function sendNotdienstRemindersAction(
year: number,
month: number,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-reminders:${session.user.kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
@@ -462,7 +492,9 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
},
});
const respondedFamilyIds = new Set(respondedFamilies.map((a) => a.child.familyId));
const respondedFamilyIds = new Set(
respondedFamilies.map((a) => a.child.familyId),
);
// 4. Find open families (not responded yet)
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
@@ -474,7 +506,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
// 5. Build emails list and send
const monthDate = new Date(year, month - 1, 1);
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const BASE_URL =
process.env.NEXTAUTH_URL ??
process.env.AUTH_URL ??
"http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
let sentCount = 0;
@@ -496,7 +531,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
if (result.success) {
sentCount++;
} else {
console.error(`E-Mail konnte nicht an ${parent.email} gesendet werden:`, result.error);
console.error(
`E-Mail konnte nicht an ${parent.email} gesendet werden:`,
result.error,
);
}
}
}
@@ -527,7 +565,9 @@ export async function resetPlanAction(planId: string) {
}
if (plan.status !== NotdienstPlanStatus.DRAFT) {
throw new UserFacingError("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
throw new UserFacingError(
"Nur Pläne im Entwurfsstatus können zurückgesetzt werden.",
);
}
// Delete all assignments
@@ -545,6 +585,8 @@ export async function resetPlanAction(planId: string) {
return { success: true };
} catch (error) {
console.error("Fehler beim Zurücksetzen des Plans:", error);
return { error: publicActionError(error, "Fehler beim Zurücksetzen des Plans.") };
return {
error: publicActionError(error, "Fehler beim Zurücksetzen des Plans."),
};
}
}
+87 -72
View File
@@ -1,14 +1,15 @@
import Link from "next/link";
import { addMonths, getYear, getMonth, format } from "date-fns";
import { de } from "date-fns/locale";
import { TerminType, UserRole } from "@prisma/client";
import { TerminType } from "@prisma/client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
import { PlanView } from "./plan-view";
import { SettingsModal } from "./_components/settings-modal";
import { requireAlarmActor } from "@/lib/alarms/auth";
import { todayKey } from "@/lib/alarms/format";
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
@@ -17,14 +18,18 @@ export default async function PlanungsZentralePage({
}: {
searchParams: Promise<{ year?: string; month?: string }>;
}) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const actor = await requireAlarmActor();
const kitaId = actor.kitaId;
const resolvedSearchParams = await searchParams;
const now = new Date();
const nextMonth = addMonths(now, 1);
const targetYear = resolvedSearchParams.year ? parseInt(resolvedSearchParams.year, 10) : getYear(nextMonth);
const targetMonth = resolvedSearchParams.month ? parseInt(resolvedSearchParams.month, 10) : (getMonth(nextMonth) + 1);
const targetYear = resolvedSearchParams.year
? parseInt(resolvedSearchParams.year, 10)
: getYear(nextMonth);
const targetMonth = resolvedSearchParams.month
? parseInt(resolvedSearchParams.month, 10)
: getMonth(nextMonth) + 1;
const monthDate = new Date(targetYear, targetMonth - 1, 1);
const monthName = format(monthDate, "MMMM", { locale: de });
@@ -33,79 +38,84 @@ export default async function PlanungsZentralePage({
const monthStart = new Date(targetYear, targetMonth - 1, 1);
const monthEnd = new Date(targetYear, targetMonth, 1);
const [plan, families, availabilities, closureCount, kita] = await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
select: {
id: true,
status: true,
publishedAt: true,
assignments: {
select: {
id: true,
childId: true,
date: true,
createdAt: true,
const [plan, families, availabilities, closureCount, kita] =
await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
select: {
id: true,
status: true,
publishedAt: true,
assignments: {
select: {
id: true,
childId: true,
date: true,
createdAt: true,
alarms: {
where: { activeAssignmentId: { not: null } },
select: { id: true },
},
},
},
},
},
}),
prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
select: {
id: true,
name: true,
children: {
where: { active: true },
select: { id: true },
}),
prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
},
orderBy: { name: "asc" },
}),
prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
date: true,
child: {
select: {
familyId: true,
select: {
id: true,
name: true,
children: {
where: { active: true },
select: { id: true },
},
},
},
orderBy: { date: "asc" },
}),
prisma.termin.count({
where: {
kitaId,
type: TerminType.SCHLIESSTAG,
startDate: {
gte: monthStart,
lt: monthEnd,
orderBy: { name: "asc" },
}),
prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
},
}),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
notdienstMinPerChildPerMonth: true,
notdienstReminderDaysBefore: true,
},
}),
]);
select: {
date: true,
child: {
select: {
familyId: true,
},
},
},
orderBy: { date: "asc" },
}),
prisma.termin.count({
where: {
kitaId,
type: TerminType.SCHLIESSTAG,
startDate: {
gte: monthStart,
lt: monthEnd,
},
},
}),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
notdienstMinPerChildPerMonth: true,
notdienstReminderDaysBefore: true,
},
}),
]);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
// aber für MVP reichen alle aktiven Kinder.
const allChildrenRaw = await prisma.child.findMany({
where: { kitaId, active: true },
@@ -179,7 +189,7 @@ export default async function PlanungsZentralePage({
// Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime()
(a) => a.date.getTime() === day.getTime(),
);
const isOriginallyUnassigned =
!assignment ||
@@ -190,6 +200,11 @@ export default async function PlanungsZentralePage({
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
isOriginallyUnassigned: !!isOriginallyUnassigned,
activeAlarmId: assignment?.alarms[0]?.id ?? null,
canAlarm:
!!assignment &&
plan?.status === "PUBLISHED" &&
day.toISOString().slice(0, 10) >= todayKey(),
};
});
+49 -6
View File
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client";
@@ -40,6 +41,8 @@ interface PlanViewProps {
days: {
date: string;
assignmentId: string | null;
activeAlarmId?: string | null;
canAlarm?: boolean;
childId: string | null;
isOriginallyUnassigned?: boolean;
}[];
@@ -92,7 +95,9 @@ export function PlanView({
const [resetDialogOpen, setResetDialogOpen] = useState(false);
const responsePercent =
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
totalFamilies > 0
? Math.round((respondedFamilies / totalFamilies) * 100)
: 0;
const handleGenerate = () => {
startTransition(async () => {
@@ -123,7 +128,9 @@ export function PlanView({
if ("error" in result && result.error) {
toast.error(result.error as string);
} else if (result.success) {
toast.success(`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`);
toast.success(
`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`,
);
}
});
};
@@ -173,7 +180,11 @@ export function PlanView({
<Button disabled={isPending} onClick={handleGenerate}>
Trotzdem generieren
</Button>
<Button variant="ghost" onClick={handleSendReminders} disabled={isPending}>
<Button
variant="ghost"
onClick={handleSendReminders}
disabled={isPending}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
@@ -408,7 +419,8 @@ export function PlanView({
<DialogHeader>
<DialogTitle>Plan zurücksetzen?</DialogTitle>
<DialogDescription>
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für diesen Monat gehen unwiderruflich verloren.
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für
diesen Monat gehen unwiderruflich verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -542,6 +554,21 @@ function PlanPreviewTable({
({assignedChild.name})
</span>
)}
{day.activeAlarmId ? (
<Link
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
href={`/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`}
>
Aktiven Alarm öffnen
</Link>
) : day.canAlarm && day.assignmentId ? (
<Link
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
href={`/dashboard/notdienst/alarm?assignment=${day.assignmentId}`}
>
Notdienstalarm auslösen
</Link>
) : null}
</div>
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
<div>
@@ -616,7 +643,7 @@ function ManualAssignmentTable({
<select
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={day.childId || "empty"}
disabled={isPending}
disabled={isPending || !!day.activeAlarmId}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
@@ -624,7 +651,9 @@ function ManualAssignmentTable({
<option value="empty">-- Unbesetzt --</option>
{allChildren.map((child) => {
const famId = child.familyId;
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
const count = famId
? (familyAssignmentCounts.get(famId) ?? 0)
: 0;
return (
<option key={child.id} value={child.id}>
{child.parentName} ({child.name}) — {count}x
@@ -637,6 +666,20 @@ function ManualAssignmentTable({
Veröffentlicht
</span>
)}
{day.assignmentId && day.canAlarm && (
<Link
className="text-xs font-semibold text-[var(--accent-deep)] underline underline-offset-4"
href={
day.activeAlarmId
? `/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`
: `/dashboard/notdienst/alarm?assignment=${day.assignmentId}`
}
>
{day.activeAlarmId
? "Aktiven Alarm öffnen"
: "Notdienstalarm auslösen"}
</Link>
)}
</div>
</div>
);
+49 -12
View File
@@ -18,7 +18,12 @@ import { getTargetMonthData } from "@/lib/date-utils";
import { prisma } from "@/lib/prisma";
import { Stat } from "@/components/ui/stat";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { NewsTicker } from "./news-ticker";
import { RsvpActionCard } from "./rsvp-action-card";
import { getAnnouncements } from "@/lib/announcements";
import { getRsvpEvents } from "@/lib/rsvp-events";
import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
import { AlarmDashboardHints } from "./notdienst/alarm/dashboard-hints";
export const metadata = { title: "Übersicht · Kita-Planer" };
@@ -55,6 +60,8 @@ export default async function DashboardPage() {
onboardingUser,
absenceChildren,
upcomingAbsences,
announcements,
rsvpEvents,
] = await Promise.all([
prisma.family.count({
where: { kitaId: session.user.kitaId },
@@ -142,6 +149,8 @@ export default async function DashboardPage() {
take: 6,
})
: Promise.resolve([]),
getAnnouncements(session, { take: 3 }),
getRsvpEvents(session, { onlyOpen: true }),
]);
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
@@ -177,7 +186,7 @@ export default async function DashboardPage() {
}
/>
<header className="mb-9 flex items-start justify-between gap-8">
<header className="mb-9 flex items-start justify-between gap-8 max-[760px]:flex-col max-[760px]:gap-3">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ÜBERSICHT · {kita.name}
@@ -191,18 +200,29 @@ export default async function DashboardPage() {
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Heute, {format(now, "EEEE, d. MMMM yyyy", { locale: de })}. Hier
siehst du auf einen Blick, was bei {kita.name} ansteht und kannst
dein Kind schnell für Abwesenheiten melden.
siehst du auf einen Blick, was bei {kita.name} ansteht.
</p>
</div>
<div className="mt-9 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
<div className="mt-9 max-[760px]:mt-0 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
<Clock3 className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(now, "EEE, d. MMM yyyy", { locale: de })}
</div>
</header>
<AlarmDashboardHints />
<RsvpActionCard events={rsvpEvents} />
<NewsTicker items={announcements} />
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
<div className="min-w-0">
{session.user.role === UserRole.ERZIEHER && (
<QuickCard
href="/dashboard/personal"
icon={Clock3}
title="Meine Arbeitszeit & Urlaub"
sub="Stunden erfassen, Urlaub beantragen und dein Zeitkonto ansehen."
/>
)}
{canReportAbsences ? (
<AbsenceCard
today={format(today, "yyyy-MM-dd")}
@@ -237,7 +257,11 @@ export default async function DashboardPage() {
</div>
<section className="mt-5 grid grid-cols-4 gap-3.5 max-[1100px]:grid-cols-2 max-[640px]:grid-cols-1">
<Stat label="Familien" value={familyCount} hint="registrierte Familien" />
<Stat
label="Familien"
value={familyCount}
hint="registrierte Familien"
/>
<Stat label="Kinder" value={childCount} hint="in der Einrichtung" />
<Stat
label={`Notdienst ${targetData.monthName}`}
@@ -260,12 +284,22 @@ export default async function DashboardPage() {
Häufige Aktionen für Vorstand und Koordination.
</p>
<div className="mt-4 grid grid-cols-3 gap-3 max-[1100px]:grid-cols-1">
<QuickCard
href="/dashboard/families"
icon={Users}
title="Familien verwalten"
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
/>
{session.user.role === UserRole.ADMIN && (
<QuickCard
href="/dashboard/personal"
icon={Clock3}
title="Personal & Freigaben"
sub="Urlaub, Arbeitswochen und Beschäftigungsprofile verwalten."
/>
)}
{session.user.role === UserRole.ADMIN && (
<QuickCard
href="/dashboard/families"
icon={Users}
title="Familien verwalten"
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
/>
)}
{kita.terminModuleEnabled ? (
<QuickCard
href="/dashboard/kalender"
@@ -274,7 +308,10 @@ export default async function DashboardPage() {
sub="Schließtage, Elternabende und Feste planen."
/>
) : null}
{kita.notdienstModuleEnabled ? (
{kita.notdienstModuleEnabled &&
[UserRole.ADMIN, UserRole.KOORDINATOR].includes(
session.user.role as "ADMIN" | "KOORDINATOR",
) ? (
<QuickCard
href="/dashboard/notdienst/plan"
icon={ListTodo}
+71
View File
@@ -0,0 +1,71 @@
"use server";
import { publicActionError } from "@/lib/action-error";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireStaffActor } from "@/lib/staff/auth";
import { executeStaffCommand } from "@/lib/staff/service";
import { deliverStaffEmails } from "@/lib/staff/email";
export type StaffActionResult = {
educatorId?: string;
error?: string;
success?: boolean;
invitePath?: string;
warning?: string;
message?: string;
};
export async function staffAction(
command: string,
data: Record<string, unknown>,
): Promise<StaffActionResult> {
const actor = await requireStaffActor();
try {
const result = await executeStaffCommand(actor, { command, data });
let warning: string | undefined;
let message: string | undefined;
if (result.emailIds?.length) {
try {
const delivery = await deliverStaffEmails(result.emailIds);
if (delivery.failed)
warning =
"Gespeichert. Einige E-Mails konnten nicht versendet werden. Im Personalbereich kannst du den Versand erneut versuchen.";
else if (
process.env.NODE_ENV === "development" &&
!process.env.RESEND_API_KEY
)
warning = "Gespeichert. Der E-Mail-Versand wurde lokal simuliert.";
else if (result.invite && delivery.sent)
message = "Aktivierungslink per E-Mail versendet.";
else if (command === "emailRetry")
message = "Ausstehende E-Mails wurden verarbeitet.";
} catch {
warning =
"Gespeichert. E-Mail-Versand ist noch ausstehend; bitte im Personalbereich erneut versuchen.";
}
}
revalidatePath("/dashboard/personal");
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/erzieher");
revalidatePath("/dashboard");
// Returned only to the authenticated admin who generated this single-use link.
return {
success: true,
educatorId: result.educatorId,
warning,
message,
...(result.invite
? { invitePath: `/invite/${result.invite.token}` }
: {}),
};
} catch (error) {
if (error instanceof z.ZodError)
return {
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
};
const message = publicActionError(error, "");
if (message) return { error: message };
return { error: "Speichern fehlgeschlagen. Bitte erneut versuchen." };
}
}
+784
View File
@@ -0,0 +1,784 @@
"use client";
import { useId, useState, useTransition, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DAY_NAMES,
clockMinute,
minuteClock,
todayKey,
} from "@/lib/staff/calculations";
import { staffAction, type StaffActionResult } from "./actions";
const control =
"w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-mid)]";
export function Field({
label,
name,
type = "text",
value,
required = true,
min,
max,
step,
}: {
label: string;
name: string;
type?: string;
value?: string | number;
required?: boolean;
min?: string | number;
max?: string | number;
step?: string | number;
}) {
const id = useId();
return (
<div className="grid gap-1.5">
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
<Input
id={id}
name={name}
type={type}
defaultValue={value}
required={required}
min={min}
max={max}
step={step}
/>
</div>
);
}
function transform(command: string, form: FormData) {
const text = (key: string) => String(form.get(key) ?? "");
const num = (key: string) => Number(text(key));
const hours = (key: string) => Math.round(num(key) * 60);
switch (command) {
case "emailPreference":
return { enabled: form.get("enabled") === "on" };
case "setup":
return {
educatorId: text("educatorId"),
employmentStart: text("employmentStart"),
employmentEnd: text("employmentEnd") || null,
trackingStart: text("trackingStart"),
openingMinutes: hours("openingHours"),
reason: text("reason"),
};
case "invite":
return { email: text("email") };
case "employmentEnd":
return {
employmentEnd: text("employmentEnd") || null,
reason: text("reason"),
};
case "pattern":
return {
effectiveFrom: text("effectiveFrom"),
dailyMinutes: DAY_NAMES.map((_, i) => hours(`day${i}`)),
reason: text("reason"),
};
case "account":
return {
year: num("year"),
entitlementUnits: Math.round(num("entitlement") * 2),
carryUnits: Math.round(num("carry") * 2),
reason: text("reason"),
};
case "override":
return {
date: text("date"),
minutes: hours("hours"),
reason: text("reason"),
};
case "adjust":
return {
date: text("date"),
minutes: hours("hours"),
reason: text("reason"),
};
case "holiday":
return { date: text("date"), name: text("name") };
case "time":
return {
date: text("date"),
startMinute: clockMinute(text("start")),
endMinute: clockMinute(text("end")),
breakMinutes: num("breakMinutes"),
note: text("note"),
};
case "leave":
return {
kind: text("kind"),
startDate: text("startDate"),
endDate: text("endDate"),
halfDay: form.get("halfDay") === "on",
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
note: text("note"),
};
case "leaveReview":
case "weekReview":
return { reason: text("reason") };
default:
return {};
}
}
function StaffForm({
command,
profileId,
extra = {},
children,
label = "Speichern",
onSuccess,
}: {
command: string;
profileId?: string;
extra?: Record<string, unknown>;
children: ReactNode;
label?: string;
onSuccess?: (result: StaffActionResult) => void;
}) {
const [pending, start] = useTransition();
const [error, setError] = useState("");
const router = useRouter();
return (
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
setError("");
start(async () => {
try {
const result = await staffAction(command, {
...transform(command, form),
...extra,
...(profileId ? { profileId } : {}),
});
if (result.error) {
setError(result.error);
return;
}
if (result.warning) toast.warning(result.warning);
else toast.success(result.message ?? "Gespeichert.");
onSuccess?.(result);
router.refresh();
} catch (error) {
setError(
error instanceof Error
? error.message
: "Speichern fehlgeschlagen.",
);
}
});
}}
>
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
{children}
<div>
<Button type="submit">
{pending ? "Wird gespeichert …" : label}
</Button>
</div>
</fieldset>
{error && (
<p role="alert" className="text-sm text-[var(--danger)]">
{error}
</p>
)}
</form>
);
}
export function CommandButton({
command,
profileId,
data,
children,
variant = "outline",
}: {
command: string;
profileId: string;
data: Record<string, unknown>;
children: ReactNode;
variant?: "outline" | "ghost" | "default";
}) {
const [pending, start] = useTransition();
const router = useRouter();
return (
<Button
type="button"
variant={variant}
size="sm"
disabled={pending}
onClick={() =>
start(async () => {
const result = await staffAction(command, { ...data, profileId });
if (result.error) toast.error(result.error);
else {
if (result.warning) toast.warning(result.warning);
else toast.success(result.message ?? "Gespeichert.");
router.refresh();
}
})
}
>
{pending ? "Bitte warten …" : children}
</Button>
);
}
export function ReviewButton({
command,
profileId,
data,
label,
approve,
}: {
command: "leaveReview" | "weekReview";
profileId: string;
data: Record<string, unknown>;
label: string;
approve: boolean;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant={approve ? "default" : "outline"}>
{label}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{label}</DialogTitle>
<DialogDescription>
Die Entscheidung wird im Personalverlauf dokumentiert.
</DialogDescription>
</DialogHeader>
<StaffForm
command={command}
profileId={profileId}
extra={{ ...data, approve }}
label={label}
onSuccess={() => setOpen(false)}
>
<Field label="Begründung / Rückmeldung" name="reason" />
</StaffForm>
</DialogContent>
</Dialog>
);
}
export function SetupForm({
educatorId,
values,
}: {
educatorId: string;
values?: {
employmentStart: string;
employmentEnd: string | null;
trackingStart: string;
openingMinutes: number;
};
}) {
return (
<StaffForm
command="setup"
extra={{ educatorId }}
label="Beschäftigungsprofil speichern"
>
<input type="hidden" name="educatorId" value={educatorId} />
<div className="grid gap-4 sm:grid-cols-2">
<Field
label="Beschäftigung ab"
name="employmentStart"
type="date"
value={values?.employmentStart ?? todayKey()}
/>
<Field
label="Beschäftigung bis (optional)"
name="employmentEnd"
type="date"
value={values?.employmentEnd ?? ""}
required={false}
/>
<Field
label="Zeiterfassung ab"
name="trackingStart"
type="date"
value={values?.trackingStart ?? todayKey()}
/>
<Field
label="Anfangssaldo in Stunden"
name="openingHours"
type="number"
step="any"
value={(values?.openingMinutes ?? 0) / 60}
/>
</div>
<Field label="Begründung" name="reason" value="Ersteinrichtung" />
</StaffForm>
);
}
export function TimeForm({
profileId,
initial,
}: {
profileId: string;
initial?: {
id: string;
date: string;
startMinute: number;
endMinute: number;
breakMinutes: number;
note: string | null;
};
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant={initial ? "outline" : "default"}
size={initial ? "sm" : "default"}
>
{initial ? "Bearbeiten" : "+ Arbeitszeit erfassen"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{initial ? "Arbeitszeit bearbeiten" : "Arbeitszeit erfassen"}
</DialogTitle>
<DialogDescription>
Erfasse Beginn, Ende und die gesamte Pause dieses Arbeitsblocks.
Weitere Blöcke am selben Tag sind möglich.
</DialogDescription>
</DialogHeader>
<StaffForm
command="time"
profileId={profileId}
extra={initial ? { id: initial.id } : {}}
label="Arbeitszeit speichern"
onSuccess={() => setOpen(false)}
>
<Field
label="Datum"
name="date"
type="date"
value={initial?.date ?? todayKey()}
max={todayKey()}
/>
<div className="grid grid-cols-2 gap-4">
<Field
label="Beginn"
name="start"
type="time"
value={minuteClock(initial?.startMinute ?? 480)}
/>
<Field
label="Ende"
name="end"
type="time"
value={minuteClock(initial?.endMinute ?? 960)}
/>
</div>
<Field
label="Pause in Minuten"
name="breakMinutes"
type="number"
min={0}
step={1}
value={initial?.breakMinutes ?? 30}
/>
<Field
label="Notiz (optional)"
name="note"
value={initial?.note ?? ""}
required={false}
/>
</StaffForm>
</DialogContent>
</Dialog>
);
}
export function LeaveForm({
profileId,
admin,
initial,
}: {
profileId: string;
admin: boolean;
initial?: {
id: string;
kind: string;
startDate: string;
endDate: string;
halfDay: boolean;
minutes: number | null;
note: string | null;
};
}) {
const [open, setOpen] = useState(false);
const [kind, setKind] = useState(initial?.kind ?? "VACATION");
const id = useId();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size={initial ? "sm" : "default"} variant="outline">
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{initial ? "Antrag ändern" : "Abwesenheit beantragen"}
</DialogTitle>
<DialogDescription>
Urlaub verbraucht nur deine vorgesehenen Arbeitstage.
Freizeitausgleich wird in Stunden für einen einzelnen Tag beantragt.
</DialogDescription>
</DialogHeader>
<StaffForm
command="leave"
profileId={profileId}
extra={initial ? { id: initial.id } : {}}
label={
kind === "SICKNESS" ? "Krankheit hinterlegen" : "Antrag speichern"
}
onSuccess={() => setOpen(false)}
>
<div className="grid gap-1.5">
<label htmlFor={`${id}-kind`} className="text-sm font-medium">
Art
</label>
<select
id={`${id}-kind`}
name="kind"
className={control}
value={kind}
onChange={(e) => setKind(e.target.value)}
>
<option value="VACATION">Urlaub</option>
<option value="TIME_OFF">Freizeitausgleich</option>
{admin && <option value="SICKNESS">Krankheit</option>}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
label="Von"
name="startDate"
type="date"
value={initial?.startDate ?? todayKey()}
/>
<Field
label="Bis"
name="endDate"
type="date"
value={initial?.endDate ?? todayKey()}
/>
</div>
{kind === "VACATION" && (
<label className="flex items-center gap-2 text-sm">
<input
name="halfDay"
type="checkbox"
defaultChecked={initial?.halfDay}
/>
Halber Urlaubstag (Von = Bis)
</label>
)}
{kind === "TIME_OFF" && (
<Field
label="Freizeitausgleich in Stunden (Von = Bis)"
name="hours"
type="number"
min={1 / 60}
max={16}
step="any"
value={(initial?.minutes ?? 60) / 60}
/>
)}
<Field
label="Notiz (optional)"
name="note"
value={initial?.note ?? ""}
required={false}
/>
<p className="text-xs leading-5 text-[var(--ink-soft)]">
Der Antrag wird anhand deines Wochenmodells, der Tagesausnahmen und
der Feiertage geprüft. Ein halber Tag entspricht der Hälfte der
Sollzeit.
</p>
</StaffForm>
</DialogContent>
</Dialog>
);
}
function SettingsSection({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<details className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-5">
<summary className="cursor-pointer font-semibold">{title}</summary>
<div className="mt-5">{children}</div>
</details>
);
}
export function StaffEmailSettings({ enabled }: { enabled: boolean }) {
return (
<StaffForm command="emailPreference" label="E-Mail-Einstellung speichern">
<label className="flex items-start gap-3 text-sm">
<input
type="checkbox"
name="enabled"
defaultChecked={enabled}
className="mt-1 accent-[var(--accent-deep)]"
/>
<span>
Entscheidungen zu Urlaub, Freizeitausgleich und Arbeitswochen per
E-Mail erhalten.
</span>
</label>
<p className="text-sm text-[var(--ink-soft)]">
Aktivierungslinks und Hinweise innerhalb der App bleiben davon
unabhängig.
</p>
</StaffForm>
);
}
export function SettingsPanel({
profileId,
educatorId,
employment,
email,
verified,
pattern,
year,
account,
}: {
profileId: string;
educatorId: string;
employment: {
employmentStart: string;
employmentEnd: string | null;
trackingStart: string;
openingMinutes: number;
};
email?: string;
verified: boolean;
pattern?: { effectiveFrom: string; dailyMinutes: number[] };
year: number;
account?: { entitlementUnits: number; carryUnits: number };
}) {
const [invitePath, setInvitePath] = useState("");
return (
<div className="grid gap-3">
<SettingsSection title="Zugang zur Zeiterfassung">
{verified ? (
<p className="text-sm">Aktives Konto: {email}</p>
) : (
<StaffForm
command="invite"
profileId={profileId}
label="Per E-Mail einladen / Account verknüpfen"
onSuccess={(r) => setInvitePath(r.invitePath ?? "")}
>
<Field
label="E-Mail-Adresse"
name="email"
type="email"
value={email ?? ""}
/>
<p className="text-sm text-[var(--ink-soft)]">
Erstellt oder verknüpft einen ErzieherInnen-Account. Der
persönliche Aktivierungslink wird per E-Mail versendet und ist
sieben Tage gültig. Ein neuer Link ersetzt den vorherigen.
</p>
</StaffForm>
)}
{invitePath && (
<div className="mt-4 grid gap-2 rounded-lg bg-[var(--accent-soft)] p-4">
<label htmlFor="activation-link" className="text-sm font-medium">
Persönlicher Aktivierungslink
</label>
<Input
id="activation-link"
readOnly
value={`${window.location.origin}${invitePath}`}
/>
<Button
variant="outline"
onClick={async () => {
await navigator.clipboard.writeText(
`${window.location.origin}${invitePath}`,
);
toast.success("Link kopiert.");
}}
>
Link kopieren
</Button>
<p className="text-xs">
Der Link wird per E-Mail versendet. Bei Bedarf kannst du ihn
zusätzlich persönlich an die Fachkraft weitergeben.
</p>
</div>
)}
</SettingsSection>
<SettingsSection title="Feste Wochentage & Sollstunden">
<StaffForm
command="pattern"
profileId={profileId}
label="Wochenmodell speichern"
>
<Field
label="Gültig ab"
name="effectiveFrom"
type="date"
value={pattern?.effectiveFrom ?? employment.trackingStart}
/>
<div className="grid gap-3 sm:grid-cols-2">
{DAY_NAMES.map((day, i) => (
<Field
key={day}
label={`${day} · Stunden`}
name={`day${i}`}
type="number"
min={0}
max={16}
step="any"
value={(pattern?.dailyMinutes[i] ?? 0) / 60}
/>
))}
</div>
<Field label="Begründung" name="reason" />
<p className="text-xs text-[var(--ink-soft)]">
0 Stunden = frei. Stunden sind Nettozeiten ohne Pause. Änderungen
gelten ab dem gewählten Datum; ältere Modelle bleiben erhalten.
</p>
</StaffForm>
</SettingsSection>
<SettingsSection title={`Urlaubsanspruch & Übertrag · ${year}`}>
<StaffForm command="account" profileId={profileId}>
<Field
label="Jahr"
name="year"
type="number"
min={2000}
max={2100}
value={year}
/>
<div className="grid grid-cols-2 gap-4">
<Field
label="Jahresanspruch in Tagen"
name="entitlement"
type="number"
min={0}
max={400}
step={0.5}
value={(account?.entitlementUnits ?? 0) / 2}
/>
<Field
label="Übertrag in Tagen"
name="carry"
type="number"
min={0}
max={400}
step={0.5}
value={(account?.carryUnits ?? 0) / 2}
/>
</div>
<Field label="Begründung" name="reason" />
<p className="text-xs text-[var(--ink-soft)]">
Anspruch individuell für die Arbeitstage dieser Person hinterlegen.
Der Übertrag wird ausdrücklich festgelegt.
</p>
</StaffForm>
</SettingsSection>
<SettingsSection title="Ausnahme für einen einzelnen Tag">
<StaffForm command="override" profileId={profileId}>
<Field label="Datum" name="date" type="date" value={todayKey()} />
<Field
label="Abweichende Sollstunden (0 = frei)"
name="hours"
type="number"
min={0}
max={16}
step="any"
value={0}
/>
<Field label="Begründung" name="reason" />
<p className="text-xs text-[var(--ink-soft)]">
Bei einem Tausch beide Tage erfassen: den ursprünglichen Tag auf 0
setzen und den neuen Arbeitstag mit Sollstunden hinterlegen.
</p>
</StaffForm>
</SettingsSection>
<SettingsSection title="Zeitkonto korrigieren">
<StaffForm command="adjust" profileId={profileId}>
<Field
label="Buchungsdatum"
name="date"
type="date"
value={todayKey()}
/>
<Field
label="Korrektur in Stunden (mit Vorzeichen)"
name="hours"
type="number"
step="any"
/>
<Field label="Begründung" name="reason" />
<p className="text-xs text-[var(--ink-soft)]">
Korrekturen bleiben im Verlauf erhalten. Falsche Buchungen durch
eine Gegenbuchung berichtigen.
</p>
</StaffForm>
</SettingsSection>
<SettingsSection title="Beschäftigungsbeginn & Anfangssaldo">
<SetupForm educatorId={educatorId} values={employment} />
<p className="mt-3 text-xs text-[var(--ink-soft)]">
Die Grunddaten sind geschützt, sobald Zeiten, Wochen oder Anträge
erfasst wurden.
</p>
</SettingsSection>
<SettingsSection title="Beschäftigungsende ändern">
<StaffForm command="employmentEnd" profileId={profileId}>
<Field
label="Beschäftigung bis (leer = unbefristet)"
name="employmentEnd"
type="date"
value={employment.employmentEnd ?? ""}
required={false}
/>
<Field label="Begründung" name="reason" />
</StaffForm>
</SettingsSection>
</div>
);
}
export function HolidayForm() {
return (
<StaffForm command="holiday" label="Feiertag speichern">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Datum" name="date" type="date" />
<Field label="Bezeichnung" name="name" />
</div>
</StaffForm>
);
}
File diff suppressed because it is too large Load Diff
+145 -98
View File
@@ -1,5 +1,7 @@
import { format } from "date-fns";
import { de } from "date-fns/locale";
import Link from "next/link";
import { UserRole } from "@prisma/client";
import {
AtSign,
Baby,
@@ -27,6 +29,7 @@ import { DeleteAccountDialog } from "./_components/delete-account-dialog";
import { MyChildrenManager } from "./_components/my-children-manager";
import { CreateFamilyDialog } from "./_components/create-family-dialog";
import { ContactForm } from "./_components/contact-form";
import { StaffEmailSettings } from "../personal/forms";
export const metadata = { title: "Mein Profil · Kita-Planer" };
@@ -52,6 +55,7 @@ export default async function ProfilPage() {
postalCode: true,
city: true,
directoryOptInAt: true,
staffDecisionEmails: true,
createdAt: true,
family: {
select: {
@@ -96,7 +100,9 @@ export default async function ProfilPage() {
</em>
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen.
{user.role === UserRole.ERZIEHER
? "Deine persönlichen Daten, dein Arbeitsbereich und Datenschutzeinstellungen."
: "Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen."}
</p>
</header>
@@ -124,25 +130,31 @@ export default async function ProfilPage() {
label="Telefon"
value={user.phone ? user.phone : <EmptyDefValue />}
/>
<DefItem icon={Shield} label="Rolle" value={<RoleBadge role={user.role} />} />
<DefItem
icon={Home}
label="Haushalt"
value={
user.family ? (
user.family.name
) : (
<div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
<p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
Dein Account ist noch keinem Haushalt zugeordnet.
</p>
<CreateFamilyDialog
defaultFamilyName={`Familie ${user.lastName}`}
/>
</div>
)
}
icon={Shield}
label="Rolle"
value={<RoleBadge role={user.role} />}
/>
{user.role !== UserRole.ERZIEHER && (
<DefItem
icon={Home}
label="Haushalt"
value={
user.family ? (
user.family.name
) : (
<div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
<p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
Dein Account ist noch keinem Haushalt zugeordnet.
</p>
<CreateFamilyDialog
defaultFamilyName={`Familie ${user.lastName}`}
/>
</div>
)
}
/>
)}
<DefItem
icon={CalendarHeart}
label="Mitglied seit"
@@ -153,6 +165,24 @@ export default async function ProfilPage() {
</Card>
<div className="grid gap-4">
{user.role === UserRole.ERZIEHER && (
<Card>
<CardHeader>
<CardTitle>Meine Arbeitszeit & Urlaub</CardTitle>
<CardDescription>
Wochenmodell, Urlaubsanspruch, Zeiterfassung und Überstunden.
</CardDescription>
</CardHeader>
<CardContent>
<Link
href="/dashboard/personal"
className="font-medium text-[var(--accent-deep)] underline underline-offset-4"
>
Personalbereich öffnen →
</Link>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
@@ -180,87 +210,104 @@ export default async function ProfilPage() {
</CardContent>
</Card>
<Card>
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Baby className="h-4 w-4 [stroke-width:1.7]" />
</div>
<div>
<CardTitle>Meine Kinder</CardTitle>
<CardDescription className="mt-2">
Kinder deines Haushalts verwalten.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<MyChildrenManager
items={(user.family?.children ?? []).map((child) => ({
id: child.id,
firstName: child.firstName,
lastName: child.lastName,
dateOfBirth: child.dateOfBirth
? child.dateOfBirth.toISOString().slice(0, 10)
: null,
}))}
canManage={!!user.familyId}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Shield className="h-4 w-4 [stroke-width:1.7]" />
</div>
<div>
<CardTitle>Meine Ämter & Dienste</CardTitle>
<CardDescription className="mt-2">
Feste Aufgaben und laufende Zuständigkeiten.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{user.dutyAssignments.length === 0 ? (
<div className="flex min-h-32 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Shield className="h-4 w-4 [stroke-width:1.7]" />
</div>
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
Keine festen Ämter übernommen
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">
Dienste und Rollen erscheinen hier, sobald sie zugewiesen
sind.
</p>
</div>
) : (
<div className="grid gap-2">
{user.dutyAssignments.map((assignment) => (
<div
key={assignment.id}
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
>
<div>
<p className="text-sm font-semibold text-[var(--ink)]">
{assignment.duty.name}
</p>
<p className="text-xs text-[var(--ink-soft)]">
laufender Dienst
</p>
</div>
<span className="rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]">
Aktiv
</span>
{user.role === UserRole.ERZIEHER && (
<Card>
<CardHeader>
<CardTitle>E-Mail-Hinweise</CardTitle>
<CardDescription>
Benachrichtigungen aus dem Personalbereich.
</CardDescription>
</CardHeader>
<CardContent>
<StaffEmailSettings enabled={user.staffDecisionEmails} />
</CardContent>
</Card>
)}
{user.role !== UserRole.ERZIEHER && (
<>
<Card>
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Baby className="h-4 w-4 [stroke-width:1.7]" />
</div>
))}
</div>
)}
</CardContent>
</Card>
<div>
<CardTitle>Meine Kinder</CardTitle>
<CardDescription className="mt-2">
Kinder deines Haushalts verwalten.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<MyChildrenManager
items={(user.family?.children ?? []).map((child) => ({
id: child.id,
firstName: child.firstName,
lastName: child.lastName,
dateOfBirth: child.dateOfBirth
? child.dateOfBirth.toISOString().slice(0, 10)
: null,
}))}
canManage={!!user.familyId}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Shield className="h-4 w-4 [stroke-width:1.7]" />
</div>
<div>
<CardTitle>Meine Ämter & Dienste</CardTitle>
<CardDescription className="mt-2">
Feste Aufgaben und laufende Zuständigkeiten.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{user.dutyAssignments.length === 0 ? (
<div className="flex min-h-32 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Shield className="h-4 w-4 [stroke-width:1.7]" />
</div>
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
Keine festen Ämter übernommen
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">
Dienste und Rollen erscheinen hier, sobald sie
zugewiesen sind.
</p>
</div>
) : (
<div className="grid gap-2">
{user.dutyAssignments.map((assignment) => (
<div
key={assignment.id}
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
>
<div>
<p className="text-sm font-semibold text-[var(--ink)]">
{assignment.duty.name}
</p>
<p className="text-xs text-[var(--ink-soft)]">
laufender Dienst
</p>
</div>
<span className="rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]">
Aktiv
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</>
)}
</div>
</div>
+12 -167
View File
@@ -1,15 +1,10 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { EventParticipationStatus } from "@prisma/client";
import { AlertTriangle } from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { AlertTriangle, Check, X } from "lucide-react";
import { toast } from "sonner";
import { submitEventRsvp } from "@/actions/rsvp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { RsvpEventDto } from "@/lib/rsvp";
import { RsvpDialog } from "@/components/rsvp-form";
import {
Card,
CardContent,
@@ -17,41 +12,18 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export type DashboardRsvpEventDto = {
id: string;
title: string;
startDate: string;
rsvpDeadline: string | null;
children: {
id: string;
name: string;
status: EventParticipationStatus | null;
}[];
};
export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] }) {
export function RsvpActionCard({ events }: { events: RsvpEventDto[] }) {
if (events.length === 0) return null;
return (
<Card className="mb-8 border-[var(--warn)] bg-[var(--warn-soft)]">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[var(--ink)]">
<AlertTriangle className="h-5 w-5 text-[var(--warn)] [stroke-width:1.7]" />
Aktion erforderlich
<Card className="mb-5 border-[var(--warn)] bg-[var(--warn-soft)]">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<AlertTriangle className="h-5 w-5 text-[var(--warn)]" />
Rückmeldung erforderlich
</CardTitle>
<CardDescription className="text-[var(--ink-soft)]">
Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
Familie.
<CardDescription>
Für diese Termine fehlt noch eine Antwort eurer Familie.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
@@ -61,7 +33,7 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
className="flex flex-col gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-medium text-[var(--ink)]">{event.title}</p>
<p className="font-medium">{event.title}</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Rückmeldung bis{" "}
{format(
@@ -78,130 +50,3 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
</Card>
);
}
function RsvpDialog({ event }: { event: DashboardRsvpEventDto }) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const initialSelection = useMemo(
() =>
Object.fromEntries(
event.children.map((child) => [child.id, child.status ?? ""]),
) as Record<string, EventParticipationStatus | "">,
[event.children],
);
const [selection, setSelection] = useState(initialSelection);
function submit() {
const missingChild = event.children.find((child) => !selection[child.id]);
if (missingChild) {
toast.error(`Bitte Rückmeldung für ${missingChild.name} auswählen.`);
return;
}
startTransition(async () => {
for (const child of event.children) {
const status = selection[child.id];
if (!status) continue;
const result = await submitEventRsvp({
eventId: event.id,
childId: child.id,
status,
});
if (result.error) {
toast.error(result.error);
return;
}
}
toast.success("Rückmeldung gespeichert.");
setOpen(false);
});
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) setSelection(initialSelection);
}}
>
<DialogTrigger asChild>
<Button size="sm">Jetzt abstimmen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rückmeldung für {event.title}</DialogTitle>
<DialogDescription>
Wähle pro Kind aus, ob es am Termin teilnimmt.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
{event.children.map((child) => (
<div
key={child.id}
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="font-medium text-[var(--ink)]">{child.name}</p>
{child.status && (
<Badge variant="outline">bereits gemeldet</Badge>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.ATTENDING,
}))
}
/>
<Check className="h-4 w-4 text-[var(--good)] [stroke-width:1.7]" />
Nimmt teil
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
<input
type="radio"
name={`rsvp-${child.id}`}
checked={
selection[child.id] ===
EventParticipationStatus.NOT_ATTENDING
}
onChange={() =>
setSelection((current) => ({
...current,
[child.id]: EventParticipationStatus.NOT_ATTENDING,
}))
}
/>
<X className="h-4 w-4 text-[var(--danger)] [stroke-width:1.7]" />
Nimmt nicht teil
</label>
</div>
</div>
))}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button type="button" disabled={isPending} onClick={submit}>
{isPending ? "Speichern..." : "Rückmeldung speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}