Add educator email invitations and staff notification preferences
This commit is contained in:
@@ -4,11 +4,14 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { requireStaffActor } from "@/lib/staff/auth";
|
||||
import { executeStaffCommand } from "@/lib/staff/service";
|
||||
import { deliverStaffEmails } from "@/lib/staff/email";
|
||||
|
||||
export type StaffActionResult = {
|
||||
error?: string;
|
||||
success?: boolean;
|
||||
invitePath?: string;
|
||||
warning?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function staffAction(
|
||||
@@ -18,6 +21,28 @@ export async function staffAction(
|
||||
const actor = await requireStaffActor();
|
||||
try {
|
||||
const result = await executeStaffCommand(actor, { command, data });
|
||||
let warning: string | undefined;
|
||||
let message: string | undefined;
|
||||
if (result.emailIds?.length) {
|
||||
try {
|
||||
const delivery = await deliverStaffEmails(result.emailIds);
|
||||
if (delivery.failed)
|
||||
warning =
|
||||
"Gespeichert. Einige E-Mails konnten nicht versendet werden. Im Personalbereich kannst du den Versand erneut versuchen.";
|
||||
else if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!process.env.RESEND_API_KEY
|
||||
)
|
||||
warning = "Gespeichert. Der E-Mail-Versand wurde lokal simuliert.";
|
||||
else if (result.invite && delivery.sent)
|
||||
message = "Aktivierungslink per E-Mail versendet.";
|
||||
else if (command === "emailRetry")
|
||||
message = "Ausstehende E-Mails wurden verarbeitet.";
|
||||
} catch {
|
||||
warning =
|
||||
"Gespeichert. E-Mail-Versand ist noch ausstehend; bitte im Personalbereich erneut versuchen.";
|
||||
}
|
||||
}
|
||||
revalidatePath("/dashboard/personal");
|
||||
revalidatePath("/dashboard/profil");
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
@@ -25,6 +50,8 @@ export async function staffAction(
|
||||
// Returned only to the authenticated admin who generated this single-use link.
|
||||
return {
|
||||
success: true,
|
||||
warning,
|
||||
message,
|
||||
...(result.invite
|
||||
? { invitePath: `/invite/${result.invite.token}` }
|
||||
: {}),
|
||||
|
||||
@@ -66,6 +66,8 @@ function transform(command: string, form: FormData) {
|
||||
const num = (key: string) => Number(text(key));
|
||||
const hours = (key: string) => Math.round(num(key) * 60);
|
||||
switch (command) {
|
||||
case "emailPreference":
|
||||
return { enabled: form.get("enabled") === "on" };
|
||||
case "setup":
|
||||
return {
|
||||
educatorId: text("educatorId"),
|
||||
@@ -169,7 +171,8 @@ function StaffForm({
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success("Gespeichert.");
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success(result.message ?? "Gespeichert.");
|
||||
onSuccess?.(result);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
@@ -224,7 +227,8 @@ export function CommandButton({
|
||||
const result = await staffAction(command, { ...data, profileId });
|
||||
if (result.error) toast.error(result.error);
|
||||
else {
|
||||
toast.success("Gespeichert.");
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success(result.message ?? "Gespeichert.");
|
||||
router.refresh();
|
||||
}
|
||||
})
|
||||
@@ -535,6 +539,29 @@ function SettingsSection({
|
||||
</details>
|
||||
);
|
||||
}
|
||||
export function StaffEmailSettings({ enabled }: { enabled: boolean }) {
|
||||
return (
|
||||
<StaffForm command="emailPreference" label="E-Mail-Einstellung speichern">
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
defaultChecked={enabled}
|
||||
className="mt-1 accent-[var(--accent-deep)]"
|
||||
/>
|
||||
<span>
|
||||
Entscheidungen zu Urlaub, Freizeitausgleich und Arbeitswochen per
|
||||
E-Mail erhalten.
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
Aktivierungslinks und Hinweise innerhalb der App bleiben davon
|
||||
unabhängig.
|
||||
</p>
|
||||
</StaffForm>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
profileId,
|
||||
educatorId,
|
||||
@@ -569,7 +596,7 @@ export function SettingsPanel({
|
||||
<StaffForm
|
||||
command="invite"
|
||||
profileId={profileId}
|
||||
label="Aktivierungslink erstellen"
|
||||
label="Per E-Mail einladen / Account verknüpfen"
|
||||
onSuccess={(r) => setInvitePath(r.invitePath ?? "")}
|
||||
>
|
||||
<Field
|
||||
@@ -580,8 +607,8 @@ export function SettingsPanel({
|
||||
/>
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
Erstellt oder verknüpft einen ErzieherInnen-Account. Der
|
||||
persönliche Link ist sieben Tage gültig. Ein neuer Link ersetzt
|
||||
den vorherigen.
|
||||
persönliche Aktivierungslink wird per E-Mail versendet und ist
|
||||
sieben Tage gültig. Ein neuer Link ersetzt den vorherigen.
|
||||
</p>
|
||||
</StaffForm>
|
||||
)}
|
||||
|
||||
@@ -119,7 +119,9 @@ export default async function PersonalPage({
|
||||
calendarLeaves,
|
||||
pendingLeaves,
|
||||
pendingWeeks,
|
||||
pendingEmails,
|
||||
] = await Promise.all([
|
||||
// All queries stay scoped to the current Kita and recipient permissions.
|
||||
admin
|
||||
? prisma.educator.findMany({
|
||||
where: { kitaId: actor.kitaId },
|
||||
@@ -190,6 +192,14 @@ export default async function PersonalPage({
|
||||
orderBy: { weekStart: "asc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
prisma.staffEmail.count({
|
||||
where: {
|
||||
profile: { kitaId: actor.kitaId },
|
||||
...(admin ? {} : { userId: actor.id }),
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const selectedEducator =
|
||||
educators.find((e) => e.id === pick("educator")) ??
|
||||
@@ -279,6 +289,16 @@ export default async function PersonalPage({
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{pendingEmails > 0 && (
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4">
|
||||
<p className="text-sm">
|
||||
{pendingEmails} E-Mail-Hinweise warten noch auf den Versand.
|
||||
</p>
|
||||
<CommandButton command="emailRetry" profileId="" data={{}}>
|
||||
Versand erneut versuchen
|
||||
</CommandButton>
|
||||
</div>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<details className="mb-6 rounded-xl border border-[var(--accent-mid)] bg-[var(--accent-soft)] p-4">
|
||||
<summary className="cursor-pointer text-sm font-semibold">
|
||||
|
||||
@@ -29,6 +29,7 @@ import { DeleteAccountDialog } from "./_components/delete-account-dialog";
|
||||
import { MyChildrenManager } from "./_components/my-children-manager";
|
||||
import { CreateFamilyDialog } from "./_components/create-family-dialog";
|
||||
import { ContactForm } from "./_components/contact-form";
|
||||
import { StaffEmailSettings } from "../personal/forms";
|
||||
|
||||
export const metadata = { title: "Mein Profil · Kita-Planer" };
|
||||
|
||||
@@ -54,6 +55,7 @@ export default async function ProfilPage() {
|
||||
postalCode: true,
|
||||
city: true,
|
||||
directoryOptInAt: true,
|
||||
staffDecisionEmails: true,
|
||||
createdAt: true,
|
||||
family: {
|
||||
select: {
|
||||
@@ -208,6 +210,19 @@ export default async function ProfilPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{user.role === UserRole.ERZIEHER && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>E-Mail-Hinweise</CardTitle>
|
||||
<CardDescription>
|
||||
Benachrichtigungen aus dem Personalbereich.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StaffEmailSettings enabled={user.staffDecisionEmails} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{user.role !== UserRole.ERZIEHER && (
|
||||
<>
|
||||
<Card>
|
||||
|
||||
Reference in New Issue
Block a user