Compare commits
6 Commits
e9595fab21
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cd16773c1 | |||
| 74dc2d6b57 | |||
| 27c5a6940a | |||
| f28956194c | |||
| 7eaa198a29 | |||
| 5dca7c76f8 |
@@ -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.
|
||||||
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
async function handleSignOut() {
|
||||||
|
"use server";
|
||||||
|
await signOut({ redirectTo: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen grid-cols-[256px_minmax(0,1fr)] bg-[var(--bg)] max-[760px]:grid-cols-1">
|
<DashboardShell
|
||||||
{/* ── Sidebar ───────────────────────────────────────────────────── */}
|
kitaName={kita.name}
|
||||||
<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">
|
userName={displayName}
|
||||||
{/* Logo / Kita-Name */}
|
userRole={session.user.role}
|
||||||
<div className="flex h-[82px] items-center gap-3 border-b border-[var(--hairline-soft)] px-5">
|
signOutAction={handleSignOut}
|
||||||
<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()}
|
{children}
|
||||||
</div>
|
</DashboardShell>
|
||||||
<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";
|
|
||||||
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 ──────────────────────────────────────────────── */}
|
|
||||||
<main className="min-w-0 overflow-y-auto">
|
|
||||||
{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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user