feat: show family assignment counts in notdienst planning dropdown and support editing unassigned days in published plans
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
import { AbsenceReason } from "@prisma/client";
|
||||||
|
|
||||||
|
export function reasonLabel(reason: AbsenceReason) {
|
||||||
|
if (reason === AbsenceReason.ILLNESS) return "Krank";
|
||||||
|
if (reason === AbsenceReason.VACATION) return "Urlaub";
|
||||||
|
return "Sonstiges";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reasonColor(reason: AbsenceReason) {
|
||||||
|
if (reason === AbsenceReason.ILLNESS) return "var(--danger)";
|
||||||
|
if (reason === AbsenceReason.VACATION) return "var(--warn)";
|
||||||
|
return "var(--accent)";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateRange(startDate: string, endDate: string) {
|
||||||
|
const start = new Date(`${startDate}T00:00:00`);
|
||||||
|
const end = new Date(`${endDate}T00:00:00`);
|
||||||
|
const startLabel = start.toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
});
|
||||||
|
const endLabel = end.toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
|
||||||
|
}
|
||||||
@@ -657,11 +657,6 @@ function MonthView({
|
|||||||
>
|
>
|
||||||
{format(day, "d")}
|
{format(day, "d")}
|
||||||
</span>
|
</span>
|
||||||
{dayEvents.length > 0 && (
|
|
||||||
<span className="text-[10px] font-semibold text-[var(--ink-muted)] tabular-nums">
|
|
||||||
{dayEvents.length} {dayEvents.length === 1 ? "Eintrag" : "Einträge"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event indicators/snippets */}
|
{/* Event indicators/snippets */}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||||
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
||||||
|
import { NotdienstAssignmentEmail } from "@/emails/NotdienstAssignmentEmail";
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// /dashboard/notdienst/plan · Server Actions
|
// /dashboard/notdienst/plan · Server Actions
|
||||||
@@ -159,14 +160,40 @@ export async function updateAssignmentAction(
|
|||||||
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction(async (tx) => {
|
const { shouldSendEmails, assignedChildInfo } = await prisma.$transaction(async (tx) => {
|
||||||
// Prüfen, ob Plan noch DRAFT ist
|
let txShouldSendEmails = false;
|
||||||
|
let txAssignedChildInfo: {
|
||||||
|
parentEmails: string[];
|
||||||
|
parentNames: string[];
|
||||||
|
dateLabel: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
const plan = await tx.notdienstPlan.findUnique({
|
const plan = await tx.notdienstPlan.findUnique({
|
||||||
where: { id: planId, kitaId },
|
where: { id: planId, kitaId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!plan || plan.status !== NotdienstPlanStatus.DRAFT) {
|
if (!plan) {
|
||||||
throw new Error("Plan kann nicht mehr bearbeitet werden.");
|
throw new Error("Plan nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
|
||||||
|
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
|
||||||
|
|
||||||
|
if (!isDraft && !isPublished) {
|
||||||
|
throw new Error("Plan kann nicht bearbeitet werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn der Plan bereits veröffentlicht ist, dürfen nur unbesetzte Termine geändert werden.
|
||||||
|
// Ein Termin gilt als "unbesetzt", wenn am Veröffentlichungstag kein Assignment existierte
|
||||||
|
// bzw. das Assignment erst nach dem Veröffentlichungs-Zeitpunkt erstellt wurde.
|
||||||
|
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
||||||
|
where: { planId, date: parsedDate },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPublished && existingAssignment) {
|
||||||
|
if (!plan.publishedAt || existingAssignment.createdAt <= plan.publishedAt) {
|
||||||
|
throw new Error("In einem bereits veröffentlichten Plan können nur unbesetzte Termine geändert werden.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Altes Assignment für diesen Tag löschen
|
// Altes Assignment für diesen Tag löschen
|
||||||
@@ -182,7 +209,16 @@ export async function updateAssignmentAction(
|
|||||||
kitaId,
|
kitaId,
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
include: {
|
||||||
|
family: {
|
||||||
|
include: {
|
||||||
|
users: {
|
||||||
|
where: { role: UserRole.ELTERN },
|
||||||
|
select: { email: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!child) {
|
if (!child) {
|
||||||
@@ -197,9 +233,57 @@ export async function updateAssignmentAction(
|
|||||||
date: parsedDate,
|
date: parsedDate,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wenn der Plan bereits veröffentlicht ist, bereiten wir E-Mail-Benachrichtigungen vor
|
||||||
|
if (isPublished) {
|
||||||
|
const parents = child.family.users.filter((u) => !!u.email);
|
||||||
|
if (parents.length > 0) {
|
||||||
|
const localFormatDate = new Date(yr, mo - 1, dy);
|
||||||
|
txAssignedChildInfo = {
|
||||||
|
parentEmails: parents.map((p) => p.email!),
|
||||||
|
parentNames: parents.map((p) => `${p.firstName} ${p.lastName}`),
|
||||||
|
dateLabel: format(localFormatDate, "EEEE, dd. MMMM yyyy", { locale: de }),
|
||||||
|
};
|
||||||
|
txShouldSendEmails = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldSendEmails: txShouldSendEmails,
|
||||||
|
assignedChildInfo: txAssignedChildInfo,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E-Mails außerhalb der DB-Transaktion senden
|
||||||
|
if (shouldSendEmails && assignedChildInfo) {
|
||||||
|
const kita = await prisma.kita.findUnique({
|
||||||
|
where: { id: kitaId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (kita) {
|
||||||
|
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||||
|
const link = `${BASE_URL}/dashboard/notdienst`;
|
||||||
|
|
||||||
|
for (let i = 0; i < assignedChildInfo.parentEmails.length; i++) {
|
||||||
|
const email = assignedChildInfo.parentEmails[i];
|
||||||
|
const name = assignedChildInfo.parentNames[i];
|
||||||
|
|
||||||
|
await sendAppEmail({
|
||||||
|
to: email,
|
||||||
|
subject: `Notdienst-Einteilung am ${assignedChildInfo.dateLabel}`,
|
||||||
|
react: createElement(NotdienstAssignmentEmail, {
|
||||||
|
parentName: name,
|
||||||
|
kitaName: kita.name,
|
||||||
|
dateLabel: assignedChildInfo.dateLabel,
|
||||||
|
link,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/dashboard/notdienst/plan");
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -41,11 +41,13 @@ export default async function PlanungsZentralePage({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
publishedAt: true,
|
||||||
assignments: {
|
assignments: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
childId: true,
|
childId: true,
|
||||||
date: true,
|
date: true,
|
||||||
|
createdAt: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -111,6 +113,7 @@ export default async function PlanungsZentralePage({
|
|||||||
id: true,
|
id: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
|
familyId: true,
|
||||||
family: {
|
family: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
@@ -135,6 +138,7 @@ export default async function PlanungsZentralePage({
|
|||||||
id: c.id,
|
id: c.id,
|
||||||
name: `${c.firstName} ${c.lastName}`,
|
name: `${c.firstName} ${c.lastName}`,
|
||||||
parentName,
|
parentName,
|
||||||
|
familyId: c.familyId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,10 +181,15 @@ export default async function PlanungsZentralePage({
|
|||||||
const assignment = plan?.assignments.find(
|
const assignment = plan?.assignments.find(
|
||||||
(a) => a.date.getTime() === day.getTime()
|
(a) => a.date.getTime() === day.getTime()
|
||||||
);
|
);
|
||||||
|
const isOriginallyUnassigned =
|
||||||
|
!assignment ||
|
||||||
|
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: day.toISOString(),
|
date: day.toISOString(),
|
||||||
assignmentId: assignment?.id || null,
|
assignmentId: assignment?.id || null,
|
||||||
childId: assignment?.childId || null,
|
childId: assignment?.childId || null,
|
||||||
|
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { NotdienstPlanStatus } from "@prisma/client";
|
import { NotdienstPlanStatus } from "@prisma/client";
|
||||||
@@ -41,11 +41,13 @@ interface PlanViewProps {
|
|||||||
date: string;
|
date: string;
|
||||||
assignmentId: string | null;
|
assignmentId: string | null;
|
||||||
childId: string | null;
|
childId: string | null;
|
||||||
|
isOriginallyUnassigned?: boolean;
|
||||||
}[];
|
}[];
|
||||||
allChildren: {
|
allChildren: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
parentName: string;
|
parentName: string;
|
||||||
|
familyId: string;
|
||||||
}[];
|
}[];
|
||||||
familyStatuses: {
|
familyStatuses: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -140,7 +142,7 @@ export function PlanView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAssignmentChange = (date: string, newChildId: string) => {
|
const handleAssignmentChange = (date: string, newChildId: string) => {
|
||||||
if (!planId || status !== "DRAFT") return;
|
if (!planId || (status !== "DRAFT" && status !== "PUBLISHED")) return;
|
||||||
const valueToSet = newChildId === "empty" ? null : newChildId;
|
const valueToSet = newChildId === "empty" ? null : newChildId;
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -567,6 +569,31 @@ function ManualAssignmentTable({
|
|||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onAssignmentChange: (date: string, newChildId: string) => void;
|
onAssignmentChange: (date: string, newChildId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// Map childId to familyId for quick lookup
|
||||||
|
const childFamilyMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
allChildren.forEach((child) => {
|
||||||
|
if (child.familyId) {
|
||||||
|
map.set(child.id, child.familyId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [allChildren]);
|
||||||
|
|
||||||
|
// Compute family assignment counts in real-time based on current selections
|
||||||
|
const familyAssignmentCounts = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
days.forEach((day) => {
|
||||||
|
if (day.childId) {
|
||||||
|
const famId = childFamilyMap.get(day.childId);
|
||||||
|
if (famId) {
|
||||||
|
counts.set(famId, (counts.get(famId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [days, childFamilyMap]);
|
||||||
|
|
||||||
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="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]">
|
||||||
@@ -590,7 +617,8 @@ function ManualAssignmentTable({
|
|||||||
{format(dateObj, "EE, dd.MM.", { locale: de })}
|
{format(dateObj, "EE, dd.MM.", { locale: de })}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{isDraft ? (
|
{isDraft || day.isOriginallyUnassigned ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<select
|
<select
|
||||||
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||||
value={day.childId || "empty"}
|
value={day.childId || "empty"}
|
||||||
@@ -600,12 +628,22 @@ function ManualAssignmentTable({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="empty">-- Unbesetzt --</option>
|
<option value="empty">-- Unbesetzt --</option>
|
||||||
{allChildren.map((child) => (
|
{allChildren.map((child) => {
|
||||||
|
const famId = child.familyId;
|
||||||
|
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
|
||||||
|
return (
|
||||||
<option key={child.id} value={child.id}>
|
<option key={child.id} value={child.id}>
|
||||||
{child.parentName} ({child.name})
|
{child.parentName} ({child.name}) — {count}x
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
|
{!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">
|
||||||
|
Nachträglich änderbar
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : assignedChild ? (
|
) : assignedChild ? (
|
||||||
<span className="text-sm text-[var(--ink)]">
|
<span className="text-sm text-[var(--ink)]">
|
||||||
{assignedChild.parentName}{" "}
|
{assignedChild.parentName}{" "}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -10,8 +10,24 @@ import { loginAction, type LoginState } from "./actions";
|
|||||||
|
|
||||||
const initialState: LoginState = {};
|
const initialState: LoginState = {};
|
||||||
|
|
||||||
export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
|
export function LoginForm({
|
||||||
|
callbackUrl,
|
||||||
|
devAdminEmail,
|
||||||
|
devAdminPassword,
|
||||||
|
devParentEmail,
|
||||||
|
devParentPassword,
|
||||||
|
}: {
|
||||||
|
callbackUrl: string;
|
||||||
|
devAdminEmail?: string;
|
||||||
|
devAdminPassword?: string;
|
||||||
|
devParentEmail?: string;
|
||||||
|
devParentPassword?: string;
|
||||||
|
}) {
|
||||||
const [state, formAction, pending] = useActionState(loginAction, initialState);
|
const [state, formAction, pending] = useActionState(loginAction, initialState);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const showDevHelpers = !!(devAdminEmail || devParentEmail);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={formAction} className="space-y-4">
|
<form action={formAction} className="space-y-4">
|
||||||
@@ -26,6 +42,8 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
aria-invalid={!!state.errors?.email}
|
aria-invalid={!!state.errors?.email}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{state.errors?.email?.[0] && (
|
{state.errors?.email?.[0] && (
|
||||||
<p className="text-xs text-destructive">{state.errors.email[0]}</p>
|
<p className="text-xs text-destructive">{state.errors.email[0]}</p>
|
||||||
@@ -41,6 +59,8 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
aria-invalid={!!state.errors?.password}
|
aria-invalid={!!state.errors?.password}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{state.errors?.password?.[0] && (
|
{state.errors?.password?.[0] && (
|
||||||
<p className="text-xs text-destructive">{state.errors.password[0]}</p>
|
<p className="text-xs text-destructive">{state.errors.password[0]}</p>
|
||||||
@@ -56,6 +76,42 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
|
|||||||
<Button type="submit" className="w-full" disabled={pending}>
|
<Button type="submit" className="w-full" disabled={pending}>
|
||||||
{pending ? "Anmelden…" : "Anmelden"}
|
{pending ? "Anmelden…" : "Anmelden"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{showDevHelpers && (
|
||||||
|
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/40 p-3 space-y-2 text-xs">
|
||||||
|
<p className="text-center font-medium text-muted-foreground">Dev-Schnellzugriff (nur lokal):</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{devAdminEmail && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEmail(devAdminEmail);
|
||||||
|
setPassword(devAdminPassword || "");
|
||||||
|
}}
|
||||||
|
className="text-xs py-1.5 h-auto cursor-pointer"
|
||||||
|
>
|
||||||
|
🔑 Admin
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{devParentEmail && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEmail(devParentEmail);
|
||||||
|
setPassword(devParentPassword || "");
|
||||||
|
}}
|
||||||
|
className="text-xs py-1.5 h-auto cursor-pointer"
|
||||||
|
>
|
||||||
|
👪 Eltern
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -36,6 +36,12 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl);
|
const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl);
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
|
||||||
|
const devAdminPassword = isDev ? process.env.DEV_LOGIN_ADMIN_PASSWORD : undefined;
|
||||||
|
const devParentEmail = isDev ? process.env.DEV_LOGIN_PARENT_EMAIL : undefined;
|
||||||
|
const devParentPassword = isDev ? process.env.DEV_LOGIN_PARENT_PASSWORD : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
@@ -44,7 +50,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
<CardDescription>Willkommen zurück.</CardDescription>
|
<CardDescription>Willkommen zurück.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<LoginForm callbackUrl={callbackUrl} />
|
<LoginForm
|
||||||
|
callbackUrl={callbackUrl}
|
||||||
|
devAdminEmail={devAdminEmail}
|
||||||
|
devAdminPassword={devAdminPassword}
|
||||||
|
devParentEmail={devParentEmail}
|
||||||
|
devParentPassword={devParentPassword}
|
||||||
|
/>
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Noch keinen Account?{" "}
|
Noch keinen Account?{" "}
|
||||||
<Link href="/register" className="font-medium text-foreground underline">
|
<Link href="/register" className="font-medium text-foreground underline">
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
|
||||||
|
import { baseBodyStyle, colors, fontStack } from "./_styles";
|
||||||
|
|
||||||
|
type NotdienstAssignmentEmailProps = {
|
||||||
|
parentName: string;
|
||||||
|
kitaName: string;
|
||||||
|
dateLabel: string;
|
||||||
|
link: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotdienstAssignmentEmail({
|
||||||
|
parentName,
|
||||||
|
kitaName,
|
||||||
|
dateLabel,
|
||||||
|
link,
|
||||||
|
}: NotdienstAssignmentEmailProps) {
|
||||||
|
return (
|
||||||
|
<Html lang="de">
|
||||||
|
<Head />
|
||||||
|
<Preview>Aktualisierung Notdienst-Plan: Zuteilung für {dateLabel}</Preview>
|
||||||
|
<Body style={styles.body}>
|
||||||
|
<Container style={styles.container}>
|
||||||
|
<Section style={styles.card}>
|
||||||
|
<Section style={styles.brandBar}>
|
||||||
|
<Text style={styles.brand}>Kita-Planer</Text>
|
||||||
|
</Section>
|
||||||
|
<Text style={styles.kicker}>Notdienst-Einteilung</Text>
|
||||||
|
<Heading style={styles.heading}>Neuer Notdienst-Termin zugewiesen</Heading>
|
||||||
|
<Text style={styles.lead}>
|
||||||
|
Hallo {parentName},
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.text}>
|
||||||
|
in der Kita <strong>{kitaName}</strong> gab es eine Änderung an der Notdienst-Planung.
|
||||||
|
Du wurdest für folgenden Tag für den Notdienst eingeteilt:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={styles.dateBadge}>
|
||||||
|
<Text style={styles.dateText}>{dateLabel}</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text style={styles.text}>
|
||||||
|
Bitte merke dir diesen Termin vor. Du kannst den vollständigen Plan auch jederzeit online im Kita-Planer einsehen.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button href={link} style={styles.button}>
|
||||||
|
Zum Kita-Planer
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Text style={styles.fallbackText}>
|
||||||
|
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.linkText}>{link}</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text style={styles.footer}>
|
||||||
|
Diese Nachricht wurde automatisch vom Kita-Planer deiner Kita versendet.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
body: baseBodyStyle,
|
||||||
|
container: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "600px",
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "32px 20px",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
padding: "0 28px 28px",
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: "10px",
|
||||||
|
border: `1px solid ${colors.border}`,
|
||||||
|
boxShadow: "0 10px 26px rgba(31, 59, 45, 0.08)",
|
||||||
|
overflow: "hidden",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
brandBar: {
|
||||||
|
margin: "0 -28px 28px",
|
||||||
|
padding: "14px 28px",
|
||||||
|
backgroundColor: colors.brandGreen,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
margin: 0,
|
||||||
|
color: colors.eyebrow,
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
kicker: {
|
||||||
|
margin: "0 0 10px",
|
||||||
|
color: colors.brandGreenAccent,
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
margin: "0 0 14px",
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: "28px",
|
||||||
|
lineHeight: "34px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
lead: {
|
||||||
|
margin: "0 0 22px",
|
||||||
|
color: colors.textBody,
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
margin: "0 0 24px",
|
||||||
|
color: colors.textBody,
|
||||||
|
fontSize: "15px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
dateBadge: {
|
||||||
|
padding: "16px",
|
||||||
|
backgroundColor: "#f7f5ef",
|
||||||
|
borderLeft: `4px solid ${colors.brandGreen}`,
|
||||||
|
borderRadius: "4px",
|
||||||
|
margin: "0 0 24px",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "18px",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "14px 20px",
|
||||||
|
backgroundColor: colors.brandGreen,
|
||||||
|
color: "#ffffff",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "15px",
|
||||||
|
fontWeight: 700,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
fallbackText: {
|
||||||
|
margin: "28px 0 8px",
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: "20px",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
margin: 0,
|
||||||
|
color: colors.link,
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: "20px",
|
||||||
|
wordBreak: "break-all" as const,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
margin: "20px 0 0",
|
||||||
|
color: colors.textFaint,
|
||||||
|
fontSize: "12px",
|
||||||
|
lineHeight: "18px",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -16,6 +16,9 @@ type SendAppEmailResult =
|
|||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
export function getAppEmailConfigError() {
|
export function getAppEmailConfigError() {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!process.env.RESEND_API_KEY) {
|
if (!process.env.RESEND_API_KEY) {
|
||||||
return "RESEND_API_KEY ist nicht konfiguriert.";
|
return "RESEND_API_KEY ist nicht konfiguriert.";
|
||||||
}
|
}
|
||||||
@@ -35,6 +38,16 @@ export async function sendAppEmail({
|
|||||||
replyTo,
|
replyTo,
|
||||||
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
||||||
if (!process.env.RESEND_API_KEY) {
|
if (!process.env.RESEND_API_KEY) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.log("\n✉️ [DEV EMAIL SIMULATION]");
|
||||||
|
console.log(`From: ${from || "not-configured"}`);
|
||||||
|
console.log(`To: ${Array.isArray(to) ? to.join(", ") : to}`);
|
||||||
|
console.log(`Subject: ${subject}`);
|
||||||
|
console.log("-----------------------------------------");
|
||||||
|
console.log("Email body react element is rendered.");
|
||||||
|
console.log("=========================================\n");
|
||||||
|
return { success: true, id: "dev-simulated-id" };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "RESEND_API_KEY ist nicht konfiguriert.",
|
error: "RESEND_API_KEY ist nicht konfiguriert.",
|
||||||
|
|||||||
Reference in New Issue
Block a user