Compare commits

...

10 Commits

20 changed files with 1176 additions and 172 deletions
+85
View File
@@ -0,0 +1,85 @@
# Live-Testplan mein-kitaplaner.de
## Kopfbereich
- Zielsystem: `https://mein-kitaplaner.de`
- Testdatum: `2026-05-17`
- Strategie: echte Testdaten auf Produktion verwenden und anschließend bereinigen
- Testkennzeichnung: alle Testnamen und Test-E-Mails mit `Codex Live-Test` kennzeichnen
- Statuslegende:
- `[ ]` offen
- `[x]` erledigt
- `[!]` Blocker / Beobachtung
## Testdaten
- Gründer/Admin: `hanivan790@acanok.com`
- Test-Kita: `Codex Live-Test Kita-Verein 20260517-2343`
- Elternteil: `kitatester1233@mailaddress.de`
- Passwort: `<TEST_PASSWORD>`
Keine echten Passwörter, Secrets, Session-Werte, Tokens oder Invite-Links in diese Datei schreiben.
## Core Journey
- [x] Landingpage erreichbar.
- Erwartung: `https://mein-kitaplaner.de` lädt mit aktuellem Landingpage-Content.
- [x] Demo-CTA führt zum Kontaktformular.
- Erwartung: CTA öffnet `/#kontakt` und das Formular ist sichtbar.
- [x] Kontaktformular versendet Bestätigungsmail.
- Bestätigt: Testmail an `nolip18234@acanok.com` ist angekommen.
- [x] Registrierung: neuer Gründer/Admin kann Account erstellen.
- Testdaten: eindeutige Admin-Testmail und `<TEST_PASSWORD>`.
- Erwartung: Account wird erstellt, keine Fehlermeldung.
- [x] Redirect nach Registrierung führt zu `/onboarding`.
- Erwartung: Gründer landet nach Registrierung direkt im Onboarding.
- [x] Onboarding: Kita wird mit Standardmodulen angelegt.
- Testdaten: `Codex Live-Test Kita-Verein YYYYMMDD-HHMM`.
- Erwartung: Notdienst, Kalender und Adressbuch bleiben standardmäßig aktiv.
- [x] Redirect nach Onboarding führt zu `/dashboard`.
- Erwartung: Dashboard lädt ohne erneute Anmeldung.
- [x] Dashboard zeigt Kita-Namen und Admin-Navigation.
- Erwartung: Sidebar zeigt Test-Kita sowie Admin-Menüpunkte wie Familienverwaltung, Dienstplan, Schwarzes Brett und ErzieherInnen.
- [x] Familienverwaltung: Familie mit Kind und Elternteil anlegen.
- Testdaten: Familie, Elternteil und Kind mit `Codex Live-Test` kennzeichnen.
- Erwartung: Familie erscheint in der Liste.
- [ ] Einladung wird per E-Mail versendet.
- Erwartung: Eltern-Testadresse erhält eine Einladung.
- Erst als erledigt markieren, wenn die Mail im Postfach bestätigt wurde.
- [ ] Invite-Link öffnet Aktivierungsseite.
- Erwartung: Seite `/invite/[token]` zeigt personalisierte Einladung und Kita-Namen.
- Invite-Link nicht in diese Datei schreiben.
- [ ] Invite-Validierung: Passwort-Pflicht, Passwort-Mismatch, Datenschutz-Pflicht.
- Erwartung: fehlende oder unterschiedliche Passwörter und fehlende Datenschutzerklärung erzeugen sichtbare Fehler.
- [ ] Elternteil kann Einladung annehmen und wird eingeloggt.
- Erwartung: Passwort setzen, Datenschutzerklärung akzeptieren, optional Adressbuch-Opt-in wählen, danach Redirect zu `/dashboard`.
- [ ] Elternrolle sieht nur erlaubte Navigation.
- Erwartung: Eltern sehen keine Admin-only Punkte wie Familienverwaltung, Dienstplan, Schwarzes Brett-Admin, ErzieherInnen.
- [ ] Logout/Login für Admin und Elternteil funktionieren.
- Erwartung: beide Testaccounts können sich abmelden und mit `<TEST_PASSWORD>` erneut anmelden.
- [x] Zugriffsschutz: `/dashboard` ohne Session leitet zu `/login`.
- Erwartung: ausgeloggter Aufruf von `/dashboard` führt zu `/login?callbackUrl=%2Fdashboard` oder entsprechender Login-Route.
- [ ] Cleanup: Test-Kita/User/Familie über Superadmin löschen.
- Erwartung: Test-Kita wird über Superadmin-Diagnose mit exakter Namensbestätigung gelöscht.
- Erst als erledigt markieren, wenn Superadmin-Ansicht keine Test-Kita/Test-User mehr zeigt.
- [ ] Nach Cleanup: Test-Login funktioniert nicht mehr / Daten nicht sichtbar.
- Erwartung: Admin- und Eltern-Testlogin schlagen fehl; Test-Kita erscheint nicht mehr.
## Beobachtungen
- [x] `2026-05-17`: Kontaktformular auf Produktion getestet. Bestätigungsmail an `nolip18234@acanok.com` ist angekommen.
- [x] `2026-05-17`: Registrierung mit `hanivan790@acanok.com`, Redirect nach `/onboarding`, Onboarding mit `Codex Live-Test Kita-Verein 20260517-2343` und Dashboard/Admin-Navigation erfolgreich geprüft.
- [!] `2026-05-17`: Familienanlage mit Elternadresse `nolip18234@acanok.com` blockiert, weil die E-Mail-Adresse bereits existiert. Mit `kitatester1233@mailaddress.de` erfolgreich fortgesetzt.
- [x] `2026-05-17`: Admin-Logout, Zugriffsschutz für `/dashboard` ohne Session und Admin-Login mit temporärem Passwort erfolgreich geprüft. Kombinierter Login-Check bleibt offen, bis ein Elternaccount aktiviert werden kann.
- [x] `2026-05-17`: Familie `Codex Live-Test Familie 20260517-2343` mit Kind und Elternteil `kitatester1233@mailaddress.de` erfolgreich angelegt; Status in der Familienverwaltung: `Einladung offen`.
- [ ] `2026-05-17`: Im Familien-Details-Dialog ist kein Invite-Link sichtbar; Fortsetzung der Invite-Tests benötigt Mailbestätigung und Invite-Link aus dem Postfach.
- [ ] Mailzeit Einladung Elternteil: offen.
- [ ] Cleanup-Ergebnis: offen.
## Pflege-Regeln
- Erledigte Schritte während des Tests direkt von `[ ]` auf `[x]` setzen.
- Blocker mit `[!]` markieren und kurz im Abschnitt Beobachtungen beschreiben.
- Mailversand nur als erledigt markieren, wenn die Mail im jeweiligen Postfach bestätigt wurde.
- Keine echten Passwörter, Secrets, Tokens oder vollständigen Invite-Links dokumentieren.
- Cleanup erst als erledigt markieren, wenn die Testdaten in der Superadmin-Ansicht nicht mehr sichtbar sind.
+53
View File
@@ -659,6 +659,24 @@ async function createAnnouncements(context: SeedContext) {
}, },
}); });
const longTitle = await prisma.announcement.create({
data: {
kitaId: kita.id,
title: "Dies ist eine extrem lange Ankuendigung mit einem superlangen Titel um das Layout des Dashboards und des Schwarzen Bretts auf korrekten Textumbruch und Mobilfreundlichkeit zu testen",
content: "Inhalt der superlangen Ankuendigung fuer Layout-Testing.",
authorId: admin.id,
},
});
const xssTitle = await prisma.announcement.create({
data: {
kitaId: kita.id,
title: "Boesartiger Titel mit <script>alert('XSS_NEWS_TITLE')</script> HTML Tags",
content: "Dieser Beitrag testet, ob die Anwendung XSS-Angriffe im Titel oder im Inhalt filtert. <script>alert('XSS_NEWS_CONTENT')</script>",
authorId: admin.id,
},
});
await prisma.attachment.create({ await prisma.attachment.create({
data: { data: {
announcementId: sommerfest.id, announcementId: sommerfest.id,
@@ -808,6 +826,41 @@ async function createTermine(
}, },
}); });
const highDensityDay = addDays(now, 7);
for (let i = 1; i <= 8; i++) {
await prisma.termin.create({
data: {
kitaId: kita.id,
title: `Parallel-Termin #${i}`,
description: `Beschreibung fuer Parallel-Termin #${i}`,
type: TerminType.SONSTIGES,
status: TerminStatus.CONFIRMED,
startDate: highDensityDay,
endDate: highDensityDay,
allDay: false,
createdById: admin.id,
approvedById: admin.id,
approvedAt: now,
},
});
}
await prisma.termin.create({
data: {
kitaId: kita.id,
title: "Boesartiger Termin <script>alert('XSS_CAL_TITLE')</script>",
description: "XSS Test im Terminkalender <script>alert('XSS_CAL_DESC')</script>",
type: TerminType.SONSTIGES,
status: TerminStatus.CONFIRMED,
startDate: addDays(now, 5),
endDate: addDays(now, 5),
allDay: true,
createdById: admin.id,
approvedById: admin.id,
approvedAt: now,
},
});
await prisma.mitbringselItem.createMany({ await prisma.mitbringselItem.createMany({
data: [ data: [
{ {
@@ -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}`;
}
@@ -70,6 +70,7 @@ export function AdminTerminModal({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
if (existingTermin) { if (existingTermin) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFormState({ setFormState({
title: existingTermin.title, title: existingTermin.title,
description: existingTermin.description || "", description: existingTermin.description || "",
@@ -61,7 +61,10 @@ const categories = [
{ label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" }, { label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" },
{ label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" }, { label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" },
{ label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" }, { label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" },
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.60 0.15 160)" }, { label: "Mein Elterndienst", type: "mein-elterndienst", color: "oklch(0.60 0.16 160)" },
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.75 0.08 160)" },
{ label: "Mein Notdienst", type: "mein-notdienst", color: "oklch(0.60 0.18 45)" },
{ label: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" },
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" }, { label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
]; ];
@@ -74,9 +77,10 @@ export function CalendarOverviewClient({
userId: string; userId: string;
isAdmin: boolean; isAdmin: boolean;
}) { }) {
const [view, setView] = useState<"liste" | "monat" | "agenda">("liste"); const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set()); const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
const [filterScope, setFilterScope] = useState<"own" | "all">(isAdmin ? "all" : "own");
// Month navigation state // Month navigation state
const [currentMonthDate, setCurrentMonthDate] = useState(new Date()); const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
@@ -104,6 +108,12 @@ export function CalendarOverviewClient({
// Filter logic // Filter logic
const filteredEvents = useMemo(() => { const filteredEvents = useMemo(() => {
return termine.filter((item) => { return termine.filter((item) => {
// 0. Filter by scope (own vs all)
if (filterScope === "own") {
if (item.kind === "duty" && !item.isOwn) return false;
if (item.kind === "notdienst" && !item.isOwn) return false;
}
// 1. Search Query filter // 1. Search Query filter
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
if (query) { if (query) {
@@ -113,21 +123,34 @@ export function CalendarOverviewClient({
? item.description.toLowerCase().includes(query) ? item.description.toLowerCase().includes(query)
: false; : false;
const familyMatch = const familyMatch =
item.kind === "duty" && item.familyName (item.kind === "duty" || item.kind === "notdienst") && item.familyName
? item.familyName.toLowerCase().includes(query) ? item.familyName.toLowerCase().includes(query)
: false; : false;
if (!titleMatch && !descMatch && !familyMatch) return false; const childMatch =
item.kind === "notdienst" && item.childName
? item.childName.toLowerCase().includes(query)
: false;
if (!titleMatch && !descMatch && !familyMatch && !childMatch) return false;
} }
// 2. Category filter // 2. Category filter
if (activeTypes.size > 0) { if (activeTypes.size > 0) {
const itemType = item.kind === "duty" ? "elterndienst" : item.type; const itemType =
item.kind === "duty"
? item.isOwn
? "mein-elterndienst"
: "elterndienst"
: item.kind === "notdienst"
? item.isOwn
? "mein-notdienst"
: "notdienst"
: item.type;
if (!activeTypes.has(itemType)) return false; if (!activeTypes.has(itemType)) return false;
} }
return true; return true;
}); });
}, [termine, searchQuery, activeTypes]); }, [termine, searchQuery, activeTypes, filterScope]);
// Check if an event falls on a specific day // Check if an event falls on a specific day
const isEventOnDay = (item: CalendarListItemDto, day: Date) => { const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
@@ -164,6 +187,7 @@ export function CalendarOverviewClient({
<div className="space-y-5"> <div className="space-y-5">
{/* Search and Filters Header */} {/* Search and Filters Header */}
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center flex-wrap gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]"> <div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
{(["liste", "monat", "agenda"] as const).map((v) => ( {(["liste", "monat", "agenda"] as const).map((v) => (
<button <button
@@ -183,6 +207,36 @@ export function CalendarOverviewClient({
))} ))}
</div> </div>
{!isAdmin && (
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)] p-0.5">
<button
type="button"
onClick={() => setFilterScope("own")}
className={[
"h-8 px-4 rounded-full text-[12px] font-semibold transition-all duration-200",
filterScope === "own"
? "bg-[var(--accent-deep)] text-[var(--surface)] shadow-sm"
: "text-[var(--ink-soft)] hover:text-[var(--ink)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Meine Dienste
</button>
<button
type="button"
onClick={() => setFilterScope("all")}
className={[
"h-8 px-4 rounded-full text-[12px] font-semibold transition-all duration-200",
filterScope === "all"
? "bg-[var(--accent-deep)] text-[var(--surface)] shadow-sm"
: "text-[var(--ink-soft)] hover:text-[var(--ink)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Alle Dienste
</button>
</div>
)}
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2"> <div className="flex flex-1 flex-wrap items-center justify-end gap-2">
{/* Category chips */} {/* Category chips */}
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -278,16 +332,40 @@ export function CalendarOverviewClient({
> >
{item.kind === "duty" ? ( {item.kind === "duty" ? (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--good-soft)] text-[var(--good)]"> <div
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" /> className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{
backgroundColor: item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)",
}}
/>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--ink-soft)] ml-2">
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" style={{ color: item.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)" }} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate"> <h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
{item.title} {item.title}
</h4> </h4>
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)]"> <Badge
Elterndienst variant="secondary"
style={{
color: item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)",
borderColor:
(item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)") + "44",
backgroundColor:
(item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)") + "11",
}}
className="border text-[10px]"
>
{item.isOwn ? "Mein Elterndienst" : "Elterndienst"}
</Badge> </Badge>
</div> </div>
<p className="mt-1 text-sm text-[var(--ink-soft)]"> <p className="mt-1 text-sm text-[var(--ink-soft)]">
@@ -309,6 +387,59 @@ export function CalendarOverviewClient({
)} )}
</div> </div>
</div> </div>
) : item.kind === "notdienst" ? (
<div className="flex items-start gap-3">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{
backgroundColor: item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)",
}}
/>
<div className="pl-2 flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
<Badge
variant="secondary"
style={{
color: item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)",
borderColor:
(item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)") + "44",
backgroundColor:
(item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)") + "11",
}}
className="border text-[10px]"
>
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
</Badge>
</div>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Kind: {item.childName} (Familie: {item.familyName})
</p>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{format(new Date(item.startDate), "dd.MM.yyyy")}
</p>
{isAdmin && (
<div className="mt-3 flex gap-2">
<a href="/dashboard/notdienst/plan">
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
Im Planer bearbeiten
</Button>
</a>
</div>
)}
</div>
</div>
) : ( ) : (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <div
@@ -526,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 */}
@@ -538,7 +664,13 @@ function MonthView({
{dayEvents.slice(0, 3).map((item) => { {dayEvents.slice(0, 3).map((item) => {
const color = const color =
item.kind === "duty" item.kind === "duty"
? "oklch(0.60 0.15 160)" ? item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)"
: item.kind === "notdienst"
? item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)"
: getCategoryColor(item.type); : getCategoryColor(item.type);
return ( return (
<div <div
@@ -632,8 +764,18 @@ function AgendaView({
{/* Items stack on right */} {/* Items stack on right */}
<div className="space-y-3"> <div className="space-y-3">
{group.items.map((item) => { {group.items.map((item) => {
const isTermin = item.kind === "termin"; const color =
const color = isTermin ? getCategoryColor(item.type) : "oklch(0.60 0.15 160)"; item.kind === "termin"
? getCategoryColor(item.type)
: item.kind === "notdienst"
? item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)"
: item.kind === "duty"
? item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)"
: "oklch(0.60 0.15 160)";
return ( return (
<div <div
@@ -650,7 +792,7 @@ function AgendaView({
<h4 className="font-bold text-[15px] text-[var(--ink)] truncate"> <h4 className="font-bold text-[15px] text-[var(--ink)] truncate">
{item.title} {item.title}
</h4> </h4>
{isTermin ? ( {item.kind === "termin" ? (
<span <span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold border" className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold border"
style={{ style={{
@@ -661,34 +803,52 @@ function AgendaView({
> >
{getTypeLabel(item.type)} {getTypeLabel(item.type)}
</span> </span>
) : item.kind === "notdienst" ? (
<Badge
variant="secondary"
className="text-[10px]"
style={{
color,
borderColor: color + "44",
backgroundColor: color + "11",
}}
>
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
</Badge>
) : ( ) : (
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]"> <Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
Elterndienst Elterndienst
</Badge> </Badge>
)} )}
{isTermin && item.status === TerminStatus.PENDING && ( {item.kind === "termin" && item.status === TerminStatus.PENDING && (
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge> <Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
)} )}
</div> </div>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1.5"> <p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" /> <Clock className="h-3.5 w-3.5" />
{isTermin {item.kind === "termin"
? item.allDay ? item.allDay
? "Ganztägig" ? "Ganztägig"
: `${format(new Date(item.startDate), "HH:mm")} - ${format( : `${format(new Date(item.startDate), "HH:mm")} - ${format(
new Date(item.endDate), new Date(item.endDate),
"HH:mm", "HH:mm",
)}` )}`
: item.kind === "notdienst"
? `${format(new Date(item.startDate), "dd.MM.yyyy")} (ganztägig)`
: `${format(new Date(item.startDate), "dd.MM.")} - ${format( : `${format(new Date(item.startDate), "dd.MM.")} - ${format(
new Date(item.endDate), new Date(item.endDate),
"dd.MM.yyyy", "dd.MM.yyyy",
)}`} )}`}
{isTermin && <MapPin className="ml-1 h-3.5 w-3.5" />} {item.kind === "termin" && <MapPin className="ml-1 h-3.5 w-3.5" />}
{isTermin ? "Kita" : `Familie: ${item.familyName}`} {item.kind === "termin"
? "Kita"
: item.kind === "notdienst"
? `Kind: ${item.childName} · Familie: ${item.familyName}`
: `Familie: ${item.familyName}`}
</p> </p>
{isTermin && item.description && ( {item.kind === "termin" && item.description && (
<p className="mt-2 text-sm text-[var(--ink-soft)] line-clamp-2 leading-relaxed"> <p className="mt-2 text-sm text-[var(--ink-soft)] line-clamp-2 leading-relaxed">
{item.description} {item.description}
</p> </p>
@@ -697,7 +857,7 @@ function AgendaView({
</div> </div>
{/* Actions for admins */} {/* Actions for admins */}
{isAdmin && isTermin && ( {isAdmin && item.kind === "termin" && (
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<AdminTerminModal <AdminTerminModal
existingTermin={item} existingTermin={item}
@@ -12,6 +12,7 @@ import {
MoreHorizontal, MoreHorizontal,
WashingMachine, WashingMachine,
Trash2, Trash2,
ShieldAlert,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -62,9 +63,21 @@ export type DutyCalendarItemDto = {
status: DutyAssignmentStatus; status: DutyAssignmentStatus;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
isOwn: boolean;
}; };
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto; export type NotdienstCalendarItemDto = {
kind: "notdienst";
id: string;
title: string;
familyName: string;
childName: string;
isOwn: boolean;
startDate: Date;
endDate: Date;
};
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
export function TerminList({ export function TerminList({
termine, termine,
@@ -123,6 +136,8 @@ export function TerminList({
{group.items.map((item) => {group.items.map((item) =>
item.kind === "duty" ? ( item.kind === "duty" ? (
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} /> <DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
) : item.kind === "notdienst" ? (
<NotdienstRow key={`notdienst-${item.id}`} notdienst={item} isAdmin={isAdmin} />
) : ( ) : (
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} /> <TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
), ),
@@ -136,7 +151,7 @@ export function TerminList({
} }
function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) { function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
const color = "var(--good)"; const color = duty.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)";
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
return ( return (
@@ -156,7 +171,9 @@ function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolea
</p> </p>
</div> </div>
<div className="flex items-center gap-2 relative"> <div className="flex items-center gap-2 relative">
<CategoryPill color={color}>Elterndienst</CategoryPill> <CategoryPill color={color}>
{duty.isOwn ? "Mein Elterndienst" : "Elterndienst"}
</CategoryPill>
{isAdmin ? ( {isAdmin ? (
<div className="relative"> <div className="relative">
<Button <Button
@@ -474,3 +491,64 @@ function getTypeLabel(type: TerminType): string {
return "Sonstiges"; return "Sonstiges";
} }
} }
function NotdienstRow({ notdienst, isAdmin }: { notdienst: NotdienstCalendarItemDto; isAdmin: boolean }) {
const color = notdienst.isOwn ? "oklch(0.60 0.18 45)" : "oklch(0.75 0.10 50)";
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<DateTile date={notdienst.startDate} />
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{notdienst.title}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })} (ganztägig)
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
Kind: {notdienst.childName}
</span>
</p>
</div>
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>
{notdienst.isOwn ? "Mein Notdienst" : "Notdienst"}
</CategoryPill>
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href="/dashboard/notdienst/plan"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
onClick={() => setMenuOpen(false)}
>
Notdienst im Planer bearbeiten
</a>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
</div>
);
}
+51 -3
View File
@@ -134,6 +134,7 @@ export default async function KalenderPage({
startDate: true, startDate: true,
endDate: true, endDate: true,
status: true, status: true,
familyId: true,
dutyType: { dutyType: {
select: { select: {
name: true, name: true,
@@ -148,6 +149,32 @@ export default async function KalenderPage({
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
const notdienstAssignmentRows = await prisma.notdienstAssignment.findMany({
where: {
kitaId: session.user.kitaId,
plan: {
status: "PUBLISHED",
},
},
select: {
id: true,
date: true,
child: {
select: {
firstName: true,
lastName: true,
familyId: true,
family: {
select: {
name: true,
},
},
},
},
},
orderBy: { date: "asc" },
});
const allUserTermine: CalendarListItemDto[] = [ const allUserTermine: CalendarListItemDto[] = [
...confirmedTermineRows.map((termin) => ({ ...confirmedTermineRows.map((termin) => ({
...termin, ...termin,
@@ -169,15 +196,36 @@ export default async function KalenderPage({
participation.status === EventParticipationStatus.ATTENDING, participation.status === EventParticipationStatus.ATTENDING,
), ),
})), })),
...dutyAssignmentRows.map((assignment) => ({ ...dutyAssignmentRows.map((assignment) => {
const isOwn = assignment.familyId === familyIdForParticipation;
return {
kind: "duty" as const, kind: "duty" as const,
id: assignment.id, id: assignment.id,
title: assignment.dutyType.name, title: isOwn
? `Mein Elterndienst (${assignment.dutyType.name})`
: `${assignment.dutyType.name} (${assignment.family.name})`,
familyName: assignment.family.name, familyName: assignment.family.name,
status: assignment.status, status: assignment.status,
startDate: assignment.startDate, startDate: assignment.startDate,
endDate: assignment.endDate, endDate: assignment.endDate,
})), isOwn,
};
}),
...notdienstAssignmentRows.map((assignment) => {
const isOwn = assignment.child.familyId === familyIdForParticipation;
return {
kind: "notdienst" as const,
id: assignment.id,
title: isOwn
? `Mein Notdienst (${assignment.child.firstName})`
: `Notdienst (${assignment.child.family.name})`,
familyName: assignment.child.family.name,
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
isOwn,
startDate: assignment.date,
endDate: assignment.date,
};
}),
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); ].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
let allPendingTermine: PendingTerminDto[] = []; let allPendingTermine: PendingTerminDto[] = [];
+14 -71
View File
@@ -1,11 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LogOut } from "lucide-react";
import { signOut } from "@/auth"; import { signOut } from "@/auth";
import { requireKitaSession } from "@/lib/auth-utils"; import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Button } from "@/components/ui/button"; import { DashboardShell } from "@/components/dashboard/dashboard-shell";
import { SidebarNav } from "@/components/dashboard/sidebar-nav";
export const metadata: Metadata = { export const metadata: Metadata = {
robots: { robots: {
@@ -15,11 +13,11 @@ export const metadata: Metadata = {
}; };
// ===================================================================== // =====================================================================
// Dashboard-Layout · Linke Sidebar // Dashboard-Layout · Responsive App Shell
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Server Component: liest kitaId aus der Session, holt den Kita-Namen // Server Component: liest kitaId aus der Session, holt den Kita-Namen
// aus der DB und rendert das App-Shell (Sidebar + main content area). // aus der DB und rendert die responsive DashboardShell (mit Desktop-Sidebar
// Der eigentliche Seiteninhalt kommt via {children} rein. // und Mobile-Drawer).
// ===================================================================== // =====================================================================
export default async function DashboardLayout({ export default async function DashboardLayout({
@@ -35,75 +33,20 @@ export default async function DashboardLayout({
}); });
const displayName = session.user.name ?? session.user.email ?? "Kita-Planer"; const displayName = session.user.name ?? session.user.email ?? "Kita-Planer";
return ( async function handleSignOut() {
<div className="grid min-h-screen grid-cols-[256px_minmax(0,1fr)] bg-[var(--bg)] max-[760px]:grid-cols-1">
{/* ── Sidebar ───────────────────────────────────────────────────── */}
<aside className="print:hidden sticky top-0 flex h-screen shrink-0 flex-col border-r border-[var(--hairline)] bg-[var(--surface)] max-[760px]:relative max-[760px]:h-auto">
{/* Logo / Kita-Name */}
<div className="flex h-[82px] items-center gap-3 border-b border-[var(--hairline-soft)] px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
{kita.name.slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
{kita.name}
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">Kita-Planer</p>
</div>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto py-6">
<SidebarNav role={session.user.role} />
</div>
{/* Footer: User-Info + Abmelden */}
<div className="border-t border-[var(--hairline-soft)] p-4">
<div className="mb-3 flex items-center gap-2.5 px-1">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
{getInitials(displayName)}
</div>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--ink)]">
{displayName}
</p>
<p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
{session.user.role}
</p>
</div>
</div>
<form
action={async () => {
"use server"; "use server";
await signOut({ redirectTo: "/" }); await signOut({ redirectTo: "/" });
}} }
>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
{/* ── Main Content ──────────────────────────────────────────────── */} return (
<main className="min-w-0 overflow-y-auto"> <DashboardShell
kitaName={kita.name}
userName={displayName}
userRole={session.user.role}
signOutAction={handleSignOut}
>
{children} {children}
</main> </DashboardShell>
</div>
); );
} }
function getInitials(value: string) {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return value.slice(0, 2).toUpperCase();
}
+93 -8
View File
@@ -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
@@ -47,8 +48,8 @@ export async function generatePlanAction(year?: number, month?: number) {
where: { where: {
kitaId, kitaId,
date: { date: {
gte: new Date(targetYear, targetMonth - 1, 1), gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
lt: new Date(targetYear, targetMonth, 1), lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
}, },
}, },
include: { child: { select: { familyId: true } } }, include: { child: { select: { familyId: true } } },
@@ -155,17 +156,44 @@ export async function updateAssignmentAction(
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!;
const parsedDate = new Date(date); const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
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
@@ -181,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) {
@@ -196,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,
}; };
}); });
+48 -8
View File
@@ -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 () => {
@@ -517,7 +519,8 @@ function PlanPreviewTable({
</div> </div>
<div className="divide-y divide-[var(--hairline-soft)]"> <div className="divide-y divide-[var(--hairline-soft)]">
{days.map((day) => { {days.map((day) => {
const dateObj = new Date(day.date); const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
const dateObj = new Date(yr, mo - 1, dy);
const assignedChild = allChildren.find( const assignedChild = allChildren.find(
(child) => child.id === day.childId, (child) => child.id === day.childId,
); );
@@ -566,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]">
@@ -574,7 +602,8 @@ function ManualAssignmentTable({
</div> </div>
<div className="divide-y divide-[var(--hairline-soft)]"> <div className="divide-y divide-[var(--hairline-soft)]">
{days.map((day) => { {days.map((day) => {
const dateObj = new Date(day.date); const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
const dateObj = new Date(yr, mo - 1, dy);
const assignedChild = allChildren.find( const assignedChild = allChildren.find(
(child) => child.id === day.childId, (child) => child.id === day.childId,
); );
@@ -588,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"}
@@ -598,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}{" "}
@@ -16,6 +16,7 @@ type ContactFormProps = {
street: string | null; street: string | null;
postalCode: string | null; postalCode: string | null;
city: string | null; city: string | null;
directoryOptIn: boolean;
}; };
}; };
@@ -30,6 +31,7 @@ export function ContactForm({ contact }: ContactFormProps) {
street: String(formData.get("street") ?? ""), street: String(formData.get("street") ?? ""),
postalCode: String(formData.get("postalCode") ?? ""), postalCode: String(formData.get("postalCode") ?? ""),
city: String(formData.get("city") ?? ""), city: String(formData.get("city") ?? ""),
directoryOptIn: formData.get("directoryOptIn") === "on",
}); });
if (result.error) { if (result.error) {
@@ -106,6 +108,22 @@ export function ContactForm({ contact }: ContactFormProps) {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 mt-2">
<input
type="checkbox"
id="directoryOptIn"
name="directoryOptIn"
defaultChecked={contact.directoryOptIn}
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
<Label
htmlFor="directoryOptIn"
className="font-normal cursor-pointer text-xs text-[var(--ink-soft)]"
>
Im Adressbuch anzeigen (Datenschutzerklärung)
</Label>
</div>
<div className="flex justify-end pt-1"> <div className="flex justify-end pt-1">
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending ? ( {isPending ? (
+16
View File
@@ -80,6 +80,7 @@ const contactSchema = z.object({
"Bitte eine gültige Postleitzahl angeben.", "Bitte eine gültige Postleitzahl angeben.",
), ),
city: z.string().trim().max(100).optional(), city: z.string().trim().max(100).optional(),
directoryOptIn: z.boolean().optional(),
}); });
// 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,
@@ -183,6 +184,20 @@ export async function updateMyContact(rawPayload: unknown) {
} }
try { try {
const currentUser = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
select: { directoryOptInAt: true },
});
let newDirectoryOptInAt: Date | null = currentUser.directoryOptInAt;
if (parsed.data.directoryOptIn) {
if (!newDirectoryOptInAt) {
newDirectoryOptInAt = new Date();
}
} else {
newDirectoryOptInAt = null;
}
await prisma.user.update({ await prisma.user.update({
where: { id: session.user.id }, where: { id: session.user.id },
data: { data: {
@@ -190,6 +205,7 @@ export async function updateMyContact(rawPayload: unknown) {
street: parsed.data.street || null, street: parsed.data.street || null,
postalCode: parsed.data.postalCode || null, postalCode: parsed.data.postalCode || null,
city: parsed.data.city || null, city: parsed.data.city || null,
directoryOptInAt: newDirectoryOptInAt,
}, },
}); });
+2
View File
@@ -51,6 +51,7 @@ export default async function ProfilPage() {
street: true, street: true,
postalCode: true, postalCode: true,
city: true, city: true,
directoryOptInAt: true,
createdAt: true, createdAt: true,
family: { family: {
select: { select: {
@@ -173,6 +174,7 @@ export default async function ProfilPage() {
street: user.street, street: user.street,
postalCode: user.postalCode, postalCode: user.postalCode,
city: user.city, city: user.city,
directoryOptIn: !!user.directoryOptInAt,
}} }}
/> />
</CardContent> </CardContent>
+58 -2
View File
@@ -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
View File
@@ -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,162 @@
"use client";
import React, { useState } from "react";
import { LogOut, Menu, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SidebarNav } from "./sidebar-nav";
import { UserRole } from "@prisma/client";
type DashboardShellProps = {
kitaName: string;
userName: string;
userRole: UserRole;
children: React.ReactNode;
signOutAction: () => void | Promise<void>;
};
export function DashboardShell({
kitaName,
userName,
userRole,
children,
signOutAction,
}: DashboardShellProps) {
const [isOpen, setIsOpen] = useState(false);
const displayName = userName;
const initials = getInitials(displayName);
return (
<div className="grid min-h-screen grid-cols-[256px_minmax(0,1fr)] bg-[var(--bg)] max-[760px]:grid-cols-1">
{/* ── Desktop Sidebar ── */}
<aside className="print:hidden sticky top-0 flex h-screen shrink-0 flex-col border-r border-[var(--hairline)] bg-[var(--surface)] max-[760px]:hidden">
{/* Logo / Kita-Name */}
<div className="flex h-[82px] items-center gap-3 border-b border-[var(--hairline-soft)] px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
{kitaName.slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
{kitaName}
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">Kita-Planer</p>
</div>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto py-6">
<SidebarNav role={userRole} />
</div>
{/* Footer: User-Info + Abmelden */}
<div className="border-t border-[var(--hairline-soft)] p-4">
<div className="mb-3 flex items-center gap-2.5 px-1">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
{initials}
</div>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--ink)]">
{displayName}
</p>
<p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
{userRole}
</p>
</div>
</div>
<form action={signOutAction}>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start text-[var(--ink-soft)] hover:text-[var(--danger)]"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
{/* ── Mobile Top Header Bar ── */}
<header className="print:hidden hidden max-[760px]:flex sticky top-0 z-40 h-16 items-center justify-between border-b border-[var(--hairline)] bg-[var(--surface)] px-5">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
{kitaName.slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
{kitaName}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(!isOpen)}
aria-label={isOpen ? "Menü schließen" : "Menü öffnen"}
>
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</Button>
</header>
{/* ── Mobile Sidebar Drawer/Overlay ── */}
{isOpen && (
<div className="print:hidden fixed inset-0 z-30 hidden max-[760px]:block">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/40 backdrop-blur-xs transition-opacity"
onClick={() => setIsOpen(false)}
/>
{/* Drawer content */}
<aside className="fixed bottom-0 top-16 left-0 flex w-[280px] max-w-[85vw] flex-col border-r border-[var(--hairline)] bg-[var(--surface)] shadow-2xl transition-transform">
<div className="flex-1 overflow-y-auto py-6" onClick={() => setIsOpen(false)}>
<SidebarNav role={userRole} />
</div>
{/* Footer */}
<div className="border-t border-[var(--hairline-soft)] p-4">
<div className="mb-3 flex items-center gap-2.5 px-1">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
{initials}
</div>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--ink)]">
{displayName}
</p>
<p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
{userRole}
</p>
</div>
</div>
<form action={signOutAction}>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start text-[var(--ink-soft)] hover:text-[var(--danger)]"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
</div>
)}
{/* ── Main Content Area ── */}
<main className="min-w-0 overflow-y-auto max-[760px]:pt-0">
{children}
</main>
</div>
);
}
function getInitials(value: string) {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return value.slice(0, 2).toUpperCase();
}
+189
View File
@@ -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,
},
};
+19 -13
View File
@@ -1,13 +1,8 @@
import { import {
addMonths, addMonths,
endOfMonth,
getDate, getDate,
getYear, getYear,
getMonth, getMonth,
isWeekend,
eachDayOfInterval,
startOfMonth,
startOfDay,
format, format,
} from "date-fns"; } from "date-fns";
import { de } from "date-fns/locale"; import { de } from "date-fns/locale";
@@ -36,17 +31,28 @@ export function getTargetMonthData(now = new Date()) {
} }
export function getWorkingDaysOfMonth(year: number, month: number) { export function getWorkingDaysOfMonth(year: number, month: number) {
// month ist 1-12, Date erwartet 0-11 const start = new Date(Date.UTC(year, month - 1, 1));
const start = startOfMonth(new Date(year, month - 1)); const end = new Date(Date.UTC(year, month, 0)); // last day of month
const end = endOfMonth(start);
const days = eachDayOfInterval({ start, end }); const days: Date[] = [];
const current = new Date(start);
return days while (current <= end) {
.filter((day) => !isWeekend(day)) const dayOfWeek = current.getUTCDay(); // 0 is Sunday, 6 is Saturday
.map((day) => startOfDay(day)); if (dayOfWeek !== 0 && dayOfWeek !== 6) {
days.push(new Date(current));
}
current.setUTCDate(current.getUTCDate() + 1);
}
return days;
} }
export function formatDateKey(date: Date) { export function formatDateKey(date: Date) {
return format(date, "yyyy-MM-dd"); return format(date, "yyyy-MM-dd");
} }
export function formatUtcDateKey(date: Date) {
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
+13
View File
@@ -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.",