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 4698 additions and 2159 deletions
+225 -43
View File
@@ -2,11 +2,14 @@ import {
DutyAssignmentStatus,
EventParticipationStatus,
TerminStatus,
TerminType,
UserRole,
} from "@prisma/client";
import { HelpCircle, ListFilter, Search } from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Input } from "@/components/ui/input";
import { AdminTerminModal } from "./_components/admin-termin-modal";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
import { TerminList, type CalendarListItemDto } from "./_components/termin-list";
@@ -17,7 +20,7 @@ export const metadata = { title: "Terminkalender · Kita-Planer" };
export default async function KalenderPage({
searchParams,
}: {
searchParams: { tab?: string };
searchParams: Promise<{ tab?: string }>;
}) {
const session = await requireKitaSession();
const isAdmin =
@@ -25,7 +28,9 @@ export default async function KalenderPage({
session.user.role === UserRole.KOORDINATOR;
const familyIdForParticipation = session.user.familyId ?? "__no_family__";
const currentTab = searchParams.tab || "übersicht";
const resolvedSearchParams = await searchParams;
const currentTab = resolvedSearchParams.tab || "übersicht";
const now = new Date();
const confirmedTermineRows = await prisma.termin.findMany({
where: {
@@ -122,7 +127,7 @@ export default async function KalenderPage({
where: {
kitaId: session.user.kitaId,
status: DutyAssignmentStatus.PLANNED,
endDate: { gte: new Date() },
endDate: { gte: now },
},
select: {
id: true,
@@ -177,7 +182,7 @@ export default async function KalenderPage({
let allPendingTermine: PendingTerminDto[] = [];
if (isAdmin) {
allPendingTermine = await prisma.termin.findMany({
const pendingRows = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.PENDING,
@@ -186,6 +191,8 @@ export default async function KalenderPage({
id: true,
title: true,
startDate: true,
createdAt: true,
type: true,
allDay: true,
createdBy: {
select: {
@@ -196,67 +203,74 @@ export default async function KalenderPage({
},
orderBy: { createdAt: "desc" },
});
allPendingTermine = pendingRows.map((termin) => ({
...termin,
ageInDays: Math.floor(
(now.getTime() - termin.createdAt.getTime()) / (1000 * 60 * 60 * 24),
),
}));
}
return (
<div className="flex h-full flex-col gap-6 p-6">
<div className="flex items-center 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]:items-start max-[900px]:flex-col">
<div>
<h1 className="text-2xl font-bold tracking-tight">Terminkalender</h1>
<p className="text-muted-foreground">
Alle anstehenden Termine, Feste und Schließtage im Überblick.
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
TERMINKALENDER
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Was steht{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
an
</em>
?
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Alle anstehenden Termine, Feste, Dienste und Schließtage im
Überblick.
</p>
</div>
<div className="flex gap-2">
<div className="flex shrink-0 flex-wrap items-center gap-2">
<TerminRequestModal />
<span className="group relative inline-flex h-9 w-9 items-center justify-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-muted)]">
<HelpCircle className="h-4 w-4 [stroke-width:1.7]" />
<span className="pointer-events-none absolute right-0 top-11 z-30 hidden w-72 rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-left text-xs leading-5 text-white shadow-lg group-hover:block">
Anfragen müssen vom Vorstand bestätigt werden, direkte Anlage
erscheint sofort im Kalender.
</span>
</span>
{isAdmin && <AdminTerminModal />}
</div>
</div>
</header>
{isAdmin ? (
<div className="flex flex-col gap-4">
<div className="flex gap-4 border-b">
<a
href="/dashboard/kalender?tab=übersicht"
className={`pb-2 text-sm font-medium transition-colors hover:text-primary ${
currentTab === "übersicht"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
}`}
>
Übersicht
</a>
<a
href="/dashboard/kalender?tab=anfragen"
className={`flex items-center gap-2 pb-2 text-sm font-medium transition-colors hover:text-primary ${
currentTab === "anfragen"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
}`}
>
Ausstehende Anfragen
{allPendingTermine.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{allPendingTermine.length}
</span>
)}
</a>
</div>
<div className="flex flex-col gap-5">
<TabBar
currentTab={currentTab}
overviewCount={allUserTermine.length}
pendingCount={allPendingTermine.length}
/>
<div className="pt-2">
<div>
{currentTab === "übersicht" ? (
<TerminList
<CalendarOverview
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
/>
) : (
) : currentTab === "anfragen" ? (
<PendingAnfragen termine={allPendingTermine} />
) : (
<CalendarOverview
termine={[]}
userId={session.user.id}
isAdmin={isAdmin}
/>
)}
</div>
</div>
) : (
<TerminList
<CalendarOverview
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
@@ -265,3 +279,171 @@ export default async function KalenderPage({
</div>
);
}
function CalendarOverview({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
}) {
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
{["Liste", "Monat", "Agenda"].map((label, index) => (
<button
key={label}
type="button"
className={[
"h-9 px-4 text-[13px] font-semibold transition",
index > 0 ? "border-l border-[var(--hairline-soft)]" : "",
index === 0
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
{label}
</button>
))}
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
<CategoryChips />
<div className="relative min-w-56">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input placeholder="Termine suchen" className="h-9 pl-9" />
</div>
</div>
</div>
<TerminList termine={termine} userId={userId} isAdmin={isAdmin} />
</div>
);
}
function TabBar({
currentTab,
overviewCount,
pendingCount,
}: {
currentTab: string;
overviewCount: number;
pendingCount: number;
}) {
return (
<div className="flex border-b border-[var(--hairline)]">
<TabLink
href="/dashboard/kalender?tab=übersicht"
active={currentTab === "übersicht"}
count={overviewCount}
>
Übersicht
</TabLink>
<TabLink
href="/dashboard/kalender?tab=anfragen"
active={currentTab === "anfragen"}
count={pendingCount}
attention={pendingCount > 0}
>
Ausstehende Anfragen
</TabLink>
<TabLink
href="/dashboard/kalender?tab=vergangen"
active={currentTab === "vergangen"}
>
Vergangene
</TabLink>
</div>
);
}
function TabLink({
href,
active,
count,
attention,
children,
}: {
href: string;
active: boolean;
count?: number;
attention?: boolean;
children: React.ReactNode;
}) {
return (
<a
href={href}
className={[
"-mb-px inline-flex h-11 items-center gap-2 border-b-2 px-1.5 text-sm transition first:pl-0",
active
? "border-[var(--accent)] font-semibold text-[var(--ink)]"
: "border-transparent font-medium text-[var(--ink-soft)] hover:text-[var(--ink)]",
].join(" ")}
>
{children}
{typeof count === "number" && (
<span
className={[
"rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums",
attention
? "bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)]"
: "border border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]",
].join(" ")}
>
{count}
</span>
)}
</a>
);
}
function CategoryChips() {
const categories = [
["Schließtag", TerminType.SCHLIESSTAG],
["Teamtag", TerminType.TEAMTAG],
["Elternabend", TerminType.ELTERNABEND],
["Fest", TerminType.KITA_FEST],
["Geburtstag", TerminType.GEBURTSTAG_INTERN],
["Sonstiges", TerminType.SONSTIGES],
] as const;
return (
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]">
<ListFilter className="h-3.5 w-3.5 [stroke-width:1.7]" />
Filter
</span>
{categories.map(([label, type]) => (
<span
key={label}
className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]"
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: getCategoryColor(type) }}
/>
{label}
</span>
))}
</div>
);
}
function getCategoryColor(type: TerminType) {
switch (type) {
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "var(--danger)";
case TerminType.ELTERNABEND:
return "oklch(0.50 0.10 280)";
case TerminType.KITA_FEST:
return "var(--good)";
case TerminType.GEBURTSTAG_INTERN:
return "oklch(0.50 0.10 320)";
default:
return "var(--ink-muted)";
}
}