feat: show planning availability and manage Kita access roles
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Stand: 9. Oktober 2026 · geprüfter Code: `4702bf5abb0037ab9e0913dbef01ffbe21bbb96d` (`origin/main` zum Prüfzeitpunkt).
|
Stand: 9. Oktober 2026 · geprüfter Code: `4702bf5abb0037ab9e0913dbef01ffbe21bbb96d` (`origin/main` zum Prüfzeitpunkt).
|
||||||
|
|
||||||
Fortschreibung: F01 (Familieneinladungen), F02 (Abwesenheitsverwaltung) und die Entfernung des künstlichen Gruppenfilters aus F12 wurden anschließend auf `codex/family-absence-flows` bearbeitet. Den neuen Ablauf und die Deployment-Schritte beschreibt [Familien und Abwesenheiten](FAMILIEN_UND_ABWESENHEITEN.md). Die folgenden Befunde dokumentieren weiterhin den ursprünglichen Prüfstand.
|
Fortschreibung: F01 (Familieneinladungen), F02 (Abwesenheitsverwaltung) und die Entfernung des künstlichen Gruppenfilters aus F12 wurden anschließend auf `codex/family-absence-flows` bearbeitet. Den neuen Ablauf und die Deployment-Schritte beschreibt [Familien und Abwesenheiten](FAMILIEN_UND_ABWESENHEITEN.md). F03, F04 und F05 (Verfügbarkeit bei manueller Zuweisung sowie Erstveröffentlichung) sind inzwischen ebenfalls umgesetzt; den aktuellen Stand und die Prüfungen dokumentiert die Ergänzung vom 12.10.2026 in [Funktionsübersicht](FUNKTIONSUEBERSICHT.md). Die folgenden Befunde dokumentieren weiterhin den ursprünglichen Prüfstand.
|
||||||
|
|
||||||
## Umfang und Methode
|
## Umfang und Methode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# Kita-Planer – Funktionsübersicht und aktueller Stand
|
# Kita-Planer – Funktionsübersicht und aktueller Stand
|
||||||
|
|
||||||
|
## Ergänzung vom 12.10.2026: Manuelle Planung und Zugriffsrechte
|
||||||
|
|
||||||
|
- Die manuelle Notdienst-Zuweisung gruppiert verfügbare Haushalte zuerst. Familien ohne Verfügbarkeit für den gewählten Tag bleiben als abgestimmter Sonderfall auswählbar und werden nach einer solchen Einteilung ausdrücklich gekennzeichnet. Geschwister zählen als ein Haushalt; Einteilungszahlen gelten ebenfalls je Haushalt. Die Anzeige verwendet dieselben gültigen Monatstage wie die Planung. Bestehende Alarm- und Schließtagssperren bleiben bestehen.
|
||||||
|
- Kita-Admins verwalten Zugriffsrollen unter `/dashboard/zugriffsrechte` sowie direkt bei Personen in Familien- und Teamverwaltung. Die Übersicht enthält auch Konten ohne eigenen Haushalt, eine Suche und die letzten 15 protokollierten Rollenänderungen der eigenen Kita.
|
||||||
|
- Haushaltskonten können Eltern, Notdienst-Koordination oder Kita-Admin sein. Ohne Haushalt stehen Koordination und Kita-Admin zur Verfügung. Konten mit Personalprofil können Fachkraft oder Kita-Admin sein; Arbeitszeiten, Anträge und Haushalt bleiben erhalten. Ein Personalprofil wird durch eine Rollenänderung nicht neu angelegt. Systemadmins werden hier nicht verwaltet. Ämter wie „Vorstand“ verleihen keine Zugriffsrechte.
|
||||||
|
- Rollenänderungen prüfen Akteur und Zielkonto erneut innerhalb einer serialisierbaren Transaktion. Eine zwischenzeitlich geänderte Rolle wird nicht überschrieben. Das bestehende Login liest die Rolle bei jeder Anfrage neu; eigene Herabstufung führt zurück zur Übersicht. Offene Aktivierungslinks benötigen weiterhin die Aktivierung, bevor Zugang entsteht.
|
||||||
|
- Mindestens ein aktiver Kita-Admin mit verifiziertem Konto, Passwort und Datenschutzzustimmung bleibt erhalten. Herabstufung, eigene Kontolöschung, Support-Löschung/-Sperre und eine E-Mail-Änderung mit erneuter Aktivierung berücksichtigen denselben Schutz. Offene Einladungen, deaktivierte/gesperrte Konten oder fehlende Zustimmung zählen nicht als Ersatz. Gleichzeitige Änderungen werden abgefangen.
|
||||||
|
- Eltern mit Admin-/Koordinationsrolle erhalten weiterhin Haushaltsalarme, können über ihre persönlichen Links antworten und erhalten Mails nach manueller Umteilung. Fachkräfte mit Admin-Rolle können ihr verknüpftes Personalprofil weiter nutzen; persönliche Benachrichtigungseinstellungen und offene Fachkraftaktivierungen bleiben funktionsfähig.
|
||||||
|
- Kein Schema-Upgrade erforderlich.
|
||||||
|
|
||||||
|
Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_roles_test npm run test:roles` nach Anwendung der vorhandenen Migrationen auf dieser separaten lokalen Testdatenbank. Die 27 Rollen-/PostgreSQL-Tests und sechs tatsächlichen Komponenten-Rendertests verwenden synthetische Konten und ersetzte Mailtransporte.
|
||||||
|
|
||||||
|
Abnahme: 33 neue Tests und 193 bestehende Sicherheits-, Planungs-, Kalender-, Versand-, Familien-, Personal- und Alarmtests erfolgreich (226 insgesamt, keine übersprungenen Tests). Browserprüfung auf Desktop und bei 390 Pixeln: verfügbare und bewusst abweichende Einteilung, Rollenänderung aus Familien-/Teamverwaltung, Suche, Änderungshistorie, Schutz für einzigen Admin und eigene Herabstufung mit unmittelbar verweigertem Verwaltungszugriff. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit sieben bestehenden Warnungen.
|
||||||
|
|
||||||
|
|
||||||
## Ergänzung vom 12.10.2026: Notdienst-Benachrichtigungen
|
## Ergänzung vom 12.10.2026: Notdienst-Benachrichtigungen
|
||||||
|
|
||||||
- Automatische Erinnerungen erfassen auch unvollständige Verfügbarkeiten. Das Soll wird aus allen aktiven Kindern und die Istzahl aus unterschiedlichen gültigen Tagen berechnet. Admin-/Koordinationskonten mit eigenem Haushalt gehören zur Eltern-Empfängergruppe.
|
- Automatische Erinnerungen erfassen auch unvollständige Verfügbarkeiten. Das Soll wird aus allen aktiven Kindern und die Istzahl aus unterschiedlichen gültigen Tagen berechnet. Admin-/Koordinationskonten mit eigenem Haushalt gehören zur Eltern-Empfängergruppe.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs",
|
"test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs",
|
||||||
"test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs",
|
"test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs",
|
||||||
"test:mail": "node --test tests/mail-transport.test.cjs",
|
"test:mail": "node --test tests/mail-transport.test.cjs",
|
||||||
|
"test:roles": "node --test tests/kita-roles.integration.test.cjs tests/notdienst-assignment-view.test.cjs",
|
||||||
"test:runtime": "node --test tests/runtime-supervisor.test.cjs",
|
"test:runtime": "node --test tests/runtime-supervisor.test.cjs",
|
||||||
"test:family-absence": "tsx --test tests/absence-policy.test.ts",
|
"test:family-absence": "tsx --test tests/absence-policy.test.ts",
|
||||||
"test:family-absence:integration": "tsx --experimental-test-module-mocks --test tests/family-absence-flows.integration.test.ts"
|
"test:family-absence:integration": "tsx --experimental-test-module-mocks --test tests/family-absence-flows.integration.test.ts"
|
||||||
|
|||||||
+91
-54
@@ -7,6 +7,12 @@ import { AuditTargetType, UserRole } from "@prisma/client";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
|
import {
|
||||||
|
assertKitaAdminRemains,
|
||||||
|
lockKitaAccounts,
|
||||||
|
} from "@/lib/kita-admin-guard";
|
||||||
|
import { serializableTransaction } from "@/lib/transaction";
|
||||||
|
import { UserFacingError } from "@/lib/action-error";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
const deleteKitaSchema = z.object({
|
const deleteKitaSchema = z.object({
|
||||||
@@ -28,7 +34,8 @@ type ActionResult = {
|
|||||||
|
|
||||||
async function requireSuperadminActor() {
|
async function requireSuperadminActor() {
|
||||||
const session = await requireRole([UserRole.SUPERADMIN]);
|
const session = await requireRole([UserRole.SUPERADMIN]);
|
||||||
if (Date.now() - session.user.authenticatedAt > 15 * 60_000) redirect("/login?reauth=1&callbackUrl=/admin");
|
if (Date.now() - session.user.authenticatedAt > 15 * 60_000)
|
||||||
|
redirect("/login?reauth=1&callbackUrl=/admin");
|
||||||
return {
|
return {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
|
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
|
||||||
@@ -96,7 +103,10 @@ export async function deleteKita(
|
|||||||
const counts = await getKitaDeleteCounts(kita.id);
|
const counts = await getKitaDeleteCounts(kita.id);
|
||||||
|
|
||||||
const attachmentIds = await prisma.$transaction(async (tx) => {
|
const attachmentIds = await prisma.$transaction(async (tx) => {
|
||||||
const attachments = await tx.attachment.findMany({ where: { announcement: { kitaId: kita.id } }, select: { id: true } });
|
const attachments = await tx.attachment.findMany({
|
||||||
|
where: { announcement: { kitaId: kita.id } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
await tx.auditLog.create({
|
await tx.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
actorUserId: actor.id,
|
actorUserId: actor.id,
|
||||||
@@ -111,7 +121,7 @@ export async function deleteKita(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await tx.kita.delete({ where: { id: kita.id } });
|
await tx.kita.delete({ where: { id: kita.id } });
|
||||||
return attachments.map(item => item.id);
|
return attachments.map((item) => item.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
await removeAttachmentFiles(attachmentIds);
|
await removeAttachmentFiles(attachmentIds);
|
||||||
@@ -156,13 +166,19 @@ async function findMutableTenantUser(userId: string, actorId: string) {
|
|||||||
return { error: "Benutzer wurde nicht gefunden." } as const;
|
return { error: "Benutzer wurde nicht gefunden." } as const;
|
||||||
}
|
}
|
||||||
if (user.id === actorId) {
|
if (user.id === actorId) {
|
||||||
return { error: "Das eigene Superadmin-Konto kann hier nicht geändert werden." } as const;
|
return {
|
||||||
|
error: "Das eigene Superadmin-Konto kann hier nicht geändert werden.",
|
||||||
|
} as const;
|
||||||
}
|
}
|
||||||
if (user.role === UserRole.SUPERADMIN) {
|
if (user.role === UserRole.SUPERADMIN) {
|
||||||
return { error: "Andere Superadmins werden in V1 nicht verwaltet." } as const;
|
return {
|
||||||
|
error: "Andere Superadmins werden in V1 nicht verwaltet.",
|
||||||
|
} as const;
|
||||||
}
|
}
|
||||||
if (!user.kitaId) {
|
if (!user.kitaId) {
|
||||||
return { error: "Nur tenant-gebundene Benutzer können verwaltet werden." } as const;
|
return {
|
||||||
|
error: "Nur tenant-gebundene Benutzer können verwaltet werden.",
|
||||||
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user } as const;
|
return { user } as const;
|
||||||
@@ -188,34 +204,44 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentIds = await prisma.$transaction(async (tx) => {
|
let attachmentIds: string[];
|
||||||
const attachments = await tx.attachment.findMany({ where: { announcement: { authorId: user.id } }, select: { id: true } });
|
try {
|
||||||
await tx.verificationToken.deleteMany({
|
attachmentIds = await serializableTransaction(async (tx) => {
|
||||||
where: { identifier: user.id },
|
await lockKitaAccounts(tx, user.kitaId!);
|
||||||
});
|
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
|
||||||
await tx.auditLog.create({
|
const attachments = await tx.attachment.findMany({
|
||||||
data: {
|
where: { announcement: { authorId: user.id } },
|
||||||
actorUserId: actor.id,
|
select: { id: true },
|
||||||
actorEmail: actor.email,
|
});
|
||||||
action: "USER_DELETE",
|
await tx.verificationToken.deleteMany({
|
||||||
targetType: AuditTargetType.USER,
|
where: { identifier: user.id },
|
||||||
targetId: user.id,
|
});
|
||||||
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
|
await tx.auditLog.create({
|
||||||
targetKitaId: user.kitaId,
|
data: {
|
||||||
metadata: {
|
actorUserId: actor.id,
|
||||||
role: user.role,
|
actorEmail: actor.email,
|
||||||
kitaName: user.kita?.name ?? null,
|
action: "USER_DELETE",
|
||||||
kitaSlug: user.kita?.slug ?? null,
|
targetType: AuditTargetType.USER,
|
||||||
familyId: user.familyId,
|
targetId: user.id,
|
||||||
familyName: user.family?.name ?? null,
|
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
|
||||||
familyUserCountBeforeDelete: user.family?._count.users ?? null,
|
targetKitaId: user.kitaId,
|
||||||
|
metadata: {
|
||||||
|
role: user.role,
|
||||||
|
kitaName: user.kita?.name ?? null,
|
||||||
|
kitaSlug: user.kita?.slug ?? null,
|
||||||
|
familyId: user.familyId,
|
||||||
|
familyName: user.family?.name ?? null,
|
||||||
|
familyUserCountBeforeDelete: user.family?._count.users ?? null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
|
await tx.user.delete({ where: { id: user.id } });
|
||||||
|
return attachments.map((item) => item.id);
|
||||||
});
|
});
|
||||||
await tx.user.delete({ where: { id: user.id } });
|
} catch (error) {
|
||||||
return attachments.map(item => item.id);
|
if (!(error instanceof UserFacingError)) throw error;
|
||||||
});
|
return { ok: false, error: error.message };
|
||||||
|
}
|
||||||
await removeAttachmentFiles(attachmentIds);
|
await removeAttachmentFiles(attachmentIds);
|
||||||
revalidatePath("/admin");
|
revalidatePath("/admin");
|
||||||
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
||||||
@@ -245,29 +271,41 @@ async function updateUserSupportState(
|
|||||||
|
|
||||||
const { user } = lookup;
|
const { user } = lookup;
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
try {
|
||||||
await tx.user.update({
|
await serializableTransaction(async (tx) => {
|
||||||
where: { id: user.id },
|
await lockKitaAccounts(tx, user.kitaId!);
|
||||||
data: { ...data, ...(action === "USER_LOCK" ? { sessionVersion: { increment: 1 } } : {}) },
|
if (action === "USER_LOCK")
|
||||||
});
|
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
|
||||||
await tx.auditLog.create({
|
await tx.user.update({
|
||||||
data: {
|
where: { id: user.id },
|
||||||
actorUserId: actor.id,
|
data: {
|
||||||
actorEmail: actor.email,
|
...data,
|
||||||
action,
|
...(action === "USER_LOCK"
|
||||||
targetType: AuditTargetType.USER,
|
? { sessionVersion: { increment: 1 } }
|
||||||
targetId: user.id,
|
: {}),
|
||||||
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
|
|
||||||
targetKitaId: user.kitaId,
|
|
||||||
metadata: {
|
|
||||||
role: user.role,
|
|
||||||
previousLockedUntil: user.lockedUntil?.toISOString() ?? null,
|
|
||||||
previousFailedLoginAttempts: user.failedLoginAttempts,
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorUserId: actor.id,
|
||||||
|
actorEmail: actor.email,
|
||||||
|
action,
|
||||||
|
targetType: AuditTargetType.USER,
|
||||||
|
targetId: user.id,
|
||||||
|
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
|
||||||
|
targetKitaId: user.kitaId,
|
||||||
|
metadata: {
|
||||||
|
role: user.role,
|
||||||
|
previousLockedUntil: user.lockedUntil?.toISOString() ?? null,
|
||||||
|
previousFailedLoginAttempts: user.failedLoginAttempts,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
} catch (error) {
|
||||||
|
if (!(error instanceof UserFacingError)) throw error;
|
||||||
|
return { ok: false, error: error.message };
|
||||||
|
}
|
||||||
revalidatePath("/admin");
|
revalidatePath("/admin");
|
||||||
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
||||||
revalidatePath("/admin/audit");
|
revalidatePath("/admin/audit");
|
||||||
@@ -296,4 +334,3 @@ export async function resetFailedLoginAttempts(
|
|||||||
failedLoginAttempts: 0,
|
failedLoginAttempts: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RoleChangeButton,
|
||||||
|
type RolePerson,
|
||||||
|
} from "../../zugriffsrechte/role-change-button";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -47,6 +51,7 @@ type EducatorDto = {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
inviteFailed: boolean;
|
inviteFailed: boolean;
|
||||||
|
account: RolePerson | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function AccessFields({
|
function AccessFields({
|
||||||
@@ -119,9 +124,11 @@ function AccessFields({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErzieherList({
|
export function ErzieherList({
|
||||||
|
currentUserId,
|
||||||
educators,
|
educators,
|
||||||
today,
|
today,
|
||||||
}: {
|
}: {
|
||||||
|
currentUserId: string;
|
||||||
educators: EducatorDto[];
|
educators: EducatorDto[];
|
||||||
today: string;
|
today: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -377,6 +384,14 @@ export function ErzieherList({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="pr-6 text-right">
|
<TableCell className="pr-6 text-right">
|
||||||
|
{ed.account ? (
|
||||||
|
<span className="mr-2 inline-block">
|
||||||
|
<RoleChangeButton
|
||||||
|
person={ed.account}
|
||||||
|
ownAccount={ed.account.id === currentUserId}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -21,7 +21,15 @@ export default async function ErzieherPage() {
|
|||||||
staffProfile: {
|
staffProfile: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
user: { select: { email: true, emailVerifiedAt: true } },
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
familyId: true,
|
||||||
|
email: true,
|
||||||
|
emailVerifiedAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
emails: {
|
emails: {
|
||||||
where: { inviteToken: { not: null }, skippedAt: null },
|
where: { inviteToken: { not: null }, skippedAt: null },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
@@ -65,10 +73,20 @@ export default async function ErzieherPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ErzieherList
|
<ErzieherList
|
||||||
|
currentUserId={session.user.id}
|
||||||
today={todayKey()}
|
today={todayKey()}
|
||||||
educators={educators.map(({ staffProfile, ...educator }) => ({
|
educators={educators.map(({ staffProfile, ...educator }) => ({
|
||||||
...educator,
|
...educator,
|
||||||
hasProfile: !!staffProfile,
|
hasProfile: !!staffProfile,
|
||||||
|
account: staffProfile?.user
|
||||||
|
? {
|
||||||
|
id: staffProfile.user.id,
|
||||||
|
name: `${educator.firstName} ${educator.lastName}`,
|
||||||
|
role: staffProfile.user.role,
|
||||||
|
familyId: staffProfile.user.familyId,
|
||||||
|
hasStaffProfile: true,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
email: staffProfile?.user?.email ?? null,
|
email: staffProfile?.user?.email ?? null,
|
||||||
verified: !!staffProfile?.user?.emailVerifiedAt,
|
verified: !!staffProfile?.user?.emailVerifiedAt,
|
||||||
inviteFailed: !!staffProfile?.emails[0]?.failed,
|
inviteFailed: !!staffProfile?.emails[0]?.failed,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { UserRole } from "@prisma/client";
|
||||||
|
import { RoleChangeButton } from "../../zugriffsrechte/role-change-button";
|
||||||
|
import { RoleBadge } from "@/components/ui/role-badge";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
|
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -29,6 +32,8 @@ interface FamilyUser {
|
|||||||
email: string;
|
email: string;
|
||||||
emailVerifiedAt: Date | null;
|
emailVerifiedAt: Date | null;
|
||||||
invitation: FamilyInvitationView;
|
invitation: FamilyInvitationView;
|
||||||
|
role: UserRole;
|
||||||
|
staffProfile: { id: string } | null;
|
||||||
dutyAssignments: {
|
dutyAssignments: {
|
||||||
id: string;
|
id: string;
|
||||||
dutyId: string;
|
dutyId: string;
|
||||||
@@ -60,12 +65,14 @@ interface DutyData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface FamiliesListClientProps {
|
interface FamiliesListClientProps {
|
||||||
|
currentUserId: string;
|
||||||
initialFamilies: FamilyData[];
|
initialFamilies: FamilyData[];
|
||||||
allDuties: DutyData[];
|
allDuties: DutyData[];
|
||||||
canManageDuties: boolean;
|
canManageDuties: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FamiliesListClient({
|
export function FamiliesListClient({
|
||||||
|
currentUserId,
|
||||||
initialFamilies,
|
initialFamilies,
|
||||||
allDuties,
|
allDuties,
|
||||||
canManageDuties,
|
canManageDuties,
|
||||||
@@ -85,7 +92,7 @@ export function FamiliesListClient({
|
|||||||
(user) =>
|
(user) =>
|
||||||
user.firstName.toLowerCase().includes(q) ||
|
user.firstName.toLowerCase().includes(q) ||
|
||||||
user.lastName.toLowerCase().includes(q) ||
|
user.lastName.toLowerCase().includes(q) ||
|
||||||
user.email.toLowerCase().includes(q)
|
user.email.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
if (matchesParent) return true;
|
if (matchesParent) return true;
|
||||||
|
|
||||||
@@ -93,20 +100,20 @@ export function FamiliesListClient({
|
|||||||
const matchesChild = family.children.some(
|
const matchesChild = family.children.some(
|
||||||
(child) =>
|
(child) =>
|
||||||
child.firstName.toLowerCase().includes(q) ||
|
child.firstName.toLowerCase().includes(q) ||
|
||||||
child.lastName.toLowerCase().includes(q)
|
child.lastName.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
return matchesChild;
|
return matchesChild;
|
||||||
});
|
});
|
||||||
|
|
||||||
const childCount = filteredFamilies.reduce(
|
const childCount = filteredFamilies.reduce(
|
||||||
(acc, family) => acc + family.children.length,
|
(acc, family) => acc + family.children.length,
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingInviteCount = filteredFamilies.reduce(
|
const pendingInviteCount = filteredFamilies.reduce(
|
||||||
(acc, family) =>
|
(acc, family) =>
|
||||||
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
|
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -123,14 +130,17 @@ export function FamiliesListClient({
|
|||||||
</em>
|
</em>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||||
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort pflegen.
|
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort
|
||||||
|
pflegen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-wrap gap-2">
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode(viewMode === "table" ? "cards" : "table")}
|
onClick={() =>
|
||||||
|
setViewMode(viewMode === "table" ? "cards" : "table")
|
||||||
|
}
|
||||||
className="flex items-center gap-1.5"
|
className="flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{viewMode === "table" ? (
|
{viewMode === "table" ? (
|
||||||
@@ -152,7 +162,9 @@ export function FamiliesListClient({
|
|||||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||||
<span className="text-[var(--ink)] tabular-nums">{filteredFamilies.length}</span>
|
<span className="text-[var(--ink)] tabular-nums">
|
||||||
|
{filteredFamilies.length}
|
||||||
|
</span>
|
||||||
Familien
|
Familien
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||||
@@ -160,7 +172,9 @@ export function FamiliesListClient({
|
|||||||
Kinder
|
Kinder
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||||
<span className="text-[var(--ink)] tabular-nums">{pendingInviteCount}</span>
|
<span className="text-[var(--ink)] tabular-nums">
|
||||||
|
{pendingInviteCount}
|
||||||
|
</span>
|
||||||
Offene Einladungen
|
Offene Einladungen
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +210,8 @@ export function FamiliesListClient({
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle>Haushalte</CardTitle>
|
<CardTitle>Haushalte</CardTitle>
|
||||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||||
{filteredFamilies.length} {filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
|
{filteredFamilies.length}{" "}
|
||||||
|
{filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
|
||||||
{childCount} Kinder · {pendingInviteCount} offene Einladungen
|
{childCount} Kinder · {pendingInviteCount} offene Einladungen
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +233,7 @@ export function FamiliesListClient({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredFamilies.map((family) => {
|
{filteredFamilies.map((family) => {
|
||||||
const hasPendingInvite = family.users.some(
|
const hasPendingInvite = family.users.some(
|
||||||
(user) => !user.emailVerifiedAt
|
(user) => !user.emailVerifiedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -250,10 +265,29 @@ export function FamiliesListClient({
|
|||||||
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
|
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
|
||||||
<span className="break-words [overflow-wrap:anywhere]">
|
<span className="break-words [overflow-wrap:anywhere]">
|
||||||
{parent.email}
|
{parent.email}
|
||||||
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
|
{!parent.emailVerifiedAt && (
|
||||||
|
<ResendInviteButton userId={parent.id} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} />
|
<InvitationStatus
|
||||||
|
userId={parent.id}
|
||||||
|
name={`${parent.firstName} ${parent.lastName}`}
|
||||||
|
invitation={parent.invitation}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<RoleBadge role={parent.role} />
|
||||||
|
<RoleChangeButton
|
||||||
|
ownAccount={parent.id === currentUserId}
|
||||||
|
person={{
|
||||||
|
id: parent.id,
|
||||||
|
name: `${parent.firstName} ${parent.lastName}`,
|
||||||
|
role: parent.role,
|
||||||
|
familyId: family.id,
|
||||||
|
hasStaffProfile: !!parent.staffProfile,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +311,9 @@ export function FamiliesListClient({
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={hasPendingInvite ? "warning" : "success"}>
|
<Badge
|
||||||
|
variant={hasPendingInvite ? "warning" : "success"}
|
||||||
|
>
|
||||||
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
|
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -323,7 +359,7 @@ export function FamiliesListClient({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{filteredFamilies.map((family) => {
|
{filteredFamilies.map((family) => {
|
||||||
const hasPendingInvite = family.users.some(
|
const hasPendingInvite = family.users.some(
|
||||||
(user) => !user.emailVerifiedAt
|
(user) => !user.emailVerifiedAt,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Card key={family.id} className="flex flex-col h-full">
|
<Card key={family.id} className="flex flex-col h-full">
|
||||||
@@ -333,9 +369,12 @@ export function FamiliesListClient({
|
|||||||
{family.name.slice(0, 1)}
|
{family.name.slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg font-semibold">{family.name}</CardTitle>
|
<CardTitle className="text-lg font-semibold">
|
||||||
|
{family.name}
|
||||||
|
</CardTitle>
|
||||||
<p className="text-xs text-[var(--ink-muted)]">
|
<p className="text-xs text-[var(--ink-muted)]">
|
||||||
{family.users.length} Elternteil{family.users.length === 1 ? "" : "e"}
|
{family.users.length} Elternteil
|
||||||
|
{family.users.length === 1 ? "" : "e"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,10 +398,29 @@ export function FamiliesListClient({
|
|||||||
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
|
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
|
||||||
<span className="break-words [overflow-wrap:anywhere]">
|
<span className="break-words [overflow-wrap:anywhere]">
|
||||||
{parent.email}
|
{parent.email}
|
||||||
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
|
{!parent.emailVerifiedAt && (
|
||||||
|
<ResendInviteButton userId={parent.id} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} />
|
<InvitationStatus
|
||||||
|
userId={parent.id}
|
||||||
|
name={`${parent.firstName} ${parent.lastName}`}
|
||||||
|
invitation={parent.invitation}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<RoleBadge role={parent.role} />
|
||||||
|
<RoleChangeButton
|
||||||
|
ownAccount={parent.id === currentUserId}
|
||||||
|
person={{
|
||||||
|
id: parent.id,
|
||||||
|
name: `${parent.firstName} ${parent.lastName}`,
|
||||||
|
role: parent.role,
|
||||||
|
familyId: family.id,
|
||||||
|
hasStaffProfile: !!parent.staffProfile,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -430,8 +488,21 @@ export function FamiliesListClient({
|
|||||||
|
|
||||||
function ResendInviteButton({ userId }: { userId: string }) {
|
function ResendInviteButton({ userId }: { userId: string }) {
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
return <Button type="button" size="sm" variant="ghost" disabled={pending} onClick={() => startTransition(async () => {
|
return (
|
||||||
const result = await resendParentInvite(userId);
|
<Button
|
||||||
if (result.error) toast.error(result.error); else toast.success("Einladung erneut gesendet.");
|
type="button"
|
||||||
})}>{pending ? "Wird gesendet…" : "Einladung erneut senden"}</Button>;
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await resendParentInvite(userId);
|
||||||
|
if (result.error) toast.error(result.error);
|
||||||
|
else toast.success("Einladung erneut gesendet.");
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pending ? "Wird gesendet…" : "Einladung erneut senden"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
queueFamilyInvitation,
|
queueFamilyInvitation,
|
||||||
resendFamilyInvitation,
|
resendFamilyInvitation,
|
||||||
} from "@/lib/family-invitations";
|
} from "@/lib/family-invitations";
|
||||||
|
import {
|
||||||
|
assertKitaAdminRemains,
|
||||||
|
lockKitaAccounts,
|
||||||
|
} from "@/lib/kita-admin-guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
||||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||||
@@ -149,7 +153,12 @@ export async function addFamilyAction(
|
|||||||
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
|
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { errors: { _form: ["Zu viele Einladungen. Bitte versuche es später erneut."] } };
|
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed)
|
||||||
|
return {
|
||||||
|
errors: {
|
||||||
|
_form: ["Zu viele Einladungen. Bitte versuche es später erneut."],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
|
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
|
||||||
if (!parsedFamily.success) {
|
if (!parsedFamily.success) {
|
||||||
@@ -310,8 +319,11 @@ export async function updateFamilyAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removedChildIdSet = new Set(removedChildIds);
|
const removedChildIdSet = new Set(removedChildIds);
|
||||||
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
|
const activeChildren = children.filter(
|
||||||
if (activeChildren.length + newChildren.length > 20) return { error: "Maximal 20 Kinder pro Familie." };
|
(child) => !removedChildIdSet.has(child.id),
|
||||||
|
);
|
||||||
|
if (activeChildren.length + newChildren.length > 20)
|
||||||
|
return { error: "Maximal 20 Kinder pro Familie." };
|
||||||
if (activeChildren.length + newChildren.length === 0) {
|
if (activeChildren.length + newChildren.length === 0) {
|
||||||
return { error: "Eine Familie benötigt mindestens ein Kind." };
|
return { error: "Eine Familie benötigt mindestens ein Kind." };
|
||||||
}
|
}
|
||||||
@@ -341,13 +353,26 @@ export async function updateFamilyAction(
|
|||||||
return { error: "Familie wurde nicht gefunden." };
|
return { error: "Familie wurde nicht gefunden." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email);
|
const needsEmail =
|
||||||
|
newParents.length > 0 ||
|
||||||
|
parents.some(
|
||||||
|
(parent) =>
|
||||||
|
existingFamily.users.find((user) => user.id === parent.id)?.email !==
|
||||||
|
parent.email,
|
||||||
|
);
|
||||||
if (needsEmail) {
|
if (needsEmail) {
|
||||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." };
|
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed)
|
||||||
|
return {
|
||||||
|
error: "Zu viele Einladungen. Bitte versuche es später erneut.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
|
const existingParentIds = new Set(
|
||||||
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
|
existingFamily.users.map((user) => user.id),
|
||||||
|
);
|
||||||
|
const existingChildIds = new Set(
|
||||||
|
existingFamily.children.map((child) => child.id),
|
||||||
|
);
|
||||||
|
|
||||||
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
|
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
|
||||||
return {
|
return {
|
||||||
@@ -367,79 +392,109 @@ export async function updateFamilyAction(
|
|||||||
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
|
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(
|
||||||
await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds);
|
async (tx) => {
|
||||||
const removed = await tx.child.count({ where: { kitaId, familyId, id: { in: removedChildIds } } });
|
await lockKitaAccounts(tx, kitaId);
|
||||||
if (removed !== removedChildIds.length) throw new UserFacingError("Mindestens ein Kind gehört nicht zu dieser Familie.");
|
const changedEmails = parents
|
||||||
await tx.family.update({
|
.filter(
|
||||||
where: { id: familyId },
|
(parent) =>
|
||||||
data: { name: familyName },
|
existingFamily.users.find((user) => user.id === parent.id)
|
||||||
});
|
?.email !== parent.email,
|
||||||
|
)
|
||||||
for (const parent of parents) {
|
.map((parent) => parent.id);
|
||||||
const current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } });
|
await assertKitaAdminRemains(tx, kitaId, changedEmails);
|
||||||
if (!current) throw new Error("Parent assignment changed");
|
await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds);
|
||||||
const emailChanged = current.email !== parent.email;
|
const removed = await tx.child.count({
|
||||||
await tx.user.update({
|
where: { kitaId, familyId, id: { in: removedChildIds } },
|
||||||
where: { id: parent.id, kitaId, familyId },
|
|
||||||
data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email,
|
|
||||||
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
|
|
||||||
});
|
});
|
||||||
if (emailChanged) {
|
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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const parent of parents) {
|
||||||
|
const current = await tx.user.findFirst({
|
||||||
|
where: { id: parent.id, kitaId, familyId },
|
||||||
|
select: { email: true },
|
||||||
|
});
|
||||||
|
if (!current) throw new Error("Parent assignment changed");
|
||||||
|
const emailChanged = current.email !== parent.email;
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: parent.id, kitaId, familyId },
|
||||||
|
data: {
|
||||||
|
firstName: parent.firstName,
|
||||||
|
lastName: parent.lastName,
|
||||||
|
email: parent.email,
|
||||||
|
...(emailChanged
|
||||||
|
? {
|
||||||
|
passwordHash: "",
|
||||||
|
emailVerifiedAt: null,
|
||||||
|
sessionVersion: { increment: 1 },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (emailChanged) {
|
||||||
|
const invite = await queueFamilyInvitation(
|
||||||
|
tx,
|
||||||
|
parent.id,
|
||||||
|
session.user.id,
|
||||||
|
);
|
||||||
|
inviteJobs.push(invite.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const parent of newParents) {
|
||||||
|
const createdParent = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
kitaId,
|
||||||
|
familyId,
|
||||||
|
email: parent.email,
|
||||||
|
firstName: parent.firstName,
|
||||||
|
lastName: parent.lastName,
|
||||||
|
passwordHash: "",
|
||||||
|
role: UserRole.ELTERN,
|
||||||
|
},
|
||||||
|
});
|
||||||
const invite = await queueFamilyInvitation(
|
const invite = await queueFamilyInvitation(
|
||||||
tx,
|
tx,
|
||||||
parent.id,
|
createdParent.id,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
);
|
);
|
||||||
inviteJobs.push(invite.id);
|
inviteJobs.push(invite.id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const parent of newParents) {
|
for (const child of activeChildren) {
|
||||||
const createdParent = await tx.user.create({
|
await tx.child.update({
|
||||||
data: {
|
where: { id: child.id, kitaId, familyId },
|
||||||
kitaId,
|
data: {
|
||||||
familyId,
|
firstName: child.firstName,
|
||||||
email: parent.email,
|
lastName: child.lastName,
|
||||||
firstName: parent.firstName,
|
},
|
||||||
lastName: parent.lastName,
|
});
|
||||||
passwordHash: "",
|
}
|
||||||
role: UserRole.ELTERN,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const invite = await queueFamilyInvitation(
|
|
||||||
tx,
|
|
||||||
createdParent.id,
|
|
||||||
session.user.id,
|
|
||||||
);
|
|
||||||
inviteJobs.push(invite.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const child of activeChildren) {
|
for (const childId of removedChildIds) {
|
||||||
await tx.child.update({
|
await tx.child.delete({ where: { id: childId, kitaId, familyId } });
|
||||||
where: { id: child.id, kitaId, familyId },
|
}
|
||||||
data: {
|
|
||||||
firstName: child.firstName,
|
|
||||||
lastName: child.lastName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const childId of removedChildIds) {
|
for (const child of newChildren) {
|
||||||
await tx.child.delete({ where: { id: childId, kitaId, familyId } });
|
await tx.child.create({
|
||||||
}
|
data: {
|
||||||
|
kitaId,
|
||||||
for (const child of newChildren) {
|
familyId,
|
||||||
await tx.child.create({
|
firstName: child.firstName,
|
||||||
data: {
|
lastName: child.lastName,
|
||||||
kitaId,
|
},
|
||||||
familyId,
|
});
|
||||||
firstName: child.firstName,
|
}
|
||||||
lastName: child.lastName,
|
},
|
||||||
},
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||||
});
|
);
|
||||||
}
|
|
||||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
@@ -448,8 +503,14 @@ export async function updateFamilyAction(
|
|||||||
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
|
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(error instanceof UserFacingError)) console.error("Fehler beim Aktualisieren der Familie:", error);
|
if (!(error instanceof UserFacingError))
|
||||||
return { error: publicActionError(error, "Familie konnte nicht aktualisiert werden.") };
|
console.error("Fehler beim Aktualisieren der Familie:", error);
|
||||||
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Familie konnte nicht aktualisiert werden.",
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const warning = await sendInviteJobs(inviteJobs);
|
const warning = await sendInviteJobs(inviteJobs);
|
||||||
@@ -464,15 +525,33 @@ export async function resendFamilyInvitationAction(
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ success?: boolean; error?: string; warning?: string }> {
|
): Promise<{ success?: boolean; error?: string; warning?: string }> {
|
||||||
const session = await requireRole([UserRole.ADMIN]);
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
if (!session.user.kitaId || typeof userId !== "string" || !userId || userId.length > 64)
|
if (
|
||||||
|
!session.user.kitaId ||
|
||||||
|
typeof userId !== "string" ||
|
||||||
|
!userId ||
|
||||||
|
userId.length > 64
|
||||||
|
)
|
||||||
return { error: "Ungültiges Elternkonto." };
|
return { error: "Ungültiges Elternkonto." };
|
||||||
if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed)
|
if (
|
||||||
|
!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600))
|
||||||
|
.allowed
|
||||||
|
)
|
||||||
return { error: "Bitte versuche es später erneut." };
|
return { error: "Bitte versuche es später erneut." };
|
||||||
if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed)
|
if (
|
||||||
|
!(
|
||||||
|
await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)
|
||||||
|
).allowed
|
||||||
|
)
|
||||||
return { error: "Bitte versuche es später erneut." };
|
return { error: "Bitte versuche es später erneut." };
|
||||||
const recipient = await prisma.user.findFirst({
|
const recipient = await prisma.user.findFirst({
|
||||||
where: { id: userId, kitaId: session.user.kitaId, familyId: { not: null },
|
where: {
|
||||||
emailVerifiedAt: null, passwordHash: "", disabledAt: null },
|
id: userId,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: { not: null },
|
||||||
|
emailVerifiedAt: null,
|
||||||
|
passwordHash: "",
|
||||||
|
disabledAt: null,
|
||||||
|
},
|
||||||
select: { email: true },
|
select: { email: true },
|
||||||
});
|
});
|
||||||
if (!recipient) return { error: "Keine offene Einladung gefunden." };
|
if (!recipient) return { error: "Keine offene Einladung gefunden." };
|
||||||
|
|||||||
@@ -32,9 +32,17 @@ export default async function FamiliesPage() {
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
role: true,
|
||||||
|
staffProfile: { select: { id: true } },
|
||||||
emailVerifiedAt: true,
|
emailVerifiedAt: true,
|
||||||
familyInvitation: {
|
familyInvitation: {
|
||||||
select: { expiresAt: true, sentAt: true, failed: true, leaseUntil: true, status: true },
|
select: {
|
||||||
|
expiresAt: true,
|
||||||
|
sentAt: true,
|
||||||
|
failed: true,
|
||||||
|
leaseUntil: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
dutyAssignments: {
|
dutyAssignments: {
|
||||||
select: {
|
select: {
|
||||||
@@ -77,11 +85,15 @@ export default async function FamiliesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||||
<FamiliesListClient
|
<FamiliesListClient
|
||||||
|
currentUserId={session.user.id}
|
||||||
initialFamilies={families.map((family) => ({
|
initialFamilies={families.map((family) => ({
|
||||||
...family,
|
...family,
|
||||||
users: family.users.map(({ familyInvitation, ...parent }) => ({
|
users: family.users.map(({ familyInvitation, ...parent }) => ({
|
||||||
...parent,
|
...parent,
|
||||||
invitation: familyInvitationView(parent.emailVerifiedAt, familyInvitation),
|
invitation: familyInvitationView(
|
||||||
|
parent.emailVerifiedAt,
|
||||||
|
familyInvitation,
|
||||||
|
),
|
||||||
})),
|
})),
|
||||||
}))}
|
}))}
|
||||||
allDuties={allDuties}
|
allDuties={allDuties}
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import { getTargetMonthData } from "@/lib/date-utils";
|
|||||||
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||||
import { todayKey } from "@/lib/alarms/format";
|
import { todayKey } from "@/lib/alarms/format";
|
||||||
import { serializableTransaction } from "@/lib/transaction";
|
import { serializableTransaction } from "@/lib/transaction";
|
||||||
import { queueNotdienstPublication, queueNotdienstReminders, deliverDueNotdienstEmails } from "@/lib/notdienst-notifications";
|
import {
|
||||||
|
queueNotdienstPublication,
|
||||||
|
queueNotdienstReminders,
|
||||||
|
deliverDueNotdienstEmails,
|
||||||
|
} from "@/lib/notdienst-notifications";
|
||||||
import { sendAppEmail } from "@/lib/mail";
|
import { sendAppEmail } from "@/lib/mail";
|
||||||
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
||||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||||
@@ -43,14 +47,20 @@ export async function generatePlanAction(year?: number, month?: number) {
|
|||||||
targetMonth = data.targetMonth;
|
targetMonth = data.targetMonth;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
|
if (!validPlanMonth(targetYear, targetMonth))
|
||||||
|
return { error: "Ungültiger Planungsmonat." };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await serializableTransaction(async (tx) => {
|
await serializableTransaction(async (tx) => {
|
||||||
const existingPlan = await tx.notdienstPlan.findUnique({
|
const existingPlan = await tx.notdienstPlan.findUnique({
|
||||||
where: { kitaId_year_month: { kitaId, year: targetYear, month: targetMonth } },
|
where: {
|
||||||
|
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (existingPlan) throw new UserFacingError("Ein Plan für diesen Monat existiert bereits.");
|
if (existingPlan)
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Ein Plan für diesen Monat existiert bereits.",
|
||||||
|
);
|
||||||
|
|
||||||
const [availabilities, workingDays] = await Promise.all([
|
const [availabilities, workingDays] = await Promise.all([
|
||||||
tx.notdienstAvailability.findMany({
|
tx.notdienstAvailability.findMany({
|
||||||
@@ -69,16 +79,29 @@ export async function generatePlanAction(year?: number, month?: number) {
|
|||||||
const familyUsageCount = new Map<string, number>();
|
const familyUsageCount = new Map<string, number>();
|
||||||
const assignments: { childId: string; date: Date }[] = [];
|
const assignments: { childId: string; date: Date }[] = [];
|
||||||
for (const day of workingDays) {
|
for (const day of workingDays) {
|
||||||
const candidates = availabilities.filter((a) => a.date.getTime() === day.getTime());
|
const candidates = availabilities.filter(
|
||||||
|
(a) => a.date.getTime() === day.getTime(),
|
||||||
|
);
|
||||||
if (!candidates.length) continue;
|
if (!candidates.length) continue;
|
||||||
const minUsage = Math.min(...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0));
|
const minUsage = Math.min(
|
||||||
const leastUsed = candidates.filter((a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage);
|
...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0),
|
||||||
const selected = leastUsed[Math.floor(Math.random() * leastUsed.length)];
|
);
|
||||||
|
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 });
|
assignments.push({ childId: selected.childId, date: day });
|
||||||
familyUsageCount.set(selected.child.familyId, minUsage + 1);
|
familyUsageCount.set(selected.child.familyId, minUsage + 1);
|
||||||
}
|
}
|
||||||
const plan = await tx.notdienstPlan.create({
|
const plan = await tx.notdienstPlan.create({
|
||||||
data: { kitaId, year: targetYear, month: targetMonth, status: NotdienstPlanStatus.DRAFT, createdById: session.user.id },
|
data: {
|
||||||
|
kitaId,
|
||||||
|
year: targetYear,
|
||||||
|
month: targetMonth,
|
||||||
|
status: NotdienstPlanStatus.DRAFT,
|
||||||
|
createdById: session.user.id,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (assignments.length) {
|
if (assignments.length) {
|
||||||
await tx.notdienstAssignment.createMany({
|
await tx.notdienstAssignment.createMany({
|
||||||
@@ -91,7 +114,9 @@ export async function generatePlanAction(year?: number, month?: number) {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return { error: publicActionError(error, "Fehler bei der Generierung des Plans.") };
|
return {
|
||||||
|
error: publicActionError(error, "Fehler bei der Generierung des Plans."),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,30 +128,55 @@ export async function publishPlanAction(planId: string) {
|
|||||||
await serializableTransaction(async (tx) => {
|
await serializableTransaction(async (tx) => {
|
||||||
const plan = await tx.notdienstPlan.findUnique({
|
const plan = await tx.notdienstPlan.findUnique({
|
||||||
where: { id: planId, kitaId },
|
where: { id: planId, kitaId },
|
||||||
include: { assignments: { include: { child: { select: { active: true } } } } },
|
include: {
|
||||||
|
assignments: { include: { child: { select: { active: true } } } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!plan) throw new UserFacingError("Plan nicht gefunden.");
|
if (!plan) throw new UserFacingError("Plan nicht gefunden.");
|
||||||
if (plan.status === NotdienstPlanStatus.PUBLISHED) return;
|
if (plan.status === NotdienstPlanStatus.PUBLISHED) return;
|
||||||
if (plan.status !== NotdienstPlanStatus.DRAFT) throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
|
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 days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
|
||||||
const validDates = new Set(days.map((day) => day.getTime()));
|
const validDates = new Set(days.map((day) => day.getTime()));
|
||||||
if (plan.assignments.some((a) => !validDates.has(a.date.getTime()) || !a.child.active)) {
|
if (
|
||||||
throw new UserFacingError("Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.");
|
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({
|
await tx.notdienstPlan.update({
|
||||||
where: { id: planId, kitaId },
|
where: { id: planId, kitaId },
|
||||||
data: { status: NotdienstPlanStatus.PUBLISHED, publishedAt: new Date() },
|
data: {
|
||||||
|
status: NotdienstPlanStatus.PUBLISHED,
|
||||||
|
publishedAt: new Date(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await queueNotdienstPublication(tx, kitaId, planId, plan.year, plan.month);
|
await queueNotdienstPublication(
|
||||||
|
tx,
|
||||||
|
kitaId,
|
||||||
|
planId,
|
||||||
|
plan.year,
|
||||||
|
plan.month,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
revalidatePath("/dashboard/notdienst/plan");
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
revalidatePath("/dashboard/notdienst");
|
revalidatePath("/dashboard/notdienst");
|
||||||
revalidatePath("/dashboard/kalender");
|
revalidatePath("/dashboard/kalender");
|
||||||
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(
|
||||||
|
() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }),
|
||||||
|
);
|
||||||
revalidatePath("/dashboard/notdienst/plan");
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
return { success: true, ...delivery };
|
return { success: true, ...delivery };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { error: publicActionError(error, "Der Plan konnte nicht veröffentlicht werden.") };
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Der Plan konnte nicht veröffentlicht werden.",
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +188,8 @@ export async function updateAssignmentAction(
|
|||||||
const actor = await requireAlarmActor();
|
const actor = await requireAlarmActor();
|
||||||
const kitaId = actor.kitaId;
|
const kitaId = actor.kitaId;
|
||||||
|
|
||||||
if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." };
|
if (!dateOnlySchema.safeParse(date).success)
|
||||||
|
return { error: "Ungültiges Datum." };
|
||||||
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
||||||
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||||
|
|
||||||
@@ -161,14 +212,27 @@ export async function updateAssignmentAction(
|
|||||||
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
|
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newChildId && (parsedDate.getUTCFullYear() !== plan.year || parsedDate.getUTCMonth() + 1 !== plan.month)) {
|
if (
|
||||||
throw new UserFacingError("Das Datum gehört nicht zum Planungsmonat.");
|
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.
|
// Clearing obsolete entries remains possible after a new closure.
|
||||||
if (newChildId) {
|
if (newChildId) {
|
||||||
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
|
const days = await loadNotdienstDays(
|
||||||
|
tx,
|
||||||
|
kitaId,
|
||||||
|
plan.year,
|
||||||
|
plan.month,
|
||||||
|
);
|
||||||
if (!days.some((day) => day.getTime() === parsedDate.getTime())) {
|
if (!days.some((day) => day.getTime() === parsedDate.getTime())) {
|
||||||
throw new UserFacingError("An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.");
|
throw new UserFacingError(
|
||||||
|
"An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +253,18 @@ export async function updateAssignmentAction(
|
|||||||
family: {
|
family: {
|
||||||
include: {
|
include: {
|
||||||
users: {
|
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 },
|
select: { email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -217,7 +292,18 @@ export async function updateAssignmentAction(
|
|||||||
family: {
|
family: {
|
||||||
include: {
|
include: {
|
||||||
users: {
|
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 },
|
select: { email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -392,7 +478,15 @@ export async function updateKitaNotdienstSettingsAction(
|
|||||||
return { error: "Kein Mandant zugeordnet." };
|
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 {
|
try {
|
||||||
await prisma.kita.update({
|
await prisma.kita.update({
|
||||||
@@ -411,33 +505,61 @@ 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 session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
const kitaId = session.user.kitaId!;
|
const kitaId = session.user.kitaId!;
|
||||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
if (!validPlanMonth(year, month))
|
||||||
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
|
return { error: "Ungültiger Planungsmonat." };
|
||||||
|
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed)
|
||||||
|
return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
|
||||||
try {
|
try {
|
||||||
const queued = await queueNotdienstReminders(kitaId, year, month);
|
const queued = await queueNotdienstReminders(kitaId, year, month);
|
||||||
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
const delivery = await deliverDueNotdienstEmails({
|
||||||
|
kitaId,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
}).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
||||||
revalidatePath("/dashboard/notdienst/plan");
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
return { success: true, queued, count: delivery.sent, ...delivery };
|
return { success: true, queued, count: delivery.sent, ...delivery };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { error: publicActionError(error, "Die Erinnerungen konnten nicht vorbereitet werden.") };
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Die Erinnerungen konnten nicht vorbereitet werden.",
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function retryNotdienstEmailsAction(year: number, month: number) {
|
export async function retryNotdienstEmailsAction(year: number, month: number) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
const kitaId = session.user.kitaId!;
|
const kitaId = session.user.kitaId!;
|
||||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
if (!validPlanMonth(year, month))
|
||||||
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte warte vor einem weiteren Versandversuch." };
|
return { error: "Ungültiger Planungsmonat." };
|
||||||
|
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed)
|
||||||
|
return { error: "Bitte warte vor einem weiteren Versandversuch." };
|
||||||
try {
|
try {
|
||||||
await prisma.notdienstEmail.updateMany({ where: { kitaId, year, month, failed: true, sentAt: null, skippedAt: null }, data: { nextAttemptAt: new Date() } });
|
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 });
|
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month });
|
||||||
revalidatePath("/dashboard/notdienst/plan");
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
return { success: true, ...delivery };
|
return { success: true, ...delivery };
|
||||||
} catch {
|
} catch {
|
||||||
return { error: "Der Versand bleibt gespeichert und wird erneut versucht." };
|
return {
|
||||||
|
error: "Der Versand bleibt gespeichert und wird erneut versucht.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { de } from "date-fns/locale";
|
|||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { formatUtcDateKey, getWorkingDaysOfMonth, getTargetMonthData } from "@/lib/date-utils";
|
import {
|
||||||
|
formatUtcDateKey,
|
||||||
|
getWorkingDaysOfMonth,
|
||||||
|
getTargetMonthData,
|
||||||
|
} from "@/lib/date-utils";
|
||||||
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||||
import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications";
|
import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications";
|
||||||
import { EmailStatusCard } from "./_components/email-status-card";
|
import { EmailStatusCard } from "./_components/email-status-card";
|
||||||
@@ -108,8 +112,6 @@ export default async function PlanungsZentralePage({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
|
// 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",
|
|
||||||
// aber für MVP reichen alle aktiven Kinder.
|
|
||||||
const allChildrenRaw = await prisma.child.findMany({
|
const allChildrenRaw = await prisma.child.findMany({
|
||||||
where: { kitaId, active: true },
|
where: { kitaId, active: true },
|
||||||
select: {
|
select: {
|
||||||
@@ -146,8 +148,11 @@ export default async function PlanungsZentralePage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const validDateKeys = new Set(workingDays.map(formatUtcDateKey));
|
const validDateKeys = new Set(workingDays.map(formatUtcDateKey));
|
||||||
const availabilities = rawAvailabilities.filter((a) => validDateKeys.has(formatUtcDateKey(a.date)));
|
const availabilities = rawAvailabilities.filter((a) =>
|
||||||
const closureCount = getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
|
validDateKeys.has(formatUtcDateKey(a.date)),
|
||||||
|
);
|
||||||
|
const closureCount =
|
||||||
|
getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
|
||||||
|
|
||||||
const availabilityDatesByFamily = new Map<string, Set<string>>();
|
const availabilityDatesByFamily = new Map<string, Set<string>>();
|
||||||
availabilities.forEach((availability) => {
|
availabilities.forEach((availability) => {
|
||||||
@@ -186,37 +191,52 @@ export default async function PlanungsZentralePage({
|
|||||||
|
|
||||||
// Wir mappen die Assignments auf die Werktage
|
// Wir mappen die Assignments auf die Werktage
|
||||||
// Keep obsolete assignments visible so the coordinator can remove them.
|
// Keep obsolete assignments visible so the coordinator can remove them.
|
||||||
const displayedDays = new Map(workingDays.map((day) => [formatUtcDateKey(day), day]));
|
const displayedDays = new Map(
|
||||||
|
workingDays.map((day) => [formatUtcDateKey(day), day]),
|
||||||
|
);
|
||||||
for (const assignment of plan?.assignments ?? []) {
|
for (const assignment of plan?.assignments ?? []) {
|
||||||
displayedDays.set(formatUtcDateKey(assignment.date), assignment.date);
|
displayedDays.set(formatUtcDateKey(assignment.date), assignment.date);
|
||||||
}
|
}
|
||||||
const activeChildIds = new Set(allChildren.map((child) => child.id));
|
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 daysWithAssignments = Array.from(displayedDays.values())
|
||||||
const assignment = plan?.assignments.find(
|
.sort((a, b) => a.getTime() - b.getTime())
|
||||||
(a) => a.date.getTime() === day.getTime(),
|
.map((day) => {
|
||||||
);
|
const assignment = plan?.assignments.find(
|
||||||
const isOriginallyUnassigned =
|
(a) => a.date.getTime() === day.getTime(),
|
||||||
!assignment ||
|
);
|
||||||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
const isOriginallyUnassigned =
|
||||||
|
!assignment ||
|
||||||
|
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
||||||
|
|
||||||
const isInvalid = !validDateKeys.has(formatUtcDateKey(day)) || !!assignment && !activeChildIds.has(assignment.childId);
|
const isInvalid =
|
||||||
return {
|
!validDateKeys.has(formatUtcDateKey(day)) ||
|
||||||
date: day.toISOString(),
|
(!!assignment && !activeChildIds.has(assignment.childId));
|
||||||
isInvalid,
|
return {
|
||||||
assignmentId: assignment?.id || null,
|
date: day.toISOString(),
|
||||||
childId: assignment?.childId || null,
|
isInvalid,
|
||||||
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
assignmentId: assignment?.id || null,
|
||||||
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
childId: assignment?.childId || null,
|
||||||
canAlarm:
|
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
||||||
!!assignment && !isInvalid &&
|
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
||||||
plan?.status === "PUBLISHED" &&
|
canAlarm:
|
||||||
day.toISOString().slice(0, 10) >= todayKey(),
|
!!assignment &&
|
||||||
};
|
!isInvalid &&
|
||||||
});
|
plan?.status === "PUBLISHED" &&
|
||||||
|
day.toISOString().slice(0, 10) >= todayKey(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const mailStatus = await getNotdienstEmailStatus(kitaId, targetYear, targetMonth);
|
const mailStatus = await getNotdienstEmailStatus(
|
||||||
|
kitaId,
|
||||||
|
targetYear,
|
||||||
|
targetMonth,
|
||||||
|
);
|
||||||
const target = getTargetMonthData(now);
|
const target = getTargetMonthData(now);
|
||||||
const remindersOpen = !target.isLocked && target.targetYear === targetYear && target.targetMonth === targetMonth && plan?.status !== "PUBLISHED";
|
const remindersOpen =
|
||||||
|
!target.isLocked &&
|
||||||
|
target.targetYear === targetYear &&
|
||||||
|
target.targetMonth === targetMonth &&
|
||||||
|
plan?.status !== "PUBLISHED";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||||
@@ -261,7 +281,14 @@ export default async function PlanungsZentralePage({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<EmailStatusCard status={mailStatus} year={targetYear} month={targetMonth} remindersOpen={remindersOpen} daysBefore={kita.notdienstReminderDaysBefore} jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32} />
|
<EmailStatusCard
|
||||||
|
status={mailStatus}
|
||||||
|
year={targetYear}
|
||||||
|
month={targetMonth}
|
||||||
|
remindersOpen={remindersOpen}
|
||||||
|
daysBefore={kita.notdienstReminderDaysBefore}
|
||||||
|
jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32}
|
||||||
|
/>
|
||||||
|
|
||||||
<PlanView
|
<PlanView
|
||||||
planId={plan?.id}
|
planId={plan?.id}
|
||||||
|
|||||||
@@ -121,8 +121,14 @@ export function PlanView({
|
|||||||
if ("error" in result && result.error) {
|
if ("error" in result && result.error) {
|
||||||
toast.error(result.error as string);
|
toast.error(result.error as string);
|
||||||
} else if ("success" in result && result.success) {
|
} 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.");
|
if (result.failed || ("deferred" in result && result.deferred))
|
||||||
else toast.success("Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.");
|
toast.warning(
|
||||||
|
"Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.",
|
||||||
|
);
|
||||||
|
else
|
||||||
|
toast.success(
|
||||||
|
"Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -133,8 +139,14 @@ export function PlanView({
|
|||||||
if ("error" in result && result.error) {
|
if ("error" in result && result.error) {
|
||||||
toast.error(result.error as string);
|
toast.error(result.error as string);
|
||||||
} else if ("success" in result && result.success) {
|
} 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.`);
|
if (result.failed || ("deferred" in result && result.deferred))
|
||||||
else toast.success(`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`);
|
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.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -157,7 +169,11 @@ export function PlanView({
|
|||||||
const valueToSet = newChildId === "empty" ? null : newChildId;
|
const valueToSet = newChildId === "empty" ? null : newChildId;
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await updateAssignmentAction(planId, date.slice(0, 10), valueToSet);
|
const result = await updateAssignmentAction(
|
||||||
|
planId,
|
||||||
|
date.slice(0, 10),
|
||||||
|
valueToSet,
|
||||||
|
);
|
||||||
if ("error" in result && result.error) {
|
if ("error" in result && result.error) {
|
||||||
toast.error(result.error as string);
|
toast.error(result.error as string);
|
||||||
} else {
|
} else {
|
||||||
@@ -248,7 +264,10 @@ export function PlanView({
|
|||||||
{family.childCount === 1 ? "" : "er"}
|
{family.childCount === 1 ? "" : "er"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<FamilyStatusPill selectedDays={family.selectedDays.length} requiredDays={family.requiredDays} />
|
<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">
|
<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.length > 0
|
||||||
? family.selectedDays.map((day) => day.label).join(" · ")
|
? family.selectedDays.map((day) => day.label).join(" · ")
|
||||||
@@ -318,8 +337,8 @@ export function PlanView({
|
|||||||
Plan automatisch generieren
|
Plan automatisch generieren
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||||
Verteilt {requiredDays} Notdiensttage fair auf{" "}
|
Verteilt {requiredDays} Notdiensttage fair auf die verfügbaren
|
||||||
die verfügbaren Familien.
|
Familien.
|
||||||
</p>
|
</p>
|
||||||
<details className="mt-3 text-xs text-[var(--ink-soft)]">
|
<details className="mt-3 text-xs text-[var(--ink-soft)]">
|
||||||
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
|
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
|
||||||
@@ -412,6 +431,7 @@ export function PlanView({
|
|||||||
<ManualAssignmentTable
|
<ManualAssignmentTable
|
||||||
days={days}
|
days={days}
|
||||||
allChildren={allChildren}
|
allChildren={allChildren}
|
||||||
|
familyStatuses={familyStatuses}
|
||||||
isDraft={isDraft}
|
isDraft={isDraft}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
onAssignmentChange={handleAssignmentChange}
|
onAssignmentChange={handleAssignmentChange}
|
||||||
@@ -451,7 +471,13 @@ export function PlanView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number; requiredDays: number }) {
|
function FamilyStatusPill({
|
||||||
|
selectedDays,
|
||||||
|
requiredDays,
|
||||||
|
}: {
|
||||||
|
selectedDays: number;
|
||||||
|
requiredDays: number;
|
||||||
|
}) {
|
||||||
if (selectedDays === 0) {
|
if (selectedDays === 0) {
|
||||||
return (
|
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)]">
|
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
|
||||||
@@ -461,7 +487,13 @@ function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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)]"}>
|
<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
|
{selectedDays} / {requiredDays} Tage
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -552,7 +584,11 @@ function PlanPreviewTable({
|
|||||||
{format(dateObj, "EEEE", { locale: de })}
|
{format(dateObj, "EEEE", { locale: de })}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[var(--ink)]">
|
<div className="text-[var(--ink)]">
|
||||||
{assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"}
|
{assignedChild
|
||||||
|
? assignedChild.parentName
|
||||||
|
: day.childId
|
||||||
|
? "Inaktives Kind"
|
||||||
|
: "Unbesetzt"}
|
||||||
{assignedChild && (
|
{assignedChild && (
|
||||||
<span className="ml-1 text-[var(--ink-muted)]">
|
<span className="ml-1 text-[var(--ink-muted)]">
|
||||||
({assignedChild.name})
|
({assignedChild.name})
|
||||||
@@ -576,8 +612,20 @@ function PlanPreviewTable({
|
|||||||
</div>
|
</div>
|
||||||
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
||||||
<div>
|
<div>
|
||||||
<Badge variant={day.isInvalid ? "warning" : assignedChild ? "success" : "destructive"}>
|
<Badge
|
||||||
{day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"}
|
variant={
|
||||||
|
day.isInvalid
|
||||||
|
? "warning"
|
||||||
|
: assignedChild
|
||||||
|
? "success"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{day.isInvalid
|
||||||
|
? "Bitte korrigieren"
|
||||||
|
: assignedChild
|
||||||
|
? "Besetzt"
|
||||||
|
: "Offen"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -588,17 +636,29 @@ function PlanPreviewTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ManualAssignmentTable({
|
export function ManualAssignmentTable({
|
||||||
days,
|
days,
|
||||||
allChildren,
|
allChildren,
|
||||||
|
familyStatuses,
|
||||||
isDraft,
|
isDraft,
|
||||||
isPending,
|
isPending,
|
||||||
onAssignmentChange,
|
onAssignmentChange,
|
||||||
}: Pick<PlanViewProps, "days" | "allChildren"> & {
|
}: Pick<PlanViewProps, "days" | "allChildren" | "familyStatuses"> & {
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onAssignmentChange: (date: string, newChildId: string) => void;
|
onAssignmentChange: (date: string, newChildId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const availabilityByFamily = useMemo(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
familyStatuses.map((family) => [
|
||||||
|
family.id,
|
||||||
|
new Set(family.selectedDays.map((day) => day.iso.slice(0, 10))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
[familyStatuses],
|
||||||
|
);
|
||||||
|
|
||||||
// Map childId to familyId for quick lookup
|
// Map childId to familyId for quick lookup
|
||||||
const childFamilyMap = useMemo(() => {
|
const childFamilyMap = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -626,7 +686,12 @@ function ManualAssignmentTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden p-0">
|
<Card className="overflow-hidden p-0">
|
||||||
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
<div className="border-b border-[var(--hairline-soft)] p-4 text-sm leading-6 text-[var(--ink-soft)]">
|
||||||
|
Verfügbare Familien stehen zuerst. Familien ohne Verfügbarkeit für den
|
||||||
|
Tag bleiben für abgestimmte Sonderfälle auswählbar. Die Einteilungszahl
|
||||||
|
gilt für den gesamten Haushalt.
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||||
<div>Datum</div>
|
<div>Datum</div>
|
||||||
<div>Eingeteilte Familie</div>
|
<div>Eingeteilte Familie</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -634,42 +699,82 @@ function ManualAssignmentTable({
|
|||||||
{days.map((day) => {
|
{days.map((day) => {
|
||||||
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
|
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
|
||||||
const dateObj = new Date(yr, mo - 1, dy);
|
const dateObj = new Date(yr, mo - 1, dy);
|
||||||
|
const dateKey = day.date.slice(0, 10);
|
||||||
|
const available = allChildren.filter((child) =>
|
||||||
|
availabilityByFamily.get(child.familyId)?.has(dateKey),
|
||||||
|
);
|
||||||
|
const unavailable = allChildren.filter(
|
||||||
|
(child) => !availabilityByFamily.get(child.familyId)?.has(dateKey),
|
||||||
|
);
|
||||||
|
const availableFamilies = new Set(
|
||||||
|
available.map((child) => child.familyId),
|
||||||
|
).size;
|
||||||
|
const selectedChild = allChildren.find(
|
||||||
|
(child) => child.id === day.childId,
|
||||||
|
);
|
||||||
|
const selectedAvailable =
|
||||||
|
!!selectedChild &&
|
||||||
|
availabilityByFamily.get(selectedChild.familyId)?.has(dateKey);
|
||||||
|
const option = (child: PlanViewProps["allChildren"][number]) => (
|
||||||
|
<option key={child.id} value={child.id} disabled={day.isInvalid}>
|
||||||
|
{child.parentName} ({child.name}) —{" "}
|
||||||
|
{familyAssignmentCounts.get(child.familyId) ?? 0}x
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.date}
|
key={day.date}
|
||||||
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
|
className="grid gap-3 sm:grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
|
||||||
>
|
>
|
||||||
<div className="font-medium text-[var(--ink)]">
|
<div className="font-medium text-[var(--ink)]">
|
||||||
{format(dateObj, "EE, dd.MM.", { locale: de })}
|
{format(dateObj, "EE, dd.MM.", { locale: de })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<select
|
<div className="w-full max-w-sm space-y-2">
|
||||||
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)]"
|
<select
|
||||||
value={day.childId || "empty"}
|
aria-label={`Familie für ${format(dateObj, "dd.MM.yyyy")}`}
|
||||||
disabled={isPending || !!day.activeAlarmId}
|
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)]"
|
||||||
onChange={(event) =>
|
value={day.childId || "empty"}
|
||||||
onAssignmentChange(day.date, event.target.value)
|
disabled={isPending || !!day.activeAlarmId}
|
||||||
}
|
onChange={(event) =>
|
||||||
>
|
onAssignmentChange(day.date, event.target.value)
|
||||||
<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>
|
<option value="empty">-- Unbesetzt --</option>
|
||||||
) : null}
|
{day.childId &&
|
||||||
{allChildren.map((child) => {
|
!allChildren.some((child) => child.id === day.childId) ? (
|
||||||
const famId = child.familyId;
|
<option value={day.childId} disabled>
|
||||||
const count = famId
|
Inaktives Kind – bitte Einteilung entfernen
|
||||||
? (familyAssignmentCounts.get(famId) ?? 0)
|
|
||||||
: 0;
|
|
||||||
return (
|
|
||||||
<option key={child.id} value={child.id} disabled={day.isInvalid}>
|
|
||||||
{child.parentName} ({child.name}) — {count}x
|
|
||||||
</option>
|
</option>
|
||||||
);
|
) : null}
|
||||||
})}
|
{available.length > 0 ? (
|
||||||
</select>
|
<optgroup label="Verfügbar an diesem Tag">
|
||||||
|
{available.map(option)}
|
||||||
|
</optgroup>
|
||||||
|
) : null}
|
||||||
|
{unavailable.length > 0 ? (
|
||||||
|
<optgroup label="Keine Verfügbarkeit für diesen Tag">
|
||||||
|
{unavailable.map(option)}
|
||||||
|
</optgroup>
|
||||||
|
) : null}
|
||||||
|
</select>
|
||||||
|
{!day.isInvalid ? (
|
||||||
|
<p
|
||||||
|
className={`text-xs leading-5 ${(selectedChild && !selectedAvailable) || availableFamilies === 0 ? "text-[var(--warn)]" : "text-[var(--ink-soft)]"}`}
|
||||||
|
>
|
||||||
|
{selectedChild
|
||||||
|
? selectedAvailable
|
||||||
|
? "Eingeteilte Familie ist verfügbar."
|
||||||
|
: "Eingeteilte Familie hat für diesen Tag keine Verfügbarkeit angegeben."
|
||||||
|
: `${availableFamilies} ${availableFamilies === 1 ? "Familie ist" : "Familien sind"} verfügbar.`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{day.isInvalid ? (
|
{day.isInvalid ? (
|
||||||
<span className="text-xs text-[var(--danger)]">Einteilung nicht mehr gültig – bitte entfernen.</span>
|
<span className="text-xs text-[var(--danger)]">
|
||||||
|
Einteilung nicht mehr gültig – bitte entfernen.
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{!isDraft && (
|
{!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">
|
<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">
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
parseDateInput,
|
parseDateInput,
|
||||||
} from "@/lib/child-birthdate";
|
} from "@/lib/child-birthdate";
|
||||||
import { serializableTransaction } from "@/lib/transaction";
|
import { serializableTransaction } from "@/lib/transaction";
|
||||||
|
import {
|
||||||
|
assertKitaAdminRemains,
|
||||||
|
lockKitaAccounts,
|
||||||
|
} from "@/lib/kita-admin-guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
||||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||||
@@ -59,7 +63,11 @@ const childSchema = z
|
|||||||
});
|
});
|
||||||
|
|
||||||
const familySchema = z.object({
|
const familySchema = z.object({
|
||||||
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
|
familyName: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Familienname ist erforderlich.")
|
||||||
|
.max(120)
|
||||||
|
.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const contactSchema = z.object({
|
const contactSchema = z.object({
|
||||||
@@ -88,10 +96,7 @@ const contactSchema = z.object({
|
|||||||
|
|
||||||
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
|
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
|
||||||
// damit der User nicht nur "Ungültige Eingabedaten" sieht.
|
// damit der User nicht nur "Ungültige Eingabedaten" sieht.
|
||||||
function firstZodMessage(
|
function firstZodMessage(error: z.ZodError, fallback: string): string {
|
||||||
error: z.ZodError,
|
|
||||||
fallback: string,
|
|
||||||
): string {
|
|
||||||
const issue = error.issues[0];
|
const issue = error.issues[0];
|
||||||
return issue?.message || fallback;
|
return issue?.message || fallback;
|
||||||
}
|
}
|
||||||
@@ -121,8 +126,13 @@ export async function createMyChild(rawPayload: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await serializableTransaction(async tx => {
|
await serializableTransaction(async (tx) => {
|
||||||
const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } });
|
const count = await tx.child.count({
|
||||||
|
where: {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: session.user.familyId!,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (count >= 20) throw new Error("Household child limit reached");
|
if (count >= 20) throw new Error("Household child limit reached");
|
||||||
await tx.child.create({
|
await tx.child.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -166,10 +176,17 @@ export async function createMyFamily(rawPayload: unknown) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const attached = await tx.user.updateMany({
|
const attached = await tx.user.updateMany({
|
||||||
where: { id: session.user.id, kitaId: session.user.kitaId, familyId: null, disabledAt: null, sessionVersion: session.user.sessionVersion },
|
where: {
|
||||||
|
id: session.user.id,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: null,
|
||||||
|
disabledAt: null,
|
||||||
|
sessionVersion: session.user.sessionVersion,
|
||||||
|
},
|
||||||
data: { familyId: family.id },
|
data: { familyId: family.id },
|
||||||
});
|
});
|
||||||
if (attached.count !== 1) throw new Error("Account already assigned or revoked");
|
if (attached.count !== 1)
|
||||||
|
throw new Error("Account already assigned or revoked");
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/dashboard/profil");
|
revalidatePath("/dashboard/profil");
|
||||||
@@ -178,7 +195,12 @@ export async function createMyFamily(rawPayload: unknown) {
|
|||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof Error && error.message === "Account already assigned or revoked")) {
|
if (
|
||||||
|
!(
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message === "Account already assigned or revoked"
|
||||||
|
)
|
||||||
|
) {
|
||||||
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
|
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
|
||||||
}
|
}
|
||||||
return { error: "Der Haushalt konnte nicht angelegt werden." };
|
return { error: "Der Haushalt konnte nicht angelegt werden." };
|
||||||
@@ -249,7 +271,11 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await prisma.child.update({
|
await prisma.child.update({
|
||||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
|
where: {
|
||||||
|
id: childId,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: session.user.familyId!,
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
firstName: parsed.data.firstName,
|
firstName: parsed.data.firstName,
|
||||||
lastName: parsed.data.lastName,
|
lastName: parsed.data.lastName,
|
||||||
@@ -271,15 +297,34 @@ export async function deleteMyChild(childId: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await serializableTransaction(async (tx) => {
|
await serializableTransaction(async (tx) => {
|
||||||
if (!session.user.familyId) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
|
if (!session.user.familyId)
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Dieses Kind gehört nicht zu deinem Haushalt.",
|
||||||
|
);
|
||||||
const child = await tx.child.findFirst({
|
const child = await tx.child.findFirst({
|
||||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
|
where: {
|
||||||
|
id: childId,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: session.user.familyId,
|
||||||
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!child) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
|
if (!child)
|
||||||
await assertChildrenRemovable(tx, session.user.kitaId, session.user.familyId, [childId]);
|
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({
|
await tx.child.delete({
|
||||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
|
where: {
|
||||||
|
id: childId,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: session.user.familyId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -287,89 +332,67 @@ export async function deleteMyChild(childId: string) {
|
|||||||
revalidatePath("/dashboard/families");
|
revalidatePath("/dashboard/families");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof UserFacingError)) console.error("Fehler beim Entfernen des Kindes:", error);
|
if (!(error instanceof UserFacingError))
|
||||||
return { error: publicActionError(error, "Das Kind konnte nicht entfernt werden.") };
|
console.error("Fehler beim Entfernen des Kindes:", error);
|
||||||
|
return {
|
||||||
|
error: publicActionError(error, "Das Kind konnte nicht entfernt werden."),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMyAccount() {
|
export async function deleteMyAccount(): Promise<{
|
||||||
|
success?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findFirst({
|
return await serializableTransaction(async (tx) => {
|
||||||
where: {
|
await lockKitaAccounts(tx, session.user.kitaId);
|
||||||
id: session.user.id,
|
const user = await tx.user.findFirst({
|
||||||
kitaId: session.user.kitaId,
|
where: { id: session.user.id, kitaId: session.user.kitaId },
|
||||||
},
|
select: { id: true, familyId: true },
|
||||||
select: {
|
});
|
||||||
id: true,
|
if (!user) throw new UserFacingError("Account wurde nicht gefunden.");
|
||||||
familyId: true,
|
await assertKitaAdminRemains(tx, session.user.kitaId, [user.id]);
|
||||||
},
|
const today = new Date();
|
||||||
});
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const assignments = user.familyId
|
||||||
if (!user) {
|
? await tx.notdienstAssignment.count({
|
||||||
return { error: "Account wurde nicht gefunden." };
|
where: {
|
||||||
}
|
kitaId: session.user.kitaId,
|
||||||
|
date: { gte: today },
|
||||||
const today = new Date();
|
plan: { status: NotdienstPlanStatus.PUBLISHED },
|
||||||
today.setHours(0, 0, 0, 0);
|
child: { familyId: user.familyId },
|
||||||
|
},
|
||||||
const activeNotdienstAssignments = user.familyId
|
})
|
||||||
? await prisma.notdienstAssignment.count({
|
: 0;
|
||||||
where: {
|
if (assignments)
|
||||||
kitaId: session.user.kitaId,
|
throw new UserFacingError(
|
||||||
date: { gte: today },
|
|
||||||
plan: { status: NotdienstPlanStatus.PUBLISHED },
|
|
||||||
child: { familyId: user.familyId },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (activeNotdienstAssignments > 0) {
|
|
||||||
return {
|
|
||||||
error:
|
|
||||||
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
|
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
|
||||||
};
|
);
|
||||||
}
|
if (user.familyId) {
|
||||||
|
const family = await tx.family.findFirst({
|
||||||
if (!user.familyId) {
|
where: { id: user.familyId, kitaId: session.user.kitaId },
|
||||||
await prisma.user.delete({
|
select: { id: true, users: { select: { id: true } } },
|
||||||
where: { id: user.id },
|
});
|
||||||
});
|
if (!family)
|
||||||
|
throw new UserFacingError("Haushalt wurde nicht gefunden.");
|
||||||
|
if (family.users.length <= 1) {
|
||||||
|
await tx.family.delete({ where: { id: family.id } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await tx.user.delete({ where: { id: user.id } });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
|
||||||
|
|
||||||
const family = await prisma.family.findFirst({
|
|
||||||
where: {
|
|
||||||
id: user.familyId,
|
|
||||||
kitaId: session.user.kitaId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
users: {
|
|
||||||
select: { id: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!family) {
|
|
||||||
return { error: "Haushalt wurde nicht gefunden." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (family.users.length <= 1) {
|
|
||||||
await prisma.family.delete({
|
|
||||||
where: { id: family.id },
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.user.delete({
|
|
||||||
where: { id: user.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Löschen des Accounts:", error);
|
if (!(error instanceof UserFacingError))
|
||||||
return { error: "Ein Fehler ist beim Löschen aufgetreten." };
|
console.error("Fehler beim Löschen des Accounts:", error);
|
||||||
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Ein Fehler ist beim Löschen aufgetreten.",
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export default async function ProfilPage() {
|
|||||||
city: true,
|
city: true,
|
||||||
directoryOptInAt: true,
|
directoryOptInAt: true,
|
||||||
staffDecisionEmails: true,
|
staffDecisionEmails: true,
|
||||||
|
staffProfile: { select: { id: true } },
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
family: {
|
family: {
|
||||||
select: {
|
select: {
|
||||||
@@ -210,7 +211,7 @@ export default async function ProfilPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{user.role === UserRole.ERZIEHER && (
|
{user.staffProfile && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>E-Mail-Hinweise</CardTitle>
|
<CardTitle>E-Mail-Hinweise</CardTitle>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { UserRole } from "@prisma/client";
|
||||||
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
|
import { changeKitaRole } from "@/lib/kita-role-management";
|
||||||
|
import { publicActionError, UserFacingError } from "@/lib/action-error";
|
||||||
|
|
||||||
|
export async function changeKitaRoleAction(
|
||||||
|
raw: unknown,
|
||||||
|
): Promise<{
|
||||||
|
success?: boolean;
|
||||||
|
error?: string;
|
||||||
|
changed?: boolean;
|
||||||
|
ownRoleChanged?: boolean;
|
||||||
|
}> {
|
||||||
|
const { user } = await requireRole([UserRole.ADMIN]);
|
||||||
|
if (!user.kitaId) return { error: "Keine Kita zugeordnet." };
|
||||||
|
try {
|
||||||
|
const result = await changeKitaRole(
|
||||||
|
{ id: user.id, kitaId: user.kitaId, sessionVersion: user.sessionVersion },
|
||||||
|
raw,
|
||||||
|
);
|
||||||
|
revalidatePath("/dashboard", "layout");
|
||||||
|
revalidatePath("/admin");
|
||||||
|
return { success: true, ...result };
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof UserFacingError))
|
||||||
|
console.error("Rolle konnte nicht geändert werden:", error);
|
||||||
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Rolle konnte nicht geändert werden. Bitte erneut versuchen.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { UserRole } from "@prisma/client";
|
||||||
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getKitaRoleHistory } from "@/lib/kita-role-management";
|
||||||
|
import { kitaRoleLabels } from "@/lib/kita-roles";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { RoleList } from "./role-list";
|
||||||
|
|
||||||
|
export const metadata = { title: "Zugriffsrechte · Kita-Planer" };
|
||||||
|
export default async function KitaRolesPage() {
|
||||||
|
const { user } = await requireRole([UserRole.ADMIN]);
|
||||||
|
const kitaId = user.kitaId!;
|
||||||
|
const [people, history] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { kitaId, role: { not: UserRole.SUPERADMIN } },
|
||||||
|
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
familyId: true,
|
||||||
|
disabledAt: true,
|
||||||
|
lockedUntil: true,
|
||||||
|
emailVerifiedAt: true,
|
||||||
|
family: { select: { name: true } },
|
||||||
|
staffProfile: { select: { id: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getKitaRoleHistory(kitaId),
|
||||||
|
]);
|
||||||
|
const now = new Date();
|
||||||
|
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)]">
|
||||||
|
VERWALTUNG · KITA-KONTEN
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||||
|
Zugriffsrechte{" "}
|
||||||
|
<em className="font-normal text-[var(--accent-deep)]">verwalten</em>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||||
|
Ernenne weitere Kita-Admins oder die Notdienst-Koordination. Rollen
|
||||||
|
gelten sofort; offene Einladungen müssen weiterhin aktiviert werden.
|
||||||
|
Mindestens ein aktiver Kita-Admin bleibt erhalten.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<RoleList
|
||||||
|
currentUserId={user.id}
|
||||||
|
people={people.map((person) => ({
|
||||||
|
id: person.id,
|
||||||
|
name: `${person.firstName} ${person.lastName}`,
|
||||||
|
email: person.email,
|
||||||
|
role: person.role,
|
||||||
|
familyId: person.familyId,
|
||||||
|
familyName: person.family?.name ?? null,
|
||||||
|
hasStaffProfile: !!person.staffProfile,
|
||||||
|
status: person.disabledAt
|
||||||
|
? "Konto deaktiviert"
|
||||||
|
: person.lockedUntil && person.lockedUntil > now
|
||||||
|
? "Anmeldung gesperrt"
|
||||||
|
: !person.emailVerifiedAt
|
||||||
|
? "Einladung offen"
|
||||||
|
: "Zugang aktiviert",
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Card className="mt-7 p-5">
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--ink)]">
|
||||||
|
Letzte Rollenänderungen
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-[var(--ink-soft)]">
|
||||||
|
Ämter und Zuständigkeiten verwaltest du weiterhin bei den Familien.
|
||||||
|
Sie verändern die Zugriffsrechte nicht.
|
||||||
|
</p>
|
||||||
|
{history.length ? (
|
||||||
|
<ul className="mt-4 divide-y divide-[var(--hairline-soft)]">
|
||||||
|
{history.map((entry) => {
|
||||||
|
const data = entry.metadata as {
|
||||||
|
previousRole?: UserRole;
|
||||||
|
newRole?: UserRole;
|
||||||
|
} | null;
|
||||||
|
return (
|
||||||
|
<li key={entry.id} className="py-3 text-sm leading-6">
|
||||||
|
<p className="font-medium text-[var(--ink)]">
|
||||||
|
{entry.targetLabel}:{" "}
|
||||||
|
{data?.previousRole
|
||||||
|
? kitaRoleLabels[data.previousRole]
|
||||||
|
: "—"}{" "}
|
||||||
|
→ {data?.newRole ? kitaRoleLabels[data.newRole] : "—"}
|
||||||
|
</p>
|
||||||
|
<p className="break-words text-xs text-[var(--ink-muted)]">
|
||||||
|
{new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
}).format(entry.createdAt)}{" "}
|
||||||
|
· {entry.actorEmail}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 text-sm text-[var(--ink-muted)]">
|
||||||
|
Noch keine Rollenänderungen protokolliert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { UserRole } from "@prisma/client";
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { availableKitaRoles, kitaRoleLabels } from "@/lib/kita-roles";
|
||||||
|
import { changeKitaRoleAction } from "./actions";
|
||||||
|
|
||||||
|
const descriptions: Record<string, string> = {
|
||||||
|
ELTERN: "Eigener Haushalt, Verfügbarkeiten, Diensttausch und Kita-Alltag.",
|
||||||
|
KOORDINATOR: "Zusätzlich Notdienstpläne, Alarme und Termine koordinieren.",
|
||||||
|
ADMIN:
|
||||||
|
"Familien, Team, Zugriffsrechte und alle Kita-Verwaltungsbereiche verwalten.",
|
||||||
|
ERZIEHER:
|
||||||
|
"Personalbereich, eigene Arbeitszeit und Anträge sowie Kinder-Abwesenheiten.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RolePerson = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
familyId: string | null;
|
||||||
|
hasStaffProfile: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoleChangeButton({
|
||||||
|
person,
|
||||||
|
ownAccount = false,
|
||||||
|
}: {
|
||||||
|
person: RolePerson;
|
||||||
|
ownAccount?: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [role, setRole] = useState(person.role);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const roles = availableKitaRoles(person);
|
||||||
|
const submit = () => {
|
||||||
|
setError("");
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const result = await changeKitaRoleAction({
|
||||||
|
userId: person.id,
|
||||||
|
role,
|
||||||
|
expectedRole: person.role,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("Zugriffsrolle gespeichert.");
|
||||||
|
setOpen(false);
|
||||||
|
if (result.ownRoleChanged) router.replace("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError(
|
||||||
|
"Die Änderung konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Zugriffsrechte für ${person.name}`}
|
||||||
|
disabled={person.role === "SUPERADMIN"}
|
||||||
|
onClick={() => {
|
||||||
|
setRole(person.role);
|
||||||
|
setError("");
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" /> Zugriffsrechte
|
||||||
|
</Button>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!pending) setOpen(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Zugriffsrechte für {person.name}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Die Zugriffsrolle bestimmt, welche Bereiche diese Person nutzen
|
||||||
|
und verwalten kann. Ämter wie „Vorstand“ werden separat vergeben.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<fieldset disabled={pending} className="grid gap-2">
|
||||||
|
<legend className="mb-2 text-sm font-semibold text-[var(--ink)]">
|
||||||
|
Zugriffsrolle
|
||||||
|
</legend>
|
||||||
|
{roles.map((value) => (
|
||||||
|
<label
|
||||||
|
key={value}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 rounded-xl border p-3 transition-colors ${role === value ? "border-[var(--accent-mid)] bg-[var(--accent-soft)]" : "border-[var(--hairline)] hover:bg-[var(--surface-2)]"}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`role-${person.id}`}
|
||||||
|
value={value}
|
||||||
|
checked={role === value}
|
||||||
|
onChange={() => setRole(value)}
|
||||||
|
className="mt-1 accent-[var(--accent-deep)]"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-semibold text-[var(--ink)]">
|
||||||
|
{kitaRoleLabels[value]}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-[var(--ink-soft)]">
|
||||||
|
{descriptions[value]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||||
|
{person.hasStaffProfile
|
||||||
|
? "Das Personalprofil mit Arbeitszeiten und Anträgen bleibt erhalten. Mit Kita-Admin-Rechten ist zusätzlich die Personalverwaltung zugänglich."
|
||||||
|
: person.familyId
|
||||||
|
? "Haushalt, Kinder und eigene Dienste bleiben erhalten."
|
||||||
|
: "Dieses Konto hat keinen eigenen Haushalt. Deshalb steht die Elternrolle nicht zur Auswahl."}
|
||||||
|
</p>
|
||||||
|
{ownAccount && role !== person.role ? (
|
||||||
|
<p className="rounded-lg bg-[var(--warn-soft)] p-3 text-sm leading-5 text-[var(--warn)]">
|
||||||
|
Du änderst deine eigenen Rechte. Nach dem Speichern gelangst du
|
||||||
|
zur Übersicht; der Zugang zur Verwaltung richtet sich dann nach
|
||||||
|
der neuen Rolle.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-sm leading-5 text-[var(--danger)]">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button disabled={pending || role === person.role} onClick={submit}>
|
||||||
|
{pending ? "Wird gespeichert …" : "Rolle speichern"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { RoleBadge } from "@/components/ui/role-badge";
|
||||||
|
import { kitaRoleLabels } from "@/lib/kita-roles";
|
||||||
|
import { RoleChangeButton, type RolePerson } from "./role-change-button";
|
||||||
|
|
||||||
|
type Person = RolePerson & {
|
||||||
|
email: string;
|
||||||
|
familyName: string | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
export function RoleList({
|
||||||
|
people,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
people: Person[];
|
||||||
|
currentUserId: string;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const search = query.trim().toLocaleLowerCase("de");
|
||||||
|
const filtered = people.filter((person) =>
|
||||||
|
`${person.name} ${person.email} ${person.familyName ?? ""} ${kitaRoleLabels[person.role]}`
|
||||||
|
.toLocaleLowerCase("de")
|
||||||
|
.includes(search),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
aria-label="Kita-Konten durchsuchen"
|
||||||
|
placeholder="Name, Haushalt oder Rolle suchen …"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[var(--ink-muted)]">
|
||||||
|
{filtered.length} {filtered.length === 1 ? "Konto" : "Konten"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Card className="overflow-hidden p-0">
|
||||||
|
<div className="hidden gap-6 border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[var(--ink-muted)] md:grid md:grid-cols-[minmax(0,1fr)_220px_160px]">
|
||||||
|
<span>Person & Zuordnung</span>
|
||||||
|
<span>Zugriffsrolle</span>
|
||||||
|
<span className="text-right">Verwalten</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||||
|
{filtered.map((person) => (
|
||||||
|
<div
|
||||||
|
key={person.id}
|
||||||
|
className="grid items-center gap-4 px-5 py-5 md:grid-cols-[minmax(0,1fr)_220px_160px] md:gap-6"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-[var(--ink)]">
|
||||||
|
{person.name}
|
||||||
|
{person.id === currentUserId ? (
|
||||||
|
<span className="ml-2 text-xs font-normal text-[var(--ink-muted)]">
|
||||||
|
Du
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-words text-sm text-[var(--ink-soft)]">
|
||||||
|
{person.email}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
||||||
|
{[
|
||||||
|
person.familyName
|
||||||
|
? `Haushalt ${person.familyName}`
|
||||||
|
: "Kein eigener Haushalt",
|
||||||
|
person.hasStaffProfile ? "Personalprofil vorhanden" : null,
|
||||||
|
person.status,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RoleBadge role={person.role} />
|
||||||
|
<div className="md:justify-self-end">
|
||||||
|
<RoleChangeButton
|
||||||
|
person={person}
|
||||||
|
ownAccount={person.id === currentUserId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!filtered.length ? (
|
||||||
|
<p className="p-8 text-center text-sm text-[var(--ink-soft)]">
|
||||||
|
Keine passenden Kita-Konten gefunden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Megaphone,
|
Megaphone,
|
||||||
Clock3,
|
Clock3,
|
||||||
BellRing,
|
BellRing,
|
||||||
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { NavItem } from "@/components/ui/nav-item";
|
import { NavItem } from "@/components/ui/nav-item";
|
||||||
@@ -21,6 +22,14 @@ import { NavItem } from "@/components/ui/nav-item";
|
|||||||
import { UserRole } from "@prisma/client";
|
import { UserRole } from "@prisma/client";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
{
|
||||||
|
section: "Verwaltung",
|
||||||
|
href: "/dashboard/zugriffsrechte",
|
||||||
|
label: "Zugriffsrechte",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
exact: true,
|
||||||
|
allowedRoles: [UserRole.ADMIN],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
section: "Heute",
|
section: "Heute",
|
||||||
href: "/dashboard/notdienst/alarm",
|
href: "/dashboard/notdienst/alarm",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { UserRole } from "@prisma/client";
|
import type { UserRole } from "@prisma/client";
|
||||||
|
|
||||||
|
import { kitaRoleLabels } from "@/lib/kita-roles";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type RoleBadgeProps = {
|
type RoleBadgeProps = {
|
||||||
@@ -7,14 +9,6 @@ type RoleBadgeProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const roleLabels: Record<string, string> = {
|
|
||||||
SUPERADMIN: "Admin",
|
|
||||||
ADMIN: "Vorstand",
|
|
||||||
KOORDINATOR: "Koordinator",
|
|
||||||
ELTERN: "Eltern",
|
|
||||||
ERZIEHER: "Erzieher",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RoleBadge({ role, className }: RoleBadgeProps) {
|
export function RoleBadge({ role, className }: RoleBadgeProps) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -23,7 +17,7 @@ export function RoleBadge({ role, className }: RoleBadgeProps) {
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{roleLabels[role] ?? role}
|
{kitaRoleLabels[role]}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -39,14 +39,29 @@ export async function deliverAlarmEmails(
|
|||||||
include: { user: true, alarm: { include: { kita: true } } },
|
include: { user: true, alarm: { include: { kita: true } } },
|
||||||
});
|
});
|
||||||
const { user, alarm } = job;
|
const { user, alarm } = job;
|
||||||
let eligible = user.kitaId === alarm.kitaId && !user.disabledAt && !!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt && (!user.lockedUntil || user.lockedUntil <= now);
|
let eligible =
|
||||||
|
user.kitaId === alarm.kitaId &&
|
||||||
|
!user.disabledAt &&
|
||||||
|
!!user.emailVerifiedAt &&
|
||||||
|
!!user.privacyPolicyAcceptedAt &&
|
||||||
|
(!user.lockedUntil || user.lockedUntil <= now);
|
||||||
if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role);
|
if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role);
|
||||||
else
|
else
|
||||||
eligible &&= user.role === "ELTERN" && user.familyId === alarm.familyId;
|
eligible &&=
|
||||||
|
["ELTERN", "ADMIN", "KOORDINATOR"].includes(user.role) &&
|
||||||
|
user.familyId === alarm.familyId;
|
||||||
if (job.kind === "REQUEST") {
|
if (job.kind === "REQUEST") {
|
||||||
const token = job.path.split("/").pop();
|
const token = job.path.split("/").pop();
|
||||||
const recipient = await prisma.notdienstAlarmRecipient.findFirst({
|
const recipient = await prisma.notdienstAlarmRecipient.findFirst({
|
||||||
where: { alarmId: alarm.id, userId: user.id, sessionVersion: user.sessionVersion, OR: [{ token: hashToken(token ?? "") }, ...(token?.length === 36 ? [{ token }] : [])] },
|
where: {
|
||||||
|
alarmId: alarm.id,
|
||||||
|
userId: user.id,
|
||||||
|
sessionVersion: user.sessionVersion,
|
||||||
|
OR: [
|
||||||
|
{ token: hashToken(token ?? "") },
|
||||||
|
...(token?.length === 36 ? [{ token }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
eligible &&=
|
eligible &&=
|
||||||
!!recipient && alarm.status === "PENDING" && alarm.expiresAt > now;
|
!!recipient && alarm.status === "PENDING" && alarm.expiresAt > now;
|
||||||
|
|||||||
+52
-16
@@ -114,22 +114,30 @@ async function queue(
|
|||||||
const users =
|
const users =
|
||||||
audience === "managers"
|
audience === "managers"
|
||||||
? await tx.user.findMany({
|
? await tx.user.findMany({
|
||||||
where: { kitaId: alarm.kitaId, role: { in: managerRoles }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: {
|
||||||
|
kitaId: alarm.kitaId,
|
||||||
|
role: { in: managerRoles },
|
||||||
|
emailVerifiedAt: { not: null },
|
||||||
|
disabledAt: null,
|
||||||
|
privacyPolicyAcceptedAt: { not: null },
|
||||||
|
},
|
||||||
})
|
})
|
||||||
: await tx.user.findMany({
|
: await tx.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId: alarm.kitaId,
|
kitaId: alarm.kitaId,
|
||||||
familyId: alarm.familyId,
|
familyId: alarm.familyId,
|
||||||
role: "ELTERN",
|
role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] },
|
||||||
emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null },
|
emailVerifiedAt: { not: null },
|
||||||
|
disabledAt: null,
|
||||||
|
privacyPolicyAcceptedAt: { not: null },
|
||||||
alarmRecipients: { some: { alarmId: alarm.id } },
|
alarmRecipients: { some: { alarmId: alarm.id } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const jobs: string[] = [];
|
const jobs: string[] = [];
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
const recipient =
|
const recipient = kind === "REQUEST" ? requestTokens?.get(user.id) : null;
|
||||||
kind === "REQUEST" ? requestTokens?.get(user.id) : null;
|
if (kind === "REQUEST" && !recipient)
|
||||||
if (kind === "REQUEST" && !recipient) throw new Error("Request token missing");
|
throw new Error("Request token missing");
|
||||||
await tx.notdienstAlarmNotification.create({
|
await tx.notdienstAlarmNotification.create({
|
||||||
data: { alarmId: alarm.id, userId: user.id, eventKey, message },
|
data: { alarmId: alarm.id, userId: user.id, eventKey, message },
|
||||||
});
|
});
|
||||||
@@ -204,7 +212,15 @@ export async function executeAlarmCommand(
|
|||||||
include: {
|
include: {
|
||||||
family: {
|
family: {
|
||||||
include: {
|
include: {
|
||||||
users: { where: { role: "ELTERN", kitaId: actor.kitaId, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } } },
|
users: {
|
||||||
|
where: {
|
||||||
|
role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] },
|
||||||
|
kitaId: actor.kitaId,
|
||||||
|
emailVerifiedAt: { not: null },
|
||||||
|
disabledAt: null,
|
||||||
|
privacyPolicyAcceptedAt: { not: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -221,10 +237,14 @@ export async function executeAlarmCommand(
|
|||||||
if (!assignment.kita.notdienstModuleEnabled)
|
if (!assignment.kita.notdienstModuleEnabled)
|
||||||
throw new UserFacingError("Notdienst ist für diese Kita deaktiviert.");
|
throw new UserFacingError("Notdienst ist für diese Kita deaktiviert.");
|
||||||
if (assignment.plan.status !== "PUBLISHED")
|
if (assignment.plan.status !== "PUBLISHED")
|
||||||
throw new UserFacingError("Bitte den Notdienstplan zuerst veröffentlichen.");
|
throw new UserFacingError(
|
||||||
|
"Bitte den Notdienstplan zuerst veröffentlichen.",
|
||||||
|
);
|
||||||
const date = dateKey(assignment.date);
|
const date = dateKey(assignment.date);
|
||||||
if (date < todayKey(now))
|
if (date < todayKey(now))
|
||||||
throw new UserFacingError("Vergangene Notdienste können nicht alarmiert werden.");
|
throw new UserFacingError(
|
||||||
|
"Vergangene Notdienste können nicht alarmiert werden.",
|
||||||
|
);
|
||||||
const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
|
const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
|
||||||
if (expiresAt <= now)
|
if (expiresAt <= now)
|
||||||
throw new UserFacingError("Dieser Einsatz ist bereits beendet.");
|
throw new UserFacingError("Dieser Einsatz ist bereits beendet.");
|
||||||
@@ -245,7 +265,9 @@ export async function executeAlarmCommand(
|
|||||||
"Für diese Einteilung besteht bereits ein aktiver Alarm.",
|
"Für diese Einteilung besteht bereits ein aktiver Alarm.",
|
||||||
);
|
);
|
||||||
const parents = assignment.child.family.users;
|
const parents = assignment.child.family.users;
|
||||||
const requestTokens = new Map(parents.map(parent => [parent.id, newToken()]));
|
const requestTokens = new Map(
|
||||||
|
parents.map((parent) => [parent.id, newToken()]),
|
||||||
|
);
|
||||||
if (!parents.length)
|
if (!parents.length)
|
||||||
throw new UserFacingError(
|
throw new UserFacingError(
|
||||||
"Für diesen Haushalt sind keine Elternkonten hinterlegt.",
|
"Für diesen Haushalt sind keine Elternkonten hinterlegt.",
|
||||||
@@ -353,7 +375,9 @@ export async function executeAlarmCommand(
|
|||||||
);
|
);
|
||||||
const reason = note.parse(data.reason);
|
const reason = note.parse(data.reason);
|
||||||
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION")
|
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION")
|
||||||
throw new UserFacingError("Es liegt keine verspätete Zusage zur Freigabe vor.");
|
throw new UserFacingError(
|
||||||
|
"Es liegt keine verspätete Zusage zur Freigabe vor.",
|
||||||
|
);
|
||||||
const approve =
|
const approve =
|
||||||
command === "approveLate" || z.boolean().parse(data.approve);
|
command === "approveLate" || z.boolean().parse(data.approve);
|
||||||
const updated = await tx.notdienstAlarm.update({
|
const updated = await tx.notdienstAlarm.update({
|
||||||
@@ -401,7 +425,12 @@ export async function executeAlarmCommand(
|
|||||||
|
|
||||||
async function recipientForToken(tx: Tx, token: string) {
|
async function recipientForToken(tx: Tx, token: string) {
|
||||||
const recipient = await tx.notdienstAlarmRecipient.findFirst({
|
const recipient = await tx.notdienstAlarmRecipient.findFirst({
|
||||||
where: { OR: [{ token: hashToken(token) }, ...(token.length === 36 ? [{ token }] : [])] },
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ token: hashToken(token) },
|
||||||
|
...(token.length === 36 ? [{ token }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
user: true,
|
||||||
alarm: { include: { kita: { select: { name: true } } } },
|
alarm: { include: { kita: { select: { name: true } } } },
|
||||||
@@ -409,9 +438,11 @@ async function recipientForToken(tx: Tx, token: string) {
|
|||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
!recipient ||
|
!recipient ||
|
||||||
recipient.user.role !== "ELTERN" ||
|
!["ELTERN", "ADMIN", "KOORDINATOR"].includes(recipient.user.role) ||
|
||||||
recipient.sessionVersion !== recipient.user.sessionVersion ||
|
recipient.sessionVersion !== recipient.user.sessionVersion ||
|
||||||
!!recipient.user.disabledAt || !recipient.user.emailVerifiedAt || !recipient.user.privacyPolicyAcceptedAt ||
|
!!recipient.user.disabledAt ||
|
||||||
|
!recipient.user.emailVerifiedAt ||
|
||||||
|
!recipient.user.privacyPolicyAcceptedAt ||
|
||||||
recipient.alarm.expiresAt <= new Date() ||
|
recipient.alarm.expiresAt <= new Date() ||
|
||||||
(recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) ||
|
(recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) ||
|
||||||
recipient.user.kitaId !== recipient.alarm.kitaId ||
|
recipient.user.kitaId !== recipient.alarm.kitaId ||
|
||||||
@@ -423,7 +454,12 @@ async function recipientForToken(tx: Tx, token: string) {
|
|||||||
export async function getPublicAlarm(token: string) {
|
export async function getPublicAlarm(token: string) {
|
||||||
if (!tokenSchema.safeParse(token).success) return null;
|
if (!tokenSchema.safeParse(token).success) return null;
|
||||||
const legacy = await prisma.notdienstAlert.findFirst({
|
const legacy = await prisma.notdienstAlert.findFirst({
|
||||||
where: { OR: [{ confirmationToken: hashToken(token) }, ...(token.length === 36 ? [{ confirmationToken: token }] : [])] },
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ confirmationToken: hashToken(token) },
|
||||||
|
...(token.length === 36 ? [{ confirmationToken: token }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
select: { alarmId: true, assignmentId: true },
|
select: { alarmId: true, assignmentId: true },
|
||||||
});
|
});
|
||||||
if (legacy && !legacy.alarmId)
|
if (legacy && !legacy.alarmId)
|
||||||
@@ -591,7 +627,7 @@ export async function importLegacyAlarms(
|
|||||||
const old = assignment.alerts;
|
const old = assignment.alerts;
|
||||||
const valid = old.filter(
|
const valid = old.filter(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.parentUser.role === "ELTERN" &&
|
["ELTERN", "ADMIN", "KOORDINATOR"].includes(a.parentUser.role) &&
|
||||||
a.parentUser.familyId === assignment.child.familyId &&
|
a.parentUser.familyId === assignment.child.familyId &&
|
||||||
a.parentUser.kitaId === assignment.kitaId,
|
a.parentUser.kitaId === assignment.kitaId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { UserFacingError } from "@/lib/action-error";
|
||||||
|
|
||||||
|
// Serialize role changes, account deletion and support locks for the same Kita.
|
||||||
|
export async function lockKitaAccounts(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
kitaId: string,
|
||||||
|
) {
|
||||||
|
const rows = await tx.$queryRaw<
|
||||||
|
{ id: string }[]
|
||||||
|
>`SELECT id FROM kitas WHERE id = ${kitaId} FOR UPDATE`;
|
||||||
|
if (!rows.length) throw new UserFacingError("Kita wurde nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertKitaAdminRemains(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
kitaId: string,
|
||||||
|
excludedUserIds: string[],
|
||||||
|
) {
|
||||||
|
const removesAdmin = await tx.user.count({
|
||||||
|
where: { kitaId, id: { in: excludedUserIds }, role: "ADMIN" },
|
||||||
|
});
|
||||||
|
if (!removesAdmin) return;
|
||||||
|
const now = new Date();
|
||||||
|
const remaining = await tx.user.count({
|
||||||
|
where: {
|
||||||
|
kitaId,
|
||||||
|
id: { notIn: excludedUserIds },
|
||||||
|
role: "ADMIN",
|
||||||
|
disabledAt: null,
|
||||||
|
emailVerifiedAt: { not: null },
|
||||||
|
privacyPolicyAcceptedAt: { not: null },
|
||||||
|
passwordHash: { not: "" },
|
||||||
|
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!remaining)
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Mindestens ein aktiver Kita-Admin muss erhalten bleiben. Ernenne zuerst eine weitere Person mit aktiviertem Zugang und Datenschutzzustimmung.",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { AuditTargetType } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { serializableTransaction } from "@/lib/transaction";
|
||||||
|
import { UserFacingError } from "@/lib/action-error";
|
||||||
|
import {
|
||||||
|
assertKitaAdminRemains,
|
||||||
|
lockKitaAccounts,
|
||||||
|
} from "@/lib/kita-admin-guard";
|
||||||
|
import { availableKitaRoles } from "@/lib/kita-roles";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
userId: z.string().min(1).max(64),
|
||||||
|
role: z.enum(["ADMIN", "KOORDINATOR", "ELTERN", "ERZIEHER"]),
|
||||||
|
expectedRole: z.enum(["ADMIN", "KOORDINATOR", "ELTERN", "ERZIEHER"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Actor = { id: string; kitaId: string; sessionVersion: number };
|
||||||
|
export async function changeKitaRole(actor: Actor, raw: unknown) {
|
||||||
|
const input = inputSchema.safeParse(raw);
|
||||||
|
if (!input.success) throw new UserFacingError("Ungültige Rollenänderung.");
|
||||||
|
return serializableTransaction(async (tx) => {
|
||||||
|
await lockKitaAccounts(tx, actor.kitaId);
|
||||||
|
const currentActor = await tx.user.findFirst({
|
||||||
|
where: {
|
||||||
|
id: actor.id,
|
||||||
|
kitaId: actor.kitaId,
|
||||||
|
role: "ADMIN",
|
||||||
|
sessionVersion: actor.sessionVersion,
|
||||||
|
disabledAt: null,
|
||||||
|
emailVerifiedAt: { not: null },
|
||||||
|
privacyPolicyAcceptedAt: { not: null },
|
||||||
|
OR: [{ lockedUntil: null }, { lockedUntil: { lte: new Date() } }],
|
||||||
|
},
|
||||||
|
select: { id: true, email: true },
|
||||||
|
});
|
||||||
|
if (!currentActor)
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Du darfst die Zugriffsrechte nicht mehr ändern. Bitte lade die Seite neu.",
|
||||||
|
);
|
||||||
|
const person = await tx.user.findFirst({
|
||||||
|
where: { id: input.data.userId, kitaId: actor.kitaId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
familyId: true,
|
||||||
|
staffProfile: { select: { id: true, kitaId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!person || person.role === "SUPERADMIN")
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Dieses Kita-Konto kann hier nicht geändert werden.",
|
||||||
|
);
|
||||||
|
if (person.role !== input.data.expectedRole)
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Die Rolle wurde inzwischen geändert. Bitte lade die Seite neu.",
|
||||||
|
);
|
||||||
|
if (person.staffProfile && person.staffProfile.kitaId !== actor.kitaId)
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Das Personalprofil gehört nicht zu dieser Kita.",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!availableKitaRoles({
|
||||||
|
familyId: person.familyId,
|
||||||
|
hasStaffProfile: !!person.staffProfile,
|
||||||
|
}).includes(input.data.role)
|
||||||
|
)
|
||||||
|
throw new UserFacingError(
|
||||||
|
person.staffProfile
|
||||||
|
? "Ein Personalprofil benötigt die Rolle Fachkraft oder Kita-Admin."
|
||||||
|
: "Eltern benötigen einen Haushalt; die Fachkraftrolle benötigt ein Personalprofil.",
|
||||||
|
);
|
||||||
|
if (person.role === input.data.role)
|
||||||
|
return { changed: false, ownRoleChanged: false };
|
||||||
|
if (person.role === "ADMIN")
|
||||||
|
await assertKitaAdminRemains(tx, actor.kitaId, [person.id]);
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: person.id, kitaId: actor.kitaId },
|
||||||
|
data: { role: input.data.role },
|
||||||
|
});
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorUserId: currentActor.id,
|
||||||
|
actorEmail: currentActor.email,
|
||||||
|
action: "USER_ROLE_CHANGE",
|
||||||
|
targetType: AuditTargetType.USER,
|
||||||
|
targetId: person.id,
|
||||||
|
targetLabel: `${person.firstName} ${person.lastName}`,
|
||||||
|
targetKitaId: actor.kitaId,
|
||||||
|
metadata: { previousRole: person.role, newRole: input.data.role },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Auth reloads the role from the database on every request. Keeping sessionVersion
|
||||||
|
// preserves already-issued household alarm links and existing login sessions.
|
||||||
|
return { changed: true, ownRoleChanged: person.id === actor.id };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKitaRoleHistory(kitaId: string) {
|
||||||
|
return prisma.auditLog.findMany({
|
||||||
|
where: { targetKitaId: kitaId, action: "USER_ROLE_CHANGE" },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 15,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
targetLabel: true,
|
||||||
|
actorEmail: true,
|
||||||
|
metadata: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { UserRole } from "@prisma/client";
|
||||||
|
|
||||||
|
export const kitaRoleLabels: Record<UserRole, string> = {
|
||||||
|
ADMIN: "Kita-Admin",
|
||||||
|
KOORDINATOR: "Notdienst-Koordination",
|
||||||
|
ELTERN: "Eltern",
|
||||||
|
ERZIEHER: "Fachkraft",
|
||||||
|
SUPERADMIN: "Systemadmin",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function availableKitaRoles(person: {
|
||||||
|
familyId: string | null;
|
||||||
|
hasStaffProfile: boolean;
|
||||||
|
}): UserRole[] {
|
||||||
|
if (person.hasStaffProfile) return ["ERZIEHER", "ADMIN"];
|
||||||
|
return person.familyId
|
||||||
|
? ["ELTERN", "KOORDINATOR", "ADMIN"]
|
||||||
|
: ["KOORDINATOR", "ADMIN"];
|
||||||
|
}
|
||||||
@@ -40,25 +40,29 @@ export async function deliverStaffEmails(ids: string[], send = sendAppEmail) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { user, profile } = job;
|
const { user, profile } = job;
|
||||||
let eligible = user.kitaId === profile.kitaId && !user.disabledAt && (!user.lockedUntil || user.lockedUntil <= now);
|
let eligible =
|
||||||
|
user.kitaId === profile.kitaId &&
|
||||||
|
!user.disabledAt &&
|
||||||
|
(!user.lockedUntil || user.lockedUntil <= now);
|
||||||
if (job.inviteToken) {
|
if (job.inviteToken) {
|
||||||
const token = await prisma.verificationToken.findUnique({
|
const token = await prisma.verificationToken.findUnique({
|
||||||
where: { token: hashToken(job.inviteToken) },
|
where: { token: hashToken(job.inviteToken) },
|
||||||
});
|
});
|
||||||
eligible &&=
|
eligible &&=
|
||||||
profile.userId === user.id &&
|
profile.userId === user.id &&
|
||||||
user.role === "ERZIEHER" &&
|
["ERZIEHER", "ADMIN"].includes(user.role) &&
|
||||||
!user.emailVerifiedAt &&
|
!user.emailVerifiedAt &&
|
||||||
!!token &&
|
!!token &&
|
||||||
token.identifier === user.id &&
|
token.identifier === user.id &&
|
||||||
token.expires > now;
|
token.expires > now;
|
||||||
} else {
|
} else {
|
||||||
eligible &&=
|
eligible &&=
|
||||||
!!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt &&
|
!!user.emailVerifiedAt &&
|
||||||
|
!!user.privacyPolicyAcceptedAt &&
|
||||||
(!user.lockedUntil || user.lockedUntil <= now) &&
|
(!user.lockedUntil || user.lockedUntil <= now) &&
|
||||||
(job.toAdmins
|
(job.toAdmins
|
||||||
? user.role === "ADMIN"
|
? user.role === "ADMIN"
|
||||||
: user.role === "ERZIEHER" &&
|
: ["ERZIEHER", "ADMIN"].includes(user.role) &&
|
||||||
profile.userId === user.id &&
|
profile.userId === user.id &&
|
||||||
user.staffDecisionEmails);
|
user.staffDecisionEmails);
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-17
@@ -81,7 +81,11 @@ async function inviteStaff(
|
|||||||
if (
|
if (
|
||||||
existingUser &&
|
existingUser &&
|
||||||
(existingUser.kitaId !== actor.kitaId ||
|
(existingUser.kitaId !== actor.kitaId ||
|
||||||
existingUser.role !== UserRole.ERZIEHER ||
|
!(
|
||||||
|
existingUser.role === UserRole.ERZIEHER ||
|
||||||
|
(existingUser.role === UserRole.ADMIN &&
|
||||||
|
existingUser.id === profile.userId)
|
||||||
|
) ||
|
||||||
(await tx.staffProfile.count({
|
(await tx.staffProfile.count({
|
||||||
where: { userId: existingUser.id, id: { not: profile.id } },
|
where: { userId: existingUser.id, id: { not: profile.id } },
|
||||||
})))
|
})))
|
||||||
@@ -205,7 +209,9 @@ export function assertStaffAccess(
|
|||||||
throw new UserFacingError("Kein Zugriff auf dieses Personalprofil.");
|
throw new UserFacingError("Kein Zugriff auf dieses Personalprofil.");
|
||||||
if (adminOnly) requireAdmin(actor);
|
if (adminOnly) requireAdmin(actor);
|
||||||
else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id)
|
else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id)
|
||||||
throw new UserFacingError("Du kannst nur dein eigenes Personalprofil verwalten.");
|
throw new UserFacingError(
|
||||||
|
"Du kannst nur dein eigenes Personalprofil verwalten.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStaffProfile(actor: StaffActor, profileId?: string) {
|
export async function getStaffProfile(actor: StaffActor, profileId?: string) {
|
||||||
@@ -320,8 +326,8 @@ export async function executeStaffCommand(
|
|||||||
kitaId: actor.kitaId,
|
kitaId: actor.kitaId,
|
||||||
role: actor.role,
|
role: actor.role,
|
||||||
privacyPolicyAcceptedAt: { not: null },
|
privacyPolicyAcceptedAt: { not: null },
|
||||||
emailVerifiedAt: { not: null },
|
emailVerifiedAt: { not: null },
|
||||||
disabledAt: null,
|
disabledAt: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
@@ -419,7 +425,9 @@ export async function executeStaffCommand(
|
|||||||
if (
|
if (
|
||||||
profile.user &&
|
profile.user &&
|
||||||
(profile.user.kitaId !== actor.kitaId ||
|
(profile.user.kitaId !== actor.kitaId ||
|
||||||
profile.user.role !== UserRole.ERZIEHER)
|
!([UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]).includes(
|
||||||
|
profile.user.role,
|
||||||
|
))
|
||||||
)
|
)
|
||||||
throw new UserFacingError(
|
throw new UserFacingError(
|
||||||
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
|
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
|
||||||
@@ -440,7 +448,11 @@ export async function executeStaffCommand(
|
|||||||
return { ...invitation, educatorId: educator.id };
|
return { ...invitation, educatorId: educator.id };
|
||||||
}
|
}
|
||||||
if (command === "emailPreference") {
|
if (command === "emailPreference") {
|
||||||
if (actor.role !== UserRole.ERZIEHER)
|
if (
|
||||||
|
!(await tx.staffProfile.count({
|
||||||
|
where: { userId: actor.id, kitaId: actor.kitaId },
|
||||||
|
}))
|
||||||
|
)
|
||||||
throw new UserFacingError(
|
throw new UserFacingError(
|
||||||
"Diese Einstellung ist für Fachkräfte vorgesehen.",
|
"Diese Einstellung ist für Fachkräfte vorgesehen.",
|
||||||
);
|
);
|
||||||
@@ -534,7 +546,9 @@ export async function executeStaffCommand(
|
|||||||
input.employmentEnd &&
|
input.employmentEnd &&
|
||||||
input.employmentEnd < input.employmentStart
|
input.employmentEnd < input.employmentStart
|
||||||
)
|
)
|
||||||
throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn.");
|
throw new UserFacingError(
|
||||||
|
"Das Beschäftigungsende liegt vor dem Beginn.",
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
input.trackingStart < input.employmentStart ||
|
input.trackingStart < input.employmentStart ||
|
||||||
(input.employmentEnd && input.trackingStart > input.employmentEnd)
|
(input.employmentEnd && input.trackingStart > input.employmentEnd)
|
||||||
@@ -545,7 +559,8 @@ export async function executeStaffCommand(
|
|||||||
const educator = await tx.educator.findFirst({
|
const educator = await tx.educator.findFirst({
|
||||||
where: { id: input.educatorId, kitaId: actor.kitaId },
|
where: { id: input.educatorId, kitaId: actor.kitaId },
|
||||||
});
|
});
|
||||||
if (!educator) throw new UserFacingError("Fachkraft nicht gefunden.");
|
if (!educator)
|
||||||
|
throw new UserFacingError("Fachkraft nicht gefunden.");
|
||||||
const existing = await tx.staffProfile.findFirst({
|
const existing = await tx.staffProfile.findFirst({
|
||||||
where: { educatorId: educator.id, kitaId: actor.kitaId },
|
where: { educatorId: educator.id, kitaId: actor.kitaId },
|
||||||
include: profileInclude,
|
include: profileInclude,
|
||||||
@@ -591,7 +606,8 @@ export async function executeStaffCommand(
|
|||||||
where: { id: profileId, kitaId: actor.kitaId },
|
where: { id: profileId, kitaId: actor.kitaId },
|
||||||
include: profileInclude,
|
include: profileInclude,
|
||||||
});
|
});
|
||||||
if (!profile) throw new UserFacingError("Personalprofil nicht gefunden.");
|
if (!profile)
|
||||||
|
throw new UserFacingError("Personalprofil nicht gefunden.");
|
||||||
assertStaffAccess(actor, profile);
|
assertStaffAccess(actor, profile);
|
||||||
const holidays = new Set(
|
const holidays = new Set(
|
||||||
(
|
(
|
||||||
@@ -631,7 +647,10 @@ export async function executeStaffCommand(
|
|||||||
for (const user of recipients) {
|
for (const user of recipients) {
|
||||||
if (
|
if (
|
||||||
!toAdmins &&
|
!toAdmins &&
|
||||||
(user.role !== UserRole.ERZIEHER || !user.staffDecisionEmails)
|
(!(
|
||||||
|
[UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]
|
||||||
|
).includes(user.role) ||
|
||||||
|
!user.staffDecisionEmails)
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
const job = await tx.staffEmail.create({
|
const job = await tx.staffEmail.create({
|
||||||
@@ -719,7 +738,9 @@ export async function executeStaffCommand(
|
|||||||
input.employmentEnd &&
|
input.employmentEnd &&
|
||||||
input.employmentEnd < dateKey(profile.employmentStart)
|
input.employmentEnd < dateKey(profile.employmentStart)
|
||||||
)
|
)
|
||||||
throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn.");
|
throw new UserFacingError(
|
||||||
|
"Das Beschäftigungsende liegt vor dem Beginn.",
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
input.employmentEnd &&
|
input.employmentEnd &&
|
||||||
(input.employmentEnd < dateKey(profile.trackingStart) ||
|
(input.employmentEnd < dateKey(profile.trackingStart) ||
|
||||||
@@ -786,7 +807,9 @@ export async function executeStaffCommand(
|
|||||||
(profile.employmentEnd &&
|
(profile.employmentEnd &&
|
||||||
input.date > dateKey(profile.employmentEnd))
|
input.date > dateKey(profile.employmentEnd))
|
||||||
)
|
)
|
||||||
throw new UserFacingError("Der Tag liegt außerhalb der Beschäftigung.");
|
throw new UserFacingError(
|
||||||
|
"Der Tag liegt außerhalb der Beschäftigung.",
|
||||||
|
);
|
||||||
if (input.remove)
|
if (input.remove)
|
||||||
await tx.staffDayOverride.deleteMany({
|
await tx.staffDayOverride.deleteMany({
|
||||||
where: { profileId, date: dateOnly(input.date) },
|
where: { profileId, date: dateOnly(input.date) },
|
||||||
@@ -859,7 +882,9 @@ export async function executeStaffCommand(
|
|||||||
})
|
})
|
||||||
.parse(data);
|
.parse(data);
|
||||||
if (input.date < dateKey(profile.trackingStart))
|
if (input.date < dateKey(profile.trackingStart))
|
||||||
throw new UserFacingError("Korrekturen müssen ab Erfassungsbeginn liegen.");
|
throw new UserFacingError(
|
||||||
|
"Korrekturen müssen ab Erfassungsbeginn liegen.",
|
||||||
|
);
|
||||||
await tx.staffAdjustment.create({
|
await tx.staffAdjustment.create({
|
||||||
data: {
|
data: {
|
||||||
profileId,
|
profileId,
|
||||||
@@ -887,7 +912,8 @@ export async function executeStaffCommand(
|
|||||||
throw new UserFacingError("Zeiteintrag nicht gefunden.");
|
throw new UserFacingError("Zeiteintrag nicht gefunden.");
|
||||||
if (existing) ensureDayEditable(profile, dateKey(existing.date));
|
if (existing) ensureDayEditable(profile, dateKey(existing.date));
|
||||||
if (command === "deleteTime") {
|
if (command === "deleteTime") {
|
||||||
if (!existing) throw new UserFacingError("Zeiteintrag nicht gefunden.");
|
if (!existing)
|
||||||
|
throw new UserFacingError("Zeiteintrag nicht gefunden.");
|
||||||
await tx.staffTimeEntry.delete({ where: { id: existing.id } });
|
await tx.staffTimeEntry.delete({ where: { id: existing.id } });
|
||||||
await history(
|
await history(
|
||||||
"Arbeitszeit gelöscht",
|
"Arbeitszeit gelöscht",
|
||||||
@@ -1135,7 +1161,8 @@ export async function executeStaffCommand(
|
|||||||
const existing = data.id
|
const existing = data.id
|
||||||
? profile.leaves.find((l) => l.id === idSchema.parse(data.id))
|
? profile.leaves.find((l) => l.id === idSchema.parse(data.id))
|
||||||
: undefined;
|
: undefined;
|
||||||
if (data.id && !existing) throw new UserFacingError("Antrag nicht gefunden.");
|
if (data.id && !existing)
|
||||||
|
throw new UserFacingError("Antrag nicht gefunden.");
|
||||||
if (command === "leaveCancel") {
|
if (command === "leaveCancel") {
|
||||||
if (
|
if (
|
||||||
!existing ||
|
!existing ||
|
||||||
@@ -1182,7 +1209,9 @@ export async function executeStaffCommand(
|
|||||||
!existing ||
|
!existing ||
|
||||||
!["PENDING", "CANCEL_REQUESTED"].includes(existing.status)
|
!["PENDING", "CANCEL_REQUESTED"].includes(existing.status)
|
||||||
)
|
)
|
||||||
throw new UserFacingError("Dieser Antrag ist nicht mehr offen.");
|
throw new UserFacingError(
|
||||||
|
"Dieser Antrag ist nicht mehr offen.",
|
||||||
|
);
|
||||||
const cancelling = existing.status === "CANCEL_REQUESTED";
|
const cancelling = existing.status === "CANCEL_REQUESTED";
|
||||||
if (input.approve)
|
if (input.approve)
|
||||||
ensureRangeEditable(
|
ensureRangeEditable(
|
||||||
@@ -1284,7 +1313,9 @@ export async function executeStaffCommand(
|
|||||||
.parse(data);
|
.parse(data);
|
||||||
if (input.kind === "SICKNESS") requireAdmin(actor);
|
if (input.kind === "SICKNESS") requireAdmin(actor);
|
||||||
if (existing && existing.status !== "PENDING")
|
if (existing && existing.status !== "PENDING")
|
||||||
throw new UserFacingError("Nur offene Anträge können bearbeitet werden.");
|
throw new UserFacingError(
|
||||||
|
"Nur offene Anträge können bearbeitet werden.",
|
||||||
|
);
|
||||||
if (input.startDate < dateKey(profile.trackingStart))
|
if (input.startDate < dateKey(profile.trackingStart))
|
||||||
throw new UserFacingError(
|
throw new UserFacingError(
|
||||||
"Abwesenheiten bitte ab Erfassungsbeginn erfassen.",
|
"Abwesenheiten bitte ab Erfassungsbeginn erfassen.",
|
||||||
|
|||||||
@@ -0,0 +1,599 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports -- Test real server code with isolated request and transport boundaries. */
|
||||||
|
const { test, before, after, beforeEach } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const vm = require("node:vm");
|
||||||
|
const ts = require("typescript");
|
||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
const { randomUUID } = require("node:crypto");
|
||||||
|
let prisma,
|
||||||
|
current,
|
||||||
|
f,
|
||||||
|
change,
|
||||||
|
changeAction,
|
||||||
|
remove,
|
||||||
|
support,
|
||||||
|
updateFamily,
|
||||||
|
alarms,
|
||||||
|
alarmMail,
|
||||||
|
staff,
|
||||||
|
staffMail,
|
||||||
|
assign;
|
||||||
|
const emails = [];
|
||||||
|
const tenants = [],
|
||||||
|
looseUsers = [];
|
||||||
|
function loader(overrides = {}) {
|
||||||
|
const cache = new Map();
|
||||||
|
function load(file) {
|
||||||
|
file = path.resolve(file);
|
||||||
|
if (cache.has(file)) return cache.get(file).exports;
|
||||||
|
const mod = { exports: {} };
|
||||||
|
cache.set(file, mod);
|
||||||
|
const code = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||||
|
compilerOptions: {
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX,
|
||||||
|
esModuleInterop: true,
|
||||||
|
},
|
||||||
|
fileName: file,
|
||||||
|
}).outputText;
|
||||||
|
const req = (spec) => {
|
||||||
|
if (Object.hasOwn(overrides, spec)) return overrides[spec];
|
||||||
|
if (spec === "server-only") return {};
|
||||||
|
if (spec === "@/lib/prisma") return { prisma };
|
||||||
|
if (spec === "next/headers")
|
||||||
|
return { headers: async () => new Headers() };
|
||||||
|
if (spec === "next/cache") return { revalidatePath() {} };
|
||||||
|
if (spec === "next/navigation")
|
||||||
|
return {
|
||||||
|
redirect(url) {
|
||||||
|
throw new Error(`REDIRECT:${url}`);
|
||||||
|
},
|
||||||
|
notFound() {
|
||||||
|
throw new Error("NOT_FOUND");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (spec.startsWith("@/") || spec.startsWith(".")) {
|
||||||
|
const target = spec.startsWith("@/")
|
||||||
|
? path.resolve("src", spec.slice(2))
|
||||||
|
: path.resolve(path.dirname(file), spec);
|
||||||
|
for (const suffix of [".ts", ".tsx", "/index.ts"])
|
||||||
|
if (fs.existsSync(target + suffix)) return load(target + suffix);
|
||||||
|
}
|
||||||
|
return require(spec);
|
||||||
|
};
|
||||||
|
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, {
|
||||||
|
filename: file,
|
||||||
|
})(req, mod, mod.exports);
|
||||||
|
return mod.exports;
|
||||||
|
}
|
||||||
|
return load;
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const raw = process.env.TEST_DATABASE_URL;
|
||||||
|
assert.ok(
|
||||||
|
raw,
|
||||||
|
"TEST_DATABASE_URL muss ausdrücklich auf die separate lokale kita_roles_test zeigen.",
|
||||||
|
);
|
||||||
|
const url = new URL(raw);
|
||||||
|
assert.ok(["127.0.0.1", "localhost"].includes(url.hostname));
|
||||||
|
assert.equal(url.pathname, "/kita_roles_test");
|
||||||
|
prisma = new PrismaClient({ datasourceUrl: raw });
|
||||||
|
const load = loader({
|
||||||
|
"@/lib/auth-utils": {
|
||||||
|
requireKitaSession: async () => ({ user: current }),
|
||||||
|
requireRole: async (roles) => {
|
||||||
|
assert.ok(roles.includes(current.role));
|
||||||
|
return { user: current };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"@/lib/mail": {
|
||||||
|
getAppEmailConfigError: () => null,
|
||||||
|
sendAppEmail: async (input) => {
|
||||||
|
emails.push(input);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
change = load("src/lib/kita-role-management.ts").changeKitaRole;
|
||||||
|
changeAction = load(
|
||||||
|
"src/app/dashboard/zugriffsrechte/actions.ts",
|
||||||
|
).changeKitaRoleAction;
|
||||||
|
remove = load("src/app/dashboard/profil/actions.ts").deleteMyAccount;
|
||||||
|
support = load("src/app/admin/actions.ts");
|
||||||
|
updateFamily = load(
|
||||||
|
"src/app/dashboard/families/actions.ts",
|
||||||
|
).updateFamilyAction;
|
||||||
|
alarms = load("src/lib/alarms/service.ts");
|
||||||
|
alarmMail = load("src/lib/alarms/email.ts");
|
||||||
|
staff = load("src/lib/staff/service.ts");
|
||||||
|
staffMail = load("src/lib/staff/email.ts");
|
||||||
|
assign = load(
|
||||||
|
"src/app/dashboard/notdienst/plan/actions.ts",
|
||||||
|
).updateAssignmentAction;
|
||||||
|
process.env.AUTH_URL = "http://127.0.0.1:3008";
|
||||||
|
});
|
||||||
|
async function user(role, extra = {}, kitaId = f.kita.id) {
|
||||||
|
return prisma.user.create({
|
||||||
|
data: {
|
||||||
|
kitaId,
|
||||||
|
email: `${randomUUID()}@example.invalid`,
|
||||||
|
firstName: "Test",
|
||||||
|
lastName: role,
|
||||||
|
passwordHash: "test-only",
|
||||||
|
emailVerifiedAt: new Date(),
|
||||||
|
privacyPolicyAcceptedAt: new Date(),
|
||||||
|
role,
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const actor = (u) => ({
|
||||||
|
id: u.id,
|
||||||
|
kitaId: u.kitaId,
|
||||||
|
sessionVersion: u.sessionVersion,
|
||||||
|
});
|
||||||
|
const changeTo = (u, role, by = f.admin) =>
|
||||||
|
change(actor(by), { userId: u.id, role, expectedRole: u.role });
|
||||||
|
beforeEach(async () => {
|
||||||
|
const kita = await prisma.kita.create({
|
||||||
|
data: { name: "Rollen-Test", slug: `roles-${randomUUID()}` },
|
||||||
|
});
|
||||||
|
tenants.push(kita.id);
|
||||||
|
f = { kita };
|
||||||
|
f.admin = await user("ADMIN");
|
||||||
|
f.family = await prisma.family.create({
|
||||||
|
data: { kitaId: kita.id, name: "Beispiel" },
|
||||||
|
});
|
||||||
|
f.parent = await user("ELTERN", { familyId: f.family.id });
|
||||||
|
f.child = await prisma.child.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
familyId: f.family.id,
|
||||||
|
firstName: "Kind",
|
||||||
|
lastName: "Test",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
current = f.admin;
|
||||||
|
emails.length = 0;
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
if (!prisma) return;
|
||||||
|
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: looseUsers } } });
|
||||||
|
await prisma.auditLog.deleteMany({
|
||||||
|
where: { targetKitaId: { in: tenants } },
|
||||||
|
});
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Eltern werden Koordination und Kita-Admin; Haushalt und Kind bleiben erhalten", async () => {
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await changeAction({
|
||||||
|
userId: f.parent.id,
|
||||||
|
expectedRole: "ELTERN",
|
||||||
|
role: "KOORDINATOR",
|
||||||
|
})
|
||||||
|
).success,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const coord = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: f.parent.id },
|
||||||
|
});
|
||||||
|
await changeTo(coord, "ADMIN");
|
||||||
|
const updated = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: coord.id },
|
||||||
|
});
|
||||||
|
assert.equal(updated.familyId, f.family.id);
|
||||||
|
assert.equal(updated.sessionVersion, coord.sessionVersion);
|
||||||
|
assert.equal(
|
||||||
|
await prisma.child.count({ where: { familyId: f.family.id } }),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("Rollenänderung protokolliert Akteur, Kita und vorherige/neue Rolle genau einmal", async () => {
|
||||||
|
await changeTo(f.parent, "ADMIN");
|
||||||
|
const jobs = await prisma.auditLog.findMany({
|
||||||
|
where: { targetKitaId: f.kita.id, action: "USER_ROLE_CHANGE" },
|
||||||
|
});
|
||||||
|
assert.equal(jobs.length, 1);
|
||||||
|
assert.equal(jobs[0].actorUserId, f.admin.id);
|
||||||
|
assert.deepEqual(jobs[0].metadata, {
|
||||||
|
previousRole: "ELTERN",
|
||||||
|
newRole: "ADMIN",
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await changeTo({ ...f.parent, role: "ADMIN" }, "ADMIN")).changed,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await prisma.auditLog.count({
|
||||||
|
where: { targetKitaId: f.kita.id, action: "USER_ROLE_CHANGE" },
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("Konto ohne Haushalt darf Koordination werden, aber nicht Eltern oder Fachkraft", async () => {
|
||||||
|
const person = await user("KOORDINATOR");
|
||||||
|
await assert.rejects(changeTo(person, "ELTERN"), /Haushalt/);
|
||||||
|
await assert.rejects(changeTo(person, "ERZIEHER"), /Personalprofil/);
|
||||||
|
assert.equal((await changeTo(person, "ADMIN")).changed, true);
|
||||||
|
});
|
||||||
|
test("Superadmin-Rolle kann weder vergeben noch verändert werden", async () => {
|
||||||
|
await assert.rejects(changeTo(f.parent, "SUPERADMIN"), /Ungültige/);
|
||||||
|
const root = await user("SUPERADMIN");
|
||||||
|
await assert.rejects(
|
||||||
|
change(actor(f.admin), {
|
||||||
|
userId: root.id,
|
||||||
|
expectedRole: "ADMIN",
|
||||||
|
role: "KOORDINATOR",
|
||||||
|
}),
|
||||||
|
/nicht geändert/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("Fremde Kita ist bei Zielkonto und Akteur ausgeschlossen", async () => {
|
||||||
|
const foreign = await prisma.kita.create({
|
||||||
|
data: { name: "Andere", slug: `roles-${randomUUID()}` },
|
||||||
|
});
|
||||||
|
tenants.push(foreign.id);
|
||||||
|
const outsider = await user("ADMIN", {}, foreign.id);
|
||||||
|
await assert.rejects(changeTo(outsider, "KOORDINATOR"), /nicht geändert/);
|
||||||
|
await assert.rejects(
|
||||||
|
change(
|
||||||
|
{ ...actor(f.admin), kitaId: foreign.id },
|
||||||
|
{ userId: outsider.id, expectedRole: "ADMIN", role: "KOORDINATOR" },
|
||||||
|
),
|
||||||
|
/nicht mehr ändern/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("Eltern, gesperrte Admins und veraltete Sessions dürfen keine Rollen ändern", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
change(actor(f.parent), {
|
||||||
|
userId: f.admin.id,
|
||||||
|
expectedRole: "ADMIN",
|
||||||
|
role: "KOORDINATOR",
|
||||||
|
}),
|
||||||
|
/nicht mehr ändern/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
change(
|
||||||
|
{ ...actor(f.admin), sessionVersion: 9 },
|
||||||
|
{ userId: f.parent.id, expectedRole: "ELTERN", role: "ADMIN" },
|
||||||
|
),
|
||||||
|
/nicht mehr ändern/,
|
||||||
|
);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: f.admin.id },
|
||||||
|
data: { disabledAt: new Date() },
|
||||||
|
});
|
||||||
|
await assert.rejects(changeTo(f.parent, "ADMIN"), /nicht mehr ändern/);
|
||||||
|
});
|
||||||
|
test("Veralteter Dialog überschreibt keine zwischenzeitliche Rollenänderung", async () => {
|
||||||
|
await changeTo(f.parent, "KOORDINATOR");
|
||||||
|
await assert.rejects(changeTo(f.parent, "ADMIN"), /inzwischen geändert/);
|
||||||
|
assert.equal(
|
||||||
|
(await prisma.user.findUnique({ where: { id: f.parent.id } })).role,
|
||||||
|
"KOORDINATOR",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("Letzter Admin kann seine Rolle nicht abgeben", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
changeTo(f.admin, "KOORDINATOR"),
|
||||||
|
/Mindestens ein aktiver/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
for (const [label, extra] of [
|
||||||
|
["offene Einladung", { emailVerifiedAt: null, passwordHash: "" }],
|
||||||
|
["deaktiviert", { disabledAt: new Date() }],
|
||||||
|
["gesperrt", { lockedUntil: new Date("2099-01-01") }],
|
||||||
|
["ohne Zustimmung", { privacyPolicyAcceptedAt: null }],
|
||||||
|
]) {
|
||||||
|
test(`Ein weiterer Admin ${label} reicht nicht als Ersatz`, async () => {
|
||||||
|
await user("ADMIN", extra);
|
||||||
|
await assert.rejects(
|
||||||
|
changeTo(f.admin, "KOORDINATOR"),
|
||||||
|
/Mindestens ein aktiver/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("Mit zweitem aktivierten Admin ist eigene Herabstufung möglich und stale Admin-Rechte sind unwirksam", async () => {
|
||||||
|
await user("ADMIN");
|
||||||
|
assert.equal((await changeTo(f.admin, "KOORDINATOR")).ownRoleChanged, true);
|
||||||
|
await assert.rejects(changeTo(f.parent, "ADMIN"), /nicht mehr ändern/);
|
||||||
|
});
|
||||||
|
test("Parallele Herabstufung zweier Admins lässt genau einen Admin bestehen", async () => {
|
||||||
|
const second = await user("ADMIN");
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
changeTo(f.admin, "KOORDINATOR", f.admin),
|
||||||
|
changeTo(second, "KOORDINATOR", second),
|
||||||
|
]);
|
||||||
|
assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
|
||||||
|
assert.equal(
|
||||||
|
await prisma.user.count({ where: { kitaId: f.kita.id, role: "ADMIN" } }),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("Eigene Kontolöschung ist für letzten Admin gesperrt", async () => {
|
||||||
|
assert.match((await remove()).error, /Mindestens ein aktiver/);
|
||||||
|
assert.ok(await prisma.user.findUnique({ where: { id: f.admin.id } }));
|
||||||
|
});
|
||||||
|
test("Eigene Kontolöschung mit zweitem aktivem Admin funktioniert weiterhin", async () => {
|
||||||
|
await user("ADMIN");
|
||||||
|
assert.equal((await remove()).success, true);
|
||||||
|
assert.equal(
|
||||||
|
await prisma.user.findUnique({ where: { id: f.admin.id } }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("Superadmin kann den letzten Kita-Admin weder löschen noch sperren", async () => {
|
||||||
|
current = await user("SUPERADMIN", {}, null);
|
||||||
|
looseUsers.push(current.id);
|
||||||
|
current.authenticatedAt = Date.now();
|
||||||
|
assert.match(
|
||||||
|
(
|
||||||
|
await support.deleteUser({
|
||||||
|
userId: f.admin.id,
|
||||||
|
confirmationEmail: f.admin.email,
|
||||||
|
})
|
||||||
|
).error,
|
||||||
|
/Mindestens ein aktiver/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
(await support.lockUser(f.admin.id)).error,
|
||||||
|
/Mindestens ein aktiver/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("Support darf zusätzlichen Admin sperren und löschen", async () => {
|
||||||
|
const second = await user("ADMIN");
|
||||||
|
current = await user("SUPERADMIN", {}, null);
|
||||||
|
looseUsers.push(current.id);
|
||||||
|
current.authenticatedAt = Date.now();
|
||||||
|
assert.equal((await support.lockUser(second.id)).ok, true);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await support.deleteUser({
|
||||||
|
userId: second.id,
|
||||||
|
confirmationEmail: second.email,
|
||||||
|
})
|
||||||
|
).ok,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("Gleichzeitige Admin-Löschung und Herabstufung lässt einen Admin bestehen", async () => {
|
||||||
|
const second = await user("ADMIN");
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
remove(),
|
||||||
|
changeTo(second, "KOORDINATOR"),
|
||||||
|
]);
|
||||||
|
const successful =
|
||||||
|
results.filter((r) => r.status === "fulfilled" && r.value.success).length +
|
||||||
|
results.filter((r) => r.status === "fulfilled" && r.value.changed).length;
|
||||||
|
assert.equal(successful, 1);
|
||||||
|
assert.equal(
|
||||||
|
await prisma.user.count({ where: { kitaId: f.kita.id, role: "ADMIN" } }),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("E-Mail-Änderung darf letzten aktivierten Admin nicht deaktivieren", async () => {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: f.admin.id },
|
||||||
|
data: { familyId: f.family.id },
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
familyId: f.family.id,
|
||||||
|
familyName: f.family.name,
|
||||||
|
parents: [
|
||||||
|
f.parent,
|
||||||
|
{ ...f.admin, email: `${randomUUID()}@example.invalid` },
|
||||||
|
],
|
||||||
|
children: [f.child],
|
||||||
|
};
|
||||||
|
assert.match((await updateFamily(payload)).error, /Mindestens ein aktiver/);
|
||||||
|
assert.equal(
|
||||||
|
(await prisma.user.findUnique({ where: { id: f.admin.id } })).email,
|
||||||
|
f.admin.email,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fachkraft wird Admin; Personalprofil bleibt bearbeitbar und eigene Mailpräferenz erhalten", async () => {
|
||||||
|
const person = await user("ERZIEHER");
|
||||||
|
const educator = await prisma.educator.create({
|
||||||
|
data: { kitaId: f.kita.id, firstName: "Team", lastName: "Test" },
|
||||||
|
});
|
||||||
|
const profile = await prisma.staffProfile.create({
|
||||||
|
data: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
educatorId: educator.id,
|
||||||
|
userId: person.id,
|
||||||
|
employmentStart: new Date("2030-01-01"),
|
||||||
|
trackingStart: new Date("2030-01-01"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(changeTo(person, "KOORDINATOR"), /Personalprofil/);
|
||||||
|
await changeTo(person, "ADMIN");
|
||||||
|
await staff.executeStaffCommand(
|
||||||
|
{ id: person.id, kitaId: f.kita.id, role: "ADMIN" },
|
||||||
|
{ command: "emailPreference", data: { enabled: false } },
|
||||||
|
);
|
||||||
|
await staff.executeStaffCommand(
|
||||||
|
{ id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" },
|
||||||
|
{
|
||||||
|
command: "educatorUpdate",
|
||||||
|
data: {
|
||||||
|
educatorId: educator.id,
|
||||||
|
firstName: "Team",
|
||||||
|
lastName: "Geändert",
|
||||||
|
active: true,
|
||||||
|
email: person.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await prisma.staffProfile.findUnique({ where: { id: profile.id } }))
|
||||||
|
.userId,
|
||||||
|
person.id,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await prisma.user.findUnique({ where: { id: person.id } }))
|
||||||
|
.staffDecisionEmails,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await changeTo({ ...person, role: "ADMIN" }, "ERZIEHER");
|
||||||
|
});
|
||||||
|
test("Offene Fachkraft-Einladung lässt sich auch nach Admin-Ernennung erneut senden", async () => {
|
||||||
|
const person = await user("ERZIEHER", {
|
||||||
|
emailVerifiedAt: null,
|
||||||
|
passwordHash: "",
|
||||||
|
});
|
||||||
|
const educator = await prisma.educator.create({
|
||||||
|
data: { kitaId: f.kita.id, firstName: "Team", lastName: "Test" },
|
||||||
|
});
|
||||||
|
await prisma.staffProfile.create({
|
||||||
|
data: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
educatorId: educator.id,
|
||||||
|
userId: person.id,
|
||||||
|
employmentStart: new Date("2030-01-01"),
|
||||||
|
trackingStart: new Date("2030-01-01"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await changeTo(person, "ADMIN");
|
||||||
|
const queued = await staff.executeStaffCommand(
|
||||||
|
{ id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" },
|
||||||
|
{ command: "educatorInvite", data: { educatorId: educator.id } },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await staffMail.deliverStaffEmails(queued.emailIds, async () => ({
|
||||||
|
success: true,
|
||||||
|
}))
|
||||||
|
).sent,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
for (const role of ["ADMIN", "KOORDINATOR"])
|
||||||
|
test(`Haushaltsalarme erreichen und akzeptieren Eltern mit Rolle ${role}`, async () => {
|
||||||
|
await changeTo(f.parent, role);
|
||||||
|
const plan = await prisma.notdienstPlan.create({
|
||||||
|
data: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
year: 2030,
|
||||||
|
month: 11,
|
||||||
|
status: "PUBLISHED",
|
||||||
|
assignments: {
|
||||||
|
create: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
childId: f.child.id,
|
||||||
|
date: new Date("2030-11-04"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { assignments: true },
|
||||||
|
});
|
||||||
|
const job = await alarms.executeAlarmCommand(
|
||||||
|
{ id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" },
|
||||||
|
"create",
|
||||||
|
{ assignmentId: plan.assignments[0].id, wholeDay: true },
|
||||||
|
);
|
||||||
|
const request = await prisma.notdienstAlarmEmail.findFirstOrThrow({
|
||||||
|
where: { alarmId: job.alarmId, userId: f.parent.id, kind: "REQUEST" },
|
||||||
|
});
|
||||||
|
const token = request.path.split("/").pop();
|
||||||
|
assert.ok(await alarms.getPublicAlarm(token));
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await alarmMail.deliverAlarmEmails([request.id], async () => ({
|
||||||
|
success: true,
|
||||||
|
}))
|
||||||
|
).sent,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await alarms.respondToAlarm(token, true);
|
||||||
|
assert.equal(
|
||||||
|
(await prisma.notdienstAlarm.findUnique({ where: { id: job.alarmId } }))
|
||||||
|
.status,
|
||||||
|
"CONFIRMED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("Bereits ausgestellter Haushalts-Alarmlink bleibt nach Ernennung gültig", async () => {
|
||||||
|
const plan = await prisma.notdienstPlan.create({
|
||||||
|
data: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
year: 2030,
|
||||||
|
month: 11,
|
||||||
|
status: "PUBLISHED",
|
||||||
|
assignments: {
|
||||||
|
create: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
childId: f.child.id,
|
||||||
|
date: new Date("2030-11-04"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { assignments: true },
|
||||||
|
});
|
||||||
|
const job = await alarms.executeAlarmCommand(
|
||||||
|
{ id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" },
|
||||||
|
"create",
|
||||||
|
{ assignmentId: plan.assignments[0].id, wholeDay: true },
|
||||||
|
);
|
||||||
|
const request = await prisma.notdienstAlarmEmail.findFirstOrThrow({
|
||||||
|
where: { alarmId: job.alarmId, userId: f.parent.id, kind: "REQUEST" },
|
||||||
|
});
|
||||||
|
await changeTo(f.parent, "ADMIN");
|
||||||
|
assert.ok(await alarms.getPublicAlarm(request.path.split("/").pop()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Letzter Admin im Ein-Personen-Haushalt bleibt auch vor kaskadierender Familienlöschung geschützt", async () => {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: f.parent.id },
|
||||||
|
data: { role: "ADMIN" },
|
||||||
|
});
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: f.admin.id },
|
||||||
|
data: { role: "KOORDINATOR" },
|
||||||
|
});
|
||||||
|
current = { ...f.parent, role: "ADMIN" };
|
||||||
|
assert.match((await remove()).error, /Mindestens ein aktiver/);
|
||||||
|
assert.ok(await prisma.family.findUnique({ where: { id: f.family.id } }));
|
||||||
|
assert.ok(await prisma.child.findUnique({ where: { id: f.child.id } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Veröffentlichte Umteilung informiert auch den Admin im betroffenen Elternhaushalt", async () => {
|
||||||
|
await changeTo(f.parent, "ADMIN");
|
||||||
|
const family = await prisma.family.create({
|
||||||
|
data: { kitaId: f.kita.id, name: "Ersatz" },
|
||||||
|
});
|
||||||
|
const parent = await user("ELTERN", { familyId: family.id });
|
||||||
|
const child = await prisma.child.create({
|
||||||
|
data: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
familyId: family.id,
|
||||||
|
firstName: "Ersatz",
|
||||||
|
lastName: "Test",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plan = await prisma.notdienstPlan.create({
|
||||||
|
data: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
year: 2030,
|
||||||
|
month: 11,
|
||||||
|
status: "PUBLISHED",
|
||||||
|
assignments: {
|
||||||
|
create: {
|
||||||
|
kitaId: f.kita.id,
|
||||||
|
childId: f.child.id,
|
||||||
|
date: new Date("2030-11-04"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await assign(plan.id, "2030-11-04", child.id);
|
||||||
|
assert.equal(result.success, true);
|
||||||
|
assert.ok(emails.some((email) => [email.to].flat().includes(f.parent.email)));
|
||||||
|
assert.ok(emails.some((email) => [email.to].flat().includes(parent.email)));
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports -- Render the actual client component through the TypeScript loader. */
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const vm = require("node:vm");
|
||||||
|
const ts = require("typescript");
|
||||||
|
const React = require("react");
|
||||||
|
const { renderToStaticMarkup } = require("react-dom/server");
|
||||||
|
function loader(overrides = {}) {
|
||||||
|
const cache = new Map();
|
||||||
|
function load(file) {
|
||||||
|
file = path.resolve(file);
|
||||||
|
if (cache.has(file)) return cache.get(file).exports;
|
||||||
|
const mod = { exports: {} };
|
||||||
|
cache.set(file, mod);
|
||||||
|
const code = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||||
|
compilerOptions: {
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX,
|
||||||
|
esModuleInterop: true,
|
||||||
|
},
|
||||||
|
fileName: file,
|
||||||
|
}).outputText;
|
||||||
|
const req = (spec) => {
|
||||||
|
if (Object.hasOwn(overrides, spec)) return overrides[spec];
|
||||||
|
if (spec === "server-only") return {};
|
||||||
|
if (spec === "@/lib/prisma")
|
||||||
|
throw new Error("No database in component render tests");
|
||||||
|
if (spec === "next/headers")
|
||||||
|
return { headers: async () => new Headers() };
|
||||||
|
if (spec === "next/cache") return { revalidatePath() {} };
|
||||||
|
if (spec === "next/navigation")
|
||||||
|
return {
|
||||||
|
redirect(url) {
|
||||||
|
throw new Error(`REDIRECT:${url}`);
|
||||||
|
},
|
||||||
|
notFound() {
|
||||||
|
throw new Error("NOT_FOUND");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (spec.startsWith("@/") || spec.startsWith(".")) {
|
||||||
|
const target = spec.startsWith("@/")
|
||||||
|
? path.resolve("src", spec.slice(2))
|
||||||
|
: path.resolve(path.dirname(file), spec);
|
||||||
|
for (const suffix of [".ts", ".tsx", "/index.ts"])
|
||||||
|
if (fs.existsSync(target + suffix)) return load(target + suffix);
|
||||||
|
}
|
||||||
|
return require(spec);
|
||||||
|
};
|
||||||
|
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, {
|
||||||
|
filename: file,
|
||||||
|
})(req, mod, mod.exports);
|
||||||
|
return mod.exports;
|
||||||
|
}
|
||||||
|
return load;
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = loader({ "./actions": {} });
|
||||||
|
const { ManualAssignmentTable } = load(
|
||||||
|
"src/app/dashboard/notdienst/plan/plan-view.tsx",
|
||||||
|
);
|
||||||
|
const children = [
|
||||||
|
{ id: "a", name: "Alex", parentName: "Haushalt A", familyId: "family-a" },
|
||||||
|
{ id: "b", name: "Ben", parentName: "Haushalt B", familyId: "family-b" },
|
||||||
|
{ id: "a2", name: "Anna", parentName: "Haushalt A", familyId: "family-a" },
|
||||||
|
];
|
||||||
|
const day = {
|
||||||
|
date: "2030-11-04T00:00:00.000Z",
|
||||||
|
assignmentId: null,
|
||||||
|
childId: null,
|
||||||
|
};
|
||||||
|
const familyStatuses = [
|
||||||
|
{ id: "family-a", selectedDays: [{ iso: day.date }] },
|
||||||
|
{ id: "family-b", selectedDays: [{ iso: "2030-11-05T00:00:00.000Z" }] },
|
||||||
|
];
|
||||||
|
const render = (overrides = {}) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(ManualAssignmentTable, {
|
||||||
|
days: [day],
|
||||||
|
allChildren: children,
|
||||||
|
familyStatuses,
|
||||||
|
isDraft: true,
|
||||||
|
isPending: false,
|
||||||
|
onAssignmentChange() {},
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
test("Verfügbare Haushalte stehen zuerst; Geschwister zählen als eine verfügbare Familie", () => {
|
||||||
|
const html = render();
|
||||||
|
assert.ok(
|
||||||
|
html.indexOf("Verfügbar an diesem Tag") <
|
||||||
|
html.indexOf("Keine Verfügbarkeit für diesen Tag"),
|
||||||
|
);
|
||||||
|
assert.match(html, /1 Familie ist verfügbar/);
|
||||||
|
assert.match(html, /value="a2"/);
|
||||||
|
assert.match(html, /value="b"/);
|
||||||
|
assert.match(html, /aria-label="Familie für 04.11.2030"/);
|
||||||
|
});
|
||||||
|
test("Bestehende Einteilung ohne Rückmeldung bleibt auswählbar und wird kenntlich gemacht", () => {
|
||||||
|
const html = render({ days: [{ ...day, childId: "b" }] });
|
||||||
|
assert.match(html, /value="b" selected/);
|
||||||
|
assert.match(
|
||||||
|
html,
|
||||||
|
/Eingeteilte Familie hat für diesen Tag keine Verfügbarkeit angegeben/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("Eingeteilte verfügbare Familie und Haushaltseinteilungszahl werden angezeigt", () => {
|
||||||
|
const html = render({
|
||||||
|
days: [
|
||||||
|
{ ...day, childId: "a" },
|
||||||
|
{ ...day, date: "2030-11-05T00:00:00.000Z", childId: "a2" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.match(html, /Eingeteilte Familie ist verfügbar/);
|
||||||
|
assert.match(html, /Haushalt A \(Alex\) — 2x/);
|
||||||
|
});
|
||||||
|
test("Fehlende Verfügbarkeiten erzeugen eine klare Angabe ohne Familien auszublenden", () => {
|
||||||
|
const html = render({ familyStatuses: [] });
|
||||||
|
assert.match(html, /0 Familien sind verfügbar/);
|
||||||
|
assert.match(html, /value="a"/);
|
||||||
|
assert.match(html, /value="b"/);
|
||||||
|
});
|
||||||
|
test("Aktiver Alarm sperrt die Auswahl weiterhin", () => {
|
||||||
|
const html = render({
|
||||||
|
days: [{ ...day, childId: "a", activeAlarmId: "alarm" }],
|
||||||
|
});
|
||||||
|
assert.match(html, /<select[^>]*disabled/);
|
||||||
|
});
|
||||||
|
test("Ungültige alte Einteilung bleibt entfernbar; neue Kinder sind dafür gesperrt", () => {
|
||||||
|
const html = render({
|
||||||
|
days: [{ ...day, childId: "inactive", isInvalid: true }],
|
||||||
|
});
|
||||||
|
assert.match(html, /value="empty"/);
|
||||||
|
assert.match(html, /value="inactive" disabled/);
|
||||||
|
assert.match(html, /value="a" disabled/);
|
||||||
|
assert.match(html, /Einteilung nicht mehr gültig/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user