feat: Betreuung, Kinderprofile und Aufnahmeverwaltung implementieren

Dashboard mit Abholslots und Filter-Scrolling, Anwesenheit mit Audit-Verlauf sowie Elternanmeldung, Warteliste und bestätigte Aufnahme ergänzen.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul SchoeneckandCopilot committed 2026-11-03 08:01:25 +01:00
1 parent 10af6159b4
commit c1513c040b
36 files changed
+2593

No files matched your search

+237
View File
@@ -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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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 };
}
+122
View File
@@ -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<void>): Promise<ActionResult> {
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<ActionResult & { code?: string; roundId?: string }> {
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<ActionResult & { code?: string }> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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.");
});
}
+31
View File
@@ -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 <SectionShell><h1>Ungültige Auswahl</h1><a href="/admissions">Zur Aufnahmeübersicht</a></SectionShell>;
}
const selected = query.round ? rounds.find((round) => round.id === query.round) : rounds[0];
if (query.round && !selected) {
return <SectionShell><h1>Aufnahmerunde nicht gefunden</h1><a href="/admissions">Zur Aufnahmeübersicht</a></SectionShell>;
}
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 <SectionShell><AdmissionsManager rounds={rounds} selected={selected ?? null}
applications={selected ? await getApplications(selected.id, page) : []} page={page} today={localClock().day}
timeZone={process.env.APP_TIMEZONE ?? "Europe/Berlin"} /></SectionShell>;
}
+3
View File
@@ -0,0 +1,3 @@
export default function ChildNotFound() {
return <main className="standalone"><h1>Kind nicht gefunden.</h1><p>Dieses Profil ist nicht vorhanden.</p><a href="/">Zur Tagesübersicht</a></main>;
}
+40
View File
@@ -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 <main className="standalone"><h1>Ungültige Auswahl</h1><p>Bitte ein gültiges Datum und eine gültige Seitennummer verwenden.</p><a href={`/children/${id}`}>Zum Profil</a></main>;
}
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 <ChildProfileView profile={profile} day={day.data} today={today} historyPage={historyPage}
auditPage={auditPage} slots={await getSlots()} timeZone={process.env.APP_TIMEZONE ?? "Europe/Berlin"} />;
}
+11
View File
@@ -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 <main className="standalone"><h1>Das hat leider nicht geklappt.</h1>
<p>Die Daten konnten nicht geladen werden. Bitte prüfe, ob die Datenbank läuft, und versuche es erneut.</p>
<button className="button primary" onClick={reset}>Erneut versuchen</button>
</main>;
}
+542
View File
@@ -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; }
}
+12
View File
@@ -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 <SectionShell><section className="parent-registration profile-panel">{round
? <ParentRegistration code={code} round={round} />
: <><h1>Anmeldung nicht verfügbar</h1><p className="muted">Der Code ist ungültig, abgelaufen oder die Anmelderunde bereits geschlossen. Bitte wendet euch an die Betreuung.</p><a href="/join">Anderen Code eingeben</a></>}</section></SectionShell>;
}
+7
View File
@@ -0,0 +1,7 @@
import { InvitationEntry } from "@/components/parent-registration";
import { SectionShell } from "@/components/section-shell";
export default function JoinPage() {
return <SectionShell><section className="parent-registration profile-panel"><span className="eyebrow">WILLKOMMEN BEI KIBU</span><h1>Ein Platz zum Wachsen.</h1>
<p className="muted">Bitte den Einladungscode eurer Betreuung eingeben.</p><InvitationEntry /></section></SectionShell>;
}
+13
View File
@@ -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 <html lang="de"><body>{children}</body></html>;
}
+29
View File
@@ -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 <main className="login-layout">
<section className="login-story">
<a className="brand" href="/"><span className="brand-icon"><Sprout size={24} /></span>KIBU<span className="brand-dot">.</span></a>
<div><span className="eyebrow"><Sun size={16} /> MEHR RAUM FÜR DAS WICHTIGE</span>
<h1>Kleine Menschen.<br />Großer Überblick.</h1>
<p>Weniger Listen, mehr Miteinander. Alles für einen entspannten Betreuungstag an einem Ort.</p>
<div className="story-art" aria-hidden="true"><div className="art-sun" /><div className="art-hill hill-back" /><div className="art-hill hill-front" /><Sprout className="art-sprout" size={130} strokeWidth={1.3} /></div>
</div>
<span className="story-footer">Mit Sorgfalt für euren Alltag gemacht.</span>
</section>
<section className="login-panel">
<div className="login-form-wrap"><span className="section-icon"><ShieldCheck size={25} /></span>
<span className="eyebrow">SCHÖN, DASS DU DA BIST</span>
<h2>Willkommen zurück.</h2><p className="muted">Melde dich für euren Betreuungsbereich an.</p>
<LoginForm />
<p className="login-note"><ShieldCheck size={14} /> Geschützter Zugang für Betreuungspersonen</p>
</div>
</section>
</main>;
}
+24
View File
@@ -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 <main className="standalone"><h1>Ungültiges Datum</h1><p>Bitte ein Datum zwischen 2000 und 2100 wählen.</p><a href="/">Zum heutigen Tag</a></main>;
}
const day = parsed?.data ?? clock.day;
const [children, slots] = await Promise.all([getChildren(day), getSlots()]);
return <Dashboard childrenData={children} staff={staff} day={day} today={clock.day}
time={clock.time} slots={slots} timeZone={process.env.APP_TIMEZONE ?? "Europe/Berlin"} />;
}
+72
View File
@@ -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<void>): Promise<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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]);
}
});
}
+10
View File
@@ -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 <SectionShell><SlotManager slots={await getSlots()} /></SectionShell>;
}
+22
View File
@@ -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 <Modal title="Dein Zugang" subtitle={email} onClose={onClose} busy={pending}>
<form action={action} className="stack">
<label>Aktuelles Passwort<input type="password" name="current" required autoComplete="current-password" maxLength={256} /></label>
<label>Neues Passwort<input type="password" name="password" required minLength={12} maxLength={256} autoComplete="new-password" /></label>
<label>Neues Passwort wiederholen<input type="password" name="confirm" required maxLength={256} autoComplete="new-password" /></label>
<p className="small muted">Mindestens 12 Zeichen. Andere Sitzungen werden nach der Änderung abgemeldet.</p>
{state.error && <p className="form-error" role="alert">{state.error}</p>}
{state.success && <p className="form-success" role="status">Dein Passwort wurde geändert.</p>}
<footer className="modal-footer"><button type="button" className="button secondary" disabled={pending} onClick={onClose}>Schließen</button>
<button className="button primary" disabled={pending}><KeyRound size={16} />{pending ? "Wird geändert …" : "Passwort ändern"}</button></footer>
</form>
</Modal>;
}
+118
View File
@@ -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<Dialog>(null);
const [result, setResult] = useState<ActionResult>({});
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<ActionResult & { code?: string; roundId?: string }>) {
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 && <p className="form-error" role="alert">{result.error}</p>;
return <>
<div className="page-heading"><div><span className="eyebrow">FAIR VERTEILTE PLÄTZE</span><h1>Aufnahme & Warteliste<span className="heading-dot">.</span></h1>
<p>Einladen, anmelden und gleichberechtigt auslosen.</p></div><button className="button primary" onClick={() => open({ kind: "new" })}><Plus size={17} />Aufnahmerunde erstellen</button></div>
{!dialog && error}
{!dialog && result.success && <p className="form-success" role="status">Änderung gespeichert.</p>}
{invitation && <section className="invitation-box profile-panel"><h2><Ticket size={20} />Einladung ist bereit</h2>
<p className="muted small">Code jetzt sichern und mit den Eltern teilen. Er wird nur hier einmal angezeigt. Ein neuer Code macht den vorherigen ungültig.</p>
<label>Einladungscode<input readOnly value={invitation} /></label>
<Link href={`/join/${invitation}`} target="_blank" rel="noreferrer">Elternformular öffnen ↗</Link>
<button className="button secondary" onClick={async () => {
try { await navigator.clipboard.writeText(`${window.location.origin}/join/${invitation}`); setCopied(true); }
catch (error) { console.error("Einladung kopieren", error); setResult({ error: "Kopieren nicht möglich. Bitte den Code manuell markieren und kopieren." }); }
}}><Copy size={15} />{copied ? "Link kopiert" : "Einladungslink kopieren"}</button>
</section>}
{!rounds.length ? <div className="empty-state"><span className="empty-icon"><Users size={30} /></span><h3>Raum für neue Gesichter.</h3><p>Erstelle eine Aufnahmerunde und teile den Einladungscode mit den Eltern.</p></div>
: <div className="admissions-layout"><aside className="round-list" aria-label="Aufnahmerunden">{rounds.map((round) => <Link className={`round-card ${selected?.id === round.id ? "active" : ""}`}
key={round.id} href={`/admissions?round=${round.id}`} onClick={() => { setResult({}); setInvitation(""); }}>
<strong>{round.name}</strong><span>{roundLabels[round.status]}{round.status === "open" && round.expiresOn < today ? " · Frist abgelaufen" : ""}</span><small>{round.total} {round.total === 1 ? "Anmeldung" : "Anmeldungen"} · {round.accepted} übernommen</small>
</Link>)}</aside>
{selected && <section className="profile-panel admission-detail"><div className="panel-title"><h2>{selected.name}</h2><span className="permission-badge allowed">{roundLabels[selected.status]}</span></div>
<p className="muted small">Anmeldung bis {selected.expiresOn} einschließlich · {selected.total} {selected.total === 1 ? "Kind" : "Kinder"} · {selected.accepted} übernommen</p>
{selected.status === "open" && <div className="admission-actions"><button className="button secondary" disabled={pending} onClick={() => open({ kind: "rotate" })}>Neuen Einladungscode erzeugen</button>
<button className="button primary" disabled={pending} onClick={() => open({ kind: "close" })}>Anmeldung schließen</button></div>}
{selected.status === "closed" && <div className="info-box"><Shuffle size={20} /><div><p>Alle Anmeldungen sind eingefroren. Jetzt X Plätze gleichberechtigt und einmalig auslosen.</p>
<button className="button primary" disabled={!selected.total || pending} onClick={() => open({ kind: "draw" })}><Shuffle size={16} />Plätze auslosen</button></div></div>}
{selected.status === "drawn" && <div className="info-box"><Check size={20} /><p>{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.</p></div>}
<div className="application-list">{applications.map((application) => <article className={`application-card ${application.status}`} key={application.id}>
<div className="application-heading"><div><h3>{application.firstName} {application.lastName}</h3><p>Klasse / Gruppe {application.className}</p></div><span className="count-pill">{applicationLabels[application.status]}</span></div>
<p className="small muted">Kontakt: {application.parentName} · {application.email}</p>
{application.notes && <p className="application-notes">{application.notes}</p>}
{application.status === "selected" && <button className="button primary" disabled={pending} onClick={() => open({ kind: "accept", application })}><Check size={15} />Aufnahme bestätigen</button>}
{application.childId && <Link className="button secondary" href={`/children/${application.childId}`}>Kinderprofil öffnen</Link>}
</article>)}</div>
{!applications.length && <p className="profile-empty">Noch keine Anmeldungen in dieser Runde.</p>}
<nav className="profile-pagination" aria-label="Anmeldeseiten">{page > 1 ? <Link href={`/admissions?round=${selected.id}&page=${page - 1}`}>← Zurück</Link> : <span />}
<span>Seite {page} / {Math.max(1, Math.ceil(selected.total / 25))} · {selected.total} {selected.total === 1 ? "Anmeldung" : "Anmeldungen"}</span>
{page * 25 < selected.total ? <Link href={`/admissions?round=${selected.id}&page=${page + 1}`}>Weiter →</Link> : <span />}</nav>
</section>}
</div>}
{dialog?.kind === "new" && <Modal title="Aufnahmerunde erstellen" subtitle="Ein Code für die Anmeldung mehrerer Familien" onClose={() => setDialog(null)} busy={pending}>
<form className="stack" onSubmit={(event) => {
event.preventDefault(); const data = new FormData(event.currentTarget);
run(() => createRound({ name: data.get("name"), expiresOn: data.get("expiresOn") }));
}}><label>Name der Runde<input name="name" required maxLength={80} placeholder="z. B. Schuljahr 2027/28" /></label>
<label>Anmeldung bis einschließlich<input name="expiresOn" type="date" min={today} max="2100-12-31" required defaultValue={today} /></label>
{error}<footer className="modal-footer"><button type="button" className="button secondary" disabled={pending} onClick={() => setDialog(null)}>Abbrechen</button><button className="button primary" disabled={pending}>Runde erstellen</button></footer>
</form></Modal>}
{dialog && dialog.kind !== "new" && selected && <Modal
title={dialog.kind === "draw" ? "Plätze verbindlich auslosen" : dialog.kind === "accept" ? "Aufnahme bestätigen" : dialog.kind === "rotate" ? "Einladungscode ersetzen" : "Anmeldung schließen"}
subtitle={selected.name} onClose={() => setDialog(null)} busy={pending}>
<form className="stack" onSubmit={(event) => {
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" ? <><p className="muted">Jede der {selected.total} Anmeldungen hat dieselbe Chance. Die Auslosung kann nicht wiederholt werden. Ausgewählte Kinder werden noch nicht automatisch angelegt.</p>
<label>Anzahl der Plätze<input name="places" type="number" min={1} max={Math.min(10000, selected.total)} required defaultValue={Math.min(5, selected.total)} /></label></>
: dialog.kind === "accept" ? <><p className="muted">{dialog.application.firstName} {dialog.application.lastName} wird als Kind ins Dashboard übernommen.</p>
<div className="info-box"><p>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.</p></div></>
: <p className="muted">{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."}</p>}
{error}<footer className="modal-footer"><button type="button" className="button secondary" disabled={pending} onClick={() => setDialog(null)}>Abbrechen</button>
<button className="button primary" disabled={pending}>{pending ? "Wird gespeichert …" : dialog.kind === "draw" ? "Jetzt einmalig auslosen" : "Bestätigen"}</button></footer>
</form>
</Modal>}
</>;
}
+125
View File
@@ -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<Schedule[]>(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 <Modal title={child ? "Kind bearbeiten" : "Ein neues Gesicht"} subtitle={child ? "Stammdaten und regelmäßige Betreuungszeiten" : "Kind anlegen und den Wochenplan festlegen"} onClose={onClose} busy={pending}>
<form className="stack" onSubmit={(event) => {
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.");
}
});
}}>
<fieldset disabled={pending} className="form-fields">
<div className="form-grid"><label>Vorname<input name="firstName" required maxLength={80} defaultValue={child?.firstName} placeholder="z. B. Emma" /></label>
<label>Nachname<input name="lastName" required maxLength={80} defaultValue={child?.lastName} placeholder="z. B. Müller" /></label></div>
<label>Klasse / Gruppe<input name="className" required maxLength={80} defaultValue={child?.className} placeholder="z. B. 2a oder Sonnengruppe" /></label>
<div className="form-section-heading"><CalendarDays size={18} /><h3>Regelmäßiger Wochenplan</h3></div>
<p className="small muted">Aktive Tage auswählen. Einzelne Abweichungen kannst du später im Tagesplan hinterlegen.</p>
<div className="schedule-table">
<div className="schedule-row schedule-labels"><span>Betreuungstag</span><span>Ankunft</span><span>Abholung</span></div>
{weekdays.map((day, index) => {
const weekday = index + 1;
const schedule = schedules.find((s) => s.weekday === weekday);
return <div key={day} className={`schedule-row ${!schedule ? "inactive" : ""}`}>
<label className="checkbox-label"><input type="checkbox" checked={!!schedule} onChange={() => toggle(weekday)} />{day}</label>
<input aria-label={`${day} Ankunft`} type="time" required={!!schedule} disabled={!schedule} value={schedule?.arrival ?? ""} onChange={(e) => update(weekday, "arrival", e.target.value)} />
<PickupField slots={slots} slotId={schedule?.slotId} pickup={schedule?.pickup ?? ""} label={`${day} Abholung`} disabled={!schedule}
onChange={(slotId, pickup) => setSchedules((previous) => previous.map((s) => s.weekday === weekday ? { ...s, slotId, pickup } : s))} />
</div>;
})}
</div>
</fieldset>
{error && <p className="form-error" role="alert">{error}</p>}
<footer className="modal-footer"><button type="button" className="button secondary" onClick={onClose} disabled={pending}>Abbrechen</button>
<button className="button primary" disabled={pending}><Save size={16} />{pending ? "Wird gespeichert …" : child ? "Änderungen speichern" : "Kind anlegen"}</button></footer>
</form>
</Modal>;
}
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<string | null>(initialSlot?.id ?? null);
return <Modal title="Tagesplan anpassen" subtitle={`${child.firstName} ${child.lastName} · ${displayDay(day)}`} onClose={onClose} busy={pending}>
<form className="stack" onSubmit={(event) => {
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.");
}
});
}}>
<fieldset disabled={pending} className="form-fields">
<label>Plan für diesen Tag<select value={mode} onChange={(e) => {
const value = e.target.value;
if (value === "weekly" || value === "absent" || value === "custom") setMode(value);
}}>
<option value="weekly">Regelmäßigen Wochenplan verwenden</option>
<option value="custom">Andere Zeiten an diesem Tag</option>
<option value="absent">Keine Betreuung an diesem Tag</option>
</select></label>
{mode === "custom" && <div className="form-grid">
<label>Geplante Ankunft<input type="time" name="arrival" required defaultValue={child.arrival ?? "12:00"} /></label>
<div className="field-label">Geplante Abholung<PickupField slots={slots} slotId={slotId} pickup={pickup}
label="Geplante Abholung" onChange={(id, time) => { setSlotId(id); setPickup(time); }} /></div>
</div>}
<div className="info-box"><CalendarDays size={18} /><p>Diese Änderung gilt nur für den ausgewählten Tag. Anwesenheit und Krankmeldung bleiben davon unberührt.</p></div>
</fieldset>
{error && <p className="form-error" role="alert">{error}</p>}
<footer className="modal-footer"><button type="button" className="button secondary" onClick={onClose} disabled={pending}>Abbrechen</button>
<button className="button primary" disabled={pending}><Check size={16} />{pending ? "Wird gespeichert …" : "Tagesplan speichern"}</button></footer>
</form>
</Modal>;
}
+143
View File
@@ -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<string, string> = {
attendance: "Anwesenheit", children: "Stammdaten", weekly_schedules: "Wochenplan",
day_overrides: "Tagesplan", pickup_contacts: "Abholberechtigung", alone_rules: "Alleingehen",
};
const fields: Record<string, string> = {
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 <details className="audit-details"><summary>Änderungen ansehen ({keys.length})</summary>
<dl>{keys.map((key) => <div key={key}><dt>{fields[key]}</dt><dd><span>{auditValue(key, entry.before?.[key], timeZone)}</span><span aria-label="geändert zu"> → </span><strong>{auditValue(key, entry.after?.[key], timeZone)}</strong></dd></div>)}</dl>
</details>;
}
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<Dialog>(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 <nav className="profile-pagination" aria-label={section === "history" ? "Verlaufsseiten" : "Audit-Seiten"}>
{page > 1 ? <Link href={url({ [section]: page - 1 })}>← Zurück</Link> : <span />}
<span>Seite {page} / {total} · {count} Einträge</span>
{page < total ? <Link href={url({ [section]: page + 1 })}>Weiter →</Link> : <span />}
</nav>;
}
const stamp = (value: string | null) => value ? `${timeInZone(value, timeZone)} Uhr` : "—";
return <div className="profile-shell">
<header className="profile-topbar"><Link className="brand" href="/"><span className="brand-icon"><Sprout size={24} /></span>KIBU<span className="brand-dot">.</span></Link>
<Link href={`/?day=${day}`} className="button secondary"><ArrowLeft size={16} />Tagesübersicht</Link></header>
<main className="profile-main">
<section className="profile-heading"><div className="profile-identity"><div className="profile-avatar">{child.firstName[0]}{child.lastName[0]}</div>
<div><span className="eyebrow">KINDERPROFIL</span><h1>{child.firstName} {child.lastName}</h1><p className="muted">Klasse / Gruppe {child.className}</p></div></div>
<button className="button secondary" onClick={() => setDialog({ kind: "child" })}><Pencil size={15} />Stammdaten & Wochenplan</button></section>
{message && <div className="profile-message" role="status"><Check size={17} />{message}<button className="icon-button" aria-label="Meldung schließen" onClick={() => setMessage("")}><X size={15} /></button></div>}
<div className="profile-columns">
<div className="profile-primary">
<section className="profile-panel">
<div className="panel-title"><h2><CalendarDays size={19} />Tagesdetails</h2><input aria-label="Profiltag auswählen" type="date" min="2000-01-01" max="2100-12-31" value={day}
onChange={(event) => { if (event.target.value) router.push(url({ day: event.target.value })); }} /></div>
<p className="muted small">{displayDay(day)} · {timeZone}</p>
<div className="profile-day-stats"><div><small>Status</small><strong>{labels[selected.status]}</strong></div>
<div><small>Angekommen</small><strong>{stamp(selected.arrivedAt)}</strong></div><div><small>Gegangen</small><strong>{stamp(selected.pickedUpAt)}</strong></div></div>
<p className="small muted">Ankunft: {child.arrival ?? "—"} Uhr · Abholung: {child.pickup ?? "—"}{child.pickupEnd ? `–${child.pickupEnd}` : ""} Uhr{selected.pickupKind ? ` · ${pickupLabels[selected.pickupKind]}` : ""}</p>
<button className="button primary correction-button" disabled={day > today} onClick={() => setDialog({ kind: "correction", record: selected })}><Pencil size={15} />{selected.version ? "Anwesenheit korrigieren" : "Anwesenheit nachtragen"}</button>
{day > today && <p className="small muted">Tatsächliche Anwesenheit ist erst am Betreuungstag erfassbar.</p>}
</section>
<section className="profile-panel"><div className="panel-title"><h2><Clock3 size={19} />Anwesenheitsverlauf</h2><span className="count-pill">{profile.counts.history}</span></div>
<p className="muted small">Tatsächliche Zeiten, unabhängig vom aktuellen Wochenplan.</p>
<div className="history-table-wrap"><table className="history-table"><thead><tr><th>Tag</th><th>Status</th><th>Ankunft</th><th>Gegangen</th><th><span className="sr-only">Aktion</span></th></tr></thead>
<tbody>{profile.history.map((record) => <tr key={record.day}><td><button className="history-day-link" onClick={() => router.push(url({ day: record.day }))}>{new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeZone: "UTC" }).format(new Date(`${record.day}T12:00:00Z`))}</button></td>
<td><span className={`status-badge ${record.status}`}>{labels[record.status]}</span>{record.pickupKind && <small className="history-kind">{pickupLabels[record.pickupKind]}</small>}</td>
<td>{stamp(record.arrivedAt)}</td><td>{stamp(record.pickedUpAt)}</td><td><button className="icon-button" aria-label={`Anwesenheit am ${record.day} korrigieren`} onClick={() => setDialog({ kind: "correction", record })}><Pencil size={15} /></button></td></tr>)}</tbody>
</table></div>
{!profile.history.length && <p className="profile-empty">Noch keine Anwesenheiten erfasst. Über die Tagesdetails kannst du einen vergessenen Eintrag nachtragen.</p>}
{pager("history", historyPage, profile.counts.history)}
</section>
<section className="profile-panel" id="audit-log"><div className="panel-title"><h2><History size={19} />Audit-Log</h2><span className="count-pill">{profile.counts.audit}</span></div>
<p className="muted small">Nachvollziehbar und unveränderlich: wer hat was wann geändert?</p>
<div className="audit-list">{profile.audit.map((entry) => <article key={entry.id} className="audit-entry">
<div className="audit-entry-heading"><strong>{entities[entry.entity] ?? entry.entity} · {entry.operation === "INSERT" ? "angelegt" : entry.operation === "DELETE" ? "entfernt" : "geändert"}</strong>
<time dateTime={new Date(entry.createdAt).toISOString()}>{new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short", timeZone }).format(new Date(entry.createdAt))}</time></div>
<p>{entry.reason}</p><small>{entry.actorName}{entry.day ? ` · Betreuungstag ${entry.day}` : ""} · #{entry.id}</small>
<AuditDetails entry={entry} timeZone={timeZone} />
</article>)}</div>
{!profile.audit.length && <p className="profile-empty">Noch keine protokollierten Änderungen. Die Aufzeichnung beginnt mit Schritt 2.</p>}
{pager("audit", auditPage, profile.counts.audit)}
</section>
</div>
<aside className="profile-secondary">
<section className="profile-panel"><div className="panel-title"><h2><Users size={19} />Abholberechtigte</h2><button className="icon-button" aria-label="Abholperson hinzufügen" onClick={() => setDialog({ kind: "contact" })}><Plus size={19} /></button></div>
<p className="small muted">Berechtigung bei jeder Abholung persönlich prüfen.</p>
{contacts.map((contact) => <article className={`contact-card ${contact.authorized ? "" : "not-authorized"}`} key={contact.id}>
<div className="contact-heading"><div><strong>{contact.name}</strong><small>{contact.relationship}</small></div>
<button className="icon-button" aria-label={`${contact.name} bearbeiten`} onClick={() => setDialog({ kind: "contact", contact })}><Pencil size={14} /></button></div>
<span className={`permission-badge ${contact.authorized ? "allowed" : "denied"}`}><ShieldCheck size={12} />{contact.authorized ? "Abholberechtigt" : "Nicht abholberechtigt"}</span>
{contact.phone && <p className="contact-phone">{contact.phone}</p>}{contact.notes && <p className="contact-notes">{contact.notes}</p>}
</article>)}
{!contacts.length && <p className="profile-empty">Noch keine Abholpersonen hinterlegt. Das bedeutet keine bestätigte Berechtigung.</p>}
<button className="button secondary full" onClick={() => setDialog({ kind: "contact" })}><Plus size={15} />Abholperson hinzufügen</button>
</section>
<section className="profile-panel"><div className="panel-title"><h2><Footprints size={19} />Alleingehen</h2><button className="icon-button" aria-label="Alleingehregeln bearbeiten" onClick={() => setDialog({ kind: "alone" })}><Pencil size={16} /></button></div>
<p className="small muted">Aktuell hinterlegte Erlaubnis. Keine automatische Abmeldung.</p>
{rules.length ? <ul className="alone-rules-list">{rules.map((rule) => <li key={rule.weekday}><span>{weekdayNames[rule.weekday - 1]}</span><strong>ab {rule.earliest} Uhr</strong></li>)}</ul>
: <p className="profile-empty">Kein Alleingang erlaubt.</p>}
<button className="button secondary full" onClick={() => setDialog({ kind: "alone" })}>Regeln bearbeiten</button>
</section>
<div className="info-box"><ShieldCheck size={18} /><p>Klassenwechsel? Die Klasse kann in den Stammdaten angepasst werden. Profil, Zeiten und Verlauf bleiben beim selben Kind erhalten.</p></div>
</aside>
</div>
<footer className="dashboard-footer"><span><Sprout size={15} />KIBU · Gut begleitet.</span><span>Alle Zeiten in {timeZone}</span></footer>
</main>
{dialog?.kind === "correction" && <CorrectionForm childId={child.id} record={dialog.record} timeZone={timeZone} onClose={() => setDialog(null)} onSaved={saved} />}
{dialog?.kind === "contact" && <ContactForm childId={child.id} contact={dialog.contact} onClose={() => setDialog(null)} onSaved={saved} />}
{dialog?.kind === "alone" && <AloneRulesForm childId={child.id} initialRules={rules} onClose={() => setDialog(null)} onSaved={saved} />}
{dialog?.kind === "child" && <ChildForm child={child} slots={slots} onClose={() => setDialog(null)} onSaved={saved} />}
</div>;
}
+279
View File
@@ -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<Filter>("all");
const [classFilter, setClassFilter] = useState("");
const [sort, setSort] = useState("name");
const [slotFilter, setSlotFilter] = useState("");
const resultsRef = useRef<HTMLElement>(null);
const [scrollRequest, setScrollRequest] = useState(0);
const [dialog, setDialog] = useState<Dialog>(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 <div className="app-shell">
<aside className="sidebar">
<a href="/" className="brand"><span className="brand-icon"><Sprout size={24} /></span>KIBU<span className="brand-dot">.</span></a>
<div className="workspace-label">EURE KINDERBETREUUNG</div>
<nav aria-label="Hauptnavigation"><a className="nav-item active" href="/" aria-current="page"><LayoutDashboard size={19} />Tagesübersicht<span className="nav-indicator" /></a>
<Link className="nav-item" href="/admissions"><Users size={19} />Aufnahme & Warteliste</Link>
<Link className="nav-item" href="/slots"><Clock3 size={19} />Abholslots</Link></nav>
<div className="sidebar-note"><div className="note-icon"><Heart size={20} /></div><h3>Gut begleitet.</h3>
<p>Jedes Kind im Blick.<br />Jeden Tag ein gutes Gefühl.</p><span className="note-line" /></div>
<div className="sidebar-bottom"><span className="secure-label"><ShieldCheck size={14} /> Geschützter Betreuungsbereich</span>
<button className="staff-button" onClick={() => setDialog({ kind: "account" })}><span className="staff-avatar">{staff.name.slice(0, 1)}</span>
<span><strong>{staff.name}</strong><small>Betreuungsperson</small></span><Settings2 size={17} /></button>
<form action={logout}><button className="logout-button"><LogOut size={15} />Abmelden</button></form>
</div>
</aside>
<div className="main-shell">
<header className="topbar"><div className="breadcrumb">Betreuungsbereich <ChevronRight size={14} /><strong>Tagesübersicht</strong></div>
<div className="topbar-right"><span className="live-dot" />Alles im Blick <span className="topbar-divider" />
<button className="icon-button mobile-account" aria-label="Zugang verwalten" onClick={() => setDialog({ kind: "account" })}><UserRound size={19} /></button>
<span className="topbar-date">{displayDay(today)}</span>
<form action={logout} className="mobile-logout"><button className="icon-button" aria-label="Abmelden"><LogOut size={18} /></button></form>
</div>
</header>
<main className="dashboard">
<section className="page-heading"><div><div className="eyebrow"><span className="tiny-sun"><Sun size={15} /></span> EIN GUTER TAG BEGINNT MIT ÜBERBLICK</div>
<h1>{isToday ? Number(time.slice(0, 2)) < 11 ? "Guten Morgen" : Number(time.slice(0, 2)) < 18 ? "Hallo zusammen" : "Guten Abend" : "Euer Tagesüberblick"}<span className="heading-dot">.</span></h1>
<p>Wer ist schon da, wer geht nach Hause? Hier läuft alles zusammen.</p></div>
<button className="button primary" onClick={() => setDialog({ kind: "child" })}><Plus size={18} />Kind hinzufügen</button>
</section>
<div className="day-toolbar"><div className="day-picker">
<button className="icon-button" aria-label="Vorheriger Tag" onClick={() => shiftDay(-1)} disabled={day === "2000-01-01"}><ChevronLeft size={17} /></button>
<label className="date-label"><CalendarDays size={18} /><span>{isToday ? "Heute, " : ""}{displayDay(day)}</span>
<input aria-label="Betreuungstag auswählen" type="date" min="2000-01-01" max="2100-12-31" value={day} onChange={(e) => navigateDate(e.target.value)} /></label>
<button className="icon-button" aria-label="Nächster Tag" onClick={() => shiftDay(1)} disabled={day === "2100-12-31"}><ChevronRight size={17} /></button>
</div>{!isToday && <button className="text-button" onClick={() => navigateDate(today)}>Zurück zu heute</button>}
<span className="refresh-note"><span className="live-dot" />{time} Uhr · aktualisiert sich automatisch</span>
</div>
<section className="stats-grid" aria-label="Tagesstatistik">
{[
{ 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) => <button key={stat.label} className={`stat-card ${filter === stat.key ? "selected" : ""}`} onClick={() => showResults(stat.key)}>
<div className="stat-top"><span>{stat.label}</span><span className={`stat-icon ${stat.color}`}><stat.icon size={19} /></span></div>
<strong>{stat.value.toString().padStart(2, "0")}</strong><small>{stat.detail}</small>
</button>)}
</section>
{isToday && due.length > 0 && <button className={`pickup-banner ${overdue.length ? "has-overdue" : ""}`} onClick={() => showResults("due")}>
<span className="banner-icon"><Clock3 size={21} /></span>
<span><strong>Zeit, sich zu verabschieden.</strong><span>{due.length} {due.length === 1 ? "Kind ist" : "Kinder sind"} jetzt zur Abholung fällig.{overdue.length > 0 ? ` ${overdue.length} ${overdue.length === 1 ? "ist" : "sind"} bereits überfällig.` : " Die nächsten 15 Minuten sind berücksichtigt."}</span></span>
<span className="banner-action">Kinder anzeigen <ArrowRight size={16} /></span>
</button>}
{!isToday && <div className="history-notice"><CalendarDays size={18} />Du siehst einen anderen Tag. Anwesenheit kann nur heute erfasst werden; Tagespläne lassen sich weiterhin anpassen.</div>}
<section className="slot-overview" aria-label="Abholslots für diesen Tag">{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 <button key={slot.id} className={`slot-overview-button ${slotFilter === slot.id ? "active" : ""}`} onClick={() => showResults("all", slot.id)}>
<Clock3 size={16} /><strong>{slot.start}–{slot.end}</strong><span>{count} anwesend</span></button>;
})}</section>
<section className="children-section" ref={resultsRef} tabIndex={-1} aria-label="Gefilterte Kinder">
<div className="section-title"><div><h2>Eure Kinder <span>{childrenData.length}</span></h2><p>Ein kleiner Überblick für einen großen Betreuungstag.</p></div>
<span className="section-caption"><Sprout size={16} /> Zusammen gut organisiert</span></div>
<div className="filter-toolbar">
<label className="search-field"><Search size={18} /><input type="search" placeholder="Kind oder Klasse suchen …" aria-label="Kind oder Klasse suchen" value={search} onChange={(e) => setSearch(e.target.value)} /></label>
<div className="filter-controls"><label className="select-control"><SlidersHorizontal size={16} /><select aria-label="Nach Klasse filtern" value={classFilter} onChange={(e) => setClassFilter(e.target.value)}><option value="">Alle Klassen</option>{classes.map((name) => <option key={name}>{name}</option>)}</select></label>
<label className="select-control"><Clock3 size={16} /><select aria-label="Nach Abholslot filtern" value={slotFilter} onChange={(event) => setSlotFilter(event.target.value)}><option value="">Alle Abholzeiten</option>{slots.map((slot) => <option key={slot.id} value={slot.id}>{slot.start}–{slot.end}</option>)}</select></label>
<label className="select-control"><ArrowDownUp size={16} /><select aria-label="Sortierung" value={sort} onChange={(e) => setSort(e.target.value)}><option value="name">Name A–Z</option><option value="pickup">Abholzeit</option></select></label></div>
</div>
<div className="filter-tabs" role="group" aria-label="Anwesenheitsfilter">{filters.map((item) => <button key={item.key}
className={`filter-tab ${filter === item.key ? "active" : ""} ${item.key === "overdue" && item.count ? "warning" : ""}`}
onClick={() => setFilter(item.key)} aria-pressed={filter === item.key}>{item.label}<span>{item.count}</span></button>)}</div>
<div className="results-caption"><span>{visible.length} {visible.length === 1 ? "Kind" : "Kinder"} angezeigt</span>
<span><span className="legend-dot" />{isToday ? "Anwesenheit für heute" : displayDay(day)}</span></div>
{visible.length > 0 ? <div className="children-grid">{visible.map((child) => {
const timing = isToday && child.status === "arrived" ? pickupState(child.pickup, time, child.pickupEnd) : "none";
return <article className={`child-card ${timing === "overdue" ? "card-overdue" : timing === "due" ? "card-due" : ""}`} key={child.id}>
<div className="child-card-top"><span className={`status-badge ${child.status}`}><span />{child.status === "picked_up" && child.pickupKind === "alone" ? "Allein gegangen" : child.status === "expected" && !child.arrival ? "Nicht eingeplant" : statusNames[child.status]}</span>
<button className="icon-button" aria-label={`${child.firstName} ${child.lastName} bearbeiten`} title="Kind & Wochenplan bearbeiten" onClick={() => setDialog({ kind: "child", child })}><Pencil size={15} /></button>
</div>
<div className="child-identity"><Link aria-label={`Profil von ${child.firstName} ${child.lastName}`} href={`/children/${child.id}?day=${day}`} className={`child-avatar ${avatarColor(child)}`}>{initials(child)}</Link><div><h3><Link href={`/children/${child.id}?day=${day}`}>{child.firstName} {child.lastName}</Link></h3><span>Klasse / Gruppe {child.className}</span></div></div>
<button className="child-schedule" onClick={() => setDialog({ kind: "day", child })} aria-label={`Tagesplan für ${child.firstName} ${child.lastName} anpassen`}>
<span><small><Clock3 size={13} /> Ankunft</small><strong>{child.arrival ?? "—"}{child.arrival && <em> Uhr</em>}</strong></span>
<span className="schedule-arrow"><ArrowRight size={15} /></span>
<span><small><DoorOpen size={13} /> {child.pickupEnd ? "Abholslot" : "Abholung"}</small><strong>{child.pickup ?? "—"}{child.pickupEnd ? `–${child.pickupEnd}` : ""}{child.pickup && <em> Uhr</em>}</strong></span>
<CalendarDays size={16} className="schedule-edit-icon" />
</button>
<div className={`child-hint ${timing}`}>
{timing === "overdue" ? <><AlertCircle size={14} />Abholung überfällig · noch anwesend</>
: timing === "due" ? <><Clock3 size={14} />{child.pickupEnd && child.pickup && child.pickup <= time ? "Abholfenster läuft jetzt" : "Abholung in den nächsten 15 Minuten"}</>
: child.status === "picked_up" ? <><Check size={14} />{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 ? <><CircleCheck size={14} />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" ? <><CalendarDays size={14} />Individueller Tagesplan</>
: child.overrideMode === "absent" ? <><CalendarDays size={14} />Für diesen Tag abgemeldet</>
: child.status === "sick" ? <><Heart size={14} />Heute wird sich ausgeruht</>
: <><CalendarDays size={14} />{child.arrival ? "Regelmäßiger Wochenplan" : "Keine Betreuung geplant"}</>}
</div>
{child.aloneFrom && <div className="card-alone-hint"><Footprints size={13} />Darf heute ab {child.aloneFrom} Uhr allein gehen</div>}
<div className="child-actions">
<button className={`attendance-button ${child.status === "arrived" ? "is-arrived" : ""}`} disabled={pending || !isToday || !["expected", "sick"].includes(child.status)}
onClick={() => attendance(child, "arrived")}><CircleCheck size={15} />Angekommen</button>
<button className={`attendance-button ${child.status === "sick" ? "is-sick" : ""}`} disabled={pending || !isToday || !["expected", "sick"].includes(child.status)}
onClick={() => attendance(child, child.status === "sick" ? "expected" : "sick")} title={child.status === "sick" ? "Krankmeldung aufheben" : "Heute krank melden"}><Thermometer size={15} />{child.status === "sick" ? "Krank ✓" : "Krank"}</button>
<button className={`attendance-button ${child.status === "picked_up" ? "is-picked" : ""}`} disabled={pending || !isToday || child.status !== "arrived"}
onClick={() => attendance(child, "planned")}><DoorOpen size={15} />{child.pickupKind === "alone" ? "Gegangen" : "Abgeholt"}</button>
</div>
<button className="parent-pickup" disabled={pending || !isToday || child.status !== "arrived"} onClick={() => setDialog({ kind: "parents", child })}>
<Users size={13} />Außerplanmäßig von Eltern abgeholt<ChevronRight size={13} />
</button>
{child.aloneFrom && <button className="parent-pickup" disabled={pending || !isToday || child.status !== "arrived" || time < child.aloneFrom} onClick={() => setDialog({ kind: "alone", child })}>
<Footprints size={13} />Allein gegangen bestätigen<ChevronRight size={13} /></button>}
<Link className="card-profile-link" href={`/children/${child.id}?day=${day}`}>Profil, Abholberechtigte & Verlauf<ChevronRight size={13} /></Link>
</article>;
})}</div> : <div className="empty-state"><span className="empty-icon"><Users size={32} /></span>
<h3>{childrenData.length ? "Hier ist gerade alles ruhig." : "Platz für eure ersten Kinder."}</h3>
<p>{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."}</p>
<button className="button primary" onClick={() => { if (!childrenData.length) setDialog({ kind: "child" }); else { setSearch(""); setFilter("all"); setClassFilter(""); setSlotFilter(""); } }}>
{childrenData.length ? <><X size={16} />Filter zurücksetzen</> : <><Plus size={17} />Erstes Kind anlegen</>}</button>
</div>}
</section>
<footer className="dashboard-footer"><span><Sprout size={15} /> KIBU · Mehr Zeit fürs Miteinander.</span><span>Alle Zeiten in {timeZone}</span></footer>
</main>
</div>
{notice && <div className={`toast ${notice.error ? "error" : ""}`} role={notice.error ? "alert" : "status"}>{notice.error ? <AlertCircle size={19} /> : <CircleCheck size={19} />}<span>{notice.text}</span>
<button className="icon-button" aria-label="Meldung schließen" onClick={() => setNotice(null)}><X size={16} /></button></div>}
{dialog?.kind === "child" && <ChildForm child={dialog.child} slots={slots} onClose={() => setDialog(null)} onSaved={saved} />}
{dialog?.kind === "day" && <DayForm child={dialog.child} day={day} slots={slots} onClose={() => setDialog(null)} onSaved={saved} />}
{dialog?.kind === "account" && <AccountForm email={staff.email} onClose={() => setDialog(null)} />}
{dialog?.kind === "parents" && <Modal title="Außerplanmäßige Abholung" subtitle={`${dialog.child.firstName} ${dialog.child.lastName}`} busy={pending} onClose={() => setDialog(null)}>
<p className="muted">Bestätige, dass das Kind jetzt außerplanmäßig von einem Elternteil abgeholt wurde. Der tatsächliche Zeitpunkt wird gespeichert.</p>
<div className="info-box"><ShieldCheck size={20} /><div><p>Die Abholberechtigung bitte persönlich prüfen.</p>
{dialog.child.contacts.length ? <ul className="pickup-contact-list">{dialog.child.contacts.map((contact, index) => <li key={index}>{contact.name} · {contact.relationship}</li>)}</ul> : <p>Keine bestätigten Abholberechtigten hinterlegt.</p>}
<Link href={`/children/${dialog.child.id}`}>Berechtigungen im Profil ansehen</Link></div></div>
{notice?.error && <p className="form-error" role="alert">{notice.text}</p>}
<footer className="modal-footer"><button className="button secondary" disabled={pending} onClick={() => setDialog(null)}>Abbrechen</button>
<button className="button primary" disabled={pending} onClick={() => attendance(dialog.child, "parents")}><Check size={16} />{pending ? "Wird gespeichert …" : "Abholung bestätigen"}</button></footer>
</Modal>}
{dialog?.kind === "alone" && <Modal title="Alleingang bestätigen" subtitle={`${dialog.child.firstName} ${dialog.child.lastName}`} busy={pending} onClose={() => setDialog(null)}>
<div className="info-box"><Footprints size={20} /><p>Heute ab {dialog.child.aloneFrom} Uhr erlaubt. Bestätige erst, wenn das Kind tatsächlich allein geht. Die Uhrzeit und dein Zugang werden protokolliert.</p></div>
{notice?.error && <p className="form-error" role="alert">{notice.text}</p>}
<footer className="modal-footer"><button className="button secondary" disabled={pending} onClick={() => setDialog(null)}>Abbrechen</button>
<button className="button primary" disabled={pending} onClick={() => attendance(dialog.child, "alone")}><Check size={16} />{pending ? "Wird gespeichert …" : "Alleingang bestätigen"}</button></footer>
</Modal>}
</div>;
}
+16
View File
@@ -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 <form action={action} className="stack">
<label>E-Mail-Adresse<input type="email" name="email" autoComplete="username" required maxLength={254} placeholder="name@betreuung.de" value={email} onChange={(event) => setEmail(event.target.value)} /></label>
<label>Passwort<input type="password" name="password" autoComplete="current-password" required maxLength={256} placeholder="Dein Passwort" /></label>
{state.error && <p className="form-error" role="alert">{state.error}</p>}
<button className="button primary full" disabled={pending}>{pending ? "Wird angemeldet …" : <>Anmelden <ArrowRight size={17} /></>}</button>
</form>;
}
+25
View File
@@ -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<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current!;
dialog.showModal();
return () => dialog.close();
}, []);
return <dialog ref={ref} className="modal" aria-labelledby="modal-title"
onCancel={(event) => { event.preventDefault(); if (!busy) onClose(); }}
onClick={(event) => { if (event.target === event.currentTarget && !busy) onClose(); }}>
<div className="modal-content">
<header className="modal-header"><div><h2 id="modal-title">{title}</h2>{subtitle && <p className="muted">{subtitle}</p>}</div>
<button type="button" className="icon-button" aria-label="Schließen" onClick={onClose} disabled={busy}><X size={20} /></button>
</header>
{children}
</div>
</dialog>;
}
+53
View File
@@ -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 <form className="stack" onSubmit={(event) => {
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}`);
}}><label>Einladungscode<input required value={code} onChange={(event) => setCode(event.target.value)} maxLength={48} autoComplete="off" /></label>
{error && <p className="form-error" role="alert">{error}</p>}<button className="button primary">Zur Anmeldung</button></form>;
}
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 <div className="registration-success"><span className="empty-icon"><Check size={32} /></span><h1>Anmeldung angekommen.</h1>
<p className="muted">Euer Kind ist für diese Aufnahmerunde vorgemerkt. Das ist noch keine Platzzusage. Die Betreuung informiert euch über das Ergebnis.</p><p className="small muted">Es wird keine automatische Bestätigungs-E-Mail verschickt.</p></div>;
return <><span className="section-icon"><Heart size={25} /></span><span className="eyebrow">ELTERNANMELDUNG</span>
<h1>Ein Platz zum Wachsen.</h1><p className="muted">{round.name} · Anmeldung bis einschließlich {round.expiresOn}</p>
<div className="info-box"><p>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.</p></div>
<form className="stack" onSubmit={(event) => {
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."); }
});
}}><fieldset className="form-fields" disabled={pending}>
<h2>Über euer Kind</h2><div className="form-grid"><label>Vorname des Kindes<input name="firstName" required maxLength={80} autoComplete="off" /></label>
<label>Nachname des Kindes<input name="lastName" required maxLength={80} autoComplete="off" /></label></div>
<label>Klasse / Gruppe zum Betreuungsstart<input name="className" required maxLength={80} placeholder="z. B. 1a oder zukünftige 1. Klasse" /></label>
<h2>Eure Kontaktdaten</h2><label>Name der Kontaktperson<input name="parentName" required maxLength={80} autoComplete="name" /></label>
<label>E-Mail-Adresse<input name="email" type="email" required maxLength={254} autoComplete="email" /></label>
<label>Organisatorische Hinweise (optional)<textarea name="notes" maxLength={500} rows={3} placeholder="Bitte keine Gesundheitsdaten oder andere sensiblen Angaben eintragen." /></label>
<label className="checkbox-label consent-label"><input name="consent" type="checkbox" required />Ich bestätige, dass diese Angaben für Anmeldung, Platzvergabe und Kontaktaufnahme durch die Betreuung verarbeitet werden dürfen.</label>
<p className="small muted">Lokale Testinstanz: bitte ausschließlich fiktive Daten verwenden. Kein Elternkonto und keine automatische Abholberechtigung. Änderungs- oder Löschwünsche bitte direkt an die Betreuung richten.</p>
</fieldset>
{error && <p className="form-error" role="alert">{error}</p>}
<button className="button primary" disabled={pending}><Send size={16} />{pending ? "Wird angemeldet …" : "Kind zur Aufnahme anmelden"}</button>
</form></>;
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import type { PickupSlot } from "@/lib/slots";
export function PickupField({ slots, slotId, pickup, onChange, disabled, label = "Abholung" }: {
slots: PickupSlot[]; slotId?: string | null; pickup: string;
onChange: (slotId: string | null, pickup: string) => void; disabled?: boolean; label?: string;
}) {
return <div className="pickup-field">
<select aria-label={label} disabled={disabled} value={slotId ?? "custom"} onChange={(event) => {
const slot = slots.find((s) => s.id === event.target.value);
onChange(slot?.id ?? null, slot?.start ?? pickup);
}}>
{slots.filter((s) => s.active || s.id === slotId).map((slot) => <option value={slot.id} key={slot.id}>
{slot.start}–{slot.end}{!slot.active ? " (inaktiv)" : ""}
</option>)}
<option value="custom">Individuelle Zeit (Ausnahme)</option>
</select>
{!slotId && <input aria-label={`${label} individuelle Zeit`} type="time" required={!disabled} disabled={disabled} value={pickup}
onChange={(event) => onChange(null, event.target.value)} />}
</div>;
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useState, useTransition } from "react";
import { Check, ShieldCheck } from "lucide-react";
import { correctAttendance, saveAloneRules, saveContact } from "@/app/profile-actions";
import type { AloneRule, AttendanceRecord, PickupContact } from "@/lib/profile";
import { displayDay, timeInZone } from "@/lib/time";
import type { ActionResult } from "@/lib/validation";
import { Modal } from "./modal";
export const weekdayNames = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"];
function useSave(onSaved: (message: string) => void) {
const [error, setError] = useState("");
const [pending, startTransition] = useTransition();
function run(action: () => Promise<ActionResult>, message: string) {
setError("");
startTransition(async () => {
try {
const result = await action();
if (result.error) setError(result.error);
else onSaved(message);
} catch (error) {
console.error("Profil speichern", error);
setError("Speichern fehlgeschlagen. Bitte die Verbindung prüfen und erneut versuchen.");
}
});
}
return { error, pending, run };
}
function FormFooter({ pending, onClose, label = "Speichern" }: {
pending: boolean; onClose: () => void; label?: string;
}) {
return <footer className="modal-footer"><button type="button" className="button secondary" onClick={onClose} disabled={pending}>Abbrechen</button>
<button className="button primary" disabled={pending}><Check size={16} />{pending ? "Wird gespeichert …" : label}</button></footer>;
}
export function CorrectionForm({ childId, record, timeZone, onClose, onSaved }: {
childId: string; record: AttendanceRecord; timeZone: string;
onClose: () => void; onSaved: (message: string) => void;
}) {
const [status, setStatus] = useState(record.status);
const { error, pending, run } = useSave(onSaved);
return <Modal title="Anwesenheit korrigieren" subtitle={`${displayDay(record.day)} · ${timeZone}`} onClose={onClose} busy={pending}>
<form className="stack" onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
run(() => correctAttendance({
childId, day: record.day, status,
arrival: form.get("arrival") ?? "", pickup: form.get("pickup") ?? "",
pickupKind: form.get("pickupKind") ?? "planned", reason: form.get("reason"),
expectedVersion: record.version,
}), "Anwesenheit korrigiert und im Audit-Log dokumentiert.");
}}>
<fieldset disabled={pending} className="form-fields">
<label>Status<select value={status} onChange={(event) => {
const value = event.target.value;
if (value === "expected" || value === "arrived" || value === "sick" || value === "picked_up") setStatus(value);
}}>
<option value="expected">Kein Anwesenheitseintrag / zurücksetzen</option>
<option value="arrived">Angekommen, noch nicht ausgecheckt</option>
<option value="picked_up">Bereits gegangen / abgeholt</option>
<option value="sick">Krank gemeldet</option>
</select></label>
{(status === "arrived" || status === "picked_up") && <div className="form-grid">
<label>Tatsächliche Ankunft<input type="time" name="arrival" required defaultValue={record.arrivedAt ? timeInZone(record.arrivedAt, timeZone) : ""} /></label>
{status === "picked_up" && <label>Tatsächliche Abholung<input type="time" name="pickup" required defaultValue={record.pickedUpAt ? timeInZone(record.pickedUpAt, timeZone) : ""} /></label>}
</div>}
{status === "picked_up" && <label>Art des Verlassens<select name="pickupKind" defaultValue={record.pickupKind ?? "planned"}>
<option value="planned">Planmäßig abgeholt</option><option value="parents">Außerplanmäßig von Eltern abgeholt</option><option value="alone">Allein gegangen</option>
</select></label>}
<label>Grund der Korrektur<textarea name="reason" required minLength={5} maxLength={500} rows={3} placeholder="z. B. Abholung um 15:10 vergessen auszuchecken" /></label>
<div className="info-box"><ShieldCheck size={18} /><p>Alter und neuer Stand, dein Zugang und der Grund bleiben im Audit-Log erhalten. Historische Alleingänge werden als tatsächliches Ereignis dokumentiert, unabhängig von heutigen Berechtigungen.</p></div>
</fieldset>
{error && <p className="form-error" role="alert">{error}</p>}
<FormFooter pending={pending} onClose={onClose} label="Korrektur speichern" />
</form>
</Modal>;
}
export function ContactForm({ childId, contact, onClose, onSaved }: {
childId: string; contact?: PickupContact; onClose: () => void; onSaved: (message: string) => void;
}) {
const { error, pending, run } = useSave(onSaved);
return <Modal title={contact ? "Abholperson bearbeiten" : "Abholperson hinzufügen"} subtitle="Berechtigung und Kontakt für dieses Kind" onClose={onClose} busy={pending}>
<form className="stack" onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
run(() => saveContact({ id: contact?.id, childId, name: form.get("name"), relationship: form.get("relationship"),
phone: form.get("phone"), authorized: form.get("authorized") === "on", notes: form.get("notes") }), "Abholperson gespeichert.");
}}>
<fieldset disabled={pending} className="form-fields">
<label>Name<input name="name" required maxLength={80} defaultValue={contact?.name} /></label>
<div className="form-grid"><label>Beziehung zum Kind<input name="relationship" required maxLength={80} placeholder="z. B. Mutter, Vater, Oma" defaultValue={contact?.relationship} /></label>
<label>Telefon (optional)<input name="phone" type="tel" maxLength={40} defaultValue={contact?.phone} /></label></div>
<label className="checkbox-label"><input type="checkbox" name="authorized" defaultChecked={contact?.authorized ?? true} />Darf dieses Kind abholen</label>
<label>Hinweise (optional)<textarea name="notes" rows={3} maxLength={500} defaultValue={contact?.notes} placeholder="z. B. Ausweis bei Abholung prüfen" /></label>
<p className="small muted">Eine Berechtigung kann entzogen werden. Frühere Angaben bleiben im Audit-Log nachvollziehbar.</p>
</fieldset>
{error && <p className="form-error" role="alert">{error}</p>}
<FormFooter pending={pending} onClose={onClose} />
</form>
</Modal>;
}
export function AloneRulesForm({ childId, initialRules, onClose, onSaved }: {
childId: string; initialRules: AloneRule[]; onClose: () => void; onSaved: (message: string) => void;
}) {
const [rules, setRules] = useState(initialRules);
const { error, pending, run } = useSave(onSaved);
return <Modal title="Alleingehen regeln" subtitle="An welchen Tagen und ab welcher Uhrzeit darf das Kind allein gehen?" onClose={onClose} busy={pending}>
<form className="stack" onSubmit={(event) => {
event.preventDefault();
run(() => saveAloneRules({ childId, rules }), "Regeln zum Alleingehen gespeichert.");
}}>
<fieldset disabled={pending} className="form-fields">
<p className="small muted">Nicht ausgewählt heißt: An diesem Wochentag ist kein Alleingang erlaubt. Zeiten ersetzen nicht den Betreuungsplan; es wird niemals automatisch ausgecheckt.</p>
<div className="alone-rule-table">{weekdayNames.map((name, index) => {
const weekday = index + 1;
const rule = rules.find((r) => r.weekday === weekday);
return <div key={name} className="alone-rule-row"><label className="checkbox-label">
<input type="checkbox" checked={!!rule} onChange={() => setRules((previous) => rule ? previous.filter((r) => r.weekday !== weekday) : [...previous, { weekday, earliest: "15:00" }])} />{name}</label>
<label>Ab<input type="time" required={!!rule} disabled={!rule} aria-label={`${name} allein ab`} value={rule?.earliest ?? ""}
onChange={(event) => setRules((previous) => previous.map((r) => r.weekday === weekday ? { ...r, earliest: event.target.value } : r))} /></label></div>;
})}</div>
</fieldset>
{error && <p className="form-error" role="alert">{error}</p>}
<FormFooter pending={pending} onClose={onClose} />
</form>
</Modal>;
}
+9
View File
@@ -0,0 +1,9 @@
import Link from "next/link";
import { ArrowLeft, Sprout } from "lucide-react";
export function SectionShell({ children }: { children: React.ReactNode }) {
return <div className="profile-shell"><header className="profile-topbar">
<Link href="/" className="brand"><span className="brand-icon"><Sprout size={24} /></span>KIBU<span className="brand-dot">.</span></Link>
<Link className="button secondary" href="/"><ArrowLeft size={16} />Tagesübersicht</Link>
</header><main className="profile-main">{children}</main></div>;
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useState, useTransition } from "react";
import { Clock3, Plus } from "lucide-react";
import { addSlot, toggleSlot } from "@/app/admission-actions";
import type { PickupSlot } from "@/lib/slots";
import type { ActionResult } from "@/lib/validation";
export function SlotManager({ slots }: { slots: PickupSlot[] }) {
const [message, setMessage] = useState<ActionResult>({});
const [pending, startTransition] = useTransition();
function run(action: () => Promise<ActionResult>) {
setMessage({});
startTransition(async () => {
try { setMessage(await action()); }
catch (error) { console.error("Slot speichern", error); setMessage({ error: "Speichern fehlgeschlagen. Bitte erneut versuchen." }); }
});
}
return <><div className="section-title"><div><span className="eyebrow">GEMEINSAM GUT ORGANISIERT</span><h1>Abholslots</h1><p>Feste Zeitfenster für einen entspannten Abschied.</p></div><Clock3 size={30} /></div>
<div className="info-box"><p>Slots haben keine Kapazitätsgrenze. Ein deaktivierter Slot bleibt in bestehenden Plänen erhalten, wird aber nicht mehr neu angeboten. Für andere Zeiten einen neuen Slot anlegen; individuelle Abholzeiten bleiben möglich.</p></div>
{message.error && <p className="form-error" role="alert">{message.error}</p>}
{message.success && <p className="form-success" role="status">Abholslots gespeichert.</p>}
<section className="slots-management">{slots.map((slot) => <article className="profile-panel slot-management-card" key={slot.id}><Clock3 size={21} />
<div><h2>{slot.start}–{slot.end} Uhr</h2><p className="muted small">{slot.active ? "Für neue Pläne verfügbar" : "Inaktiv · bestehende Pläne bleiben erhalten"}</p></div>
<button className="button secondary" disabled={pending} onClick={() => run(() => toggleSlot({ id: slot.id, active: !slot.active }))}>{slot.active ? "Deaktivieren" : "Aktivieren"}</button></article>)}</section>
<section className="profile-panel"><h2>Neues Abholfenster</h2><form className="inline-form stack" onSubmit={(event) => {
event.preventDefault(); const form = new FormData(event.currentTarget);
run(() => addSlot({ start: form.get("start"), end: form.get("end") }));
}}><label>Beginn<input name="start" type="time" required defaultValue="14:00" /></label><label>Ende<input name="end" type="time" required defaultValue="14:15" /></label>
<button className="button primary" disabled={pending}><Plus size={16} />Slot anlegen</button></form></section>
</>;
}
+96
View File
@@ -0,0 +1,96 @@
import { createHash, randomBytes, randomInt } from "node:crypto";
import type { PoolClient } from "pg";
import type { z } from "zod";
import { db } from "./db";
import { InputError } from "./corrections";
import { localClock } from "./time";
import { auditContext } from "./audit";
import type { applicationSchema } from "./validation";
export const invitationHash = (code: string) => createHash("sha256").update(code).digest("hex");
export const newInvitation = () => randomBytes(24).toString("hex");
export type Round = {
id: string; name: string; expiresOn: string; status: "open" | "closed" | "drawn";
places: number | null; drawnAt: string | null; total: number; selected: number; accepted: number;
};
export type Application = {
id: string; firstName: string; lastName: string; className: string; parentName: string; email: string; notes: string;
status: "waiting" | "selected" | "waitlisted" | "accepted"; childId: string | null; submittedAt: string;
};
export async function getRounds(): Promise<Round[]> {
const result = await db.query<Round>(`SELECT r.id, r.name, to_char(r.expires_on, 'YYYY-MM-DD') AS "expiresOn", r.status,
r.places, r.drawn_at::text AS "drawnAt", count(a.id)::int AS total,
count(a.id) FILTER(WHERE a.status = 'selected')::int AS selected,
count(a.id) FILTER(WHERE a.status = 'accepted')::int AS accepted
FROM admission_rounds r LEFT JOIN applications a ON a.round_id = r.id
GROUP BY r.id ORDER BY r.created_at DESC, r.id`);
return result.rows;
}
export async function getApplications(roundId: string, page = 1) {
const result = await db.query<Application>(`SELECT id, first_name AS "firstName", last_name AS "lastName",
class_name AS "className", parent_name AS "parentName", email, notes, status, child_id AS "childId",
submitted_at::text AS "submittedAt" FROM applications WHERE round_id = $1
ORDER BY CASE status WHEN 'selected' THEN 0 WHEN 'accepted' THEN 1 ELSE 2 END, last_name, first_name, id
LIMIT 25 OFFSET $2`, [roundId, (page - 1) * 25]);
return result.rows;
}
export async function publicRound(code: string) {
if (!/^[a-f0-9]{48}$/.test(code)) return null;
const result = await db.query<{ name: string; expiresOn: string }>(`SELECT name, to_char(expires_on, 'YYYY-MM-DD') AS "expiresOn"
FROM admission_rounds WHERE code_hash = $1 AND status = 'open' AND expires_on >= $2::date`,
[invitationHash(code), localClock().day]);
return result.rows[0] ?? null;
}
export function chooseWinners<T>(candidates: readonly T[], places: number, random: (min: number, max: number) => number = randomInt): T[] {
if (!Number.isInteger(places) || places < 1 || places > candidates.length) throw new InputError("Die Platzzahl muss zwischen 1 und der Zahl der Anmeldungen liegen.");
const shuffled = [...candidates];
for (let index = 0; index < places; index++) {
const chosen = random(index, shuffled.length);
[shuffled[index], shuffled[chosen]] = [shuffled[chosen], shuffled[index]];
}
return shuffled.slice(0, places);
}
export async function submitApplication(client: PoolClient, input: z.infer<typeof applicationSchema>) {
const round = await client.query<{ id: string }>(`SELECT id FROM admission_rounds WHERE code_hash = $1
AND status = 'open' AND expires_on >= $2::date FOR UPDATE`, [invitationHash(input.code), localClock().day]);
if (!round.rowCount) throw new InputError("Dieser Einladungscode ist ungültig, abgelaufen oder die Anmeldung ist geschlossen.");
const result = await client.query(`INSERT INTO applications(round_id, first_name, last_name, class_name, parent_name, email, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING RETURNING id`,
[round.rows[0].id, input.firstName, input.lastName, input.className, input.parentName, input.email, input.notes]);
if (!result.rowCount) throw new InputError("Für dieses Kind und diese E-Mail-Adresse liegt in dieser Runde bereits eine Anmeldung vor.");
}
export async function drawRound(client: PoolClient, roundId: string, places: number, staffId: string) {
const round = await client.query<{ status: string }>("SELECT status FROM admission_rounds WHERE id = $1 FOR UPDATE", [roundId]);
if (!round.rowCount) throw new InputError("Aufnahmerunde nicht gefunden.");
if (round.rows[0].status !== "closed") throw new InputError("Bitte die Anmeldung vor der Auslosung schließen. Bereits ausgeloste Runden können nicht erneut ausgelost werden.");
const candidates = await client.query<{ id: string }>(
"SELECT id FROM applications WHERE round_id = $1 AND status = 'waiting' ORDER BY id", [roundId]);
const winners = chooseWinners(candidates.rows.map((row) => row.id), places);
await client.query(`UPDATE applications SET status = CASE WHEN id = ANY($2::uuid[]) THEN 'selected' ELSE 'waitlisted' END
WHERE round_id = $1`, [roundId, winners]);
await client.query(`UPDATE admission_rounds SET status = 'drawn', places = $2, drawn_by = $3, drawn_at = now() WHERE id = $1`,
[roundId, places, staffId]);
}
export async function acceptApplication(client: PoolClient, applicationId: string, staffId: string) {
const application = await client.query<{ round_id: string }>("SELECT round_id FROM applications WHERE id = $1", [applicationId]);
if (!application.rowCount) throw new InputError("Anmeldung nicht gefunden.");
const round = await client.query<{ status: string }>("SELECT status FROM admission_rounds WHERE id = $1 FOR UPDATE", [application.rows[0].round_id]);
if (round.rows[0].status !== "drawn") throw new InputError("Diese Runde wurde noch nicht ausgelost.");
const result = await client.query<{ status: string; first_name: string; last_name: string; class_name: string }>(
"SELECT status, first_name, last_name, class_name FROM applications WHERE id = $1 FOR UPDATE", [applicationId]);
const applicant = result.rows[0];
if (applicant.status !== "selected") throw new InputError("Nur ausgewählte und noch nicht übernommene Kinder können bestätigt werden.");
await auditContext(client, staffId, "Ausgelostes Kind nach Bestätigung aus der Warteliste übernommen");
const child = await client.query<{ id: string }>(
"INSERT INTO children(first_name, last_name, class_name) VALUES ($1, $2, $3) RETURNING id",
[applicant.first_name, applicant.last_name, applicant.class_name]);
await client.query(`UPDATE applications SET status = 'accepted', child_id = $2, accepted_by = $3, accepted_at = now() WHERE id = $1`,
[applicationId, child.rows[0].id, staffId]);
}
+6
View File
@@ -0,0 +1,6 @@
import type { PoolClient } from "pg";
export async function auditContext(client: PoolClient, staffId: string, reason: string) {
await client.query("SELECT set_config('kibu.actor_id', $1, true), set_config('kibu.reason', $2, true)",
[staffId, reason]);
}
+43
View File
@@ -0,0 +1,43 @@
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { db } from "./db";
const COOKIE = "kibu_session";
export const tokenHash = (token: string) => createHash("sha256").update(token).digest("hex");
export type Staff = { id: string; email: string; name: string };
export async function currentStaff(): Promise<Staff | null> {
const token = (await cookies()).get(COOKIE)?.value;
if (!token || !/^[a-f0-9]{64}$/.test(token)) return null;
const result = await db.query<Staff>(
`SELECT s.id, s.email, s.name FROM sessions t JOIN staff s ON s.id = t.staff_id
WHERE t.token_hash = $1 AND t.expires_at > now()`, [tokenHash(token)],
);
return result.rows[0] ?? null;
}
export async function requireStaff() {
const staff = await currentStaff();
if (!staff) redirect("/login");
return staff;
}
export async function createSession(staffId: string) {
const token = randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 12 * 60 * 60 * 1000);
await db.query("DELETE FROM sessions WHERE expires_at <= now()");
await db.query("INSERT INTO sessions(token_hash, staff_id, expires_at) VALUES ($1, $2, $3)",
[tokenHash(token), staffId, expires]);
(await cookies()).set(COOKIE, token, {
httpOnly: true, sameSite: "lax", secure: process.env.SESSION_SECURE === "true",
path: "/", expires,
});
}
export async function deleteSession() {
const jar = await cookies();
const token = jar.get(COOKIE)?.value;
if (token) await db.query("DELETE FROM sessions WHERE token_hash = $1", [tokenHash(token)]);
jar.delete(COOKIE);
}
+1
View File
@@ -0,0 +1 @@
export const PROFILE_PAGE_SIZE = 25;
+44
View File
@@ -0,0 +1,44 @@
import type { PoolClient } from "pg";
import type { z } from "zod";
import { correctionSchema } from "./validation";
import { localClock, localInstant } from "./time";
import { auditContext } from "./audit";
export class InputError extends Error {}
export async function applyCorrection(client: PoolClient, staffId: string, value: z.infer<typeof correctionSchema>, now = new Date()) {
const clock = localClock(now);
if (value.day > clock.day) throw new InputError("Zukünftige Anwesenheiten können nicht erfasst werden.");
const child = await client.query("SELECT id FROM children WHERE id = $1 FOR UPDATE", [value.childId]);
if (!child.rowCount) throw new InputError("Kind nicht gefunden.");
const current = await client.query<{ version: string }>(
"SELECT version::text FROM attendance WHERE child_id = $1 AND day = $2", [value.childId, value.day]);
if ((current.rows[0]?.version ?? null) !== value.expectedVersion) {
throw new InputError("Der Eintrag wurde inzwischen geändert. Bitte Profil neu laden und die aktuellen Zeiten prüfen.");
}
const timeZone = process.env.APP_TIMEZONE ?? "Europe/Berlin";
let arrival: Date | null = null;
let pickup: Date | null = null;
try {
if (value.arrival) arrival = localInstant(value.day, value.arrival, timeZone);
if (value.pickup) pickup = localInstant(value.day, value.pickup, timeZone);
} catch (error) {
if (error instanceof Error) throw new InputError(error.message);
throw error;
}
if ((arrival && arrival > now) || (pickup && pickup > now)) {
throw new InputError("Tatsächliche Ankunft und Abholung dürfen nicht in der Zukunft liegen.");
}
await auditContext(client, staffId, value.reason);
if (value.status === "expected") {
if (!current.rowCount) throw new InputError("Für diesen Tag gibt es keinen Eintrag zurückzusetzen.");
await client.query("DELETE FROM attendance WHERE child_id = $1 AND day = $2", [value.childId, value.day]);
} else {
await client.query(`INSERT INTO attendance(child_id, day, status, arrived_at, picked_up_at, pickup_kind, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT(child_id, day) DO UPDATE SET status = EXCLUDED.status, arrived_at = EXCLUDED.arrived_at,
picked_up_at = EXCLUDED.picked_up_at, pickup_kind = EXCLUDED.pickup_kind, updated_by = EXCLUDED.updated_by`,
[value.childId, value.day, value.status, arrival, pickup,
value.status === "picked_up" ? value.pickupKind : null, staffId]);
}
}
+55
View File
@@ -0,0 +1,55 @@
import { db } from "./db";
import type { Schedule } from "./validation";
import type { Pool } from "pg";
export type Child = {
id: string;
firstName: string;
lastName: string;
className: string;
schedules: Schedule[];
arrival: string | null;
pickup: string | null;
pickupEnd: string | null;
slotId: string | null;
overrideMode: "weekly" | "absent" | "custom";
status: "expected" | "arrived" | "sick" | "picked_up";
arrivedAt: string | null;
pickedUpAt: string | null;
pickupKind: "planned" | "parents" | "alone" | null;
aloneFrom: string | null;
contacts: { name: string; relationship: string }[];
};
export async function getChildren(day: string, connection: Pick<Pool, "query"> = db, id?: string): Promise<Child[]> {
const weekday = new Date(`${day}T12:00:00Z`).getUTCDay() || 7;
const result = await connection.query<Child>(`
SELECT c.id, c.first_name AS "firstName", c.last_name AS "lastName", c.class_name AS "className",
COALESCE((SELECT json_agg(json_build_object('weekday', ws.weekday,
'arrival', to_char(ws.arrival, 'HH24:MI'), 'pickup', to_char(ws.pickup, 'HH24:MI'),
'slotId', ws.pickup_slot_id)
ORDER BY ws.weekday) FROM weekly_schedules ws WHERE ws.child_id = c.id), '[]'::json) AS schedules,
CASE WHEN o.child_id IS NOT NULL THEN to_char(o.arrival, 'HH24:MI')
ELSE to_char(w.arrival, 'HH24:MI') END AS arrival,
CASE WHEN o.child_id IS NOT NULL THEN to_char(o.pickup, 'HH24:MI')
ELSE to_char(w.pickup, 'HH24:MI') END AS pickup,
CASE WHEN o.child_id IS NOT NULL THEN to_char(o.pickup_end, 'HH24:MI')
ELSE to_char(w.pickup_end, 'HH24:MI') END AS "pickupEnd",
CASE WHEN o.child_id IS NOT NULL THEN o.pickup_slot_id ELSE w.pickup_slot_id END AS "slotId",
CASE WHEN o.child_id IS NULL THEN 'weekly' WHEN o.attending THEN 'custom'
ELSE 'absent' END AS "overrideMode",
COALESCE(a.status, 'expected') AS status,
a.arrived_at::text AS "arrivedAt", a.picked_up_at::text AS "pickedUpAt", a.pickup_kind AS "pickupKind",
to_char(r.earliest, 'HH24:MI') AS "aloneFrom",
COALESCE((SELECT json_agg(json_build_object('name', pc.name, 'relationship', pc.relationship) ORDER BY pc.name)
FROM pickup_contacts pc WHERE pc.child_id = c.id AND pc.authorized), '[]'::json) AS contacts
FROM children c
LEFT JOIN weekly_schedules w ON w.child_id = c.id AND w.weekday = $2
LEFT JOIN day_overrides o ON o.child_id = c.id AND o.day = $1::date
LEFT JOIN attendance a ON a.child_id = c.id AND a.day = $1::date
LEFT JOIN alone_rules r ON r.child_id = c.id AND r.weekday = $2
WHERE ($3::uuid IS NULL OR c.id = $3)
ORDER BY c.last_name, c.first_name, c.id
`, [day, weekday, id ?? null]);
return result.rows;
}
+55
View File
@@ -0,0 +1,55 @@
import { db } from "./db";
import { getChildren } from "./data";
import { PROFILE_PAGE_SIZE } from "./constants";
import type { Pool } from "pg";
export type PickupContact = {
id: string; childId: string; name: string; relationship: string; phone: string;
authorized: boolean; notes: string;
};
export type AloneRule = { weekday: number; earliest: string };
export type AttendanceRecord = {
day: string; status: "expected" | "arrived" | "sick" | "picked_up";
arrivedAt: string | null; pickedUpAt: string | null; pickupKind: "planned" | "parents" | "alone" | null;
version: string | null; updatedBy: string | null;
};
export type AuditEntry = {
id: string; entity: string; day: string | null; operation: "INSERT" | "UPDATE" | "DELETE";
actorName: string; reason: string; before: Record<string, unknown> | null;
after: Record<string, unknown> | null; createdAt: string;
};
export async function getAuditEntries(id: string, page: number, connection: Pick<Pool, "query"> = db) {
const result = await connection.query<AuditEntry>(`SELECT id::text, entity, to_char(day, 'YYYY-MM-DD') AS day, operation, actor_name AS "actorName",
reason, before_data AS before, after_data AS after, created_at::text AS "createdAt"
FROM audit_log WHERE child_id = $1 ORDER BY audit_log.id DESC LIMIT $2 OFFSET $3`, [id, PROFILE_PAGE_SIZE, (page - 1) * PROFILE_PAGE_SIZE]);
return result.rows;
}
export async function getProfile(id: string, day: string, historyPage = 1, auditPage = 1) {
const child = (await getChildren(day, db, id))[0];
if (!child) return null;
const [contacts, rules, history, audit, counts, selected] = await Promise.all([
db.query<PickupContact>(`SELECT id, child_id AS "childId", name, relationship, phone, authorized, notes
FROM pickup_contacts WHERE child_id = $1 ORDER BY authorized DESC, name, id`, [id]),
db.query<AloneRule>("SELECT weekday, to_char(earliest, 'HH24:MI') AS earliest FROM alone_rules WHERE child_id = $1 ORDER BY weekday", [id]),
db.query<AttendanceRecord>(`SELECT to_char(a.day, 'YYYY-MM-DD') AS day, a.status,
a.arrived_at::text AS "arrivedAt", a.picked_up_at::text AS "pickedUpAt", a.pickup_kind AS "pickupKind",
a.version::text, s.name AS "updatedBy" FROM attendance a JOIN staff s ON s.id = a.updated_by
WHERE a.child_id = $1 ORDER BY a.day DESC LIMIT $2 OFFSET $3`, [id, PROFILE_PAGE_SIZE, (historyPage - 1) * PROFILE_PAGE_SIZE]),
getAuditEntries(id, auditPage),
db.query<{ history: number; audit: number }>(`SELECT
(SELECT count(*)::int FROM attendance WHERE child_id = $1) AS history,
(SELECT count(*)::int FROM audit_log WHERE child_id = $1) AS audit`, [id]),
db.query<AttendanceRecord>(`SELECT to_char(a.day, 'YYYY-MM-DD') AS day, a.status,
a.arrived_at::text AS "arrivedAt", a.picked_up_at::text AS "pickedUpAt", a.pickup_kind AS "pickupKind",
a.version::text, s.name AS "updatedBy" FROM attendance a JOIN staff s ON s.id = a.updated_by
WHERE a.child_id = $1 AND a.day = $2::date`, [id, day]),
]);
return {
child, contacts: contacts.rows, rules: rules.rows, history: history.rows, audit,
counts: counts.rows[0],
selected: selected.rows[0] ?? { day, status: "expected" as const, arrivedAt: null, pickedUpAt: null, pickupKind: null, version: null, updatedBy: null },
};
}
export type ChildProfile = NonNullable<Awaited<ReturnType<typeof getProfile>>>;
+23
View File
@@ -0,0 +1,23 @@
import type { PoolClient } from "pg";
import { db } from "./db";
import { InputError } from "./corrections";
export type PickupSlot = { id: string; start: string; end: string; active: boolean };
export async function getSlots(): Promise<PickupSlot[]> {
const result = await db.query<PickupSlot>(`SELECT id, to_char(starts_at, 'HH24:MI') AS start,
to_char(ends_at, 'HH24:MI') AS end, active FROM pickup_slots ORDER BY starts_at, ends_at`);
return result.rows;
}
export async function resolvePickup(client: PoolClient, arrival: string, pickup: string, slotId?: string | null) {
if (!slotId) {
if (!pickup || arrival >= pickup) throw new InputError("Die Abholzeit muss nach der Ankunft liegen.");
return { pickup, end: null, slotId: null };
}
const result = await client.query<PickupSlot>(`SELECT id, to_char(starts_at, 'HH24:MI') AS start,
to_char(ends_at, 'HH24:MI') AS end, active FROM pickup_slots WHERE id = $1 FOR SHARE`, [slotId]);
const slot = result.rows[0];
if (!slot) throw new InputError("Abholslot nicht gefunden. Bitte neu laden.");
if (arrival >= slot.start) throw new InputError("Die Ankunft muss vor dem Abholslot liegen.");
return { pickup: slot.start, end: slot.end, slotId: slot.id };
}
+49
View File
@@ -0,0 +1,49 @@
export function localClock(now = new Date()) {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: process.env.APP_TIMEZONE ?? "Europe/Berlin",
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", hourCycle: "h23",
}).formatToParts(now);
const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)!.value;
const day = `${get("year")}-${get("month")}-${get("day")}`;
const weekday = new Date(`${day}T12:00:00Z`).getUTCDay() || 7;
return { day, time: `${get("hour")}:${get("minute")}`, weekday };
}
export function pickupState(pickup: string | null, time: string, end?: string | null) {
if (!pickup) return "none";
const minutes = (value: string) => Number(value.slice(0, 2)) * 60 + Number(value.slice(3, 5));
const delta = minutes(pickup) - minutes(time);
if (minutes(end ?? pickup) < minutes(time)) return "overdue";
if (delta <= 15) return "due";
return "later";
}
export function displayDay(day: string) {
return new Intl.DateTimeFormat("de-DE", {
weekday: "long", day: "numeric", month: "long", timeZone: "UTC",
}).format(new Date(`${day}T12:00:00Z`));
}
export function timeInZone(instant: string, timeZone: string) {
return new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" })
.format(new Date(instant));
}
export function localInstant(day: string, time: string, timeZone: string): Date {
const wall = Date.parse(`${day}T${time}:00Z`);
const format = (date: Date) => new Intl.DateTimeFormat("sv-SE", {
timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit",
minute: "2-digit", second: "2-digit", hourCycle: "h23",
}).format(date).replace(" ", "T");
const offsets = new Set([-36, 0, 36].map((hours) => {
const sample = new Date(wall + hours * 3_600_000);
return Date.parse(`${format(sample)}Z`) - sample.getTime();
}));
const candidates = [...offsets].map((offset) => new Date(wall - offset))
.filter((date) => format(date) === `${day}T${time}:00`);
if (candidates.length !== 1) {
throw new Error("Diese Uhrzeit ist wegen der Zeitumstellung nicht eindeutig oder existiert nicht. Bitte eine andere Uhrzeit wählen.");
}
return candidates[0];
}
+92
View File
@@ -0,0 +1,92 @@
import { z } from "zod";
export const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Bitte eine gültige Uhrzeit angeben.");
export const daySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(
(value) => {
const date = new Date(`${value}T12:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
&& value >= "2000-01-01" && value <= "2100-12-31";
},
"Bitte ein gültiges Datum zwischen 2000 und 2100 wählen.",
);
export const idSchema = z.uuid();
const nameSchema = z.string().trim().min(1, "Bitte alle Namensfelder ausfüllen.").max(80, "Maximal 80 Zeichen.");
export const scheduleSchema = z.object({
weekday: z.number().int().min(1).max(7),
arrival: timeSchema,
pickup: timeSchema,
slotId: idSchema.nullable().optional(),
}).refine((value) => !!value.slotId || value.arrival < value.pickup, "Die Abholzeit muss nach der Ankunft liegen.");
export const childSchema = z.object({
id: idSchema.optional(),
firstName: nameSchema,
lastName: nameSchema,
className: nameSchema,
schedules: z.array(scheduleSchema).max(7),
}).refine((value) => new Set(value.schedules.map((s) => s.weekday)).size === value.schedules.length,
"Jeder Wochentag darf nur einmal vorkommen.");
export const overrideSchema = z.object({
childId: idSchema,
day: daySchema,
mode: z.enum(["weekly", "absent", "custom"]),
arrival: z.union([timeSchema, z.literal("")]),
pickup: z.union([timeSchema, z.literal("")]),
slotId: idSchema.nullable().optional(),
}).refine((value) => value.mode !== "custom"
|| (value.arrival !== "" && (!!value.slotId || (value.pickup !== "" && value.arrival < value.pickup))),
"Bitte Ankunft und eine spätere Abholzeit angeben.");
export type ChildInput = z.infer<typeof childSchema>;
export type Schedule = z.infer<typeof scheduleSchema>;
export type ActionResult = { error?: string; success?: boolean };
export const contactSchema = z.object({
id: idSchema.optional(),
childId: idSchema,
name: nameSchema,
relationship: nameSchema,
phone: z.string().trim().max(40),
authorized: z.boolean(),
notes: z.string().trim().max(500),
});
export const aloneRulesSchema = z.object({
childId: idSchema,
rules: z.array(z.object({ weekday: z.number().int().min(1).max(7), earliest: timeSchema })).max(7),
}).refine((value) => new Set(value.rules.map((rule) => rule.weekday)).size === value.rules.length,
"Jeder Wochentag darf nur einmal vorkommen.");
export const correctionSchema = z.object({
childId: idSchema,
day: daySchema,
status: z.enum(["expected", "arrived", "sick", "picked_up"]),
arrival: z.union([timeSchema, z.literal("")]),
pickup: z.union([timeSchema, z.literal("")]),
pickupKind: z.enum(["planned", "parents", "alone"]),
reason: z.string().trim().min(5, "Bitte einen aussagekräftigen Grund mit mindestens 5 Zeichen angeben.").max(500),
expectedVersion: z.string().regex(/^\d+$/).nullable(),
}).superRefine((value, context) => {
const hasArrival = value.status === "arrived" || value.status === "picked_up";
if ((hasArrival && !value.arrival) || (!hasArrival && (value.arrival || value.pickup))
|| (value.status === "arrived" && value.pickup)
|| (value.status === "picked_up" && (!value.pickup || value.pickup < value.arrival))) {
context.addIssue({ code: "custom", message: "Bitte zum Status passende Zeiten angeben; Abholung darf nicht vor der Ankunft liegen." });
}
});
export const slotSchema = z.object({
start: timeSchema, end: timeSchema,
}).refine((v) => v.start < v.end, "Das Slot-Ende muss nach dem Beginn liegen.");
export const roundSchema = z.object({
name: nameSchema, expiresOn: daySchema,
});
export const applicationSchema = z.object({
code: z.string().regex(/^[a-f0-9]{48}$/, "Ungültiger Einladungscode."),
firstName: nameSchema, lastName: nameSchema, className: nameSchema, parentName: nameSchema,
email: z.email().max(254).transform((v) => v.toLowerCase()),
notes: z.string().trim().max(500),
consent: z.literal(true, { error: "Bitte die Verarbeitung der Anmeldedaten bestätigen." }),
});