feat: complete notdienst planning, quick wins and notifications
This commit is contained in:
@@ -11,6 +11,8 @@ import { requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
|
||||
const INVITE_TOKEN_TTL_DAYS = 7;
|
||||
const BASE_URL =
|
||||
@@ -387,6 +389,9 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds);
|
||||
const removed = await tx.child.count({ where: { kitaId, familyId, id: { in: removedChildIds } } });
|
||||
if (removed !== removedChildIds.length) throw new UserFacingError("Mindestens ein Kind gehört nicht zu dieser Familie.");
|
||||
await tx.family.update({
|
||||
where: { id: familyId },
|
||||
data: { name: familyName },
|
||||
@@ -460,7 +465,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
@@ -469,8 +474,8 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
||||
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
|
||||
}
|
||||
|
||||
console.error("Fehler beim Aktualisieren der Familie:", error);
|
||||
return { error: "Familie konnte nicht aktualisiert werden." };
|
||||
if (!(error instanceof UserFacingError)) console.error("Fehler beim Aktualisieren der Familie:", error);
|
||||
return { error: publicActionError(error, "Familie konnte nicht aktualisiert werden.") };
|
||||
}
|
||||
|
||||
const emailError = await sendInviteJobs(kita.name, inviteJobs);
|
||||
|
||||
@@ -207,7 +207,7 @@ export function CalendarOverviewClient({
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{!isAdmin && pendingSwapRequests.length > 0 && (
|
||||
{pendingSwapRequests.length > 0 && (
|
||||
<NotdienstSwapRequestsPanel requests={pendingSwapRequests} />
|
||||
)}
|
||||
|
||||
@@ -469,7 +469,7 @@ export function CalendarOverviewClient({
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!isAdmin && item.canRequestSwap && (
|
||||
{item.canRequestSwap && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<NotdienstSwapDialog
|
||||
target={item}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Trash2, Plus, Utensils } from "lucide-react";
|
||||
import { Trash2, Plus, Utensils, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { addMitbringsel, deleteMitbringsel } from "../actions";
|
||||
import {
|
||||
addMitbringsel,
|
||||
deleteMitbringsel,
|
||||
updateMitbringsel,
|
||||
} from "../actions";
|
||||
|
||||
type MitbringselItemDto = {
|
||||
id: string;
|
||||
@@ -27,30 +31,42 @@ export function MitbringselList({
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const [content, setContent] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
if (isPending || !content.trim()) return;
|
||||
startTransition(async () => {
|
||||
const res = await addMitbringsel({ terminId, content });
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
if (res.error) toast.error(res.error);
|
||||
else {
|
||||
toast.success("Eintrag hinzugefügt.");
|
||||
setContent("");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (isPending || !editingId || !editContent.trim()) return;
|
||||
startTransition(async () => {
|
||||
const res = await updateMitbringsel({
|
||||
itemId: editingId,
|
||||
content: editContent,
|
||||
});
|
||||
if (res.error) toast.error(res.error);
|
||||
else {
|
||||
toast.success("Beitrag gespeichert.");
|
||||
setEditingId(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
startTransition(async () => {
|
||||
const res = await deleteMitbringsel(id);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("Eintrag gelöscht.");
|
||||
}
|
||||
if (res.error) toast.error(res.error);
|
||||
else toast.success("Eintrag gelöscht.");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -60,8 +76,7 @@ export function MitbringselList({
|
||||
<Utensils className="h-4 w-4 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
||||
Mitbring-Liste
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
|
||||
<div className="flex max-h-60 flex-col gap-2 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-xs italic text-[var(--ink-faint)]">
|
||||
Noch keine Einträge vorhanden.
|
||||
@@ -70,54 +85,118 @@ export function MitbringselList({
|
||||
items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-[var(--hairline-soft)] bg-[var(--surface)] p-2 text-sm text-[var(--ink)]"
|
||||
className="rounded-md border border-[var(--hairline-soft)] bg-[var(--surface)] p-2 text-sm text-[var(--ink)]"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium text-[var(--accent-deep)]">
|
||||
{item.user.firstName} {item.user.lastName}
|
||||
</span>
|
||||
<span>{item.content}</span>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium text-[var(--accent-deep)]">
|
||||
{item.user.firstName} {item.user.lastName}
|
||||
</span>
|
||||
{editingId !== item.id ? (
|
||||
<p className="break-words">{item.content}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{editingId !== item.id ? (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
{item.userId === currentUserId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={`Beitrag bearbeiten: ${item.content}`}
|
||||
onClick={() => {
|
||||
setEditingId(item.id);
|
||||
setEditContent(item.content);
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{isAdmin || item.userId === currentUserId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
aria-label={`Beitrag löschen: ${item.content}`}
|
||||
onClick={() => handleDelete(item.id)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{(isAdmin || item.userId === currentUserId) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
disabled={isPending}
|
||||
{editingId === item.id ? (
|
||||
<form
|
||||
className="mt-2 flex flex-wrap gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label="Mitbringbeitrag bearbeiten"
|
||||
value={editContent}
|
||||
onChange={(event) => setEditContent(event.target.value)}
|
||||
maxLength={500}
|
||||
disabled={isPending}
|
||||
className="h-8 min-w-0 flex-[1_1_180px] text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={isPending || !editContent.trim()}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={isPending}
|
||||
onClick={() => setEditingId(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-1">
|
||||
<form
|
||||
className="mt-1 flex gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleAdd();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
aria-label="Neuer Mitbringbeitrag"
|
||||
placeholder="Was bringst du mit?"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
maxLength={500}
|
||||
disabled={isPending}
|
||||
className="h-8 text-sm"
|
||||
className="h-8 min-w-0 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={handleAdd}
|
||||
disabled={!content.trim() || isPending}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4 [stroke-width:1.7]" />
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -760,23 +760,18 @@ function NotdienstRow({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : notdienst.canRequestSwap ? (
|
||||
) : null}
|
||||
{notdienst.canRequestSwap ? (
|
||||
<NotdienstSwapDialog
|
||||
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>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9" />
|
||||
)}
|
||||
) : !isAdmin ? <div className="w-9 h-9" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import { serializableTransaction } from "@/lib/transaction";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { lockEvent } from "@/lib/event-lock";
|
||||
import { canRequestNotdienstSwap } from "@/lib/notdienst-swaps";
|
||||
|
||||
// =====================================================================
|
||||
// /dashboard/kalender · Server Actions
|
||||
@@ -127,7 +128,7 @@ async function getSwapAssignment(
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: {
|
||||
email: true,
|
||||
firstName: true,
|
||||
@@ -335,11 +336,8 @@ function buildTerminData(
|
||||
export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
if (!(await rateLimit(`createNotdienstSwapRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
||||
if (session.user.role !== UserRole.ELTERN) {
|
||||
return { error: "Nur Eltern können Notdienst-Tausche anfragen." };
|
||||
}
|
||||
if (!session.user.familyId) {
|
||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||
if (!canRequestNotdienstSwap(session.user.role, session.user.familyId)) {
|
||||
return { error: "Nur Eltern mit einem eigenen Haushalt können Notdienst-Tausche anfragen." };
|
||||
}
|
||||
|
||||
const parsed = swapRequestSchema.safeParse(rawPayload);
|
||||
@@ -467,7 +465,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -493,7 +491,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -636,7 +634,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -654,7 +652,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -1059,6 +1057,44 @@ export async function addMitbringsel(rawPayload: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
const updateMitbringselSchema = z.object({
|
||||
itemId: z.string().min(1).max(100),
|
||||
content: mitbringselSchema.shape.content,
|
||||
});
|
||||
|
||||
export async function updateMitbringsel(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
const parsed = updateMitbringselSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) return { error: "Bitte einen Beitrag mit 1 bis 500 Zeichen eingeben." };
|
||||
if (!(await rateLimit(`updateMitbringsel:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) {
|
||||
return { error: "Zu viele Änderungen. Bitte versuche es später erneut." };
|
||||
}
|
||||
|
||||
try {
|
||||
await serializableTransaction(async (tx) => {
|
||||
const item = await tx.mitbringselItem.findFirst({
|
||||
where: { id: parsed.data.itemId, kitaId: session.user.kitaId, userId: session.user.id },
|
||||
select: { terminId: true },
|
||||
});
|
||||
if (!item) throw new UserFacingError("Du kannst nur eigene Mitbringbeiträge bearbeiten.");
|
||||
await lockEvent(tx, item.terminId, session.user.kitaId);
|
||||
const termin = await tx.termin.findFirst({
|
||||
where: { id: item.terminId, kitaId: session.user.kitaId, status: TerminStatus.CONFIRMED, mitbringselListEnabled: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!termin) throw new UserFacingError("Die Mitbring-Liste ist für diesen Termin nicht mehr aktiv.");
|
||||
await tx.mitbringselItem.update({
|
||||
where: { id: parsed.data.itemId, kitaId: session.user.kitaId, userId: session.user.id },
|
||||
data: { content: parsed.data.content },
|
||||
});
|
||||
});
|
||||
revalidatePath("/dashboard/kalender");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: publicActionError(error, "Der Beitrag konnte nicht gespeichert werden.") };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMitbringsel(itemId: string) {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { HelpCircle } from "lucide-react";
|
||||
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canRequestNotdienstSwap } from "@/lib/notdienst-swaps";
|
||||
import { getRsvpEvents } from "@/lib/rsvp-events";
|
||||
import { AdminTerminModal } from "./_components/admin-termin-modal";
|
||||
import {
|
||||
@@ -213,7 +214,7 @@ export default async function KalenderPage({
|
||||
});
|
||||
|
||||
const pendingSwapRows =
|
||||
!isAdmin && session.user.familyId
|
||||
session.user.familyId && canRequestNotdienstSwap(session.user.role, session.user.familyId)
|
||||
? await prisma.notdienstSwapRequest.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
@@ -306,9 +307,8 @@ export default async function KalenderPage({
|
||||
...notdienstAssignmentRows.map((assignment) => {
|
||||
const isOwn = assignment.child.familyId === familyIdForParticipation;
|
||||
const canRequestSwap =
|
||||
!isAdmin &&
|
||||
canRequestNotdienstSwap(session.user.role, session.user.familyId) &&
|
||||
!isOwn &&
|
||||
!!session.user.familyId &&
|
||||
assignment.date >= today;
|
||||
return {
|
||||
kind: "notdienst" as const,
|
||||
|
||||
@@ -5,8 +5,10 @@ import { UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getTargetMonthData } from "@/lib/date-utils";
|
||||
import { formatUtcDateKey, getTargetMonthData } from "@/lib/date-utils";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||
|
||||
// =====================================================================
|
||||
// /dashboard/notdienst · Server Actions
|
||||
@@ -64,90 +66,60 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
|
||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||
}
|
||||
|
||||
// ── 2. Validierung (Soll-Tage) ─────────────────────────────────────
|
||||
const kita = await prisma.kita.findUniqueOrThrow({
|
||||
where: { id: kitaId },
|
||||
select: { notdienstMinPerChildPerMonth: true },
|
||||
});
|
||||
|
||||
const requiredTotal = childrenIds.length * kita.notdienstMinPerChildPerMonth;
|
||||
if (dates.length < requiredTotal) {
|
||||
return {
|
||||
error: `Bitte wähle mindestens ${requiredTotal} Tage aus.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Sicherstellen, dass alle Kinder zum Haushalt des angemeldeten Users gehören.
|
||||
const validChildren = await prisma.child.findMany({
|
||||
where: {
|
||||
id: { in: childrenIds },
|
||||
kitaId,
|
||||
familyId: session.user.familyId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (validChildren.length !== childrenIds.length) {
|
||||
return { error: "Ungültige Kinder-Zuordnung erkannt." };
|
||||
}
|
||||
|
||||
// Termine in Date-Objekte wandeln und sortieren
|
||||
const parsedDates = dates.map(dateKeyToUtcDate);
|
||||
const hasOutsideTargetMonth = parsedDates.some(
|
||||
(date) =>
|
||||
date.getUTCFullYear() !== targetYear ||
|
||||
date.getUTCMonth() !== targetMonth - 1,
|
||||
);
|
||||
if (hasOutsideTargetMonth) {
|
||||
return { error: "Bitte wähle Termine im Zielmonat aus." };
|
||||
}
|
||||
|
||||
// Round-Robin Zuweisung
|
||||
const inserts: {
|
||||
kitaId: string;
|
||||
childId: string;
|
||||
userId: string;
|
||||
date: Date;
|
||||
}[] = [];
|
||||
|
||||
for (let i = 0; i < parsedDates.length; i++) {
|
||||
const childId = validChildren[i % validChildren.length].id;
|
||||
inserts.push({
|
||||
kitaId,
|
||||
childId,
|
||||
userId: session.user.id,
|
||||
date: parsedDates[i],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Datenbank-Transaktion ───────────────────────────────────────
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// a) Alle bestehenden Haushaltseinträge im Zielmonat für diese Kinder löschen
|
||||
await serializableTransaction(async (tx) => {
|
||||
const [kita, children, workingDays] = await Promise.all([
|
||||
tx.kita.findUniqueOrThrow({
|
||||
where: { id: kitaId },
|
||||
select: { notdienstMinPerChildPerMonth: true },
|
||||
}),
|
||||
tx.child.findMany({
|
||||
where: { kitaId, familyId: session.user.familyId!, active: true },
|
||||
select: { id: true },
|
||||
orderBy: { id: "asc" },
|
||||
}),
|
||||
loadNotdienstDays(tx, kitaId, targetYear, targetMonth),
|
||||
]);
|
||||
const suppliedIds = new Set(childrenIds);
|
||||
if (!children.length || suppliedIds.size !== childrenIds.length ||
|
||||
suppliedIds.size !== children.length || !children.every((child) => suppliedIds.has(child.id))) {
|
||||
throw new UserFacingError("Bitte lade die Seite neu und wähle die Termine für alle aktiven Kinder deines Haushalts aus.");
|
||||
}
|
||||
const validDates = new Set(workingDays.map(formatUtcDateKey));
|
||||
if (dates.some((date) => !validDates.has(date))) {
|
||||
throw new UserFacingError("Bitte wähle nur geöffnete Werktage im Zielmonat aus. Wochenenden und Schließtage sind nicht möglich.");
|
||||
}
|
||||
const requiredTotal = children.length * kita.notdienstMinPerChildPerMonth;
|
||||
if (dates.length < requiredTotal) {
|
||||
throw new UserFacingError(`Bitte wähle mindestens ${requiredTotal} Tage aus.`);
|
||||
}
|
||||
const activeIds = children.map((child) => child.id);
|
||||
await tx.notdienstAvailability.deleteMany({
|
||||
where: {
|
||||
kitaId,
|
||||
childId: { in: childrenIds },
|
||||
child: { kitaId, familyId: session.user.familyId! },
|
||||
date: {
|
||||
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
||||
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// b) Neue Einträge setzen
|
||||
if (inserts.length > 0) {
|
||||
await tx.notdienstAvailability.createMany({
|
||||
data: inserts,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
await tx.notdienstAvailability.createMany({
|
||||
data: dates.map((date, index) => ({
|
||||
kitaId,
|
||||
childId: activeIds[index % activeIds.length],
|
||||
userId: session.user.id,
|
||||
date: dateKeyToUtcDate(date),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/notdienst");
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true, dates };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Speichern der Notdienste:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." };
|
||||
if (!(error instanceof UserFacingError)) console.error("Fehler beim Speichern der Notdienste:", error);
|
||||
return { error: publicActionError(error, "Ein Fehler ist aufgetreten beim Speichern der Termine.") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ import { de } from "date-fns/locale";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDateKey, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
import { formatDateKey, formatUtcDateKey } from "@/lib/date-utils";
|
||||
import { getNotdienstDays } from "@/lib/notdienst-days";
|
||||
import { saveNotdienstAvailabilities } from "./actions";
|
||||
|
||||
export const notdienstEventCategories = {
|
||||
@@ -100,23 +101,21 @@ export function NotdienstForm({
|
||||
}: NotdienstFormProps) {
|
||||
const router = useRouter();
|
||||
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor.
|
||||
const [selectedDates, setSelectedDates] = useState<Set<string>>(
|
||||
const [selection, setSelectedDates] = useState<Set<string>>(
|
||||
new Set(initialSelectedDates),
|
||||
);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const closureDateKeys = useMemo(
|
||||
() => getClosureDateKeys(calendarItems),
|
||||
[calendarItems],
|
||||
const selectableDateKeys = useMemo(
|
||||
() => new Set(getNotdienstDays(targetYear, targetMonth,
|
||||
calendarItems.filter((item) => item.category === "closure" || item.category === "team"),
|
||||
).map(formatUtcDateKey)),
|
||||
[calendarItems, targetMonth, targetYear],
|
||||
);
|
||||
const selectedDates = useMemo(
|
||||
() => new Set(Array.from(selection).filter((key) => selectableDateKeys.has(key))),
|
||||
[selection, selectableDateKeys],
|
||||
);
|
||||
const selectableDateKeys = useMemo(() => {
|
||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
||||
return new Set(
|
||||
workingDays
|
||||
.map(formatDateKey)
|
||||
.filter((dateKey) => !closureDateKeys.has(dateKey)),
|
||||
);
|
||||
}, [closureDateKeys, targetMonth, targetYear]);
|
||||
const monthStart = startOfMonth(new Date(targetYear, targetMonth - 1));
|
||||
const selectedCount = selectedDates.size;
|
||||
const hasMinimumRequirement = requiredDaysTotal > 0;
|
||||
@@ -375,12 +374,9 @@ export function NotdienstCalendar({
|
||||
const isInTargetMonth = isSameMonth(day, visibleMonthStart);
|
||||
const isSelected = selected.has(dateKey);
|
||||
const dayEvents = getCalendarItemsForDay(events, day);
|
||||
const hasClosure = dayEvents.some(
|
||||
(event) => event.category === "closure",
|
||||
);
|
||||
const isSelectable =
|
||||
isInTargetMonth && selectableDateKeys.has(dateKey) && !hasClosure;
|
||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
|
||||
const hasClosure = isInTargetMonth && !isWeekend && !selectableDateKeys.has(dateKey);
|
||||
const isSelectable = isInTargetMonth && selectableDateKeys.has(dateKey);
|
||||
|
||||
return (
|
||||
<CalendarTile
|
||||
@@ -603,25 +599,6 @@ function getCalendarItemsForDay(
|
||||
});
|
||||
}
|
||||
|
||||
function getClosureDateKeys(items: NotdienstCalendarItem[]) {
|
||||
const closureKeys = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.category !== "closure") continue;
|
||||
|
||||
const days = eachDayOfInterval({
|
||||
start: startOfDay(item.startDate),
|
||||
end: startOfDay(item.endDate),
|
||||
});
|
||||
|
||||
for (const day of days) {
|
||||
closureKeys.add(formatDateKey(day));
|
||||
}
|
||||
}
|
||||
|
||||
return closureKeys;
|
||||
}
|
||||
|
||||
function formatEventDate(event: NotdienstCalendarItem) {
|
||||
const date = format(event.startDate, "EEEE, d. MMMM yyyy", { locale: de });
|
||||
return event.allDay
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Manrope } from "next/font/google";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatDateKey, getTargetMonthData } from "@/lib/date-utils";
|
||||
import { formatUtcDateKey, getTargetMonthData } from "@/lib/date-utils";
|
||||
import { getNotdienstDays } from "@/lib/notdienst-days";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NotdienstEntry } from "./notdienst-entry";
|
||||
|
||||
@@ -77,7 +78,7 @@ export default async function NotdienstPage() {
|
||||
kitaId,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
startDate: { lt: targetMonthEnd },
|
||||
endDate: { gte: targetMonthStart },
|
||||
endDate: { gte: new Date(targetMonthStart.getTime() - 86400000) },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -93,7 +94,10 @@ export default async function NotdienstPage() {
|
||||
])
|
||||
: [[], []];
|
||||
|
||||
const selectedDates = availabilities.map((a) => formatDateKey(a.date));
|
||||
const validDateKeys = new Set(getNotdienstDays(targetYear, targetMonth,
|
||||
termine.filter((termin) => termin.type === TerminType.SCHLIESSTAG || termin.type === TerminType.TEAMTAG),
|
||||
).map(formatUtcDateKey));
|
||||
const selectedDates = availabilities.map((a) => formatUtcDateKey(a.date)).filter((key) => validDateKeys.has(key));
|
||||
|
||||
return (
|
||||
<div className={`${manrope.className} mx-auto max-w-7xl px-4 py-8 sm:px-8`}>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { NotdienstEmailStatus } from "@/lib/notdienst-notifications";
|
||||
import {
|
||||
retryNotdienstEmailsAction,
|
||||
sendNotdienstRemindersAction,
|
||||
} from "../actions";
|
||||
|
||||
export function EmailStatusCard({
|
||||
status,
|
||||
year,
|
||||
month,
|
||||
remindersOpen,
|
||||
daysBefore,
|
||||
jobConfigured,
|
||||
}: {
|
||||
status: NotdienstEmailStatus;
|
||||
year: number;
|
||||
month: number;
|
||||
remindersOpen: boolean;
|
||||
daysBefore: number;
|
||||
jobConfigured: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const run = (retry: boolean) =>
|
||||
startTransition(async () => {
|
||||
const result = retry
|
||||
? await retryNotdienstEmailsAction(year, month)
|
||||
: await sendNotdienstRemindersAction(year, month);
|
||||
if (result.error) toast.error(result.error);
|
||||
else if ("failed" in result && result.failed)
|
||||
toast.warning(
|
||||
"Einige E-Mails sind noch nicht versendet. Der nächste Versuch erfolgt automatisch oder über diesen Button.",
|
||||
);
|
||||
else if ("sent" in result)
|
||||
toast.success(`${result.sent} E-Mails versendet.`);
|
||||
router.refresh();
|
||||
});
|
||||
const labels = {
|
||||
sent: "Versendet",
|
||||
pending: "Wartet auf Versand",
|
||||
failed: "Versand fehlgeschlagen",
|
||||
skipped: "Nicht mehr erforderlich",
|
||||
};
|
||||
return (
|
||||
<Card className="mb-5 print:hidden p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-medium text-[var(--ink)]">
|
||||
E-Mail-Versand
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
{daysBefore
|
||||
? `Automatische Erinnerung ab dem ${Math.max(1, 26 - daysBefore)}. um 9 Uhr. Eintragsfrist: der 26.`
|
||||
: "Automatische Erinnerungen sind ausgeschaltet."}
|
||||
</p>
|
||||
{!jobConfigured ? (
|
||||
<p className="mt-1 text-xs text-[var(--warn)]">
|
||||
Der Hintergrundversand ist noch nicht konfiguriert. Offene E-Mails
|
||||
können hier manuell gesendet werden.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{remindersOpen ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={() => run(false)}
|
||||
>
|
||||
An fehlende Verfügbarkeiten erinnern
|
||||
</Button>
|
||||
) : null}
|
||||
{status.failed + status.pending > 0 ? (
|
||||
<Button size="sm" disabled={pending} onClick={() => run(true)}>
|
||||
Offene E-Mails erneut senden
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={() => router.refresh()}
|
||||
>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[var(--ink-soft)]">
|
||||
{status.sent} versendet · {status.pending} ausstehend · {status.failed}{" "}
|
||||
fehlgeschlagen · {status.skipped} nicht mehr erforderlich
|
||||
</p>
|
||||
{status.entries.length ? (
|
||||
<details className="mt-3 text-sm">
|
||||
<summary className="cursor-pointer font-medium text-[var(--accent-deep)]">
|
||||
Versanddetails ansehen
|
||||
</summary>
|
||||
<ul className="mt-3 max-h-64 overflow-y-auto divide-y divide-[var(--hairline-soft)]">
|
||||
{status.entries.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex flex-wrap justify-between gap-2 py-2"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
{entry.name} · {entry.familyName}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
{entry.kind === "REMINDER"
|
||||
? "Verfügbarkeitserinnerung"
|
||||
: "Planveröffentlichung"}
|
||||
</p>
|
||||
{entry.error ? (
|
||||
<p className="mt-1 text-xs text-[var(--danger)]">
|
||||
{entry.error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
entry.status === "failed"
|
||||
? "text-[var(--danger)]"
|
||||
: "text-[var(--ink-soft)]"
|
||||
}
|
||||
>
|
||||
{labels[entry.status]}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-[var(--ink-muted)]">
|
||||
Für diesen Monat wurden noch keine E-Mails vorbereitet.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-[var(--ink-muted)]">
|
||||
Versendet bedeutet: vom Maildienst angenommen. Die tatsächliche
|
||||
Zustellung ins Postfach kann hier nicht bestätigt werden.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -35,8 +35,8 @@ export function SettingsModal({
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (minPerChild < 0 || reminderDays < 0) {
|
||||
toast.error("Werte dürfen nicht negativ sein.");
|
||||
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) {
|
||||
toast.error("Bitte ein Soll von 1 bis 31 und eine Erinnerungsfrist von 0 bis 31 Tagen eingeben.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,25 +74,26 @@ export function SettingsModal({
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="minPerChild">
|
||||
Mindesteinteilungen pro Kind/Monat
|
||||
Verfügbarkeitstage pro Kind/Monat
|
||||
</Label>
|
||||
<Input
|
||||
id="minPerChild"
|
||||
name="minPerChild"
|
||||
type="number"
|
||||
min="0"
|
||||
min="1"
|
||||
max="31"
|
||||
required
|
||||
value={minPerChild}
|
||||
onChange={(e) => setMinPerChild(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
Bestimmt das Richtmaß der automatischen Verteilung für Kinder im jeweiligen Monat.
|
||||
So viele unterschiedliche Tage müssen Eltern je aktivem Kind als Verfügbarkeit eintragen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="reminderDays">
|
||||
Erinnerungsfrist (Tage vor Monatsende)
|
||||
Erinnerung (Tage vor Eintragsfrist)
|
||||
</Label>
|
||||
<Input
|
||||
id="reminderDays"
|
||||
@@ -100,11 +101,12 @@ export function SettingsModal({
|
||||
type="number"
|
||||
min="0"
|
||||
required
|
||||
max="31"
|
||||
value={reminderDays}
|
||||
onChange={(e) => setReminderDays(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
Frist, nach der Eltern automatisch per Mail an offene Verfügbarkeiten erinnert werden.
|
||||
Die Eintragsfrist endet am 26. Bei 7 Tagen erfolgt die Erinnerung ab dem 19. um 9 Uhr. 0 deaktiviert automatische Erinnerungen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,12 @@ import { de } from "date-fns/locale";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
||||
import { getTargetMonthData } from "@/lib/date-utils";
|
||||
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||
import { todayKey } from "@/lib/alarms/format";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { queueNotdienstPublication, queueNotdienstReminders, deliverDueNotdienstEmails } from "@/lib/notdienst-notifications";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import {
|
||||
@@ -42,93 +45,44 @@ export async function generatePlanAction(year?: number, month?: number) {
|
||||
|
||||
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
|
||||
|
||||
// Prüfen, ob schon ein Plan existiert
|
||||
const existingPlan = await prisma.notdienstPlan.findUnique({
|
||||
where: {
|
||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingPlan) {
|
||||
return { error: "Ein Plan für diesen Monat existiert bereits." };
|
||||
}
|
||||
|
||||
// 1. Alle Verfügbarkeiten der Eltern für diesen Monat holen
|
||||
const availabilities = await prisma.notdienstAvailability.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
date: {
|
||||
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
||||
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
||||
},
|
||||
},
|
||||
include: { child: { select: { familyId: true } } },
|
||||
});
|
||||
|
||||
// 2. Werktage holen
|
||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
||||
|
||||
// 3. Algorithmus: Wir verteilen die Slots
|
||||
// Counter für "Gerechtigkeit": wie oft wurde diese Familie schon eingeteilt?
|
||||
const familyUsageCount: Record<string, number> = {};
|
||||
|
||||
const assignmentsToCreate: { childId: string; date: Date }[] = [];
|
||||
|
||||
for (const day of workingDays) {
|
||||
// Alle Verfügbarkeiten für diesen spezifischen Tag
|
||||
const availForDay = availabilities.filter(
|
||||
(a) => a.date.getTime() === day.getTime(),
|
||||
);
|
||||
|
||||
if (availForDay.length === 0) {
|
||||
// Niemand verfügbar -> Bleibt leer ("Unbesetzt")
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finde die Familie, die am wenigsten oft dran war
|
||||
// Zufall einbauen bei Gleichstand
|
||||
const sorted = availForDay.sort((a, b) => {
|
||||
const countA = familyUsageCount[a.child.familyId] || 0;
|
||||
const countB = familyUsageCount[b.child.familyId] || 0;
|
||||
if (countA === countB) {
|
||||
return Math.random() - 0.5; // Zufallsmischung
|
||||
}
|
||||
return countA - countB;
|
||||
});
|
||||
|
||||
const selected = sorted[0];
|
||||
|
||||
assignmentsToCreate.push({
|
||||
childId: selected.childId,
|
||||
date: day,
|
||||
});
|
||||
|
||||
// Usage-Counter erhöhen
|
||||
familyUsageCount[selected.child.familyId] =
|
||||
(familyUsageCount[selected.child.familyId] || 0) + 1;
|
||||
}
|
||||
|
||||
// 4. In der DB speichern
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const plan = await tx.notdienstPlan.create({
|
||||
data: {
|
||||
kitaId,
|
||||
year: targetYear,
|
||||
month: targetMonth,
|
||||
status: NotdienstPlanStatus.DRAFT,
|
||||
createdById: session.user.id,
|
||||
},
|
||||
await serializableTransaction(async (tx) => {
|
||||
const existingPlan = await tx.notdienstPlan.findUnique({
|
||||
where: { kitaId_year_month: { kitaId, year: targetYear, month: targetMonth } },
|
||||
});
|
||||
if (existingPlan) throw new UserFacingError("Ein Plan für diesen Monat existiert bereits.");
|
||||
|
||||
if (assignmentsToCreate.length > 0) {
|
||||
await tx.notdienstAssignment.createMany({
|
||||
data: assignmentsToCreate.map((a) => ({
|
||||
const [availabilities, workingDays] = await Promise.all([
|
||||
tx.notdienstAvailability.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
planId: plan.id,
|
||||
childId: a.childId,
|
||||
date: a.date,
|
||||
})),
|
||||
child: { kitaId, active: true },
|
||||
date: {
|
||||
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
||||
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
||||
},
|
||||
},
|
||||
include: { child: { select: { familyId: true } } },
|
||||
}),
|
||||
loadNotdienstDays(tx, kitaId, targetYear, targetMonth),
|
||||
]);
|
||||
const familyUsageCount = new Map<string, number>();
|
||||
const assignments: { childId: string; date: Date }[] = [];
|
||||
for (const day of workingDays) {
|
||||
const candidates = availabilities.filter((a) => a.date.getTime() === day.getTime());
|
||||
if (!candidates.length) continue;
|
||||
const minUsage = Math.min(...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0));
|
||||
const leastUsed = candidates.filter((a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage);
|
||||
const selected = leastUsed[Math.floor(Math.random() * leastUsed.length)];
|
||||
assignments.push({ childId: selected.childId, date: day });
|
||||
familyUsageCount.set(selected.child.familyId, minUsage + 1);
|
||||
}
|
||||
const plan = await tx.notdienstPlan.create({
|
||||
data: { kitaId, year: targetYear, month: targetMonth, status: NotdienstPlanStatus.DRAFT, createdById: session.user.id },
|
||||
});
|
||||
if (assignments.length) {
|
||||
await tx.notdienstAssignment.createMany({
|
||||
data: assignments.map((a) => ({ ...a, kitaId, planId: plan.id })),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -137,7 +91,7 @@ export async function generatePlanAction(year?: number, month?: number) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { error: "Fehler bei der Generierung des Plans." };
|
||||
return { error: publicActionError(error, "Fehler bei der Generierung des Plans.") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,17 +99,35 @@ export async function publishPlanAction(planId: string) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
|
||||
await prisma.notdienstPlan.update({
|
||||
where: { id: planId, kitaId },
|
||||
data: {
|
||||
status: NotdienstPlanStatus.PUBLISHED,
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
revalidatePath("/dashboard/notdienst");
|
||||
return { success: true };
|
||||
try {
|
||||
await serializableTransaction(async (tx) => {
|
||||
const plan = await tx.notdienstPlan.findUnique({
|
||||
where: { id: planId, kitaId },
|
||||
include: { assignments: { include: { child: { select: { active: true } } } } },
|
||||
});
|
||||
if (!plan) throw new UserFacingError("Plan nicht gefunden.");
|
||||
if (plan.status === NotdienstPlanStatus.PUBLISHED) return;
|
||||
if (plan.status !== NotdienstPlanStatus.DRAFT) throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
|
||||
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
|
||||
const validDates = new Set(days.map((day) => day.getTime()));
|
||||
if (plan.assignments.some((a) => !validDates.has(a.date.getTime()) || !a.child.active)) {
|
||||
throw new UserFacingError("Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.");
|
||||
}
|
||||
await tx.notdienstPlan.update({
|
||||
where: { id: planId, kitaId },
|
||||
data: { status: NotdienstPlanStatus.PUBLISHED, publishedAt: new Date() },
|
||||
});
|
||||
await queueNotdienstPublication(tx, kitaId, planId, plan.year, plan.month);
|
||||
});
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
revalidatePath("/dashboard/notdienst");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
return { success: true, ...delivery };
|
||||
} catch (error) {
|
||||
return { error: publicActionError(error, "Der Plan konnte nicht veröffentlicht werden.") };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAssignmentAction(
|
||||
@@ -189,11 +161,19 @@ export async function updateAssignmentAction(
|
||||
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
|
||||
}
|
||||
|
||||
if (newChildId && (parsedDate.getUTCFullYear() !== plan.year || parsedDate.getUTCMonth() + 1 !== plan.month)) {
|
||||
throw new UserFacingError("Das Datum gehört nicht zum Planungsmonat.");
|
||||
}
|
||||
// Clearing obsolete entries remains possible after a new closure.
|
||||
if (newChildId) {
|
||||
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
|
||||
if (!days.some((day) => day.getTime() === parsedDate.getTime())) {
|
||||
throw new UserFacingError("An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
if (isPublished) {
|
||||
const today = new Date();
|
||||
const todayUtc = new Date(
|
||||
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
);
|
||||
const todayUtc = new Date(`${todayKey()}T00:00:00Z`);
|
||||
if (parsedDate < todayUtc) {
|
||||
throw new UserFacingError(
|
||||
"Vergangene Notdienste können nicht geändert werden.",
|
||||
@@ -202,7 +182,7 @@ export async function updateAssignmentAction(
|
||||
}
|
||||
|
||||
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
||||
where: { planId, date: parsedDate },
|
||||
where: { kitaId, planId, date: parsedDate },
|
||||
include: {
|
||||
child: {
|
||||
include: {
|
||||
@@ -431,118 +411,33 @@ 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]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const kitaId = session.user.kitaId!;
|
||||
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 };
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Get Kita Name
|
||||
const kita = await prisma.kita.findUnique({
|
||||
where: { id: kitaId },
|
||||
select: { name: true },
|
||||
});
|
||||
if (!kita) {
|
||||
return { error: "Kita nicht gefunden." };
|
||||
}
|
||||
|
||||
// 2. Fetch all families in this Kita with at least one active child and retrieve parents
|
||||
const families = await prisma.family.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
children: { some: { active: true } },
|
||||
},
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 3. For target month, find availability entries
|
||||
const monthStart = new Date(year, month - 1, 1);
|
||||
const monthEnd = new Date(year, month, 1);
|
||||
|
||||
const respondedFamilies = await prisma.notdienstAvailability.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
date: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
child: {
|
||||
select: {
|
||||
familyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const respondedFamilyIds = new Set(
|
||||
respondedFamilies.map((a) => a.child.familyId),
|
||||
);
|
||||
|
||||
// 4. Find open families (not responded yet)
|
||||
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
|
||||
|
||||
if (openFamilies.length === 0) {
|
||||
return { success: true, count: 0 };
|
||||
}
|
||||
|
||||
// 5. Build emails list and send
|
||||
const monthDate = new Date(year, month - 1, 1);
|
||||
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ??
|
||||
process.env.AUTH_URL ??
|
||||
"http://localhost:3000";
|
||||
const link = `${BASE_URL}/dashboard/notdienst`;
|
||||
|
||||
let sentCount = 0;
|
||||
for (const family of openFamilies) {
|
||||
for (const parent of family.users) {
|
||||
if (!parent.email) continue;
|
||||
|
||||
const result = await sendAppEmail({
|
||||
to: parent.email,
|
||||
subject: `Erinnerung: Bitte Verfügbarkeiten für den Notdienst ${monthLabel} eintragen`,
|
||||
react: createElement(NotdienstAvailabilityReminderEmail, {
|
||||
parentName: `${parent.firstName} ${parent.lastName}`,
|
||||
kitaName: kita.name,
|
||||
monthLabel,
|
||||
link,
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
sentCount++;
|
||||
} else {
|
||||
console.error(
|
||||
`E-Mail konnte nicht an ${parent.email} gesendet werden:`,
|
||||
result.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, count: sentCount };
|
||||
const queued = await queueNotdienstReminders(kitaId, year, month);
|
||||
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
return { success: true, queued, count: delivery.sent, ...delivery };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Versenden der Erinnerungen:", error);
|
||||
return { error: "Fehler beim Versenden der Erinnerungen." };
|
||||
return { error: publicActionError(error, "Die Erinnerungen konnten nicht vorbereitet werden.") };
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryNotdienstEmailsAction(year: number, month: number) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte warte vor einem weiteren Versandversuch." };
|
||||
try {
|
||||
await prisma.notdienstEmail.updateMany({ where: { kitaId, year, month, failed: true, sentAt: null, skippedAt: null }, data: { nextAttemptAt: new Date() } });
|
||||
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month });
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
return { success: true, ...delivery };
|
||||
} catch {
|
||||
return { error: "Der Versand bleibt gespeichert und wird erneut versucht." };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { addMonths, getYear, getMonth, format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { TerminType } from "@prisma/client";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
import { formatUtcDateKey, getWorkingDaysOfMonth, getTargetMonthData } from "@/lib/date-utils";
|
||||
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||
import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications";
|
||||
import { EmailStatusCard } from "./_components/email-status-card";
|
||||
import { PlanView } from "./plan-view";
|
||||
import { SettingsModal } from "./_components/settings-modal";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
@@ -23,22 +25,21 @@ export default async function PlanungsZentralePage({
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const now = new Date();
|
||||
const nextMonth = addMonths(now, 1);
|
||||
const defaultTarget = getTargetMonthData(now);
|
||||
const targetYear = resolvedSearchParams.year
|
||||
? parseInt(resolvedSearchParams.year, 10)
|
||||
: getYear(nextMonth);
|
||||
: defaultTarget.targetYear;
|
||||
const targetMonth = resolvedSearchParams.month
|
||||
? parseInt(resolvedSearchParams.month, 10)
|
||||
: getMonth(nextMonth) + 1;
|
||||
: defaultTarget.targetMonth;
|
||||
|
||||
const monthDate = new Date(targetYear, targetMonth - 1, 1);
|
||||
const monthName = format(monthDate, "MMMM", { locale: de });
|
||||
|
||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
||||
const monthStart = new Date(targetYear, targetMonth - 1, 1);
|
||||
const monthEnd = new Date(targetYear, targetMonth, 1);
|
||||
const monthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
|
||||
const monthEnd = new Date(Date.UTC(targetYear, targetMonth, 1));
|
||||
|
||||
const [plan, families, availabilities, closureCount, kita] =
|
||||
const [plan, families, rawAvailabilities, workingDays, kita] =
|
||||
await Promise.all([
|
||||
prisma.notdienstPlan.findUnique({
|
||||
where: {
|
||||
@@ -80,6 +81,7 @@ export default async function PlanungsZentralePage({
|
||||
prisma.notdienstAvailability.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
child: { kitaId, active: true },
|
||||
date: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
@@ -95,16 +97,7 @@ export default async function PlanungsZentralePage({
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
}),
|
||||
prisma.termin.count({
|
||||
where: {
|
||||
kitaId,
|
||||
type: TerminType.SCHLIESSTAG,
|
||||
startDate: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
},
|
||||
},
|
||||
}),
|
||||
loadNotdienstDays(prisma, kitaId, targetYear, targetMonth),
|
||||
prisma.kita.findUniqueOrThrow({
|
||||
where: { id: kitaId },
|
||||
select: {
|
||||
@@ -152,6 +145,10 @@ export default async function PlanungsZentralePage({
|
||||
};
|
||||
});
|
||||
|
||||
const validDateKeys = new Set(workingDays.map(formatUtcDateKey));
|
||||
const availabilities = rawAvailabilities.filter((a) => validDateKeys.has(formatUtcDateKey(a.date)));
|
||||
const closureCount = getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
|
||||
|
||||
const availabilityDatesByFamily = new Map<string, Set<string>>();
|
||||
availabilities.forEach((availability) => {
|
||||
const familyId = availability.child.familyId;
|
||||
@@ -174,11 +171,12 @@ export default async function PlanungsZentralePage({
|
||||
}).format(new Date(date)),
|
||||
})),
|
||||
childCount: family.children.length,
|
||||
requiredDays: family.children.length * kita.notdienstMinPerChildPerMonth,
|
||||
};
|
||||
});
|
||||
|
||||
const respondedFamilies = familyStatuses.filter(
|
||||
(family) => family.selectedDays.length > 0,
|
||||
(family) => family.selectedDays.length >= family.requiredDays,
|
||||
).length;
|
||||
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
|
||||
const totalAvailabilitySlots = availabilities.length;
|
||||
@@ -187,7 +185,13 @@ export default async function PlanungsZentralePage({
|
||||
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
|
||||
|
||||
// Wir mappen die Assignments auf die Werktage
|
||||
const daysWithAssignments = workingDays.map((day) => {
|
||||
// Keep obsolete assignments visible so the coordinator can remove them.
|
||||
const displayedDays = new Map(workingDays.map((day) => [formatUtcDateKey(day), day]));
|
||||
for (const assignment of plan?.assignments ?? []) {
|
||||
displayedDays.set(formatUtcDateKey(assignment.date), assignment.date);
|
||||
}
|
||||
const activeChildIds = new Set(allChildren.map((child) => child.id));
|
||||
const daysWithAssignments = Array.from(displayedDays.values()).sort((a, b) => a.getTime() - b.getTime()).map((day) => {
|
||||
const assignment = plan?.assignments.find(
|
||||
(a) => a.date.getTime() === day.getTime(),
|
||||
);
|
||||
@@ -195,19 +199,25 @@ export default async function PlanungsZentralePage({
|
||||
!assignment ||
|
||||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
||||
|
||||
const isInvalid = !validDateKeys.has(formatUtcDateKey(day)) || !!assignment && !activeChildIds.has(assignment.childId);
|
||||
return {
|
||||
date: day.toISOString(),
|
||||
isInvalid,
|
||||
assignmentId: assignment?.id || null,
|
||||
childId: assignment?.childId || null,
|
||||
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
||||
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
||||
canAlarm:
|
||||
!!assignment &&
|
||||
!!assignment && !isInvalid &&
|
||||
plan?.status === "PUBLISHED" &&
|
||||
day.toISOString().slice(0, 10) >= todayKey(),
|
||||
};
|
||||
});
|
||||
|
||||
const mailStatus = await getNotdienstEmailStatus(kitaId, targetYear, targetMonth);
|
||||
const target = getTargetMonthData(now);
|
||||
const remindersOpen = !target.isLocked && target.targetYear === targetYear && target.targetMonth === targetMonth && plan?.status !== "PUBLISHED";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
|
||||
@@ -226,7 +236,7 @@ export default async function PlanungsZentralePage({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="print:hidden flex shrink-0 items-center gap-2">
|
||||
<div className="print:hidden flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
||||
<Link
|
||||
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, -1))}&month=${getMonth(addMonths(monthDate, -1)) + 1}`}
|
||||
@@ -251,8 +261,11 @@ export default async function PlanungsZentralePage({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<EmailStatusCard status={mailStatus} year={targetYear} month={targetMonth} remindersOpen={remindersOpen} daysBefore={kita.notdienstReminderDaysBefore} jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32} />
|
||||
|
||||
<PlanView
|
||||
planId={plan?.id}
|
||||
remindersOpen={remindersOpen}
|
||||
status={plan?.status}
|
||||
days={daysWithAssignments}
|
||||
allChildren={allChildren}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
|
||||
interface PlanViewProps {
|
||||
planId?: string;
|
||||
remindersOpen: boolean;
|
||||
status?: NotdienstPlanStatus;
|
||||
days: {
|
||||
date: string;
|
||||
@@ -45,6 +46,7 @@ interface PlanViewProps {
|
||||
canAlarm?: boolean;
|
||||
childId: string | null;
|
||||
isOriginallyUnassigned?: boolean;
|
||||
isInvalid?: boolean;
|
||||
}[];
|
||||
allChildren: {
|
||||
id: string;
|
||||
@@ -56,6 +58,7 @@ interface PlanViewProps {
|
||||
id: string;
|
||||
name: string;
|
||||
childCount: number;
|
||||
requiredDays: number;
|
||||
selectedDays: {
|
||||
iso: string;
|
||||
label: string;
|
||||
@@ -75,6 +78,7 @@ interface PlanViewProps {
|
||||
|
||||
export function PlanView({
|
||||
planId,
|
||||
remindersOpen,
|
||||
status,
|
||||
days,
|
||||
allChildren,
|
||||
@@ -116,8 +120,9 @@ export function PlanView({
|
||||
const result = await publishPlanAction(planId);
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else {
|
||||
toast.success("Plan veröffentlicht! Er ist nun fix.");
|
||||
} else if ("success" in result && result.success) {
|
||||
if (result.failed || ("deferred" in result && result.deferred)) toast.warning("Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.");
|
||||
else toast.success("Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.");
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -127,10 +132,9 @@ export function PlanView({
|
||||
const result = await sendNotdienstRemindersAction(year, month);
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else if (result.success) {
|
||||
toast.success(
|
||||
`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`,
|
||||
);
|
||||
} else if ("success" in result && result.success) {
|
||||
if (result.failed || ("deferred" in result && result.deferred)) toast.warning(`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`);
|
||||
else toast.success(`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -153,7 +157,7 @@ export function PlanView({
|
||||
const valueToSet = newChildId === "empty" ? null : newChildId;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateAssignmentAction(planId, date, valueToSet);
|
||||
const result = await updateAssignmentAction(planId, date.slice(0, 10), valueToSet);
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else {
|
||||
@@ -162,7 +166,7 @@ export function PlanView({
|
||||
});
|
||||
};
|
||||
|
||||
if (!planId && respondedFamilies === 0) {
|
||||
if (!planId && totalAvailabilitySlots === 0) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
|
||||
@@ -183,7 +187,7 @@ export function PlanView({
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSendReminders}
|
||||
disabled={isPending}
|
||||
disabled={isPending || !remindersOpen}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||
@@ -211,7 +215,7 @@ export function PlanView({
|
||||
</h2>
|
||||
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
|
||||
{respondedFamilies} von {totalFamilies} Familien haben
|
||||
geantwortet · noch {openFamilies} offen
|
||||
vollständig geantwortet · noch {openFamilies} offen
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
|
||||
@@ -244,7 +248,7 @@ export function PlanView({
|
||||
{family.childCount === 1 ? "" : "er"}
|
||||
</p>
|
||||
</div>
|
||||
<FamilyStatusPill selectedDays={family.selectedDays.length} />
|
||||
<FamilyStatusPill selectedDays={family.selectedDays.length} requiredDays={family.requiredDays} />
|
||||
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
|
||||
{family.selectedDays.length > 0
|
||||
? family.selectedDays.map((day) => day.label).join(" · ")
|
||||
@@ -258,7 +262,7 @@ export function PlanView({
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSendReminders}
|
||||
disabled={isPending || openFamilies === 0}
|
||||
disabled={isPending || openFamilies === 0 || !remindersOpen}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||
@@ -315,7 +319,7 @@ export function PlanView({
|
||||
</h3>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||
Verteilt {requiredDays} Notdiensttage fair auf{" "}
|
||||
{respondedFamilies} Familien.
|
||||
die verfügbaren Familien.
|
||||
</p>
|
||||
<details className="mt-3 text-xs text-[var(--ink-soft)]">
|
||||
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
|
||||
@@ -447,7 +451,7 @@ export function PlanView({
|
||||
);
|
||||
}
|
||||
|
||||
function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
|
||||
function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number; requiredDays: number }) {
|
||||
if (selectedDays === 0) {
|
||||
return (
|
||||
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
|
||||
@@ -457,8 +461,8 @@ function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]">
|
||||
{selectedDays} Tage
|
||||
<span className={selectedDays >= requiredDays ? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]" : "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"}>
|
||||
{selectedDays} / {requiredDays} Tage
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -548,7 +552,7 @@ function PlanPreviewTable({
|
||||
{format(dateObj, "EEEE", { locale: de })}
|
||||
</div>
|
||||
<div className="text-[var(--ink)]">
|
||||
{assignedChild ? assignedChild.parentName : "Unbesetzt"}
|
||||
{assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"}
|
||||
{assignedChild && (
|
||||
<span className="ml-1 text-[var(--ink-muted)]">
|
||||
({assignedChild.name})
|
||||
@@ -572,8 +576,8 @@ function PlanPreviewTable({
|
||||
</div>
|
||||
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
||||
<div>
|
||||
<Badge variant={assignedChild ? "success" : "destructive"}>
|
||||
{assignedChild ? "Besetzt" : "Offen"}
|
||||
<Badge variant={day.isInvalid ? "warning" : assignedChild ? "success" : "destructive"}>
|
||||
{day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -649,18 +653,24 @@ function ManualAssignmentTable({
|
||||
}
|
||||
>
|
||||
<option value="empty">-- Unbesetzt --</option>
|
||||
{day.childId && !allChildren.some((child) => child.id === day.childId) ? (
|
||||
<option value={day.childId} disabled>Inaktives Kind – bitte Einteilung entfernen</option>
|
||||
) : null}
|
||||
{allChildren.map((child) => {
|
||||
const famId = child.familyId;
|
||||
const count = famId
|
||||
? (familyAssignmentCounts.get(famId) ?? 0)
|
||||
: 0;
|
||||
return (
|
||||
<option key={child.id} value={child.id}>
|
||||
<option key={child.id} value={child.id} disabled={day.isInvalid}>
|
||||
{child.parentName} ({child.name}) — {count}x
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{day.isInvalid ? (
|
||||
<span className="text-xs text-[var(--danger)]">Einteilung nicht mehr gültig – bitte entfernen.</span>
|
||||
) : null}
|
||||
{!isDraft && (
|
||||
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
|
||||
Veröffentlicht
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from "@/lib/child-birthdate";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
|
||||
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
|
||||
// Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
|
||||
@@ -268,26 +270,25 @@ export async function deleteMyChild(childId: string) {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
try {
|
||||
const child = await requireOwnFamilyChild(
|
||||
childId,
|
||||
session.user.familyId,
|
||||
session.user.kitaId,
|
||||
);
|
||||
|
||||
if (!child) {
|
||||
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
|
||||
}
|
||||
|
||||
await prisma.child.delete({
|
||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
|
||||
await serializableTransaction(async (tx) => {
|
||||
if (!session.user.familyId) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
|
||||
const child = await tx.child.findFirst({
|
||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!child) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
|
||||
await assertChildrenRemovable(tx, session.user.kitaId, session.user.familyId, [childId]);
|
||||
await tx.child.delete({
|
||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/profil");
|
||||
revalidatePath("/dashboard/families");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Entfernen des Kindes:", error);
|
||||
return { error: "Das Kind konnte nicht entfernt werden." };
|
||||
if (!(error instanceof UserFacingError)) console.error("Fehler beim Entfernen des Kindes:", error);
|
||||
return { error: publicActionError(error, "Das Kind konnte nicht entfernt werden.") };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user