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>
|
||||
|
||||
@@ -16,12 +16,14 @@ type InviteEmailProps = {
|
||||
parentName: string;
|
||||
kitaName: string;
|
||||
inviteLink: string;
|
||||
staffAccount?: boolean;
|
||||
};
|
||||
|
||||
export function InviteEmail({
|
||||
parentName,
|
||||
kitaName,
|
||||
inviteLink,
|
||||
staffAccount = false,
|
||||
}: InviteEmailProps) {
|
||||
return (
|
||||
<Html lang="de">
|
||||
@@ -36,13 +38,16 @@ export function InviteEmail({
|
||||
<Text style={styles.kicker}>Einladung zur Kita-App</Text>
|
||||
<Heading style={styles.heading}>Willkommen bei {kitaName}</Heading>
|
||||
<Text style={styles.lead}>
|
||||
Hallo {parentName}, dein Eltern-Account wurde vorbereitet.
|
||||
Hallo {parentName}, dein {staffAccount ? "Fachkraft" : "Eltern"}
|
||||
-Account wurde vorbereitet.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.text}>
|
||||
Über den folgenden Link kannst du dein Passwort setzen und deinen
|
||||
Account aktivieren. Danach hast du Zugriff auf die Kita-Planung,
|
||||
Termine und deine Familiendaten.
|
||||
Account aktivieren.{" "}
|
||||
{staffAccount
|
||||
? "Danach kannst du deine Arbeitszeiten erfassen, Urlaub beantragen und dein Zeitkonto einsehen. Der Link ist sieben Tage gültig und kann einmal verwendet werden."
|
||||
: "Danach hast du Zugriff auf die Kita-Planung, Termine und deine Familiendaten."}
|
||||
</Text>
|
||||
|
||||
<Button href={inviteLink} style={styles.button}>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { baseBodyStyle, colors, fontStack } from "./_styles";
|
||||
|
||||
export function StaffNotificationEmail({
|
||||
name,
|
||||
kitaName,
|
||||
message,
|
||||
link,
|
||||
}: {
|
||||
name: string;
|
||||
kitaName: string;
|
||||
message: string;
|
||||
link: string;
|
||||
}) {
|
||||
return (
|
||||
<Html lang="de">
|
||||
<Head />
|
||||
<Preview>Neuer Hinweis im Personalbereich von {kitaName}</Preview>
|
||||
<Body style={baseBodyStyle}>
|
||||
<Container
|
||||
style={{
|
||||
maxWidth: "600px",
|
||||
padding: "32px 24px",
|
||||
fontFamily: fontStack,
|
||||
}}
|
||||
>
|
||||
<Heading style={{ color: colors.textPrimary }}>
|
||||
Personalbereich · {kitaName}
|
||||
</Heading>
|
||||
<Text>Hallo {name},</Text>
|
||||
<Text style={{ lineHeight: "24px" }}>{message}</Text>
|
||||
<Text>
|
||||
Die Einzelheiten findest du nach der Anmeldung im Personalbereich.
|
||||
</Text>
|
||||
<Button
|
||||
href={link}
|
||||
style={{
|
||||
backgroundColor: colors.brandGreen,
|
||||
color: "#fff",
|
||||
padding: "14px 20px",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
>
|
||||
Personalbereich öffnen
|
||||
</Button>
|
||||
<Text style={{ fontSize: "12px", color: colors.textMuted }}>
|
||||
Fachkräfte können Entscheidungshinweise unter „Mein Profil“
|
||||
abschalten. Hinweise innerhalb der App bleiben erhalten.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
+12
-7
@@ -9,6 +9,7 @@ type SendAppEmailInput = {
|
||||
react: ReactNode;
|
||||
from?: string;
|
||||
replyTo?: string | string[];
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
type SendAppEmailResult =
|
||||
@@ -36,6 +37,7 @@ export async function sendAppEmail({
|
||||
react,
|
||||
from = process.env.EMAIL_FROM,
|
||||
replyTo,
|
||||
idempotencyKey,
|
||||
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
@@ -63,13 +65,16 @@ export async function sendAppEmail({
|
||||
|
||||
try {
|
||||
resendClient ??= new Resend(process.env.RESEND_API_KEY);
|
||||
const { data, error } = await resendClient.emails.send({
|
||||
from: from!,
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
replyTo,
|
||||
});
|
||||
const { data, error } = await resendClient.emails.send(
|
||||
{
|
||||
from: from!,
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
replyTo,
|
||||
},
|
||||
{ idempotencyKey },
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error("Resend konnte die E-Mail nicht versenden:", error);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createElement } from "react";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { InviteEmail } from "@/emails/InviteEmail";
|
||||
import { StaffNotificationEmail } from "@/emails/StaffNotificationEmail";
|
||||
|
||||
// Called after the staff transaction commits. Tests inject a transport and never send mail.
|
||||
export async function deliverStaffEmails(ids: string[], send = sendAppEmail) {
|
||||
let failed = 0;
|
||||
let sent = 0;
|
||||
const deliver = async (id: string) => {
|
||||
const now = new Date();
|
||||
const leaseUntil = new Date(now.getTime() + 5 * 60_000);
|
||||
const claim = await prisma.staffEmail.updateMany({
|
||||
where: {
|
||||
id,
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
|
||||
},
|
||||
data: { leaseUntil, attempts: { increment: 1 } },
|
||||
});
|
||||
if (!claim.count) return;
|
||||
const finish = (data: {
|
||||
sentAt?: Date;
|
||||
skippedAt?: Date;
|
||||
failed: boolean;
|
||||
}) =>
|
||||
prisma.staffEmail.updateMany({
|
||||
where: { id, leaseUntil },
|
||||
data: { ...data, leaseUntil: null },
|
||||
});
|
||||
try {
|
||||
const job = await prisma.staffEmail.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
user: true,
|
||||
profile: { include: { kita: true, educator: true } },
|
||||
},
|
||||
});
|
||||
const { user, profile } = job;
|
||||
let eligible = user.kitaId === profile.kitaId;
|
||||
if (job.inviteToken) {
|
||||
const token = await prisma.verificationToken.findUnique({
|
||||
where: { token: job.inviteToken },
|
||||
});
|
||||
eligible &&=
|
||||
profile.userId === user.id &&
|
||||
user.role === "ERZIEHER" &&
|
||||
!user.emailVerifiedAt &&
|
||||
!!token &&
|
||||
token.identifier === user.id &&
|
||||
token.expires > now;
|
||||
} else {
|
||||
eligible &&=
|
||||
!!user.privacyPolicyAcceptedAt &&
|
||||
(!user.lockedUntil || user.lockedUntil <= now) &&
|
||||
(job.toAdmins
|
||||
? user.role === "ADMIN"
|
||||
: user.role === "ERZIEHER" &&
|
||||
profile.userId === user.id &&
|
||||
user.staffDecisionEmails);
|
||||
}
|
||||
if (!eligible) {
|
||||
await finish({ skippedAt: now, failed: false });
|
||||
return;
|
||||
}
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
process.env.AUTH_URL ||
|
||||
process.env.NEXTAUTH_URL;
|
||||
if (!base) throw new Error("App-Adresse für E-Mails fehlt.");
|
||||
const site = new URL(base);
|
||||
if (
|
||||
!["https:", "http:"].includes(site.protocol) ||
|
||||
site.username ||
|
||||
site.password
|
||||
)
|
||||
throw new Error("App-Adresse für E-Mails ist ungültig.");
|
||||
const link = new URL(job.path, site.origin).toString();
|
||||
const result = await send({
|
||||
to: user.email,
|
||||
subject: job.subject,
|
||||
idempotencyKey: `staff-email/${job.id}`,
|
||||
react: job.inviteToken
|
||||
? createElement(InviteEmail, {
|
||||
parentName: `${profile.educator.firstName} ${profile.educator.lastName}`,
|
||||
kitaName: profile.kita.name,
|
||||
inviteLink: link,
|
||||
staffAccount: true,
|
||||
})
|
||||
: createElement(StaffNotificationEmail, {
|
||||
name: user.firstName,
|
||||
kitaName: profile.kita.name,
|
||||
message: job.message,
|
||||
link,
|
||||
}),
|
||||
});
|
||||
if (!result.success) {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
} else {
|
||||
await finish({ sentAt: new Date(), failed: false });
|
||||
sent++;
|
||||
}
|
||||
} catch {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
}
|
||||
};
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
for (let offset = 0; offset < uniqueIds.length; offset += 4)
|
||||
await Promise.all(uniqueIds.slice(offset, offset + 4).map(deliver));
|
||||
return { sent, failed };
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export type StaffNotice = {
|
||||
toAdmins: boolean;
|
||||
};
|
||||
type CommandResult = {
|
||||
emailIds?: string[];
|
||||
notice?: StaffNotice;
|
||||
invite?: { email: string; name: string; token: string; kitaName: string };
|
||||
};
|
||||
@@ -214,6 +215,33 @@ export async function executeStaffCommand(
|
||||
throw new Error(
|
||||
"Dein Konto hat keinen Zugriff auf den Personalbereich.",
|
||||
);
|
||||
if (command === "emailPreference") {
|
||||
if (actor.role !== UserRole.ERZIEHER)
|
||||
throw new Error(
|
||||
"Diese Einstellung ist für Fachkräfte vorgesehen.",
|
||||
);
|
||||
await tx.user.update({
|
||||
where: { id: actor.id },
|
||||
data: {
|
||||
staffDecisionEmails: z.boolean().parse(data.enabled),
|
||||
},
|
||||
});
|
||||
return {};
|
||||
}
|
||||
if (command === "emailRetry") {
|
||||
const jobs = await tx.staffEmail.findMany({
|
||||
where: {
|
||||
profile: { kitaId: actor.kitaId },
|
||||
...(actor.role === UserRole.ADMIN ? {} : { userId: actor.id }),
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 50,
|
||||
});
|
||||
return { emailIds: jobs.map((job) => job.id) };
|
||||
}
|
||||
if (command === "holiday") {
|
||||
requireAdmin(actor);
|
||||
const input = z
|
||||
@@ -356,6 +384,7 @@ export async function executeStaffCommand(
|
||||
const notice = async (
|
||||
text: string,
|
||||
toAdmins: boolean,
|
||||
email?: { message: string; query: string },
|
||||
): Promise<CommandResult> => {
|
||||
const recipients = await tx.user.findMany({
|
||||
where: {
|
||||
@@ -364,7 +393,7 @@ export async function executeStaffCommand(
|
||||
? { role: UserRole.ADMIN }
|
||||
: { id: profile.userId ?? "" }),
|
||||
},
|
||||
select: { id: true },
|
||||
select: { id: true, role: true, staffDecisionEmails: true },
|
||||
});
|
||||
await tx.staffNotification.createMany({
|
||||
data: recipients.map((user) => ({
|
||||
@@ -373,7 +402,28 @@ export async function executeStaffCommand(
|
||||
message: `${profile.educator.firstName} ${profile.educator.lastName}: ${text}`,
|
||||
})),
|
||||
});
|
||||
return { notice: { profileId, text, toAdmins } };
|
||||
const emailIds: string[] = [];
|
||||
if (email) {
|
||||
for (const user of recipients) {
|
||||
if (
|
||||
!toAdmins &&
|
||||
(user.role !== UserRole.ERZIEHER || !user.staffDecisionEmails)
|
||||
)
|
||||
continue;
|
||||
const job = await tx.staffEmail.create({
|
||||
data: {
|
||||
profileId,
|
||||
userId: user.id,
|
||||
toAdmins,
|
||||
subject: "Kita-Planer: Hinweis im Personalbereich",
|
||||
message: `${profile.educator.firstName} ${profile.educator.lastName}: ${email.message}`,
|
||||
path: `/dashboard/personal?educator=${encodeURIComponent(profile.educatorId)}&${email.query}`,
|
||||
},
|
||||
});
|
||||
emailIds.push(job.id);
|
||||
}
|
||||
}
|
||||
return { notice: { profileId, text, toAdmins }, emailIds };
|
||||
};
|
||||
if (command === "noticeRead") {
|
||||
await tx.staffNotification.updateMany({
|
||||
@@ -446,7 +496,18 @@ export async function executeStaffCommand(
|
||||
where: { id: actor.kitaId },
|
||||
select: { name: true },
|
||||
});
|
||||
const job = await tx.staffEmail.create({
|
||||
data: {
|
||||
profileId,
|
||||
userId: user.id,
|
||||
subject: `Einladung zu ${kita.name} im Kita-Planer`,
|
||||
message: "Account aktivieren",
|
||||
path: `/invite/${token}`,
|
||||
inviteToken: token,
|
||||
},
|
||||
});
|
||||
return {
|
||||
emailIds: [job.id],
|
||||
invite: {
|
||||
email,
|
||||
name: `${profile.educator.firstName} ${profile.educator.lastName}`,
|
||||
@@ -845,6 +906,10 @@ export async function executeStaffCommand(
|
||||
return notice(
|
||||
`Arbeitswoche ab ${start} wurde zur Prüfung eingereicht.`,
|
||||
true,
|
||||
{
|
||||
message: `Arbeitswoche ${start}–${end} wurde zur Prüfung eingereicht.`,
|
||||
query: `tab=zeit&week=${start}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
requireAdmin(actor);
|
||||
@@ -901,6 +966,10 @@ export async function executeStaffCommand(
|
||||
return notice(
|
||||
`Arbeitswoche ab ${start}: ${input.approve ? "genehmigt" : "zur Korrektur zurückgegeben"}. ${input.reason}`,
|
||||
false,
|
||||
{
|
||||
message: `Arbeitswoche ${start}–${end}: ${input.approve ? "genehmigt" : "zur Korrektur zurückgegeben"}.`,
|
||||
query: `tab=zeit&week=${start}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -937,6 +1006,12 @@ export async function executeStaffCommand(
|
||||
? "Ein offener Abwesenheitsantrag wurde zurückgezogen."
|
||||
: "Die Stornierung einer genehmigten Abwesenheit wurde beantragt.",
|
||||
true,
|
||||
existing.kind === "SICKNESS"
|
||||
? undefined
|
||||
: {
|
||||
message: `Abwesenheitsantrag ${dateKey(existing.startDate)}–${dateKey(existing.endDate)}: ${status === "CANCELLED" ? "zurückgezogen" : "Stornierung beantragt"}.`,
|
||||
query: `tab=urlaub&year=${existing.startDate.getUTCFullYear()}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (command === "leaveReview") {
|
||||
@@ -1032,6 +1107,12 @@ export async function executeStaffCommand(
|
||||
return notice(
|
||||
`${existing.kind === "VACATION" ? "Urlaub" : existing.kind === "TIME_OFF" ? "Freizeitausgleich" : "Krankmeldung"}: ${status === "CANCELLED" ? "storniert" : status === "REJECTED" ? "abgelehnt" : "genehmigt"}. ${input.reason}`,
|
||||
false,
|
||||
existing.kind === "SICKNESS"
|
||||
? undefined
|
||||
: {
|
||||
message: `${existing.kind === "VACATION" ? "Urlaub" : "Freizeitausgleich"} ${dateKey(existing.startDate)}–${dateKey(existing.endDate)}: ${status === "CANCELLED" ? "storniert" : status === "REJECTED" ? "abgelehnt" : existing.status === "CANCEL_REQUESTED" ? "Stornierung abgelehnt; Genehmigung bleibt bestehen" : "genehmigt"}.`,
|
||||
query: `tab=urlaub&year=${existing.startDate.getUTCFullYear()}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!profile.educator.active)
|
||||
@@ -1140,6 +1221,12 @@ export async function executeStaffCommand(
|
||||
return notice(
|
||||
`${input.kind === "SICKNESS" ? "Krankheit wurde hinterlegt" : "Ein Abwesenheitsantrag wurde eingereicht"}: ${input.startDate}–${input.endDate}.`,
|
||||
input.kind !== "SICKNESS",
|
||||
input.kind === "SICKNESS"
|
||||
? undefined
|
||||
: {
|
||||
message: `${input.kind === "VACATION" ? "Urlaubsantrag" : "Antrag auf Freizeitausgleich"} ${input.startDate}–${input.endDate} wurde ${existing ? "geändert" : "eingereicht"}.`,
|
||||
query: `tab=urlaub&year=${input.startDate.slice(0, 4)}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw new Error("Unbekannte Aktion.");
|
||||
|
||||
Reference in New Issue
Block a user