Apply linen design system across app

This commit is contained in:
t.indorf
2026-05-20 22:47:52 +02:00
parent 99ff978116
commit 1c5d2f10ac
56 changed files with 4693 additions and 2154 deletions
@@ -0,0 +1,204 @@
"use client";
import { useState } from "react";
import { AbsenceReason } from "@prisma/client";
import { CalendarDays, Circle, MessageSquareText, UserRound } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export type AbsenceTableItem = {
id: string;
childName: string;
familyName: string;
reason: AbsenceReason;
note: string | null;
startDate: string;
endDate: string;
};
export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
const [activeAbsence, setActiveAbsence] = useState<AbsenceTableItem | null>(
null,
);
return (
<>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Kind</TableHead>
<TableHead>Zeitraum</TableHead>
<TableHead>Grund</TableHead>
<TableHead>Bemerkung</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{absences.map((absence) => (
<TableRow
key={absence.id}
className="cursor-pointer border-[var(--hairline-soft)] hover:bg-[var(--surface-2)]"
onClick={() => setActiveAbsence(absence)}
>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-xs font-semibold text-[var(--accent-deep)]">
{getInitials(absence.childName)}
</div>
<div className="min-w-0">
<p className="truncate font-semibold text-[var(--ink)]">
{absence.childName}
</p>
<p className="truncate text-xs text-[var(--ink-soft)]">
{absence.familyName}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-[var(--ink-soft)]">
{formatDateRange(absence.startDate, absence.endDate)}
</TableCell>
<TableCell>
<span className="inline-flex items-center gap-2 text-[var(--ink)]">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(absence.reason) }}
/>
{reasonLabel(absence.reason)}
</span>
</TableCell>
<TableCell className="max-w-[280px] truncate text-[var(--ink-soft)]">
{absence.note || "Keine Bemerkung hinterlegt."}
</TableCell>
<TableCell>
<Badge variant="success">Bestätigt</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Dialog
open={!!activeAbsence}
onOpenChange={(open) => !open && setActiveAbsence(null)}
>
<DialogContent className="left-auto right-0 top-0 h-full max-w-md translate-x-0 translate-y-0 rounded-none border-y-0 border-r-0 border-[var(--hairline)] bg-[var(--surface)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right">
{activeAbsence ? (
<>
<DialogHeader>
<DialogTitle className="text-2xl font-medium tracking-[-0.02em] text-[var(--ink)]">
{activeAbsence.childName}
</DialogTitle>
<DialogDescription>{activeAbsence.familyName}</DialogDescription>
</DialogHeader>
<div className="mt-4 grid gap-4">
<DetailLine
icon={CalendarDays}
label="Zeitraum"
value={formatDateRange(
activeAbsence.startDate,
activeAbsence.endDate,
)}
/>
<DetailLine
icon={Circle}
label="Grund"
value={reasonLabel(activeAbsence.reason)}
dotColor={reasonColor(activeAbsence.reason)}
/>
<DetailLine
icon={MessageSquareText}
label="Bemerkung"
value={activeAbsence.note || "Keine Bemerkung hinterlegt."}
/>
<DetailLine icon={UserRound} label="Status" value="Bestätigt" />
</div>
</>
) : null}
</DialogContent>
</Dialog>
</>
);
}
function DetailLine({
icon: Icon,
label,
value,
dotColor,
}: {
icon: typeof CalendarDays;
label: string;
value: string;
dotColor?: string;
}) {
return (
<div className="rounded-lg border border-[var(--hairline-soft)] bg-[var(--surface-2)] p-3">
<p className="flex items-center gap-2 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
{dotColor ? (
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: dotColor }}
/>
) : (
<Icon className="h-3.5 w-3.5 [stroke-width:1.7]" />
)}
{label}
</p>
<p className="mt-2 text-sm font-medium text-[var(--ink)]">{value}</p>
</div>
);
}
function getInitials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
}
export function reasonLabel(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) return "Krank";
if (reason === AbsenceReason.VACATION) return "Urlaub";
return "Sonstiges";
}
export function reasonColor(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) return "var(--danger)";
if (reason === AbsenceReason.VACATION) return "var(--warn)";
return "var(--accent)";
}
export function formatDateRange(startDate: string, endDate: string) {
const start = new Date(`${startDate}T00:00:00`);
const end = new Date(`${endDate}T00:00:00`);
const startLabel = start.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
});
const endLabel = end.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
}
+284 -121
View File
@@ -1,164 +1,327 @@
import Link from "next/link";
import { addDays, format } from "date-fns";
import { addDays, differenceInCalendarDays, format, isWeekend } from "date-fns";
import { de } from "date-fns/locale";
import { ArrowLeft, ArrowRight, CalendarDays, Stethoscope } from "lucide-react";
import { AbsenceReason } from "@prisma/client";
import {
ArrowLeft,
ArrowRight,
CalendarCheck2,
CalendarDays,
ChevronDown,
ChevronRight,
Stethoscope,
} from "lucide-react";
import { AbsenceReason, TerminStatus, TerminType, UserRole } from "@prisma/client";
import { getDailyAbsences } from "@/actions/absences";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
AbsenceTable,
reasonColor,
reasonLabel,
type AbsenceTableItem,
} from "./absence-table";
export const metadata = { title: "Abwesenheiten · Kita-Planer" };
export default async function AdminAbwesenheitenPage({
searchParams,
}: {
searchParams: { date?: string };
searchParams: Promise<{ date?: string }>;
}) {
const selectedDate = parseDateParam(searchParams.date);
const session = await requireRole([
UserRole.ADMIN,
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
]);
const resolvedSearchParams = await searchParams;
const kitaId = session.user.kitaId!;
const today = startOfLocalDay(new Date());
const selectedDate = parseDateParam(resolvedSearchParams.date, today);
const dateKey = format(selectedDate, "yyyy-MM-dd");
const result = await getDailyAbsences({ date: dateKey });
const absences = result.absences ?? [];
const childCount = await prisma.child.count({
where: { kitaId, active: true },
});
const upcomingAbsenceCount = await prisma.absence.count({
where: {
kitaId,
startDate: { gt: selectedDate },
},
});
const closure = await prisma.termin.findFirst({
where: {
kitaId,
status: TerminStatus.CONFIRMED,
type: { in: [TerminType.SCHLIESSTAG, TerminType.TEAMTAG] },
startDate: { lte: selectedDate },
endDate: { gte: selectedDate },
},
select: { title: true, type: true },
orderBy: { startDate: "asc" },
});
const isToday = dateKey === format(today, "yyyy-MM-dd");
const isTooFarFuture = differenceInCalendarDays(selectedDate, today) > 30;
const isClosedDay = isWeekend(selectedDate) || !!closure;
const tableItems: AbsenceTableItem[] = absences.map((absence) => ({
id: absence.id,
childName: absence.childName,
familyName: absence.familyName,
reason: absence.reason,
note: absence.note,
startDate: absence.startDate,
endDate: absence.endDate,
}));
const countsByReason = countByReason(tableItems);
return (
<div className="min-h-full bg-slate-50 px-4 py-6 sm:px-8">
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex flex-col gap-4 rounded-lg border bg-background p-5 shadow-sm lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Stethoscope className="h-4 w-4" />
Tagesübersicht
</div>
<h1 className="mt-2 text-3xl font-bold tracking-tight">
Heute fehlen ({absences.length} Kinder)
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{format(selectedDate, "EEEE, dd. MMMM yyyy", { locale: de })}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={hrefForDate(addDays(selectedDate, -1))}>
<ArrowLeft className="h-4 w-4" />
Zurück
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/dashboard/admin/abwesenheiten">Heute</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={hrefForDate(addDays(selectedDate, 1))}>
Vor
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<form className="flex items-center gap-2">
<Input
type="date"
name="date"
defaultValue={dateKey}
className="h-9 w-40"
/>
<Button type="submit" size="sm">
Anzeigen
</Button>
</form>
</div>
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[1100px]:flex-col max-[1100px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
TAGESÜBERSICHT
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Heute fehlen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
{absences.length} Kinder
</em>
</h1>
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
{format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })}
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{childCount - absences.length} anwesend
</span>
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<SegmentLink href={hrefForDate(addDays(selectedDate, -1))}>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück
</SegmentLink>
<SegmentLink href={hrefForDate(today)} active={isToday}>
Heute
</SegmentLink>
<SegmentLink href={hrefForDate(addDays(selectedDate, 1))}>
Vor
<ArrowRight className="h-3.5 w-3.5" />
</SegmentLink>
</div>
<form className="flex items-center gap-2">
<Input
type="date"
name="date"
defaultValue={dateKey}
className="w-[140px]"
/>
<Button type="submit">Anzeigen</Button>
</form>
</div>
</header>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<FilterChip label="Alle Gruppen" />
<FilterChip label="Alle Gründe" />
</div>
{tableItems.length > 0 ? (
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--ink-soft)]">
<ReasonInlineStat
reason={AbsenceReason.ILLNESS}
count={countsByReason[AbsenceReason.ILLNESS] ?? 0}
/>
<ReasonInlineStat
reason={AbsenceReason.OTHER}
count={countsByReason[AbsenceReason.OTHER] ?? 0}
/>
<ReasonInlineStat
reason={AbsenceReason.VACATION}
count={countsByReason[AbsenceReason.VACATION] ?? 0}
/>
<span>
letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })}
</span>
</div>
) : null}
</div>
<div className="mt-4">
{result.error ? (
<Card className="border-destructive/30">
<CardContent className="p-6 text-sm text-destructive">
<Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
<CardContent className="pt-[22px] text-sm text-[var(--danger)]">
{result.error}
</CardContent>
</Card>
) : absences.length === 0 ? (
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-background p-8 text-center">
<div className="mx-auto max-w-md">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
<CalendarDays className="h-10 w-10" />
) : tableItems.length > 0 ? (
<Card>
<CardHeader className="pb-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle>
{tableItems.length} von {childCount} Kindern abwesend ·{" "}
{upcomingAbsenceCount} anstehend
</CardTitle>
<Badge variant="secondary">{dateKey}</Badge>
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Keine Abwesenheiten
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Für diesen Tag sind aktuell keine Kinder abgemeldet.
</p>
</div>
</div>
</CardHeader>
<CardContent>
<AbsenceTable absences={tableItems} />
</CardContent>
</Card>
) : isTooFarFuture ? (
<EmptyState
tone="accent"
title="Noch nicht im Vorschauzeitraum"
description="Abwesenheiten werden erst 30 Tage im Voraus angezeigt."
/>
) : isClosedDay ? (
<EmptyState
tone="accent"
title={`Kita geschlossen am ${format(selectedDate, "d. MMMM", {
locale: de,
})}`}
description={
closure
? closure.title
: "Wochenende · für diesen Tag werden keine Anwesenheiten erwartet."
}
/>
) : (
<div className="grid gap-3">
{absences.map((absence) => (
<Card key={absence.id} className="overflow-hidden">
<CardHeader className="flex flex-col gap-3 p-5 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-xl">
{absence.childName}
</CardTitle>
<CardDescription className="mt-1">
{absence.familyName}
</CardDescription>
</div>
<ReasonBadge reason={absence.reason} />
</CardHeader>
<CardContent className="grid gap-3 px-5 pb-5 sm:grid-cols-[180px_1fr]">
<div className="text-sm">
<p className="font-medium">Zeitraum</p>
<p className="text-muted-foreground">
{formatDateRange(absence.startDate, absence.endDate)}
</p>
</div>
<div className="text-sm">
<p className="font-medium">Notiz</p>
<p className="text-muted-foreground">
{absence.note || "Keine Bemerkung hinterlegt."}
</p>
</div>
</CardContent>
</Card>
))}
</div>
<EmptyState
tone="good"
title="Keine Abwesenheiten"
description="Für diesen Tag sind aktuell keine Kinder abgemeldet."
/>
)}
</div>
</div>
);
}
function parseDateParam(value: string | undefined) {
if (!value) return new Date();
function SegmentLink({
href,
active,
children,
}: {
href: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className={[
"inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition last:border-r-0 hover:bg-[var(--surface-2)] hover:text-[var(--ink)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
active ? "bg-[var(--accent-soft)] text-[var(--accent-deep)]" : "",
].join(" ")}
>
{children}
</Link>
);
}
function FilterChip({ label }: { label: string }) {
return (
<button
type="button"
className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-transparent px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--accent-soft)] hover:text-[var(--accent-deep)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
{label}
<ChevronDown className="h-3.5 w-3.5" />
</button>
);
}
function ReasonInlineStat({
reason,
count,
}: {
reason: AbsenceReason;
count: number;
}) {
return (
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(reason) }}
/>
{reasonLabel(reason)} {count}
</span>
);
}
function EmptyState({
tone,
title,
description,
}: {
tone: "good" | "accent";
title: string;
description: string;
}) {
const isGood = tone === "good";
return (
<Card>
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-8 py-12 text-center">
<div
className={[
"flex h-12 w-12 items-center justify-center rounded-2xl",
isGood
? "bg-[var(--good-soft)] text-[var(--good)]"
: "bg-[var(--accent-soft)] text-[var(--accent-deep)]",
].join(" ")}
>
{isGood ? (
<CalendarCheck2 className="h-6 w-6 [stroke-width:1.7]" />
) : (
<CalendarDays className="h-6 w-6 [stroke-width:1.7]" />
)}
</div>
<h2 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
{title}
</h2>
<p className="mt-2 max-w-md text-sm text-[var(--ink-soft)]">
{description}
</p>
<Link
href="/dashboard/admin/abwesenheiten"
className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
Wochenübersicht öffnen
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</CardContent>
</Card>
);
}
function parseDateParam(value: string | undefined, fallback: Date) {
if (!value) return fallback;
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
}
function hrefForDate(date: Date) {
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`;
}
function formatDateRange(startDate: string, endDate: string) {
const start = new Date(`${startDate}T00:00:00`);
const end = new Date(`${endDate}T00:00:00`);
const startLabel = format(start, "dd.MM.", { locale: de });
const endLabel = format(end, "dd.MM.yyyy", { locale: de });
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
function startOfLocalDay(date: Date) {
const nextDate = new Date(date);
nextDate.setHours(0, 0, 0, 0);
return nextDate;
}
function ReasonBadge({ reason }: { reason: AbsenceReason }) {
if (reason === AbsenceReason.ILLNESS) {
return <Badge variant="destructive">Krank</Badge>;
}
if (reason === AbsenceReason.VACATION) {
return <Badge variant="warning">Urlaub</Badge>;
}
return <Badge variant="outline">Sonstiges</Badge>;
function countByReason(items: AbsenceTableItem[]) {
return items.reduce<Partial<Record<AbsenceReason, number>>>((acc, item) => {
acc[item.reason] = (acc[item.reason] ?? 0) + 1;
return acc;
}, {});
}
@@ -116,9 +116,11 @@ export function DutyPlanner({
if (dutyTypes.length === 0) {
return (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-sm font-medium">Noch keine Diensttypen</p>
<p className="mt-1 text-sm text-muted-foreground">
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
<p className="text-sm font-medium text-[var(--ink)]">
Noch keine Diensttypen
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid.
</p>
</div>
@@ -136,29 +138,29 @@ export function DutyPlanner({
disabled={isPending}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
<BellRing className="h-4 w-4" />
<BellRing className="h-4 w-4 [stroke-width:1.7]" />
)}
Reminder diese Woche senden
</Button>
</div>
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[760px] border-collapse text-sm">
<div className="overflow-x-auto rounded-lg border border-[var(--hairline)]">
<table className="w-full min-w-[760px] border-collapse text-[13.5px] text-[var(--ink)]">
<thead>
<tr className="border-b bg-muted/50">
<th className="sticky left-0 z-10 w-56 bg-muted/95 px-4 py-3 text-left font-medium">
<tr className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
<th className="sticky left-0 z-10 w-56 bg-[var(--surface-2)] px-4 py-3 text-left text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Woche
</th>
{dutyTypes.map((dutyType) => (
<th
key={dutyType.id}
className="min-w-56 px-4 py-3 text-left font-medium"
className="min-w-56 px-4 py-3 text-left font-medium text-[var(--ink)]"
>
<div>{dutyType.name}</div>
{dutyType.description && (
<div className="mt-1 line-clamp-1 text-xs font-normal text-muted-foreground">
<div className="mt-1 line-clamp-1 text-xs font-normal text-[var(--ink-muted)]">
{dutyType.description}
</div>
)}
@@ -168,8 +170,11 @@ export function DutyPlanner({
</thead>
<tbody>
{weeks.map((week) => (
<tr key={week.key} className="border-b last:border-b-0">
<td className="sticky left-0 z-10 bg-background px-4 py-3 font-medium">
<tr
key={week.key}
className="border-b border-[var(--hairline-soft)] last:border-b-0 hover:bg-[var(--surface-2)]"
>
<td className="sticky left-0 z-10 bg-[var(--surface)] px-4 py-3 font-medium">
{week.label}
</td>
{dutyTypes.map((dutyType) => {
+83 -70
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns";
import { de } from "date-fns/locale";
import { CalendarRange, ClipboardList, Plus, Trash2 } from "lucide-react";
import { CalendarRange, Plus, Trash2 } from "lucide-react";
import { UserRole } from "@prisma/client";
import { Button } from "@/components/ui/button";
@@ -34,15 +34,16 @@ const MAX_WEEK_COUNT = 104;
export default async function DienstePage({
searchParams,
}: {
searchParams: { start?: string; weeks?: string };
searchParams: Promise<{ start?: string; weeks?: string }>;
}) {
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
if (!session.user.kitaId) {
throw new Error("Kein Mandant zugeordnet.");
}
const rangeStart = getRangeStart(searchParams.start);
const weekCount = getWeekCount(searchParams.weeks);
const resolvedSearchParams = await searchParams;
const rangeStart = getRangeStart(resolvedSearchParams.start);
const weekCount = getWeekCount(resolvedSearchParams.weeks);
const weeks = buildWeeks(rangeStart, weekCount);
const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate));
@@ -83,77 +84,88 @@ export default async function DienstePage({
const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes;
const familyDtos: DutyPlanFamilyDto[] = families;
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map((assignment) => ({
id: assignment.id,
dutyTypeId: assignment.dutyTypeId,
familyId: assignment.familyId,
startDate: toDateKey(assignment.startDate),
}));
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map(
(assignment) => ({
id: assignment.id,
dutyTypeId: assignment.dutyTypeId,
familyId: assignment.familyId,
startDate: toDateKey(assignment.startDate),
}),
);
return (
<div className="space-y-6 p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<ClipboardList className="h-4 w-4" />
Admin
</div>
<h1 className="mt-2 text-2xl font-bold tracking-tight">
Dienstplan-Manager
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ADMIN · DIENSTPLAN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Dienste{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
planen
</em>
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird
direkt gespeichert.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{dutyTypes.length} Diensttypen
</span>
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{families.length} Familien
</span>
</div>
</header>
<Card className="w-full lg:max-w-xl">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<CalendarRange className="h-4 w-4" />
Zeitraum
</CardTitle>
<CardDescription>
Du kannst beliebig weit voraus planen, jeweils in handlichen
Ausschnitten.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="grid gap-1.5">
<Label htmlFor="start">Startwoche</Label>
<Input
id="start"
name="start"
type="date"
defaultValue={toDateInputValue(rangeStart)}
/>
<Card className="mb-5">
<CardContent className="pt-[22px]">
<form className="flex flex-wrap items-end gap-3">
<div className="mr-auto min-w-64">
<div className="flex items-center gap-2 text-base font-medium tracking-[-0.02em] text-[var(--ink)]">
<CalendarRange className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
Zeitraum
</div>
<div className="grid gap-1.5">
<Label htmlFor="weeks">Wochen</Label>
<Input
id="weeks"
name="weeks"
type="number"
min={1}
max={MAX_WEEK_COUNT}
defaultValue={weekCount}
/>
</div>
<Button type="submit">Anzeigen</Button>
<Button asChild type="button" variant="outline">
<Link
href={`/dashboard/admin/dienste?start=${toDateInputValue(
rangeStart,
)}&weeks=${Math.min(weekCount + 12, MAX_WEEK_COUNT)}`}
>
Weitere Wochen laden
</Link>
</Button>
</form>
</CardContent>
</Card>
</div>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{weeks[0].label} bis {weeks[weeks.length - 1].label}
</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="start">Startwoche</Label>
<Input
id="start"
name="start"
type="date"
defaultValue={toDateInputValue(rangeStart)}
/>
</div>
<div className="grid w-28 gap-1.5">
<Label htmlFor="weeks">Wochen</Label>
<Input
id="weeks"
name="weeks"
type="number"
min={1}
max={MAX_WEEK_COUNT}
defaultValue={weekCount}
/>
</div>
<Button type="submit">Anzeigen</Button>
<Button asChild type="button" variant="ghost">
<Link
href={`/dashboard/admin/dienste?start=${toDateInputValue(
rangeStart,
)}&weeks=${Math.min(weekCount + 12, MAX_WEEK_COUNT)}`}
>
Weitere Wochen laden
</Link>
</Button>
</form>
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-[340px_1fr]">
<Card>
@@ -186,19 +198,19 @@ export default async function DienstePage({
<div className="space-y-2">
{dutyTypes.length === 0 ? (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
<p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3 text-sm italic text-[var(--ink-faint)]">
Noch keine Diensttypen angelegt.
</p>
) : (
dutyTypes.map((dutyType) => (
<div
key={dutyType.id}
className="flex items-start justify-between gap-3 rounded-md border p-3"
className="flex items-start justify-between gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="min-w-0">
<p className="font-medium">{dutyType.name}</p>
<p className="font-medium text-[var(--ink)]">{dutyType.name}</p>
{dutyType.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
<p className="mt-1 line-clamp-2 text-xs text-[var(--ink-soft)]">
{dutyType.description}
</p>
)}
@@ -213,9 +225,10 @@ export default async function DienstePage({
type="submit"
variant="ghost"
size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
aria-label={`${dutyType.name} löschen`}
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</form>
</div>
@@ -85,29 +85,59 @@ export function AdminCalendarManager({
termine: AdminTerminDto[];
childRows: ChildDto[];
}) {
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
const pendingCount = termine.filter(
(termin) => termin.status === TerminStatus.PENDING,
).length;
return (
<div className="flex h-full flex-col gap-6 p-6">
<div className="flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Event-Anmeldungen & Ausflüge
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ADMIN · EVENT-ANMELDUNGEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Termine{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
koordinieren
</em>
</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
verfolgen.
</p>
</div>
<TerminFormDialog mode="create" />
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{termine.length} Termine
</span>
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
{rsvpCount} RSVP
</span>
{pendingCount > 0 && (
<span className="inline-flex h-9 items-center rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-3 text-[13px] font-semibold text-[var(--warn)]">
{pendingCount} ausstehend
</span>
)}
<TerminFormDialog mode="create" />
</div>
</header>
{termine.length === 0 ? (
<div className="rounded-md border border-dashed p-10 text-center">
<CalendarPlus className="mx-auto h-10 w-10 text-muted-foreground" />
<h2 className="mt-4 text-lg font-semibold">Noch keine Termine</h2>
<p className="mt-1 text-sm text-muted-foreground">
<Card className="border-dashed">
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<CalendarPlus className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h2 className="mt-4 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Noch keine Termine
</h2>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
</p>
</div>
</p>
</div>
</Card>
) : (
<div className="grid gap-4">
{termine.map((termin) => (
@@ -134,7 +164,7 @@ function AdminTerminCard({
return (
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30">
<CardHeader className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap gap-2">
@@ -149,8 +179,8 @@ function AdminTerminCard({
)}
</div>
<CardTitle className="text-lg">{termin.title}</CardTitle>
<CardDescription className="mt-1 flex flex-wrap items-center gap-2">
<Clock className="h-3.5 w-3.5" />
<CardDescription className="mt-1 flex flex-wrap items-center gap-2 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(new Date(termin.startDate), "dd.MM.yyyy", {
locale: de,
})}
@@ -176,20 +206,20 @@ function AdminTerminCard({
<CardContent className="grid gap-5 pt-5">
{termin.description && (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
<p className="whitespace-pre-wrap text-sm text-[var(--ink-soft)]">
{termin.description}
</p>
)}
{termin.requiresRsvp ? (
<div className="grid gap-4">
<div className="flex flex-col gap-3 rounded-md border bg-amber-50/70 p-4 text-amber-950 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-[var(--ink)] sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
{stats.pending} Ausstehend
</p>
<p className="mt-1 text-sm text-amber-900/80">
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Frist:{" "}
{termin.rsvpDeadline
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
@@ -198,11 +228,11 @@ function AdminTerminCard({
: "bis Terminbeginn"}
</p>
</div>
<Users className="h-5 w-5 shrink-0" />
<Users className="h-5 w-5 shrink-0 text-[var(--warn)] [stroke-width:1.7]" />
</div>
{termin.participantInfo && (
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-4 text-sm text-yellow-950">
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
<span className="font-medium">Hinweis für Teilnehmer: </span>
{termin.participantInfo}
</div>
@@ -233,7 +263,7 @@ function AdminTerminCard({
</Table>
</div>
) : (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm italic text-[var(--ink-faint)]">
Für diesen Termin ist keine Teilnahme-Abfrage aktiv.
</div>
)}
@@ -304,11 +334,11 @@ function TerminFormDialog({
}}
>
<DialogTrigger asChild>
<Button variant={mode === "edit" ? "outline" : "default"} size="sm">
<Button variant={mode === "edit" ? "pill" : "default"} size="sm">
{mode === "edit" ? (
<Pencil className="h-4 w-4" />
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
) : (
<CalendarPlus className="h-4 w-4" />
<CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
)}
{mode === "edit" ? "Bearbeiten" : "Termin anlegen"}
</Button>
@@ -351,7 +381,7 @@ function TerminFormDialog({
id={`${mode}-type`}
name="type"
defaultValue={termin?.type ?? TerminType.KITA_FEST}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
required
>
{Object.values(TerminType).map((type) => (
@@ -389,13 +419,13 @@ function TerminFormDialog({
</div>
</div>
<div className="grid gap-3 rounded-md border p-4">
<div className="grid gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="allDay"
defaultChecked={termin?.allDay ?? false}
className="h-4 w-4 rounded border-input"
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Ganztägiger Termin
</label>
@@ -404,7 +434,7 @@ function TerminFormDialog({
type="checkbox"
name="mitbringselListEnabled"
defaultChecked={termin?.mitbringselListEnabled ?? false}
className="h-4 w-4 rounded border-input"
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Mitbring-Liste aktiv
</label>
@@ -414,14 +444,14 @@ function TerminFormDialog({
name="requiresRsvp"
checked={requiresRsvp}
onChange={(event) => setRequiresRsvp(event.target.checked)}
className="h-4 w-4 rounded border-input"
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
Teilnahme-Abfrage (RSVP) aktivieren
</label>
</div>
{requiresRsvp && (
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50/60 p-4">
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
<div className="grid gap-2">
<Label htmlFor={`${mode}-rsvpDeadline`}>
Rückmeldefrist
@@ -495,7 +525,7 @@ function RsvpStatusBadge({
}
return (
<Badge variant="outline" className="gap-1 text-muted-foreground">
<Badge variant="outline" className="gap-1 text-[var(--ink-muted)]">
<HelpCircle className="h-3.5 w-3.5" />
Ausstehend
</Badge>
@@ -566,19 +596,19 @@ function getTypeBadgeClass(type: TerminType) {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "border-transparent bg-amber-500 text-white";
return "border-transparent bg-[var(--warn-soft)] text-[var(--warn)]";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "border-transparent bg-rose-500 text-white";
return "border-transparent bg-[var(--danger-soft)] text-[var(--danger)]";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "border-transparent bg-blue-500 text-white";
return "border-transparent bg-[oklch(0.94_0.04_280)] text-[oklch(0.48_0.08_280)]";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "border-transparent bg-purple-500 text-white";
return "border-transparent bg-[var(--accent-soft)] text-[var(--accent-deep)]";
default:
return "border-slate-300 bg-slate-100 text-slate-800";
return "border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]";
}
}
+9 -9
View File
@@ -67,14 +67,14 @@ export function NewsForm() {
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
required
/>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-[var(--ink-muted)]">
Markdown ist aktiv. HTML wird nicht interpretiert.
</p>
</div>
{content.trim() && (
<div className="rounded-md border bg-muted/30 p-4 text-sm">
<p className="mb-3 text-xs font-semibold uppercase text-muted-foreground">
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm">
<p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
Vorschau
</p>
<MarkdownContent content={content} />
@@ -83,7 +83,7 @@ export function NewsForm() {
<div className="grid gap-2">
<Label htmlFor="attachments">Anhänge</Label>
<div className="rounded-md border border-dashed bg-background p-4">
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<Input
id="attachments"
name="attachments"
@@ -91,14 +91,14 @@ export function NewsForm() {
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
multiple
/>
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<UploadCloud className="h-3.5 w-3.5" />
<p className="mt-2 flex items-center gap-1 text-xs text-[var(--ink-muted)]">
<UploadCloud className="h-3.5 w-3.5 [stroke-width:1.7]" />
PDF, JPG und PNG bis 10 MB pro Datei.
</p>
</div>
</div>
<div className="rounded-md border p-4">
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<div className="flex items-center gap-2">
<Checkbox
id="sendEmail"
@@ -130,9 +130,9 @@ export function NewsForm() {
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
<Send className="h-4 w-4" />
<Send className="h-4 w-4 [stroke-width:1.7]" />
)}
Veröffentlichen
</Button>
+43 -24
View File
@@ -1,6 +1,6 @@
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { FileText, Megaphone } from "lucide-react";
import { FileText, Megaphone, Send } from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
@@ -49,26 +49,37 @@ export default async function AdminNewsPage() {
});
return (
<div className="px-8 py-8">
<div className="mb-8">
<Badge className="mb-3 bg-emerald-100 text-emerald-800 hover:bg-emerald-100">
Offizielle Kita-Kommunikation
</Badge>
<h1 className="flex items-center gap-3 text-2xl font-semibold tracking-tight">
<Megaphone className="h-6 w-6 text-emerald-700" />
Digitales Schwarzes Brett
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
werden geschützt gespeichert und nur für eingeloggte Nutzer derselben
Kita ausgeliefert.
</p>
</div>
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
KOMMUNIKATION · SCHWARZES BRETT
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Meldungen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
veröffentlichen
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
werden geschützt gespeichert und nur für eingeloggte Nutzer
derselben Kita ausgeliefert.
</p>
</div>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<Megaphone className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{announcements.length} zuletzt
</span>
</header>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card>
<CardHeader>
<CardTitle>Neue Ankündigung</CardTitle>
<CardTitle className="flex items-center gap-2">
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
Neue Ankündigung
</CardTitle>
<CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als
geschützte Anhänge hinzugefügt werden.
@@ -88,27 +99,35 @@ export default async function AdminNewsPage() {
</CardHeader>
<CardContent className="grid gap-3">
{announcements.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
Noch keine Ankündigungen veröffentlicht.
</p>
<div className="flex max-h-[280px] min-h-[220px] flex-col items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Megaphone className="h-6 w-6 [stroke-width:1.7]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Noch keine Ankündigungen
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Veröffentlichte Meldungen erscheinen hier.
</p>
</div>
) : (
announcements.map((announcement) => (
<div
key={announcement.id}
className="rounded-md border bg-background p-3"
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="mb-2 flex items-start justify-between gap-2">
<p className="font-medium leading-snug">
<p className="font-medium leading-snug text-[var(--ink)]">
{announcement.title}
</p>
{announcement.attachments.length > 0 && (
<Badge variant="secondary" className="shrink-0">
<FileText className="h-3 w-3" />
<FileText className="h-3 w-3 [stroke-width:1.7]" />
{announcement.attachments.length}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-[var(--ink-muted)]">
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
locale: de,
})}{" "}