diff --git a/src/app/actions.ts b/src/app/actions.ts new file mode 100644 index 0000000..3f5bb1c --- /dev/null +++ b/src/app/actions.ts @@ -0,0 +1,237 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { createSession, deleteSession, requireStaff } from "@/lib/auth"; +import { hashPassword, verifyPassword } from "@/lib/password"; +import { childSchema, daySchema, idSchema, overrideSchema, type ActionResult } from "@/lib/validation"; +import { localClock } from "@/lib/time"; +import { auditContext } from "@/lib/audit"; +import { resolvePickup } from "@/lib/slots"; +import { InputError } from "@/lib/corrections"; + +const invalid = (error: z.ZodError): ActionResult => ({ error: error.issues[0]?.message ?? "Ungültige Eingabe." }); +const failed = (context: string, error: unknown): ActionResult => { + console.error(context, error); + return { error: "Speichern fehlgeschlagen. Bitte erneut versuchen. Bei anhaltenden Fehlern die Betreuung informieren." }; +}; + +export async function login(_previous: ActionResult, form: FormData): Promise { + const input = z.object({ + email: z.email().max(254), + password: z.string().min(1).max(256), + }).safeParse({ + email: String(form.get("email") ?? "").trim().toLowerCase(), + password: form.get("password"), + }); + if (!input.success) return { error: "Bitte eine gültige E-Mail-Adresse und das Passwort eingeben." }; + try { + const attempts = await db.query<{ attempts: number }>(` + INSERT INTO login_attempts(email, attempts, window_start) VALUES ($1, 1, now()) + ON CONFLICT(email) DO UPDATE SET + attempts = CASE WHEN login_attempts.window_start < now() - interval '15 minutes' + THEN 1 ELSE login_attempts.attempts + 1 END, + window_start = CASE WHEN login_attempts.window_start < now() - interval '15 minutes' + THEN now() ELSE login_attempts.window_start END + RETURNING attempts`, [input.data.email]); + if (attempts.rows[0].attempts > 10) return { error: "Zu viele Versuche. Bitte in 15 Minuten erneut anmelden." }; + const result = await db.query<{ id: string; password_hash: string }>( + "SELECT id, password_hash FROM staff WHERE email = $1", [input.data.email]); + const staff = result.rows[0]; + const valid = await verifyPassword(input.data.password, + staff?.password_hash ?? `00000000000000000000000000000000:${"00".repeat(64)}`); + if (!staff || !valid) return { error: "E-Mail-Adresse oder Passwort ist nicht korrekt." }; + await db.query("DELETE FROM login_attempts WHERE email = $1", [input.data.email]); + await createSession(staff.id); + } catch (error) { + return failed("Login fehlgeschlagen", error); + } + redirect("/"); +} + +export async function logout() { + await deleteSession(); + redirect("/login"); +} + +export async function saveChild(input: unknown): Promise { + const staff = await requireStaff(); + const parsed = childSchema.safeParse(input); + if (!parsed.success) return invalid(parsed.error); + const child = parsed.data; + const client = await db.connect(); + try { + await client.query("BEGIN"); + await auditContext(client, staff.id, child.id ? "Stammdaten / Wochenplan bearbeitet" : "Kind angelegt"); + let id = child.id; + if (id) { + const result = await client.query( + "UPDATE children SET first_name = $2, last_name = $3, class_name = $4 WHERE id = $1 RETURNING id", + [id, child.firstName, child.lastName, child.className]); + if (!result.rowCount) { + await client.query("ROLLBACK"); + return { error: "Das Kind wurde nicht gefunden. Bitte die Seite neu laden." }; + } + } else { + const result = await client.query<{ id: string }>( + "INSERT INTO children(first_name, last_name, class_name) VALUES ($1, $2, $3) RETURNING id", + [child.firstName, child.lastName, child.className]); + id = result.rows[0].id; + } + await client.query("DELETE FROM weekly_schedules WHERE child_id = $1 AND NOT (weekday = ANY($2::int[]))", + [id, child.schedules.map((schedule) => schedule.weekday)]); + for (const schedule of child.schedules) { + const pickup = await resolvePickup(client, schedule.arrival, schedule.pickup, schedule.slotId); + await client.query(`INSERT INTO weekly_schedules(child_id, weekday, arrival, pickup, pickup_end, pickup_slot_id) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT(child_id, weekday) DO UPDATE SET arrival = EXCLUDED.arrival, pickup = EXCLUDED.pickup, + pickup_end = EXCLUDED.pickup_end, pickup_slot_id = EXCLUDED.pickup_slot_id`, + [id, schedule.weekday, schedule.arrival, pickup.pickup, pickup.end, pickup.slotId]); + } + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + if (error instanceof InputError) return { error: error.message }; + return failed("Kind speichern fehlgeschlagen", error); + } finally { + client.release(); + } + revalidatePath("/"); + revalidatePath("/children/[id]", "page"); + return { success: true }; +} + +export async function saveOverride(input: unknown): Promise { + const staff = await requireStaff(); + const parsed = overrideSchema.safeParse(input); + if (!parsed.success) return invalid(parsed.error); + const value = parsed.data; + const client = await db.connect(); + try { + await client.query("BEGIN"); + await auditContext(client, staff.id, "Tagesplan angepasst"); + const child = await client.query("SELECT id FROM children WHERE id = $1 FOR UPDATE", [value.childId]); + if (!child.rowCount) { + await client.query("ROLLBACK"); + return { error: "Kind nicht gefunden." }; + } + if (value.mode === "weekly") { + await client.query("DELETE FROM day_overrides WHERE child_id = $1 AND day = $2", [value.childId, value.day]); + } else { + const pickup = value.mode === "custom" ? await resolvePickup(client, value.arrival, value.pickup, value.slotId) + : { pickup: null, end: null, slotId: null }; + await client.query(`INSERT INTO day_overrides(child_id, day, attending, arrival, pickup, pickup_end, pickup_slot_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT(child_id, day) DO UPDATE SET + attending = EXCLUDED.attending, arrival = EXCLUDED.arrival, pickup = EXCLUDED.pickup, + pickup_end = EXCLUDED.pickup_end, pickup_slot_id = EXCLUDED.pickup_slot_id`, + [value.childId, value.day, value.mode === "custom", + value.mode === "custom" ? value.arrival : null, pickup.pickup, pickup.end, pickup.slotId]); + } + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + if (error instanceof InputError) return { error: error.message }; + return failed("Tagesplan speichern fehlgeschlagen", error); + } finally { + client.release(); + } + revalidatePath("/"); + revalidatePath("/children/[id]", "page"); + return { success: true }; +} + +export async function setAttendance(input: unknown): Promise { + const staff = await requireStaff(); + const parsed = z.object({ + childId: idSchema, + day: daySchema, + action: z.enum(["arrived", "sick", "expected", "planned", "parents", "alone"]), + }).safeParse(input); + if (!parsed.success) return invalid(parsed.error); + const { childId, day, action } = parsed.data; + if (day !== localClock().day) return { error: "Anwesenheit kann nur für heute erfasst werden. Bitte aktualisieren." }; + const client = await db.connect(); + try { + await client.query("BEGIN"); + await auditContext(client, staff.id, action === "alone" ? "Alleingang bestätigt" : "Anwesenheit im Dashboard erfasst"); + const child = await client.query("SELECT id FROM children WHERE id = $1 FOR UPDATE", [childId]); + if (!child.rowCount) { + await client.query("ROLLBACK"); + return { error: "Kind nicht gefunden." }; + } + const record = await client.query<{ status: string }>( + "SELECT status FROM attendance WHERE child_id = $1 AND day = $2", [childId, day]); + const status = record.rows[0]?.status ?? "expected"; + const allowed = action === "arrived" ? ["expected", "sick"].includes(status) + : action === "sick" ? status === "expected" + : action === "expected" ? status === "sick" + : status === "arrived"; + if (!allowed) { + await client.query("ROLLBACK"); + return { error: "Der Status wurde bereits geändert. Bitte die aktuellen Angaben prüfen." }; + } + if (action === "alone") { + const clock = localClock(); + const rule = await client.query(`SELECT child_id FROM alone_rules + WHERE child_id = $1 AND weekday = $2 AND earliest <= $3::time`, [childId, clock.weekday, clock.time]); + if (!rule.rowCount) { + await client.query("ROLLBACK"); + return { error: "Für jetzt ist kein Alleingang erlaubt. Bitte die Regeln im Profil prüfen." }; + } + } + if (action === "expected") { + await client.query("DELETE FROM attendance WHERE child_id = $1 AND day = $2", [childId, day]); + } else if (action === "planned" || action === "parents" || action === "alone") { + await client.query(`UPDATE attendance SET status = 'picked_up', picked_up_at = now(), + pickup_kind = $3, updated_by = $4 WHERE child_id = $1 AND day = $2`, [childId, day, action, staff.id]); + } else { + await client.query(`INSERT INTO attendance(child_id, day, status, arrived_at, updated_by) + VALUES ($1, $2, $3, CASE WHEN $3 = 'arrived' THEN now() END, $4) + ON CONFLICT(child_id, day) DO UPDATE SET status = EXCLUDED.status, + arrived_at = EXCLUDED.arrived_at, picked_up_at = NULL, pickup_kind = NULL, updated_by = EXCLUDED.updated_by`, + [childId, day, action, staff.id]); + } + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + return failed("Anwesenheit speichern fehlgeschlagen", error); + } finally { + client.release(); + } + revalidatePath("/"); + revalidatePath("/children/[id]", "page"); + return { success: true }; +} + +export async function changePassword(_previous: ActionResult, form: FormData): Promise { + const staff = await requireStaff(); + const parsed = z.object({ + current: z.string().min(1).max(256), + password: z.string().min(12, "Das neue Passwort muss mindestens 12 Zeichen haben.").max(256), + confirm: z.string(), + }).refine((v) => v.password === v.confirm, "Die neuen Passwörter stimmen nicht überein.") + .safeParse(Object.fromEntries(form)); + if (!parsed.success) return invalid(parsed.error); + const client = await db.connect(); + try { + await client.query("BEGIN"); + const result = await client.query<{ password_hash: string }>( + "SELECT password_hash FROM staff WHERE id = $1 FOR UPDATE", [staff.id]); + if (!await verifyPassword(parsed.data.current, result.rows[0].password_hash)) { + await client.query("ROLLBACK"); + return { error: "Das aktuelle Passwort ist nicht korrekt." }; + } + await client.query("UPDATE staff SET password_hash = $2 WHERE id = $1", + [staff.id, await hashPassword(parsed.data.password)]); + await client.query("DELETE FROM sessions WHERE staff_id = $1", [staff.id]); + await client.query("COMMIT"); + await createSession(staff.id); + } catch (error) { + await client.query("ROLLBACK"); + return failed("Passwort ändern fehlgeschlagen", error); + } finally { + client.release(); + } + return { success: true }; +} diff --git a/src/app/admission-actions.ts b/src/app/admission-actions.ts new file mode 100644 index 0000000..b39dde8 --- /dev/null +++ b/src/app/admission-actions.ts @@ -0,0 +1,122 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import type { PoolClient } from "pg"; +import { db } from "@/lib/db"; +import { requireStaff } from "@/lib/auth"; +import { applicationSchema, idSchema, roundSchema, slotSchema, type ActionResult } from "@/lib/validation"; +import { InputError } from "@/lib/corrections"; +import { acceptApplication, drawRound, invitationHash, newInvitation, submitApplication } from "@/lib/admissions"; +import { localClock } from "@/lib/time"; + +async function transaction(work: (client: PoolClient) => Promise): Promise { + let client: PoolClient | undefined; + try { + client = await db.connect(); + await client.query("BEGIN"); + await work(client); + await client.query("COMMIT"); + } catch (error) { + if (client) await client.query("ROLLBACK"); + if (error instanceof InputError) return { error: error.message }; + console.error("Aufnahme / Slot speichern fehlgeschlagen", error); + return { error: "Speichern fehlgeschlagen. Bitte erneut versuchen." }; + } finally { client?.release(); } + revalidatePath("/admissions"); + revalidatePath("/slots"); + revalidatePath("/"); + return { success: true }; +} + +export async function createRound(input: unknown): Promise { + const staff = await requireStaff(); + const parsed = roundSchema.safeParse(input); + if (!parsed.success) return { error: parsed.error.issues[0].message }; + if (parsed.data.expiresOn < localClock().day) return { error: "Die Anmeldefrist darf nicht in der Vergangenheit liegen." }; + const code = newInvitation(); + let roundId: string | undefined; + const result = await transaction(async (client) => { + const round = await client.query<{ id: string }>("INSERT INTO admission_rounds(name, code_hash, expires_on, created_by) VALUES ($1, $2, $3, $4) RETURNING id", + [parsed.data.name, invitationHash(code), parsed.data.expiresOn, staff.id]); + roundId = round.rows[0].id; + }); + return result.error ? result : { ...result, code, roundId }; +} + +export async function rotateInvitation(roundId: unknown): Promise { + await requireStaff(); + const parsed = idSchema.safeParse(roundId); + if (!parsed.success) return { error: "Ungültige Aufnahmerunde." }; + const code = newInvitation(); + const result = await transaction(async (client) => { + const updated = await client.query("UPDATE admission_rounds SET code_hash = $2 WHERE id = $1 AND status = 'open' RETURNING id", + [parsed.data, invitationHash(code)]); + if (!updated.rowCount) throw new InputError("Nur offene Runden können einen neuen Einladungscode erhalten."); + }); + return result.error ? result : { ...result, code }; +} + +export async function closeRound(roundId: unknown): Promise { + await requireStaff(); + const parsed = idSchema.safeParse(roundId); + if (!parsed.success) return { error: "Ungültige Aufnahmerunde." }; + return transaction(async (client) => { + const result = await client.query("UPDATE admission_rounds SET status = 'closed' WHERE id = $1 AND status = 'open' RETURNING id", [parsed.data]); + if (!result.rowCount) throw new InputError("Diese Runde ist nicht mehr offen. Bitte neu laden."); + }); +} + +export async function runDraw(input: unknown): Promise { + const staff = await requireStaff(); + const parsed = z.object({ roundId: idSchema, places: z.number().int().min(1).max(10000) }).safeParse(input); + if (!parsed.success) return { error: "Bitte eine gültige Platzzahl zwischen 1 und 10.000 eingeben." }; + return transaction((client) => drawRound(client, parsed.data.roundId, parsed.data.places, staff.id)); +} + +export async function confirmAdmission(applicationId: unknown): Promise { + const staff = await requireStaff(); + const parsed = idSchema.safeParse(applicationId); + if (!parsed.success) return { error: "Ungültige Anmeldung." }; + return transaction((client) => acceptApplication(client, parsed.data, staff.id)); +} + +export async function registerChild(input: unknown): Promise { + const parsed = applicationSchema.safeParse(input); + if (!parsed.success) return { error: parsed.error.issues[0].message }; + try { + await db.query("DELETE FROM application_limits WHERE window_start < now() - interval '1 day'"); + const limit = await db.query<{ attempts: number }>(`INSERT INTO application_limits(key_hash, attempts, window_start) + VALUES ($1, 1, now()) ON CONFLICT(key_hash) DO UPDATE SET + attempts = CASE WHEN application_limits.window_start < now() - interval '1 hour' THEN 1 ELSE application_limits.attempts + 1 END, + window_start = CASE WHEN application_limits.window_start < now() - interval '1 hour' THEN now() ELSE application_limits.window_start END RETURNING attempts`, + [invitationHash(`${parsed.data.code}:${parsed.data.email}`)]); + if (limit.rows[0].attempts > 10) return { error: "Zu viele Anmeldungen. Bitte in einer Stunde erneut versuchen." }; + } catch (error) { + console.error("Anmeldelimit prüfen fehlgeschlagen", error); + return { error: "Die Anmeldung ist gerade nicht erreichbar. Bitte später erneut versuchen." }; + } + return transaction((client) => submitApplication(client, parsed.data)); +} + +export async function addSlot(input: unknown): Promise { + await requireStaff(); + const parsed = slotSchema.safeParse(input); + if (!parsed.success) return { error: parsed.error.issues[0].message }; + return transaction(async (client) => { + const result = await client.query(`INSERT INTO pickup_slots(starts_at, ends_at) VALUES ($1, $2) + ON CONFLICT(starts_at, ends_at) DO UPDATE SET active = true WHERE NOT pickup_slots.active RETURNING id`, + [parsed.data.start, parsed.data.end]); + if (!result.rowCount) throw new InputError("Dieses Abholfenster existiert bereits."); + }); +} + +export async function toggleSlot(input: unknown): Promise { + await requireStaff(); + const parsed = z.object({ id: idSchema, active: z.boolean() }).safeParse(input); + if (!parsed.success) return { error: "Ungültiger Abholslot." }; + return transaction(async (client) => { + const result = await client.query("UPDATE pickup_slots SET active = $2 WHERE id = $1 RETURNING id", [parsed.data.id, parsed.data.active]); + if (!result.rowCount) throw new InputError("Abholslot nicht gefunden."); + }); +} diff --git a/src/app/admissions/page.tsx b/src/app/admissions/page.tsx new file mode 100644 index 0000000..7cfff36 --- /dev/null +++ b/src/app/admissions/page.tsx @@ -0,0 +1,31 @@ +import { redirect } from "next/navigation"; +import { requireStaff } from "@/lib/auth"; +import { getApplications, getRounds } from "@/lib/admissions"; +import { idSchema } from "@/lib/validation"; +import { localClock } from "@/lib/time"; +import { SectionShell } from "@/components/section-shell"; +import { AdmissionsManager } from "@/components/admissions-manager"; + +export const dynamic = "force-dynamic"; +export default async function AdmissionsPage({ searchParams }: { + searchParams: Promise<{ round?: string; page?: string }>; +}) { + await requireStaff(); + const query = await searchParams; + const rounds = await getRounds(); + if ((query.round && !idSchema.safeParse(query.round).success) + || (query.page && !/^[1-9]\d{0,5}$/.test(query.page))) { + return

Ungültige Auswahl

Zur Aufnahmeübersicht
; + } + const selected = query.round ? rounds.find((round) => round.id === query.round) : rounds[0]; + if (query.round && !selected) { + return

Aufnahmerunde nicht gefunden

Zur Aufnahmeübersicht
; + } + const page = Number(query.page ?? 1); + if (selected && page > Math.max(1, Math.ceil(selected.total / 25))) { + redirect(`/admissions?round=${selected.id}&page=${Math.max(1, Math.ceil(selected.total / 25))}`); + } + return ; +} diff --git a/src/app/children/[id]/not-found.tsx b/src/app/children/[id]/not-found.tsx new file mode 100644 index 0000000..aa0bb80 --- /dev/null +++ b/src/app/children/[id]/not-found.tsx @@ -0,0 +1,3 @@ +export default function ChildNotFound() { + return

Kind nicht gefunden.

Dieses Profil ist nicht vorhanden.

Zur Tagesübersicht
; +} diff --git a/src/app/children/[id]/page.tsx b/src/app/children/[id]/page.tsx new file mode 100644 index 0000000..bd6b4a3 --- /dev/null +++ b/src/app/children/[id]/page.tsx @@ -0,0 +1,40 @@ +import { notFound, redirect } from "next/navigation"; +import { requireStaff } from "@/lib/auth"; +import { daySchema, idSchema } from "@/lib/validation"; +import { localClock } from "@/lib/time"; +import { getProfile } from "@/lib/profile"; +import { ChildProfileView } from "@/components/child-profile"; +import { PROFILE_PAGE_SIZE } from "@/lib/constants"; +import { getSlots } from "@/lib/slots"; + +export const dynamic = "force-dynamic"; +const pageNumber = (value: string | undefined) => { + if (value === undefined) return 1; + return /^[1-9]\d{0,5}$/.test(value) ? Number(value) : null; +}; + +export default async function ProfilePage({ params, searchParams }: { + params: Promise<{ id: string }>; + searchParams: Promise<{ day?: string; history?: string; audit?: string }>; +}) { + await requireStaff(); + const { id } = await params; + if (!idSchema.safeParse(id).success) notFound(); + const query = await searchParams; + const today = localClock().day; + const day = daySchema.safeParse(query.day ?? today); + const historyPage = pageNumber(query.history); + const auditPage = pageNumber(query.audit); + if (!day.success || !historyPage || !auditPage) { + return

Ungültige Auswahl

Bitte ein gültiges Datum und eine gültige Seitennummer verwenden.

Zum Profil
; + } + const profile = await getProfile(id, day.data, historyPage, auditPage); + if (!profile) notFound(); + const lastHistory = Math.max(1, Math.ceil(profile.counts.history / PROFILE_PAGE_SIZE)); + const lastAudit = Math.max(1, Math.ceil(profile.counts.audit / PROFILE_PAGE_SIZE)); + if (historyPage > lastHistory || auditPage > lastAudit) { + redirect(`/children/${id}?day=${day.data}&history=${Math.min(historyPage, lastHistory)}&audit=${Math.min(auditPage, lastAudit)}`); + } + return ; +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..a13c839 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { useEffect } from "react"; + +export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { console.error("KIBU Seitenfehler", error); }, [error]); + return

Das hat leider nicht geklappt.

+

Die Daten konnten nicht geladen werden. Bitte prüfe, ob die Datenbank läuft, und versuche es erneut.

+ +
; +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..e5a6deb --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,542 @@ +:root { + --page: #f7f8fa; + --ink: #25372f; + --muted: #84908b; + --green: #356853; + --green-dark: #28523f; + --line: #e8ece9; + --soft-green: #eaf3ec; + --white: #fff; + --radius: 16px; +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--page); color: var(--ink); font-family: "Segoe UI", Arial, sans-serif; font-size: 14px; -webkit-font-smoothing: antialiased; } +button, input, select { font: inherit; } +button, a, input, select { -webkit-tap-highlight-color: transparent; } +button, select { cursor: pointer; } +button { color: inherit; } +button:disabled { cursor: not-allowed; } +a { color: var(--green); text-decoration: none; } +button, input, select, a { outline-offset: 4px; } +:focus-visible { outline: 3px solid #799e88; } +button { transition: background .15s, border-color .15s, box-shadow .15s; } +button:not(:disabled):active { transform: translateY(1px); } +svg { flex-shrink: 0; vertical-align: middle; } +h1, h2, h3, p { margin: 0; } +.muted { color: #78867f; line-height: 1.65; } +.small { font-size: 12px; } +.brand { display: flex; align-items: center; gap: 10px; font-size: 30px; font-weight: 800; letter-spacing: -1.5px; color: var(--ink); } +.brand-icon { display: grid; place-items: center; background: var(--green); color: white; border-radius: 12px; width: 40px; height: 40px; } +.brand-dot { color: #a4b99b; margin-left: -10px; } +.button { display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid transparent; border-radius: 9px; min-height: 43px; padding: 11px 17px; font-size: 13px; font-weight: 600; white-space: nowrap; } +.button.primary { background: var(--green); color: white; box-shadow: 0 3px 7px #35685314; } +.button.primary:not(:disabled):hover { background: var(--green-dark); } +.button.secondary { background: white; border-color: #dfe6e1; color: #65756a; } +.button.secondary:not(:disabled):hover { background: #f2f6f3; } +.button:disabled { opacity: .55; } +.button.full { width: 100%; } +.icon-button { background: transparent; border: 0; display: inline-grid; place-items: center; border-radius: 7px; padding: 7px; color: #87948b; flex-shrink: 0; } +.icon-button:hover:not(:disabled) { background: #edf2ee; color: var(--green); } +.icon-button:disabled { opacity: .35; } +.text-button { border: 0; background: transparent; color: var(--green); font-size: 12px; font-weight: 600; padding: 8px; } +.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: 1.6px; color: #849185; display: flex; align-items: center; gap: 8px; } +.app-shell { display: flex; min-height: 100vh; } +.sidebar { width: 236px; position: fixed; inset: 0 auto 0 0; background: white; border-right: 1px solid var(--line); padding: 32px 21px 20px; display: flex; flex-direction: column; z-index: 20; } +.sidebar .brand { margin: 1px 9px 49px; } +.workspace-label { font-size: 9px; font-weight: 700; letter-spacing: 1.6px; color: #a0aaa4; margin: 0 12px 17px; } +.nav-item { display: flex; gap: 12px; align-items: center; padding: 14px 13px; font-size: 13px; font-weight: 600; border-radius: 9px; } +.nav-item.active { background: #edf4ee; color: #426c52; } +.nav-indicator { width: 6px; height: 6px; background: #598068; border-radius: 50%; margin-left: auto; } +.sidebar-note { margin: auto 5px 35px; background: #f7f8f2; border: 1px solid #eff0e7; border-radius: 13px; padding: 22px 18px; position: relative; overflow: hidden; } +.note-icon { width: 37px; height: 37px; display: grid; place-items: center; color: #80956b; background: #e9eddc; border-radius: 50%; margin-bottom: 15px; } +.sidebar-note h3 { font-size: 14px; font-weight: 650; margin-bottom: 8px; } +.sidebar-note p { color: #8b9484; font-size: 12px; line-height: 1.8; } +.note-line { display: block; height: 2px; width: 26px; background: #b7c3a6; margin-top: 17px; } +.sidebar-bottom { border-top: 1px solid var(--line); padding-top: 20px; } +.secure-label { font-size: 9px; color: #929e95; display: flex; align-items: center; gap: 6px; justify-content: center; } +.staff-button { display: flex; gap: 10px; align-items: center; text-align: left; width: 100%; background: none; border: 0; padding: 19px 2px 10px; } +.staff-avatar { height: 35px; width: 35px; display: grid; place-items: center; background: #ede8df; color: #897a61; border-radius: 50%; font-weight: 650; } +.staff-button strong { display: block; font-size: 12px; font-weight: 650; } +.staff-button small { display: block; font-size: 10px; color: #9aa39d; margin-top: 4px; } +.staff-button > svg { margin-left: auto; color: #95a197; } +.logout-button { border: 0; background: none; display: flex; gap: 8px; align-items: center; color: #89978e; font-size: 11px; padding: 7px 3px; margin-left: 42px; } +.logout-button:hover { color: var(--green); } +.main-shell { width: calc(100% - 236px); margin-left: 236px; } +.topbar { height: 77px; background: #ffffffc9; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; padding: 0 39px; gap: 15px; } +.breadcrumb { display: flex; align-items: center; gap: 12px; color: #9aa49f; font-size: 11px; } +.breadcrumb strong { color: #5e6f64; font-weight: 500; } +.topbar-right { display: flex; align-items: center; gap: 8px; color: #839188; font-size: 10px; white-space: nowrap; } +.live-dot { height: 6px; width: 6px; display: inline-block; flex-shrink: 0; background: #8ca788; border-radius: 50%; } +.topbar-divider { width: 1px; height: 18px; background: var(--line); margin: 0 10px; } +.topbar-date { text-transform: capitalize; } +.mobile-account, .mobile-logout { display: none; } +.dashboard { max-width: 1540px; margin: 0 auto; padding: 38px 39px 20px; } +.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 27px; } +.tiny-sun { color: #a5a36b; } +.page-heading h1 { font-size: clamp(29px, 2.6vw, 39px); font-weight: 650; letter-spacing: -1.6px; line-height: 1.25; margin: 12px 0 9px; } +.heading-dot { color: #99ad91; } +.page-heading p { color: #8a968e; font-size: 12px; line-height: 1.7; } +.day-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 22px; } +.day-picker { display: flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 9px; background: white; padding: 3px 4px; } +.date-label { display: flex; align-items: center; position: relative; gap: 8px; padding: 0 7px; font-size: 11px; font-weight: 600; cursor: pointer; } +.date-label svg { color: #79947c; } +.date-label input { opacity: 0; position: absolute; inset: 0; width: 100%; cursor: pointer; padding: 0; border: 0; height: 100%; min-width: 0; } +.date-label input::-webkit-calendar-picker-indicator { position: absolute; inset: 0; width: 100%; height: 100%; cursor: pointer; } +.date-label:focus-within { outline: 2px solid #799e88; outline-offset: 4px; border-radius: 3px; } +.refresh-note { margin-left: auto; color: #98a39b; font-size: 10px; display: flex; align-items: center; gap: 6px; } +.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-bottom: 23px; } +.stat-card { background: white; border: 1px solid var(--line); border-radius: 12px; padding: 19px 20px 17px; text-align: left; box-shadow: 0 2px 4px #25372f02; } +.stat-card:hover, .stat-card.selected { border-color: #c9d8cd; } +.stat-top { display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 11px; color: #7e8d82; } +.stat-icon { height: 34px; width: 34px; display: grid; place-items: center; border-radius: 10px; flex-shrink: 0; } +.lilac { background: #eeebf7; color: #9682b0; } +.sage { background: #e9f2e8; color: #739775; } +.peach { background: #faf0e4; color: #be946a; } +.blue { background: #eaf1f8; color: #7d9bb5; } +.yellow { background: #f7f1d8; color: #b2a25f; } +.stat-card > strong { display: block; font-size: 33px; font-weight: 600; letter-spacing: -1px; margin: 6px 0 8px; line-height: 1.1; } +.stat-card > small { display: block; color: #9ba69e; font-size: 10px; line-height: 1.5; } +.pickup-banner { display: flex; align-items: center; width: 100%; text-align: left; border: 1px solid #e8e4d3; border-radius: 11px; background: #fbf8ee; gap: 14px; padding: 15px 20px; margin-bottom: 29px; } +.pickup-banner.has-overdue { border-color: #f0dfc7; background: #fffaef; } +.banner-icon { color: #b49a65; width: 39px; height: 39px; border-radius: 11px; background: #f1ebd9; display: grid; place-items: center; } +.pickup-banner strong { display: block; font-size: 12px; color: #7d6e48; font-weight: 650; margin-bottom: 5px; } +.pickup-banner > span:nth-child(2) > span { display: block; font-size: 11px; color: #a19679; line-height: 1.6; } +.banner-action { display: flex; align-items: center; gap: 9px; font-size: 11px; color: #927d48; font-weight: 600; margin-left: auto; white-space: nowrap; } +.history-notice { display: flex; align-items: center; gap: 10px; color: #788878; background: #edf3ee; padding: 14px 17px; font-size: 12px; line-height: 1.6; border-radius: 10px; margin-bottom: 25px; } +.section-title { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin: 29px 0 21px; } +.section-title h2 { font-size: 20px; letter-spacing: -.5px; font-weight: 650; display: flex; align-items: center; gap: 9px; } +.section-title h2 > span { font-size: 10px; background: #e9eeeb; color: #849587; padding: 3px 7px; border-radius: 5px; letter-spacing: 0; font-weight: 600; } +.section-title p { font-size: 11px; color: #95a098; margin-top: 7px; line-height: 1.7; } +.section-caption { color: #a1ada2; font-size: 10px; display: flex; gap: 6px; align-items: center; } +.filter-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 17px; } +.search-field { display: flex; align-items: center; gap: 11px; background: white; border: 1px solid var(--line); border-radius: 8px; padding: 0 13px; width: 330px; max-width: 100%; color: #9aa79d; } +.search-field input { border: 0; background: transparent; padding: 12px 0; min-width: 0; width: 100%; font-size: 11px; color: var(--ink); outline: none; height: 41px; } +.search-field:focus-within { border-color: #8eac98; box-shadow: 0 0 0 2px #e4eee6; } +input::placeholder { color: #a5aea8; } +.filter-controls { display: flex; gap: 9px; } +.select-control { display: flex; align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 8px; background: white; padding: 0 11px; color: #93a096; } +.select-control select { background: none; border: 0; height: 41px; color: #718174; font-size: 11px; padding-right: 5px; max-width: 180px; } +.filter-tabs { display: flex; flex-wrap: wrap; gap: 5px; border-bottom: 1px solid #e5eae6; padding-bottom: 13px; } +.filter-tab { border: 1px solid transparent; background: transparent; color: #94a097; padding: 8px 11px; display: flex; align-items: center; gap: 7px; border-radius: 7px; font-size: 11px; white-space: nowrap; } +.filter-tab > span { padding: 2px 5px; background: #ebefec; border-radius: 4px; font-size: 9px; min-width: 18px; } +.filter-tab.active { background: #e7f0e8; color: #5a7b61; border-color: #e0eae1; font-weight: 600; } +.filter-tab.active > span { background: #d5e5d7; color: #648168; } +.filter-tab.warning { color: #b58b5e; } +.filter-tab:hover { background: #edf2ee; } +.results-caption { display: flex; align-items: center; justify-content: space-between; color: #a0aaa3; font-size: 10px; padding: 16px 0; } +.results-caption > span:last-child { display: flex; gap: 6px; align-items: center; } +.legend-dot { display: inline-block; width: 5px; height: 5px; background: #a7b59f; border-radius: 50%; } +.children-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 17px; } +.child-card { border: 1px solid #e5eae6; border-radius: 13px; background: white; overflow: hidden; box-shadow: 0 3px 7px #263e2b03; min-width: 0; } +.child-card.card-overdue { border-color: #e5cdaa; } +.child-card.card-due { border-color: #dfd6b7; } +.child-card-top { display: flex; align-items: center; justify-content: space-between; padding: 15px 17px 0; } +.status-badge { display: inline-flex; align-items: center; gap: 5px; border-radius: 5px; padding: 5px 7px; font-size: 9px; font-weight: 500; color: #99a196; background: #f2f4f1; } +.status-badge > span { width: 4px; height: 4px; border-radius: 50%; background: currentColor; } +.status-badge.arrived { color: #6f9576; background: #edf5ec; } +.status-badge.sick { color: #9f8bb2; background: #f3eff8; } +.status-badge.picked_up { color: #8d9ea9; background: #eef3f6; } +.child-card-top .icon-button { padding: 4px; color: #acb5ae; } +.child-identity { display: flex; align-items: center; gap: 12px; padding: 18px 19px 20px; } +.child-avatar { width: 47px; height: 47px; display: grid; place-items: center; border-radius: 15px; font-size: 15px; font-weight: 650; flex-shrink: 0; } +.child-identity > div:last-child { min-width: 0; } +.child-identity h3 { font-size: 14px; font-weight: 650; overflow-wrap: anywhere; letter-spacing: -.2px; line-height: 1.5; } +.child-identity span { font-size: 10px; color: #9ca79f; display: block; margin-top: 4px; overflow-wrap: anywhere; } +.child-schedule { display: flex; align-items: center; justify-content: space-between; gap: 8px; background: #f8faf8; border: 1px solid #eef2ee; border-radius: 8px; width: calc(100% - 38px); margin: 0 19px; padding: 11px 12px; text-align: left; } +.child-schedule:hover { background: #eef4ef; border-color: #dce8df; } +.child-schedule small { display: flex; gap: 5px; align-items: center; font-size: 9px; color: #96a398; margin-bottom: 5px; } +.child-schedule strong { display: block; font-size: 14px; font-weight: 600; color: #5f7264; } +.child-schedule em { font-style: normal; font-size: 9px; color: #a6b0a7; font-weight: 400; } +.schedule-arrow { color: #b8c5ba; padding-top: 10px; } +.schedule-edit-icon { color: #bbc6bc; } +.child-hint { display: flex; align-items: center; gap: 5px; margin: 0 19px; padding: 13px 0; font-size: 9px; color: #a5aea5; min-height: 40px; line-height: 1.5; } +.child-hint.overdue { color: #ba8c57; } +.child-hint.due { color: #ac9a5d; } +.child-actions { display: flex; gap: 5px; padding: 0 15px 15px; } +.attendance-button { border: 1px solid #e8ede8; color: #94a197; background: white; border-radius: 6px; padding: 8px 4px; font-size: 9px; display: flex; align-items: center; justify-content: center; gap: 4px; flex: 1; min-width: 0; } +.attendance-button:first-child { flex: 1.25; } +.attendance-button:hover:not(:disabled) { background: #edf5ef; border-color: #a9c1ae; color: var(--green); } +.attendance-button:disabled { color: #b5bfb7; background: #fafbf9; border-color: #eff2ee; } +.attendance-button.is-arrived:disabled { color: #6b9474; background: #eff6ed; border-color: #e0ebdf; } +.attendance-button.is-sick { color: #9981ac; background: #f5f0f9; border-color: #ebe2f1; } +.attendance-button.is-picked:disabled { color: #819eaf; background: #eff4f7; border-color: #e4edf2; } +.parent-pickup { width: 100%; border: 0; border-top: 1px solid #f0f3ef; background: #fcfdfb; color: #9caaa0; font-size: 9px; display: flex; align-items: center; justify-content: center; gap: 5px; padding: 10px 6px; } +.parent-pickup:hover:not(:disabled) { background: #edf4ed; color: var(--green); } +.parent-pickup:disabled { color: #b8c1b9; } +.empty-state { text-align: center; padding: 65px 20px; border: 1px dashed #d8e2d9; border-radius: 13px; background: #ffffffad; } +.empty-icon { display: grid; place-items: center; width: 70px; height: 70px; border-radius: 23px; background: #edf3eb; color: #8aab8f; margin: 0 auto 20px; } +.empty-state h3 { font-size: 20px; font-weight: 600; letter-spacing: -.5px; } +.empty-state p { font-size: 12px; line-height: 1.8; color: #8f9b93; max-width: 390px; margin: 10px auto 23px; } +.dashboard-footer { display: flex; justify-content: space-between; gap: 15px; font-size: 9px; color: #a6b0a8; border-top: 1px solid var(--line); margin-top: 32px; padding-top: 17px; } +.dashboard-footer > span:first-child { display: flex; gap: 6px; align-items: center; } +.toast { position: fixed; bottom: 24px; left: calc(50% + 118px); transform: translateX(-50%); z-index: 50; display: flex; align-items: center; gap: 11px; padding: 13px 16px; color: #426c52; background: #f3fcf4; border: 1px solid #cfe2d1; box-shadow: 0 8px 35px #25413021; border-radius: 11px; max-width: min(650px, calc(100vw - 32px)); width: max-content; font-size: 12px; } +.toast.error { color: #a65c44; background: #fff7f2; border-color: #eacbbd; } +.modal { padding: 0; border: 1px solid #e0e8df; border-radius: 18px; width: min(590px, calc(100vw - 30px)); max-height: calc(100dvh - 45px); background: white; color: var(--ink); box-shadow: 0 25px 100px #20362a33; } +.modal::backdrop { background: #20342b66; backdrop-filter: blur(3px); } +.modal-content { padding: 26px; } +.modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 25px; } +.modal-header h2 { font-size: 22px; letter-spacing: -.6px; font-weight: 650; } +.modal-header p { font-size: 12px; margin-top: 7px; } +.stack { display: flex; flex-direction: column; gap: 19px; } +label { font-size: 12px; font-weight: 500; } +.stack label:not(.checkbox-label) { display: flex; flex-direction: column; gap: 8px; } +.stack input:not([type="checkbox"]), .stack select { width: 100%; height: 44px; padding: 10px 12px; border: 1px solid #dde5df; border-radius: 8px; background: #fff; color: #4a6050; font-size: 13px; } +.stack input:disabled { background: #f5f7f4; border-color: #ebefea; } +.stack input:focus, .stack select:focus { outline: 2px solid #b5cebd; outline-offset: 1px; border-color: #8eaf98; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; } +.form-fields { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 18px; min-width: 0; } +.form-section-heading { display: flex; align-items: center; gap: 9px; margin-top: 5px; color: #78937e; } +.form-section-heading h3 { font-size: 13px; color: var(--ink); font-weight: 600; } +.form-section-heading + p { margin-top: -9px; } +.schedule-table { border: 1px solid var(--line); border-radius: 9px; overflow: hidden; } +.schedule-row { display: grid; grid-template-columns: 1.25fr 1fr 1fr; gap: 12px; padding: 9px 12px; align-items: center; } +.schedule-row + .schedule-row { border-top: 1px solid #f0f3ee; } +.schedule-labels { background: #f6f8f4; font-size: 10px; color: #8f9d91; } +.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 11px; } +.checkbox-label input { accent-color: var(--green); width: 15px; height: 15px; flex-shrink: 0; } +.schedule-row input[type="time"] { padding: 5px 7px; font-size: 12px; height: 34px; min-width: 0; } +.schedule-row.inactive .checkbox-label { color: #a5afa4; } +.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding-top: 19px; margin-top: 4px; border-top: 1px solid var(--line); } +.info-box { display: flex; align-items: flex-start; gap: 10px; border-radius: 9px; background: #f2f6f0; color: #7e937e; padding: 14px; margin-top: 5px; } +.info-box p { font-size: 12px; line-height: 1.75; } +.info-box svg { margin-top: 2px; } +.form-error { background: #fff2eb; color: #ab5d40; border: 1px solid #f1d7c9; border-radius: 8px; padding: 12px; font-size: 12px; line-height: 1.7; } +.form-success { background: #eff8ed; color: #547752; border: 1px solid #d5e9d1; border-radius: 8px; padding: 12px; font-size: 12px; } +.login-layout { min-height: 100dvh; display: grid; grid-template-columns: 1fr 1fr; background: #fff; } +.login-story { position: relative; overflow: hidden; background: #edf2e8; padding: 45px 60px 30px; display: flex; flex-direction: column; justify-content: space-between; gap: 60px; } +.login-story > div { max-width: 470px; margin: 0 auto; width: 100%; } +.login-story h1 { font-size: clamp(34px, 3.8vw, 57px); letter-spacing: -2px; line-height: 1.16; font-weight: 650; margin: 23px 0; } +.login-story p { font-size: 14px; line-height: 1.9; color: #84917f; max-width: 350px; } +.story-art { height: 230px; position: relative; margin-top: 20px; overflow: hidden; border-radius: 40% 40% 0 0; } +.art-sun { width: 85px; height: 85px; border-radius: 50%; background: #e7dcb0; position: absolute; left: 60%; top: 20px; } +.art-hill { width: 110%; height: 200px; border-radius: 50% 50% 0 0; position: absolute; bottom: -90px; } +.hill-back { background: #d5dfcc; left: 23%; transform: rotate(-20deg); } +.hill-front { background: #bfd0b4; left: -40%; bottom: -95px; transform: rotate(20deg); } +.art-sprout { position: absolute; left: 27%; bottom: 0; color: #71916e; transform: rotate(-12deg); } +.story-footer { color: #94a18d; font-size: 10px; } +.login-panel { display: flex; justify-content: center; align-items: center; padding: 60px 30px; } +.login-form-wrap { width: 360px; max-width: 100%; } +.section-icon { width: 55px; height: 55px; border-radius: 16px; display: grid; place-items: center; background: #edf4ed; color: #7b9d80; margin-bottom: 29px; } +.login-form-wrap h2 { font-size: 29px; font-weight: 650; letter-spacing: -1px; margin: 13px 0 10px; } +.login-form-wrap > .muted { font-size: 12px; margin-bottom: 32px; } +.login-form-wrap .stack { gap: 21px; } +.login-form-wrap .button { margin-top: 4px; } +.login-note { color: #a0aba1; font-size: 10px; display: flex; align-items: center; justify-content: center; gap: 7px; margin-top: 28px; } +.standalone { max-width: 650px; margin: 10vh auto; padding: 30px; } +.standalone h1 { font-size: 27px; margin-bottom: 15px; } +.standalone p { color: #7f8d82; line-height: 1.8; margin-bottom: 25px; } +@media (min-width: 1700px) { .children-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } +@media (max-width: 1250px) { + .sidebar { width: 211px; padding: 29px 15px 18px; } + .main-shell { margin-left: 211px; width: calc(100% - 211px); } + .dashboard { padding: 30px 26px 20px; } + .topbar { padding: 0 26px; } + .children-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .stat-card { padding: 15px 13px; } + .stat-top { font-size: 10px; } + .stat-icon { width: 29px; height: 29px; border-radius: 8px; } + .stats-grid { gap: 11px; } + .toast { left: calc(50% + 105px); } + .section-caption { display: none; } +} +@media (max-width: 960px) { + .sidebar { width: 185px; padding: 25px 11px 18px; } + .sidebar .brand { margin-left: 7px; } + .nav-item { font-size: 11px; gap: 8px; padding: 13px 9px; } + .workspace-label { font-size: 8px; letter-spacing: 1px; margin-left: 9px; } + .sidebar-note { padding: 17px 13px; } + .main-shell { margin-left: 185px; width: calc(100% - 185px); } + .topbar { height: 66px; padding: 0 23px; } + .topbar-date, .topbar-divider { display: none; } + .dashboard { padding: 28px 23px 20px; } + .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .stat-card { padding: 17px; } + .stat-top { font-size: 11px; } + .page-heading { align-items: flex-start; } + .page-heading > .button { font-size: 11px; padding: 10px 12px; margin-top: 24px; } + .page-heading .eyebrow { font-size: 8px; letter-spacing: 1px; } + .page-heading p { font-size: 11px; max-width: 280px; } + .refresh-note { display: none; } + .banner-action { font-size: 0; } + .filter-toolbar { flex-wrap: wrap; gap: 10px; } + .search-field { width: 100%; } + .filter-controls { width: 100%; } + .select-control { flex: 1; } + .select-control select { width: 100%; max-width: none; } + .children-grid { gap: 12px; } + .child-identity { padding: 15px 13px; gap: 9px; } + .child-identity h3 { font-size: 13px; } + .child-avatar { width: 38px; height: 38px; font-size: 13px; border-radius: 12px; } + .child-schedule { margin: 0 12px; width: calc(100% - 24px); padding: 10px 9px; gap: 5px; } + .schedule-edit-icon { display: none; } + .child-card-top { padding: 12px 12px 0; } + .child-hint { margin: 0 12px; font-size: 8px; } + .child-actions { padding: 0 9px 12px; gap: 4px; } + .attendance-button { font-size: 8px; flex-direction: column; gap: 5px; } + .parent-pickup { font-size: 8px; gap: 3px; } + .toast { left: calc(50% + 92px); max-width: calc(100vw - 215px); } + .login-story { padding: 35px 32px; } + .login-story h1 { letter-spacing: -1.4px; } +} +@media (max-width: 700px) { + .sidebar { position: static; width: auto; border-right: 0; border-bottom: 1px solid var(--line); padding: 14px 20px; flex-direction: row; align-items: center; justify-content: space-between; } + .sidebar .brand { margin: 0; font-size: 26px; } + .brand-icon { width: 34px; height: 34px; border-radius: 10px; } + .sidebar .workspace-label, .sidebar-note, .sidebar-bottom { display: none; } + .nav-item { font-size: 10px; padding: 10px; } + .nav-indicator { display: none; } + .app-shell { display: block; } + .main-shell { width: 100%; margin-left: 0; } + .topbar { height: 45px; padding: 0 20px; } + .breadcrumb { font-size: 9px; gap: 6px; } + .topbar-right { font-size: 9px; gap: 5px; } + .mobile-account, .mobile-logout { display: inline-grid; } + .dashboard { padding: 27px 20px 20px; } + .page-heading { flex-wrap: wrap; gap: 18px; margin-bottom: 23px; } + .page-heading h1 { font-size: 32px; } + .page-heading p { max-width: none; font-size: 11px; } + .page-heading > .button { margin-top: 0; } + .day-toolbar { flex-wrap: wrap; } + .day-picker { gap: 1px; } + .date-label { font-size: 10px; } + .day-picker .icon-button { padding: 6px; } + .stats-grid { gap: 11px; } + .stat-card { padding: 15px; } + .stat-card > strong { font-size: 30px; } + .stat-card > small { font-size: 9px; } + .pickup-banner { padding: 13px; gap: 11px; margin-bottom: 25px; } + .pickup-banner strong { font-size: 11px; } + .pickup-banner > span:nth-child(2) > span { font-size: 10px; } + .banner-icon { width: 33px; height: 33px; flex-shrink: 0; } + .section-title h2 { font-size: 19px; } + .section-title p { font-size: 10px; } + .section-title { margin-top: 24px; } + .filter-tabs { gap: 3px; } + .filter-tab { font-size: 10px; padding: 8px; gap: 5px; } + .toast { left: 50%; max-width: calc(100vw - 30px); bottom: 15px; } + .dashboard-footer { flex-wrap: wrap; gap: 12px; line-height: 1.7; } + .login-layout { display: block; } + .login-story { padding: 24px; gap: 30px; } + .login-story > div { margin: 0; max-width: none; } + .login-story h1 { font-size: 32px; margin: 14px 0 13px; } + .login-story p { font-size: 12px; max-width: 350px; } + .login-story .eyebrow { font-size: 8px; } + .story-art, .story-footer { display: none; } + .login-panel { padding: 35px 24px; } + .login-form-wrap { width: 410px; } + .login-form-wrap h2 { font-size: 25px; } + .section-icon { display: none; } +} +@media (max-width: 440px) { + .children-grid { grid-template-columns: 1fr; gap: 16px; } + .child-identity { padding: 17px 18px; gap: 13px; } + .child-avatar { width: 47px; height: 47px; font-size: 15px; } + .child-identity h3 { font-size: 15px; } + .child-card-top { padding: 15px 18px 0; } + .child-schedule { width: calc(100% - 36px); margin: 0 18px; padding: 12px 15px; } + .schedule-edit-icon { display: block; } + .child-hint { margin: 0 18px; font-size: 10px; } + .child-actions { padding: 0 15px 15px; gap: 6px; } + .attendance-button { font-size: 9px; flex-direction: row; padding: 9px 4px; } + .parent-pickup { font-size: 9px; padding: 12px; gap: 5px; } + .topbar-right > .live-dot, .topbar-right { font-size: 0; } + .topbar-right > .live-dot { display: none; } + .modal-content { padding: 20px 16px; } + .modal-header h2 { font-size: 20px; } + .schedule-row { gap: 6px; padding: 9px 7px; grid-template-columns: 1.1fr 1fr 1fr; } + .checkbox-label { font-size: 10px; gap: 5px; } + .schedule-row input[type="time"] { font-size: 11px; padding: 4px; } + .modal-footer .button { font-size: 11px; padding: 10px 12px; } + .form-grid { gap: 10px; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; scroll-behavior: auto !important; } } + +.child-identity h3 a { color: inherit; } +.child-identity h3 a:hover { color: var(--green); text-decoration: underline; } +.card-profile-link { display: flex; align-items: center; justify-content: center; gap: 7px; padding: 11px; border-top: 1px solid var(--line); font-size: 10px; background: #f5f8f3; color: #76917b; } +.card-profile-link:hover { background: #eaf2e8; } +.card-alone-hint { display: flex; align-items: center; gap: 5px; padding: 0 18px 12px; color: #879575; font-size: 10px; line-height: 1.6; } +.pickup-contact-list { margin: 8px 0; padding-left: 17px; font-size: 12px; line-height: 1.8; } +.info-box a { font-size: 12px; text-decoration: underline; } +.stack textarea { resize: vertical; min-height: 80px; width: 100%; padding: 11px 12px; border: 1px solid #dde5df; border-radius: 8px; color: var(--ink); font: inherit; font-size: 13px; } +.profile-shell { min-height: 100vh; } +.profile-topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 22px max(24px, calc((100vw - 1300px) / 2)); background: white; border-bottom: 1px solid var(--line); } +.profile-main { max-width: 1348px; margin: auto; padding: 34px 24px; } +.profile-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 30px; } +.profile-identity { display: flex; align-items: center; gap: 20px; min-width: 0; } +.profile-avatar { width: 75px; height: 75px; border-radius: 23px; display: grid; place-items: center; background: #e4ecdf; color: #739475; font-size: 25px; font-weight: 650; flex-shrink: 0; } +.profile-heading h1 { font-size: 32px; letter-spacing: -1px; margin: 8px 0 5px; overflow-wrap: anywhere; } +.profile-heading p { font-size: 12px; } +.profile-columns { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 23px; align-items: start; } +.profile-primary, .profile-secondary { display: flex; flex-direction: column; gap: 23px; min-width: 0; } +.profile-panel { padding: 24px; border: 1px solid var(--line); border-radius: 14px; background: white; min-width: 0; } +.panel-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; } +.panel-title h2 { display: flex; align-items: center; gap: 9px; font-size: 17px; font-weight: 600; letter-spacing: -.3px; } +.panel-title h2 svg { color: #8ca28d; } +.panel-title input { border: 1px solid var(--line); border-radius: 7px; padding: 7px; color: #788a7b; background: #fafcf8; font-size: 12px; } +.profile-day-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin: 22px 0; } +.profile-day-stats > div { background: #f6f9f4; border: 1px solid #edf2e8; border-radius: 10px; padding: 17px 13px; } +.profile-day-stats small { display: block; font-size: 11px; color: #92a08d; margin-bottom: 9px; } +.profile-day-stats strong { font-size: 17px; font-weight: 600; color: #58765b; } +.correction-button { margin-top: 19px; } +.count-pill { font-size: 11px; color: #869c83; background: #edf4e9; padding: 4px 8px; border-radius: 6px; } +.history-table-wrap { overflow-x: auto; margin: 16px -3px 0; } +.history-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 12px; } +.history-table th { font-size: 10px; font-weight: 500; color: #97a290; padding: 12px 7px; background: #f8faf5; } +.history-table td { border-bottom: 1px solid #edf2e9; padding: 13px 7px; white-space: nowrap; color: #7b8c76; } +.history-day-link { border: 0; background: transparent; padding: 3px 0; color: #57754f; font-size: 12px; } +.history-day-link:hover { text-decoration: underline; } +.history-kind { display: block; font-size: 9px; color: #a0aa98; margin-top: 6px; } +.profile-pagination { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 18px; font-size: 10px; color: #9aa58f; } +.profile-pagination a { font-weight: 600; padding: 7px 0; } +.profile-empty { color: #a0aa96; font-size: 12px; line-height: 1.8; margin: 20px 0; } +.audit-list { margin-top: 12px; } +.audit-entry { padding: 17px 0; border-bottom: 1px solid var(--line); } +.audit-entry-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px; } +.audit-entry-heading strong { color: #6a805f; font-size: 12px; font-weight: 600; } +.audit-entry time { color: #9eaa96; font-size: 10px; } +.audit-entry p { margin: 8px 0; color: #84927a; font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; } +.audit-entry > small { font-size: 10px; color: #a6af9b; overflow-wrap: anywhere; } +.audit-details { margin-top: 12px; font-size: 11px; color: #77906a; } +.audit-details summary { cursor: pointer; } +.audit-details dl { background: #f7faf4; border-radius: 8px; padding: 7px 12px; margin: 12px 0 0; } +.audit-details dl > div { padding: 7px 0; display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 8px; } +.audit-details dt { color: #9ba791; } +.audit-details dd { margin: 0; line-height: 1.7; overflow-wrap: anywhere; } +.audit-details dd strong { font-weight: 500; } +.contact-card { border: 1px solid #e8eee1; border-radius: 10px; padding: 14px; margin: 15px 0; background: #fdfefa; } +.contact-card.not-authorized { background: #faf8f6; border-color: #ede6dd; } +.contact-heading { display: flex; justify-content: space-between; align-items: start; gap: 6px; } +.contact-heading strong { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } +.contact-heading small { display: block; margin-top: 5px; font-size: 11px; color: #99a58e; } +.permission-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 9px; padding: 4px 6px; border-radius: 4px; margin-top: 12px; } +.permission-badge.allowed { background: #eaf3e4; color: #7d9a6f; } +.permission-badge.denied { background: #f6e8df; color: #b48b6f; } +.contact-phone { margin-top: 12px; font-size: 12px; color: #829275; } +.contact-notes { margin-top: 10px; color: #9ca68f; font-size: 11px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; } +.alone-rules-list { list-style: none; padding: 0; margin: 17px 0; } +.alone-rules-list li { display: flex; justify-content: space-between; gap: 10px; border-bottom: 1px solid #edf2e7; padding: 11px 0; font-size: 12px; color: #96a18a; } +.alone-rules-list strong { font-weight: 500; color: #79916b; } +.alone-rule-table { border: 1px solid var(--line); border-radius: 10px; padding: 4px 13px; } +.alone-rule-row { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 11px 0; } +.alone-rule-row + .alone-rule-row { border-top: 1px solid var(--line); } +.stack .alone-rule-row > label:last-child { flex-direction: row; align-items: center; font-size: 11px; color: #93a086; } +.alone-rule-row input[type="time"] { max-width: 130px; } +.profile-message { display: flex; gap: 10px; align-items: center; background: #edf5e6; border: 1px solid #dfead6; color: #789267; padding: 12px 15px; border-radius: 9px; margin-bottom: 23px; font-size: 12px; } +.profile-message .icon-button { margin-left: auto; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +@media (max-width: 1000px) { + .profile-columns { grid-template-columns: minmax(0, 1fr) 290px; gap: 16px; } + .profile-panel { padding: 19px; } + .panel-title { flex-wrap: wrap; } + .profile-day-stats { gap: 8px; } + .profile-day-stats > div { padding: 14px 9px; } + .profile-day-stats strong { font-size: 14px; } +} +@media (max-width: 780px) { + .profile-columns { grid-template-columns: 1fr; } + .profile-secondary { grid-row: 1; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } + .profile-secondary > .info-box { grid-column: 1 / -1; margin: 0; } + .profile-heading { flex-wrap: wrap; } + .profile-heading h1 { font-size: 27px; } +} +@media (max-width: 500px) { + .profile-main { padding: 25px 15px; } + .profile-topbar { padding: 16px; gap: 10px; } + .profile-topbar .brand { font-size: 25px; } + .profile-topbar .button { font-size: 10px; padding: 9px 10px; } + .profile-heading h1 { font-size: 24px; } + .profile-avatar { width: 57px; height: 57px; border-radius: 18px; font-size: 19px; } + .profile-identity { gap: 13px; } + .profile-secondary { display: flex; } + .profile-panel { padding: 17px; } + .panel-title h2 { font-size: 16px; } + .profile-day-stats { gap: 6px; } + .profile-day-stats strong { font-size: 12px; } + .profile-day-stats small { font-size: 9px; } + .audit-details dl > div { grid-template-columns: 1fr; gap: 3px; } + .history-table td { font-size: 10px; padding: 11px 5px; } +} + +.children-section { scroll-margin-top: 20px; } +.children-section:focus { outline: none; } +.slot-overview { display: flex; flex-wrap: wrap; gap: 10px; margin: 22px 0 15px; } +.slot-overview-button { border: 1px solid #e1e9df; border-radius: 10px; padding: 13px 15px; background: white; display: flex; gap: 8px; align-items: center; flex: 1; color: #769178; white-space: nowrap; } +.slot-overview-button strong { font-size: 12px; font-weight: 600; } +.slot-overview-button span { font-size: 10px; color: #9aa891; margin-left: auto; } +.slot-overview-button.active, .slot-overview-button:hover { background: #eaf3e7; border-color: #b6ceb1; } +.pickup-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.stack .pickup-field select { font-size: 11px; height: 36px; padding: 6px 4px; min-width: 0; } +.schedule-row { grid-template-columns: 1fr .8fr 1.65fr; gap: 9px; } +.field-label { display: flex; flex-direction: column; gap: 8px; font-size: 12px; font-weight: 500; } +.slots-management { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin: 23px 0; } +.slot-management-card { display: flex; align-items: center; gap: 14px; } +.slot-management-card > svg { color: #93aa88; } +.slot-management-card h2 { font-size: 18px; margin-bottom: 5px; } +.slot-management-card .button { margin-left: auto; font-size: 10px; padding: 9px; } +.inline-form.stack { display: flex; flex-direction: row; align-items: end; gap: 15px; margin-top: 23px; flex-wrap: wrap; } +.inline-form label { flex: 1; min-width: 100px; } +.admissions-layout { display: grid; grid-template-columns: 270px minmax(0, 1fr); gap: 24px; align-items: start; } +.round-list { display: flex; flex-direction: column; gap: 12px; } +.round-card { border: 1px solid #e1e8dc; border-radius: 11px; background: white; padding: 18px; display: flex; flex-direction: column; gap: 10px; color: #72816a; overflow-wrap: anywhere; } +.round-card.active { background: #edf4e9; border-color: #b9cbb0; } +.round-card strong { font-size: 14px; font-weight: 600; } +.round-card span { font-size: 11px; } +.round-card small { font-size: 10px; color: #9aa58d; } +.admission-actions { display: flex; gap: 12px; margin: 23px 0; flex-wrap: wrap; } +.admission-detail .info-box .button { margin-top: 14px; } +.application-list { display: flex; flex-direction: column; gap: 14px; margin-top: 23px; } +.application-card { padding: 19px; border: 1px solid #e8eee1; border-radius: 11px; } +.application-card.selected { border-color: #bdd3b2; background: #fbfef8; } +.application-heading { display: flex; justify-content: space-between; align-items: start; gap: 13px; margin-bottom: 11px; } +.application-heading h3 { font-size: 16px; font-weight: 600; overflow-wrap: anywhere; } +.application-heading p { color: #9ca58f; font-size: 11px; margin-top: 7px; } +.application-card .button { margin-top: 16px; } +.application-card > p { overflow-wrap: anywhere; } +.application-notes { font-size: 12px; line-height: 1.7; color: #8c997b; padding: 12px; border-radius: 8px; background: #f7faf1; white-space: pre-wrap; margin-top: 13px; } +.invitation-box { margin-bottom: 25px; display: flex; align-items: start; flex-direction: column; gap: 14px; } +.invitation-box h2 { display: flex; align-items: center; gap: 10px; font-size: 19px; } +.invitation-box label { width: 100%; } +.invitation-box input { display: block; width: 100%; margin-top: 10px; border: 1px solid #dfe8d8; background: #f7faf4; border-radius: 7px; padding: 12px; color: #68815e; font-size: 12px; } +.parent-registration { max-width: 650px; margin: 25px auto; padding: 34px; } +.parent-registration h1 { font-size: 30px; letter-spacing: -.8px; margin: 12px 0 14px; } +.parent-registration > .info-box { margin: 23px 0; } +.parent-registration .stack { margin-top: 25px; } +.parent-registration h2 { font-size: 16px; font-weight: 600; margin-top: 8px; } +.consent-label { line-height: 1.8; align-items: start; } +.consent-label input { margin-top: 5px; } +.registration-success { padding: 35px 0; text-align: center; } +.registration-success p + p { margin-top: 15px; } +@media (max-width: 1250px) { + .filter-controls { flex-wrap: wrap; } + .filter-toolbar { align-items: start; } + .slots-management { grid-template-columns: 1fr; } +} +@media (max-width: 960px) { .sidebar nav .nav-item { font-size: 10px; } } +@media (max-width: 780px) { + .admissions-layout { grid-template-columns: 1fr; } + .round-list { flex-direction: row; overflow-x: auto; padding-bottom: 10px; } + .round-card { min-width: 220px; max-width: 260px; } +} +@media (max-width: 700px) { + .sidebar { flex-wrap: wrap; gap: 12px; } + .sidebar nav { display: flex; flex-wrap: wrap; gap: 5px; } + .sidebar nav .nav-item { font-size: 9px; gap: 6px; padding: 9px 7px; } + .sidebar nav svg { width: 14px; } + .filter-controls { flex-wrap: wrap; } + .select-control { min-width: 120px; } + .slot-overview-button { padding: 11px; gap: 6px; min-width: 180px; } + .slot-overview-button strong { font-size: 11px; } + .slot-management-card { flex-wrap: wrap; } + .parent-registration { padding: 23px; } + .parent-registration h1 { font-size: 26px; } + .invitation-box input { font-size: 10px; } +} +@media (max-width: 440px) { + .slot-overview-button { min-width: calc(50% - 5px); flex-wrap: wrap; } + .slot-overview-button span { margin-left: 22px; } + .schedule-row { grid-template-columns: .9fr .7fr 1.4fr; gap: 5px; } + .stack .pickup-field select { font-size: 10px; } + .application-card { padding: 14px; } + .application-heading { flex-wrap: wrap; } +} diff --git a/src/app/join/[code]/page.tsx b/src/app/join/[code]/page.tsx new file mode 100644 index 0000000..673f0b9 --- /dev/null +++ b/src/app/join/[code]/page.tsx @@ -0,0 +1,12 @@ +import { publicRound } from "@/lib/admissions"; +import { ParentRegistration } from "@/components/parent-registration"; +import { SectionShell } from "@/components/section-shell"; + +export const dynamic = "force-dynamic"; +export default async function RegistrationPage({ params }: { params: Promise<{ code: string }> }) { + const { code } = await params; + const round = await publicRound(code); + return
{round + ? + : <>

Anmeldung nicht verfügbar

Der Code ist ungültig, abgelaufen oder die Anmelderunde bereits geschlossen. Bitte wendet euch an die Betreuung.

Anderen Code eingeben}
; +} diff --git a/src/app/join/page.tsx b/src/app/join/page.tsx new file mode 100644 index 0000000..dd47d16 --- /dev/null +++ b/src/app/join/page.tsx @@ -0,0 +1,7 @@ +import { InvitationEntry } from "@/components/parent-registration"; +import { SectionShell } from "@/components/section-shell"; + +export default function JoinPage() { + return
WILLKOMMEN BEI KIBU

Ein Platz zum Wachsen.

+

Bitte den Einladungscode eurer Betreuung eingeben.

; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..3d973bf --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "KIBU · Kinderbetreuung", + description: "Ankommen. Wohlfühlen. Gut nach Hause kommen.", + robots: { index: false, follow: false }, + referrer: "no-referrer", +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return {children}; +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..5fbc80c --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,29 @@ +import { redirect } from "next/navigation"; +import { currentStaff } from "@/lib/auth"; +import { LoginForm } from "@/components/login-form"; +import { Sprout, ShieldCheck, Sun } from "lucide-react"; + +export const dynamic = "force-dynamic"; + +export default async function LoginPage() { + if (await currentStaff()) redirect("/"); + return
+
+ KIBU. +
MEHR RAUM FÜR DAS WICHTIGE +

Kleine Menschen.
Großer Überblick.

+

Weniger Listen, mehr Miteinander. Alles für einen entspannten Betreuungstag an einem Ort.

+
+
+
+ SCHÖN, DASS DU DA BIST +

Willkommen zurück.

Melde dich für euren Betreuungsbereich an.

+ +

Geschützter Zugang für Betreuungspersonen

+
+
+
; +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..eca9b04 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,24 @@ +import { requireStaff } from "@/lib/auth"; +import { getChildren } from "@/lib/data"; +import { localClock } from "@/lib/time"; +import { daySchema } from "@/lib/validation"; +import { Dashboard } from "@/components/dashboard"; +import { getSlots } from "@/lib/slots"; + +export const dynamic = "force-dynamic"; + +export default async function Home({ searchParams }: { + searchParams: Promise<{ day?: string }>; +}) { + const staff = await requireStaff(); + const params = await searchParams; + const clock = localClock(); + const parsed = params.day === undefined ? null : daySchema.safeParse(params.day); + if (parsed && !parsed.success) { + return

Ungültiges Datum

Bitte ein Datum zwischen 2000 und 2100 wählen.

Zum heutigen Tag
; + } + const day = parsed?.data ?? clock.day; + const [children, slots] = await Promise.all([getChildren(day), getSlots()]); + return ; +} diff --git a/src/app/profile-actions.ts b/src/app/profile-actions.ts new file mode 100644 index 0000000..f0806d2 --- /dev/null +++ b/src/app/profile-actions.ts @@ -0,0 +1,72 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { db } from "@/lib/db"; +import { requireStaff } from "@/lib/auth"; +import { aloneRulesSchema, contactSchema, correctionSchema, type ActionResult } from "@/lib/validation"; +import { auditContext } from "@/lib/audit"; +import { applyCorrection, InputError } from "@/lib/corrections"; +import type { PoolClient } from "pg"; + +async function mutate(childId: string, staffId: string, reason: string, work: (client: PoolClient) => Promise): Promise { + let client: PoolClient | undefined; + try { + client = await db.connect(); + await client.query("BEGIN"); + const child = await client.query("SELECT id FROM children WHERE id = $1 FOR UPDATE", [childId]); + if (!child.rowCount) throw new InputError("Kind nicht gefunden."); + await auditContext(client, staffId, reason); + await work(client); + await client.query("COMMIT"); + } catch (error) { + if (client) await client.query("ROLLBACK"); + if (error instanceof InputError) return { error: error.message }; + console.error("Profiländerung fehlgeschlagen", error); + return { error: "Änderung konnte nicht gespeichert werden. Bitte erneut versuchen." }; + } finally { + client?.release(); + } + revalidatePath("/"); + revalidatePath(`/children/${childId}`); + return { success: true }; +} + +export async function correctAttendance(input: unknown): Promise { + const staff = await requireStaff(); + const parsed = correctionSchema.safeParse(input); + if (!parsed.success) return { error: parsed.error.issues[0].message }; + return mutate(parsed.data.childId, staff.id, parsed.data.reason, (client) => applyCorrection(client, staff.id, parsed.data)); +} + +export async function saveContact(input: unknown): Promise { + const staff = await requireStaff(); + const parsed = contactSchema.safeParse(input); + if (!parsed.success) return { error: parsed.error.issues[0].message }; + const contact = parsed.data; + return mutate(contact.childId, staff.id, "Abholberechtigung bearbeitet", async (client) => { + if (contact.id) { + const result = await client.query(`UPDATE pickup_contacts SET name = $3, relationship = $4, + phone = $5, authorized = $6, notes = $7 WHERE id = $1 AND child_id = $2 RETURNING id`, + [contact.id, contact.childId, contact.name, contact.relationship, contact.phone, contact.authorized, contact.notes]); + if (!result.rowCount) throw new InputError("Abholperson nicht gefunden. Bitte neu laden."); + } else { + await client.query(`INSERT INTO pickup_contacts(child_id, name, relationship, phone, authorized, notes) + VALUES ($1, $2, $3, $4, $5, $6)`, + [contact.childId, contact.name, contact.relationship, contact.phone, contact.authorized, contact.notes]); + } + }); +} + +export async function saveAloneRules(input: unknown): Promise { + const staff = await requireStaff(); + const parsed = aloneRulesSchema.safeParse(input); + if (!parsed.success) return { error: parsed.error.issues[0].message }; + const { childId, rules } = parsed.data; + return mutate(childId, staff.id, "Regeln zum Alleingehen angepasst", async (client) => { + await client.query("DELETE FROM alone_rules WHERE child_id = $1 AND NOT (weekday = ANY($2::int[]))", [childId, rules.map((r) => r.weekday)]); + for (const rule of rules) { + await client.query(`INSERT INTO alone_rules(child_id, weekday, earliest) VALUES ($1, $2, $3) + ON CONFLICT(child_id, weekday) DO UPDATE SET earliest = EXCLUDED.earliest`, [childId, rule.weekday, rule.earliest]); + } + }); +} diff --git a/src/app/slots/page.tsx b/src/app/slots/page.tsx new file mode 100644 index 0000000..32cfbac --- /dev/null +++ b/src/app/slots/page.tsx @@ -0,0 +1,10 @@ +import { requireStaff } from "@/lib/auth"; +import { getSlots } from "@/lib/slots"; +import { SectionShell } from "@/components/section-shell"; +import { SlotManager } from "@/components/slot-manager"; + +export const dynamic = "force-dynamic"; +export default async function SlotsPage() { + await requireStaff(); + return ; +} diff --git a/src/components/account-form.tsx b/src/components/account-form.tsx new file mode 100644 index 0000000..6df9728 --- /dev/null +++ b/src/components/account-form.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useActionState } from "react"; +import { KeyRound } from "lucide-react"; +import { changePassword } from "@/app/actions"; +import { Modal } from "./modal"; + +export function AccountForm({ email, onClose }: { email: string; onClose: () => void }) { + const [state, action, pending] = useActionState(changePassword, {}); + return +
+ + + +

Mindestens 12 Zeichen. Andere Sitzungen werden nach der Änderung abgemeldet.

+ {state.error &&

{state.error}

} + {state.success &&

Dein Passwort wurde geändert.

} +
+
+
+
; +} diff --git a/src/components/admissions-manager.tsx b/src/components/admissions-manager.tsx new file mode 100644 index 0000000..5495979 --- /dev/null +++ b/src/components/admissions-manager.tsx @@ -0,0 +1,118 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState, useTransition } from "react"; +import { Check, Copy, Plus, Shuffle, Ticket, Users } from "lucide-react"; +import { closeRound, confirmAdmission, createRound, rotateInvitation, runDraw } from "@/app/admission-actions"; +import type { Application, Round } from "@/lib/admissions"; +import type { ActionResult } from "@/lib/validation"; +import { Modal } from "./modal"; + +type Dialog = { kind: "new" } | { kind: "close" } | { kind: "rotate" } | { kind: "draw" } + | { kind: "accept"; application: Application } | null; +const applicationLabels = { waiting: "Angemeldet", selected: "Ausgewählt", waitlisted: "Warteliste", accepted: "Übernommen" }; +const roundLabels = { open: "Anmeldung offen", closed: "Anmeldung geschlossen", drawn: "Ausgelost" }; + +export function AdmissionsManager({ rounds, selected, applications, page, today, timeZone }: { + rounds: Round[]; selected: Round | null; applications: Application[]; page: number; today: string; + timeZone: string; +}) { + const router = useRouter(); + const [dialog, setDialog] = useState(null); + const [result, setResult] = useState({}); + const [invitation, setInvitation] = useState(""); + const [pending, startTransition] = useTransition(); + const [copied, setCopied] = useState(false); + useEffect(() => { + const timer = setInterval(() => { if (!dialog) router.refresh(); }, 30_000); + return () => clearInterval(timer); + }, [router, dialog]); + function run(action: () => Promise) { + setResult({}); + startTransition(async () => { + try { + const response = await action(); + setResult(response); + if (response.success) { + setDialog(null); + if (response.code) { setInvitation(response.code); setCopied(false); } + if (response.roundId) router.push(`/admissions?round=${response.roundId}`); + } + } catch (error) { + console.error("Aufnahme speichern", error); + setResult({ error: "Speichern fehlgeschlagen. Bitte erneut versuchen." }); + } + }); + } + function open(next: Dialog) { setResult({}); setDialog(next); } + const error = result.error &&

{result.error}

; + return <> +
FAIR VERTEILTE PLÄTZE

Aufnahme & Warteliste.

+

Einladen, anmelden und gleichberechtigt auslosen.

+ {!dialog && error} + {!dialog && result.success &&

Änderung gespeichert.

} + {invitation &&

Einladung ist bereit

+

Code jetzt sichern und mit den Eltern teilen. Er wird nur hier einmal angezeigt. Ein neuer Code macht den vorherigen ungültig.

+ + Elternformular öffnen ↗ + +
} + {!rounds.length ?

Raum für neue Gesichter.

Erstelle eine Aufnahmerunde und teile den Einladungscode mit den Eltern.

+ :
+ {selected &&

{selected.name}

{roundLabels[selected.status]}
+

Anmeldung bis {selected.expiresOn} einschließlich · {selected.total} {selected.total === 1 ? "Kind" : "Kinder"} · {selected.accepted} übernommen

+ {selected.status === "open" &&
+
} + {selected.status === "closed" &&

Alle Anmeldungen sind eingefroren. Jetzt X Plätze gleichberechtigt und einmalig auslosen.

+
} + {selected.status === "drawn" &&

{selected.places} Plätze wurden am {selected.drawnAt ? new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short", timeZone }).format(new Date(selected.drawnAt)) : "—"} ausgelost. + {" "}{selected.selected} Kinder warten auf Bestätigung. Nicht ausgewählte Kinder bleiben auf der Warteliste. Eine erneute Auslosung ist ausgeschlossen.

} +
{applications.map((application) =>
+

{application.firstName} {application.lastName}

Klasse / Gruppe {application.className}

{applicationLabels[application.status]}
+

Kontakt: {application.parentName} · {application.email}

+ {application.notes &&

{application.notes}

} + {application.status === "selected" && } + {application.childId && Kinderprofil öffnen} +
)}
+ {!applications.length &&

Noch keine Anmeldungen in dieser Runde.

} + +
} +
} + {dialog?.kind === "new" && setDialog(null)} busy={pending}> +
{ + event.preventDefault(); const data = new FormData(event.currentTarget); + run(() => createRound({ name: data.get("name"), expiresOn: data.get("expiresOn") })); + }}> + + {error}
+
} + {dialog && dialog.kind !== "new" && selected && setDialog(null)} busy={pending}> +
{ + event.preventDefault(); const form = new FormData(event.currentTarget); + if (dialog.kind === "draw") run(() => runDraw({ roundId: selected.id, places: Number(form.get("places")) })); + else if (dialog.kind === "accept") run(() => confirmAdmission(dialog.application.id)); + else if (dialog.kind === "rotate") run(() => rotateInvitation(selected.id)); + else run(() => closeRound(selected.id)); + }}> + {dialog.kind === "draw" ? <>

Jede der {selected.total} Anmeldungen hat dieselbe Chance. Die Auslosung kann nicht wiederholt werden. Ausgewählte Kinder werden noch nicht automatisch angelegt.

+ + : dialog.kind === "accept" ? <>

{dialog.application.firstName} {dialog.application.lastName} wird als Kind ins Dashboard übernommen.

+

Wochenplan und Abholberechtigungen anschließend im Profil ergänzen. Ein Elternkontakt ist nicht automatisch abholberechtigt. Bitte vorher prüfen, ob das Kind bereits unabhängig von dieser Runde angelegt wurde.

+ :

{dialog.kind === "rotate" ? "Der bisherige Code und Einladungslink werden sofort ungültig. Bestehende Anmeldungen bleiben erhalten." : "Es sind danach keine weiteren Anmeldungen möglich. Anschließend kann die einmalige Auslosung starten."}

} + {error}
+
+
+
} + ; +} diff --git a/src/components/child-form.tsx b/src/components/child-form.tsx new file mode 100644 index 0000000..b50026f --- /dev/null +++ b/src/components/child-form.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { CalendarDays, Check, Save } from "lucide-react"; +import { saveChild, saveOverride } from "@/app/actions"; +import type { Child } from "@/lib/data"; +import type { Schedule } from "@/lib/validation"; +import { displayDay } from "@/lib/time"; +import { Modal } from "./modal"; +import type { PickupSlot } from "@/lib/slots"; +import { PickupField } from "./pickup-field"; + +const weekdays = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]; + +export function ChildForm({ child, slots, onClose, onSaved }: { + child?: Child; slots: PickupSlot[]; onClose: () => void; onSaved: (message: string) => void; +}) { + const defaultSlot = slots.find((s) => s.active && s.start === "16:00") ?? slots.find((s) => s.active); + const defaultSchedule = (weekday: number) => ({ weekday, arrival: "12:00", pickup: defaultSlot?.start ?? "16:00", slotId: defaultSlot?.id ?? null }); + const [schedules, setSchedules] = useState(child?.schedules + ?? [1, 2, 3, 4, 5].map(defaultSchedule)); + const [error, setError] = useState(""); + const [pending, startTransition] = useTransition(); + function toggle(weekday: number) { + setSchedules((previous) => previous.some((s) => s.weekday === weekday) + ? previous.filter((s) => s.weekday !== weekday) + : [...previous, defaultSchedule(weekday)]); + } + function update(weekday: number, field: "arrival" | "pickup", value: string) { + setSchedules((previous) => previous.map((s) => s.weekday === weekday ? { ...s, [field]: value } : s)); + } + return +
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + setError(""); + startTransition(async () => { + try { + const result = await saveChild({ + id: child?.id, firstName: data.get("firstName"), lastName: data.get("lastName"), + className: data.get("className"), schedules, + }); + if (result.error) setError(result.error); + else onSaved(child ? "Änderungen gespeichert." : "Kind erfolgreich angelegt."); + } catch (error) { + console.error("Kind speichern", error); + setError("Speichern fehlgeschlagen. Bitte die Verbindung prüfen und erneut versuchen."); + } + }); + }}> +
+
+
+ +

Regelmäßiger Wochenplan

+

Aktive Tage auswählen. Einzelne Abweichungen kannst du später im Tagesplan hinterlegen.

+
+
BetreuungstagAnkunftAbholung
+ {weekdays.map((day, index) => { + const weekday = index + 1; + const schedule = schedules.find((s) => s.weekday === weekday); + return
+ + update(weekday, "arrival", e.target.value)} /> + setSchedules((previous) => previous.map((s) => s.weekday === weekday ? { ...s, slotId, pickup } : s))} /> +
; + })} +
+
+ {error &&

{error}

} +
+
+
+
; +} + +export function DayForm({ child, day, slots, onClose, onSaved }: { + child: Child; day: string; slots: PickupSlot[]; onClose: () => void; onSaved: (message: string) => void; +}) { + const [mode, setMode] = useState(child.overrideMode); + const [error, setError] = useState(""); + const [pending, startTransition] = useTransition(); + const initialSlot = child.pickup ? slots.find((s) => s.id === child.slotId) : slots.find((s) => s.active); + const [pickup, setPickup] = useState(child.pickup ?? initialSlot?.start ?? "16:00"); + const [slotId, setSlotId] = useState(initialSlot?.id ?? null); + return +
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + setError(""); + startTransition(async () => { + try { + const result = await saveOverride({ childId: child.id, day, mode, + arrival: data.get("arrival") ?? "", pickup, slotId }); + if (result.error) setError(result.error); + else onSaved("Tagesplan gespeichert."); + } catch (error) { + console.error("Tagesplan speichern", error); + setError("Tagesplan konnte nicht gespeichert werden. Bitte erneut versuchen."); + } + }); + }}> +
+ + {mode === "custom" &&
+ +
Geplante Abholung { setSlotId(id); setPickup(time); }} />
+
} +

Diese Änderung gilt nur für den ausgewählten Tag. Anwesenheit und Krankmeldung bleiben davon unberührt.

+
+ {error &&

{error}

} +
+
+
+
; +} diff --git a/src/components/child-profile.tsx b/src/components/child-profile.tsx new file mode 100644 index 0000000..78b0483 --- /dev/null +++ b/src/components/child-profile.tsx @@ -0,0 +1,143 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { ArrowLeft, CalendarDays, Check, Clock3, Footprints, History, Pencil, Plus, ShieldCheck, Sprout, Users, X } from "lucide-react"; +import type { AttendanceRecord, AuditEntry, ChildProfile, PickupContact } from "@/lib/profile"; +import { PROFILE_PAGE_SIZE } from "@/lib/constants"; +import { displayDay, timeInZone } from "@/lib/time"; +import { ChildForm } from "./child-form"; +import { AloneRulesForm, ContactForm, CorrectionForm, weekdayNames } from "./profile-forms"; +import type { PickupSlot } from "@/lib/slots"; + +type Dialog = { kind: "correction"; record: AttendanceRecord } | { kind: "contact"; contact?: PickupContact } + | { kind: "alone" } | { kind: "child" } | null; +const labels = { expected: "Kein Eintrag", arrived: "Anwesend", sick: "Krank", picked_up: "Gegangen" }; +const pickupLabels = { planned: "Planmäßig abgeholt", parents: "Von Eltern abgeholt", alone: "Allein gegangen" }; +const entities: Record = { + attendance: "Anwesenheit", children: "Stammdaten", weekly_schedules: "Wochenplan", + day_overrides: "Tagesplan", pickup_contacts: "Abholberechtigung", alone_rules: "Alleingehen", +}; +const fields: Record = { + first_name: "Vorname", last_name: "Nachname", class_name: "Klasse", day: "Tag", weekday: "Wochentag", + arrival: "Geplante Ankunft", pickup: "Geplante Abholung", attending: "Betreuung geplant", + status: "Status", arrived_at: "Angekommen", picked_up_at: "Gegangen", pickup_kind: "Art des Verlassens", + name: "Name", relationship: "Beziehung", phone: "Telefon", authorized: "Abholberechtigt", notes: "Hinweise", earliest: "Allein ab", + pickup_end: "Abholfenster bis", pickup_slot_id: "Abholslot (ID)", +}; +function auditValue(key: string, value: unknown, timeZone: string): string { + if (value === null || value === undefined || value === "") return "—"; + if (typeof value === "boolean") return value ? "Ja" : "Nein"; + if (key === "weekday" && typeof value === "number") return weekdayNames[value - 1]; + if (key === "status" && typeof value === "string" && value in labels) return labels[value as keyof typeof labels]; + if (key === "pickup_kind" && typeof value === "string" && value in pickupLabels) return pickupLabels[value as keyof typeof pickupLabels]; + if ((key === "arrived_at" || key === "picked_up_at") && typeof value === "string") { + return new Intl.DateTimeFormat("de-DE", { timeZone, dateStyle: "short", timeStyle: "short" }).format(new Date(value)); + } + return String(value); +} +function AuditDetails({ entry, timeZone }: { entry: AuditEntry; timeZone: string }) { + const keys = Object.keys(fields).filter((key) => JSON.stringify(entry.before?.[key]) !== JSON.stringify(entry.after?.[key])); + return
Änderungen ansehen ({keys.length}) +
{keys.map((key) =>
{fields[key]}
{auditValue(key, entry.before?.[key], timeZone)} → {auditValue(key, entry.after?.[key], timeZone)}
)}
+
; +} + +export function ChildProfileView({ profile, day, today, timeZone, historyPage, auditPage, slots }: { + profile: ChildProfile; day: string; today: string; timeZone: string; historyPage: number; auditPage: number; + slots: PickupSlot[]; +}) { + const router = useRouter(); + const [dialog, setDialog] = useState(null); + const [message, setMessage] = useState(""); + const { child, contacts, rules, selected } = profile; + useEffect(() => { + const timer = setInterval(() => { if (!dialog) router.refresh(); }, 30_000); + return () => clearInterval(timer); + }, [router, dialog]); + function saved(text: string) { setDialog(null); setMessage(text); } + function url(values: { day?: string; history?: number; audit?: number }) { + const query = new URLSearchParams({ day: values.day ?? day, history: String(values.history ?? historyPage), audit: String(values.audit ?? auditPage) }); + return `/children/${child.id}?${query}`; + } + function pager(section: "history" | "audit", page: number, count: number) { + const total = Math.max(1, Math.ceil(count / PROFILE_PAGE_SIZE)); + return ; + } + const stamp = (value: string | null) => value ? `${timeInZone(value, timeZone)} Uhr` : "—"; + return
+
KIBU. + Tagesübersicht
+
+
{child.firstName[0]}{child.lastName[0]}
+
KINDERPROFIL

{child.firstName} {child.lastName}

Klasse / Gruppe {child.className}

+
+ {message &&
{message}
} +
+
+
+

Tagesdetails

{ if (event.target.value) router.push(url({ day: event.target.value })); }} />
+

{displayDay(day)} · {timeZone}

+
Status{labels[selected.status]}
+
Angekommen{stamp(selected.arrivedAt)}
Gegangen{stamp(selected.pickedUpAt)}
+

Ankunft: {child.arrival ?? "—"} Uhr · Abholung: {child.pickup ?? "—"}{child.pickupEnd ? `–${child.pickupEnd}` : ""} Uhr{selected.pickupKind ? ` · ${pickupLabels[selected.pickupKind]}` : ""}

+ + {day > today &&

Tatsächliche Anwesenheit ist erst am Betreuungstag erfassbar.

} +
+

Anwesenheitsverlauf

{profile.counts.history}
+

Tatsächliche Zeiten, unabhängig vom aktuellen Wochenplan.

+
+ {profile.history.map((record) => + + )} +
TagStatusAnkunftGegangenAktion
{labels[record.status]}{record.pickupKind && {pickupLabels[record.pickupKind]}}{stamp(record.arrivedAt)}{stamp(record.pickedUpAt)}
+ {!profile.history.length &&

Noch keine Anwesenheiten erfasst. Über die Tagesdetails kannst du einen vergessenen Eintrag nachtragen.

} + {pager("history", historyPage, profile.counts.history)} +
+

Audit-Log

{profile.counts.audit}
+

Nachvollziehbar und unveränderlich: wer hat was wann geändert?

+
{profile.audit.map((entry) =>
+
{entities[entry.entity] ?? entry.entity} · {entry.operation === "INSERT" ? "angelegt" : entry.operation === "DELETE" ? "entfernt" : "geändert"} +
+

{entry.reason}

{entry.actorName}{entry.day ? ` · Betreuungstag ${entry.day}` : ""} · #{entry.id} + +
)}
+ {!profile.audit.length &&

Noch keine protokollierten Änderungen. Die Aufzeichnung beginnt mit Schritt 2.

} + {pager("audit", auditPage, profile.counts.audit)} +
+
+ +
+
KIBU · Gut begleitet.Alle Zeiten in {timeZone}
+
+ {dialog?.kind === "correction" && setDialog(null)} onSaved={saved} />} + {dialog?.kind === "contact" && setDialog(null)} onSaved={saved} />} + {dialog?.kind === "alone" && setDialog(null)} onSaved={saved} />} + {dialog?.kind === "child" && setDialog(null)} onSaved={saved} />} +
; +} diff --git a/src/components/dashboard.tsx b/src/components/dashboard.tsx new file mode 100644 index 0000000..cc49aca --- /dev/null +++ b/src/components/dashboard.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { + ArrowDownUp, ArrowRight, CalendarDays, Check, ChevronLeft, ChevronRight, Clock3, Heart, + LayoutDashboard, LogOut, Plus, Search, Settings2, ShieldCheck, SlidersHorizontal, + Sprout, Sun, Users, X, CircleCheck, AlertCircle, DoorOpen, Thermometer, Pencil, UserRound, Footprints, +} from "lucide-react"; +import { logout, setAttendance } from "@/app/actions"; +import type { Staff } from "@/lib/auth"; +import type { Child } from "@/lib/data"; +import { displayDay, pickupState } from "@/lib/time"; +import { ChildForm, DayForm } from "./child-form"; +import { AccountForm } from "./account-form"; +import { Modal } from "./modal"; +import type { PickupSlot } from "@/lib/slots"; + +type Filter = "all" | "arrived" | "expected" | "due" | "overdue" | "sick" | "picked_up"; +type AttendanceAction = "arrived" | "sick" | "expected" | "planned" | "parents" | "alone"; +type Dialog = { kind: "child"; child?: Child } | { kind: "day"; child: Child } + | { kind: "parents"; child: Child } | { kind: "alone"; child: Child } | { kind: "account" } | null; +const statusNames = { expected: "Erwartet", arrived: "Anwesend", sick: "Krank", picked_up: "Abgeholt" }; + +function isPlanned(child: Child) { return child.arrival !== null || child.status !== "expected"; } +function initials(child: Child) { return `${child.firstName[0]}${child.lastName[0]}`.toLocaleUpperCase("de"); } +function avatarColor(child: Child) { + return ["peach", "lilac", "sage", "blue", "yellow"][ + Array.from(child.id).reduce((sum, char) => sum + char.charCodeAt(0), 0) % 5]; +} + +export function Dashboard({ childrenData, staff, day, today, time, timeZone, slots }: { + childrenData: Child[]; staff: Staff; day: string; today: string; time: string; timeZone: string; + slots: PickupSlot[]; +}) { + const router = useRouter(); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const [classFilter, setClassFilter] = useState(""); + const [sort, setSort] = useState("name"); + const [slotFilter, setSlotFilter] = useState(""); + const resultsRef = useRef(null); + const [scrollRequest, setScrollRequest] = useState(0); + const [dialog, setDialog] = useState(null); + const [notice, setNotice] = useState<{ text: string; error: boolean } | null>(null); + const [pending, startTransition] = useTransition(); + const isToday = day === today; + useEffect(() => { + if (!scrollRequest) return; + resultsRef.current?.scrollIntoView({ + behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth", block: "start", + }); + resultsRef.current?.focus({ preventScroll: true }); + }, [scrollRequest]); + function showResults(nextFilter: Filter, slot = "") { + setFilter(nextFilter); setSearch(""); setClassFilter(""); setSlotFilter(slot); + setScrollRequest((value) => value + 1); + } + useEffect(() => { + const interval = setInterval(() => router.refresh(), 30_000); + const refresh = () => { if (document.visibilityState === "visible") router.refresh(); }; + document.addEventListener("visibilitychange", refresh); + return () => { clearInterval(interval); document.removeEventListener("visibilitychange", refresh); }; + }, [router]); + useEffect(() => { + if (!notice || notice.error) return; + const timer = setTimeout(() => setNotice(null), 5000); + return () => clearTimeout(timer); + }, [notice]); + const classes = Array.from(new Set(childrenData.map((c) => c.className))).sort((a, b) => a.localeCompare(b, "de")); + const planned = childrenData.filter(isPlanned); + const present = childrenData.filter((c) => c.status === "arrived"); + const due = isToday ? present.filter((c) => ["due", "overdue"].includes(pickupState(c.pickup, time, c.pickupEnd))) : []; + const overdue = due.filter((c) => pickupState(c.pickup, time, c.pickupEnd) === "overdue"); + const pickedUp = childrenData.filter((c) => c.status === "picked_up"); + const sick = childrenData.filter((c) => c.status === "sick"); + const expected = planned.filter((c) => c.status === "expected"); + const visible = useMemo(() => childrenData.filter((child) => { + const query = search.trim().toLocaleLowerCase("de"); + const matchesSearch = `${child.firstName} ${child.lastName} ${child.className}`.toLocaleLowerCase("de").includes(query); + const matchesClass = !classFilter || child.className === classFilter; + const matchesFilter = filter === "all" ? true + : filter === "expected" ? child.status === "expected" && isPlanned(child) + : filter === "due" ? isToday && child.status === "arrived" && ["due", "overdue"].includes(pickupState(child.pickup, time, child.pickupEnd)) + : filter === "overdue" ? isToday && child.status === "arrived" && pickupState(child.pickup, time, child.pickupEnd) === "overdue" + : child.status === filter; + return matchesSearch && matchesClass && matchesFilter && (!slotFilter || child.slotId === slotFilter); + }).sort((a, b) => sort === "pickup" + ? (a.pickup ?? "99:99").localeCompare(b.pickup ?? "99:99") || a.lastName.localeCompare(b.lastName, "de") + : a.lastName.localeCompare(b.lastName, "de") || a.firstName.localeCompare(b.firstName, "de")), + [childrenData, search, classFilter, filter, sort, time, isToday, slotFilter]); + + function saved(message: string) { + setDialog(null); + setNotice({ text: message, error: false }); + } + function attendance(child: Child, action: AttendanceAction) { + setNotice(null); + startTransition(async () => { + try { + const result = await setAttendance({ childId: child.id, day, action }); + if (result.error) setNotice({ text: result.error, error: true }); + else saved(action === "parents" ? `${child.firstName} wurde außerplanmäßig von Eltern abgeholt.` + : action === "alone" ? `${child.firstName} ist allein gegangen.` + : action === "expected" ? "Krankmeldung aufgehoben." + : `${child.firstName}: ${statusNames[action === "planned" ? "picked_up" : action]}.`); + } catch (error) { + console.error("Anwesenheit ändern", error); + setNotice({ text: "Änderung fehlgeschlagen. Bitte die Verbindung prüfen und erneut versuchen.", error: true }); + } + }); + } + function navigateDate(value: string) { + if (!value) return; + setFilter("all"); + router.push(value === today ? "/" : `/?day=${encodeURIComponent(value)}`); + } + function shiftDay(offset: number) { + const date = new Date(`${day}T12:00:00Z`); + date.setUTCDate(date.getUTCDate() + offset); + const next = date.toISOString().slice(0, 10); + if (next >= "2000-01-01" && next <= "2100-12-31") navigateDate(next); + } + const filters: { key: Filter; label: string; count: number }[] = [ + { key: "all", label: "Alle Kinder", count: childrenData.length }, + { key: "arrived", label: "Anwesend", count: present.length }, + { key: "expected", label: "Erwartet", count: expected.length }, + ...(isToday ? [{ key: "due" as const, label: "Jetzt abholen", count: due.length }, + { key: "overdue" as const, label: "Überfällig", count: overdue.length }] : []), + { key: "sick", label: "Krank", count: sick.length }, + { key: "picked_up", label: "Abgeholt / gegangen", count: pickedUp.length }, + ]; + + return
+ +
+
Betreuungsbereich Tagesübersicht
+
Alles im Blick + + {displayDay(today)} +
+
+
+
+
EIN GUTER TAG BEGINNT MIT ÜBERBLICK
+

{isToday ? Number(time.slice(0, 2)) < 11 ? "Guten Morgen" : Number(time.slice(0, 2)) < 18 ? "Hallo zusammen" : "Guten Abend" : "Euer Tagesüberblick"}.

+

Wer ist schon da, wer geht nach Hause? Hier läuft alles zusammen.

+ +
+
+ + + +
{!isToday && } + {time} Uhr · aktualisiert sich automatisch +
+
+ {[ + { label: isToday ? "Heute eingeplant" : "Eingeplant", value: planned.length, icon: Users, color: "lilac", detail: `${childrenData.length} Kinder insgesamt`, key: "all" as Filter }, + { label: "Gerade anwesend", value: present.length, icon: CircleCheck, color: "sage", detail: "Angekommen & gut betreut", key: "arrived" as Filter }, + { label: isToday ? "Jetzt abholen" : "Bereits gegangen", value: isToday ? due.length : pickedUp.length, icon: Clock3, color: "peach", + detail: isToday ? overdue.length ? `${overdue.length} davon überfällig` : "In den nächsten 15 Minuten" : "Für diesen Tag dokumentiert", key: isToday ? "due" as Filter : "picked_up" as Filter }, + { label: "Krank gemeldet", value: sick.length, icon: Thermometer, color: "blue", detail: "Wir wünschen gute Besserung", key: "sick" as Filter }, + ].map((stat) => )} +
+ {isToday && due.length > 0 && } + {!isToday &&
Du siehst einen anderen Tag. Anwesenheit kann nur heute erfasst werden; Tagespläne lassen sich weiterhin anpassen.
} +
{slots.filter((slot) => slot.active || childrenData.some((child) => child.slotId === slot.id)).map((slot) => { + const count = childrenData.filter((child) => child.slotId === slot.id && child.status === "arrived").length; + return ; + })}
+
+

Eure Kinder {childrenData.length}

Ein kleiner Überblick für einen großen Betreuungstag.

+ Zusammen gut organisiert
+
+ +
+ +
+
+
{filters.map((item) => )}
+
{visible.length} {visible.length === 1 ? "Kind" : "Kinder"} angezeigt + {isToday ? "Anwesenheit für heute" : displayDay(day)}
+ {visible.length > 0 ?
{visible.map((child) => { + const timing = isToday && child.status === "arrived" ? pickupState(child.pickup, time, child.pickupEnd) : "none"; + return
+
{child.status === "picked_up" && child.pickupKind === "alone" ? "Allein gegangen" : child.status === "expected" && !child.arrival ? "Nicht eingeplant" : statusNames[child.status]} + +
+
{initials(child)}

{child.firstName} {child.lastName}

Klasse / Gruppe {child.className}
+ +
+ {timing === "overdue" ? <>Abholung überfällig · noch anwesend + : timing === "due" ? <>{child.pickupEnd && child.pickup && child.pickup <= time ? "Abholfenster läuft jetzt" : "Abholung in den nächsten 15 Minuten"} + : child.status === "picked_up" ? <>{child.pickupKind === "alone" ? "Allein gegangen" : child.pickupKind === "parents" ? "Außerplanmäßig von Eltern abgeholt" : "Planmäßig abgeholt"}{child.pickedUpAt && ` · ${new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone }).format(new Date(child.pickedUpAt))}`} + : child.status === "arrived" && child.arrivedAt ? <>Seit {new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone }).format(new Date(child.arrivedAt))} Uhr da{child.arrival ? "" : " · außerplanmäßig"} + : child.overrideMode === "custom" ? <>Individueller Tagesplan + : child.overrideMode === "absent" ? <>Für diesen Tag abgemeldet + : child.status === "sick" ? <>Heute wird sich ausgeruht + : <>{child.arrival ? "Regelmäßiger Wochenplan" : "Keine Betreuung geplant"}} +
+ {child.aloneFrom &&
Darf heute ab {child.aloneFrom} Uhr allein gehen
} +
+ + + +
+ + {child.aloneFrom && } + Profil, Abholberechtigte & Verlauf +
; + })}
:
+

{childrenData.length ? "Hier ist gerade alles ruhig." : "Platz für eure ersten Kinder."}

+

{childrenData.length ? "Keine Kinder passen zu dieser Suche oder diesem Filter." : "Lege das erste Kind mit Klasse und Betreuungszeiten an. Dann kann euer Tag beginnen."}

+ +
} +
+
KIBU · Mehr Zeit fürs Miteinander.Alle Zeiten in {timeZone}
+
+
+ {notice &&
{notice.error ? : }{notice.text} +
} + {dialog?.kind === "child" && setDialog(null)} onSaved={saved} />} + {dialog?.kind === "day" && setDialog(null)} onSaved={saved} />} + {dialog?.kind === "account" && setDialog(null)} />} + {dialog?.kind === "parents" && setDialog(null)}> +

Bestätige, dass das Kind jetzt außerplanmäßig von einem Elternteil abgeholt wurde. Der tatsächliche Zeitpunkt wird gespeichert.

+

Die Abholberechtigung bitte persönlich prüfen.

+ {dialog.child.contacts.length ?
    {dialog.child.contacts.map((contact, index) =>
  • {contact.name} · {contact.relationship}
  • )}
:

Keine bestätigten Abholberechtigten hinterlegt.

} + Berechtigungen im Profil ansehen
+ {notice?.error &&

{notice.text}

} +
+
+
} + {dialog?.kind === "alone" && setDialog(null)}> +

Heute ab {dialog.child.aloneFrom} Uhr erlaubt. Bestätige erst, wenn das Kind tatsächlich allein geht. Die Uhrzeit und dein Zugang werden protokolliert.

+ {notice?.error &&

{notice.text}

} +
+
+
} +
; +} diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx new file mode 100644 index 0000000..863798c --- /dev/null +++ b/src/components/login-form.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { ArrowRight } from "lucide-react"; +import { login } from "@/app/actions"; + +export function LoginForm() { + const [state, action, pending] = useActionState(login, {}); + const [email, setEmail] = useState(""); + return
+ + + {state.error &&

{state.error}

} + +
; +} diff --git a/src/components/modal.tsx b/src/components/modal.tsx new file mode 100644 index 0000000..1b1ec79 --- /dev/null +++ b/src/components/modal.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { X } from "lucide-react"; + +export function Modal({ title, subtitle, onClose, busy = false, children }: { + title: string; subtitle?: string; onClose: () => void; busy?: boolean; children: React.ReactNode; +}) { + const ref = useRef(null); + useEffect(() => { + const dialog = ref.current!; + dialog.showModal(); + return () => dialog.close(); + }, []); + return { event.preventDefault(); if (!busy) onClose(); }} + onClick={(event) => { if (event.target === event.currentTarget && !busy) onClose(); }}> +
+
{subtitle &&

{subtitle}

}
+ +
+ {children} +
+
; +} diff --git a/src/components/parent-registration.tsx b/src/components/parent-registration.tsx new file mode 100644 index 0000000..6973bd5 --- /dev/null +++ b/src/components/parent-registration.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Check, Heart, Send } from "lucide-react"; +import { registerChild } from "@/app/admission-actions"; + +export function InvitationEntry() { + const router = useRouter(); + const [code, setCode] = useState(""); + const [error, setError] = useState(""); + return
{ + event.preventDefault(); + const value = code.trim().toLowerCase(); + if (!/^[a-f0-9]{48}$/.test(value)) { setError("Bitte den vollständigen 48-stelligen Einladungscode eingeben."); return; } + router.push(`/join/${value}`); + }}> + {error &&

{error}

}
; +} + +export function ParentRegistration({ code, round }: { code: string; round: { name: string; expiresOn: string } }) { + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); + const [pending, startTransition] = useTransition(); + if (success) return

Anmeldung angekommen.

+

Euer Kind ist für diese Aufnahmerunde vorgemerkt. Das ist noch keine Platzzusage. Die Betreuung informiert euch über das Ergebnis.

Es wird keine automatische Bestätigungs-E-Mail verschickt.

; + return <>ELTERNANMELDUNG +

Ein Platz zum Wachsen.

{round.name} · Anmeldung bis einschließlich {round.expiresOn}

+

Bei mehr Anmeldungen als Plätzen wird gleichberechtigt ausgelost. Die Anmeldung ist keine Platzzusage. Bitte jedes Kind nur einmal anmelden; Geschwister können einzeln angemeldet werden.

+
{ + event.preventDefault(); const form = new FormData(event.currentTarget); setError(""); + startTransition(async () => { + try { + const result = await registerChild({ code, firstName: form.get("firstName"), lastName: form.get("lastName"), + className: form.get("className"), parentName: form.get("parentName"), email: form.get("email"), + notes: form.get("notes"), consent: form.get("consent") === "on" }); + if (result.error) setError(result.error); else setSuccess(true); + } catch (error) { console.error("Elternanmeldung", error); setError("Anmeldung fehlgeschlagen. Bitte erneut versuchen."); } + }); + }}>
+

Über euer Kind

+
+ +

Eure Kontaktdaten

+ +