Apply linen design system across app
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import type { ComponentProps, FormEvent } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { TerminType } from "@prisma/client";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -20,18 +21,77 @@ import { Label } from "@/components/ui/label";
|
||||
import { createTerminAdmin } from "../actions";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function AdminTerminModal() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
type AdminTerminFormState = {
|
||||
title: string;
|
||||
description: string;
|
||||
type: TerminType;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
allDay: boolean;
|
||||
};
|
||||
|
||||
const initialFormState: AdminTerminFormState = {
|
||||
title: "",
|
||||
description: "",
|
||||
type: TerminType.KITA_FEST,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
allDay: false,
|
||||
};
|
||||
|
||||
type AdminTerminModalProps = {
|
||||
triggerLabel?: string;
|
||||
triggerVariant?: ComponentProps<typeof Button>["variant"];
|
||||
triggerClassName?: string;
|
||||
};
|
||||
|
||||
export function AdminTerminModal({
|
||||
triggerLabel = "Termin direkt anlegen",
|
||||
triggerVariant,
|
||||
triggerClassName = "gap-2",
|
||||
}: AdminTerminModalProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formState, setFormState] = useState<AdminTerminFormState>(initialFormState);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
|
||||
const minEndDateTime = formState.startDate || minDateTime;
|
||||
|
||||
const updateField = <TKey extends keyof AdminTerminFormState>(
|
||||
key: TKey,
|
||||
value: AdminTerminFormState[TKey],
|
||||
) => {
|
||||
setFormState((current) => {
|
||||
const next = { ...current, [key]: value };
|
||||
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
|
||||
next.endDate = value;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!formState.startDate || !formState.endDate) {
|
||||
toast.error("Bitte Start und Ende ausfüllen.");
|
||||
return;
|
||||
}
|
||||
if (formState.startDate < minDateTime) {
|
||||
toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen.");
|
||||
return;
|
||||
}
|
||||
if (formState.endDate < formState.startDate) {
|
||||
toast.error("Das Ende darf nicht vor dem Start liegen.");
|
||||
return;
|
||||
}
|
||||
|
||||
const handleAction = (formData: FormData) => {
|
||||
const data = {
|
||||
title: formData.get("title") as string,
|
||||
description: formData.get("description") as string,
|
||||
type: formData.get("type") as TerminType,
|
||||
startDate: new Date(formData.get("startDate") as string).toISOString(),
|
||||
endDate: new Date(formData.get("endDate") as string).toISOString(),
|
||||
allDay: formData.get("allDay") === "on",
|
||||
title: formState.title,
|
||||
description: formState.description,
|
||||
type: formState.type,
|
||||
startDate: new Date(formState.startDate).toISOString(),
|
||||
endDate: new Date(formState.endDate).toISOString(),
|
||||
allDay: formState.allDay,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
@@ -40,6 +100,7 @@ export function AdminTerminModal() {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("Termin wurde direkt angelegt!");
|
||||
setFormState(initialFormState);
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
@@ -48,13 +109,13 @@ export function AdminTerminModal() {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2 bg-primary">
|
||||
<Button variant={triggerVariant} className={triggerClassName}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Termin direkt anlegen
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form action={handleAction}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin anlegen (Admin)</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -65,12 +126,25 @@ export function AdminTerminModal() {
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Titel</Label>
|
||||
<Input id="title" name="title" required placeholder="z.B. Sommerfest" />
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="z.B. Sommerfest"
|
||||
value={formState.title}
|
||||
onChange={(event) => updateField("title", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Beschreibung (Optional)</Label>
|
||||
<Textarea id="description" name="description" placeholder="Details zum Termin..." />
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Details zum Termin..."
|
||||
value={formState.description}
|
||||
onChange={(event) => updateField("description", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
@@ -78,7 +152,9 @@ export function AdminTerminModal() {
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
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"
|
||||
value={formState.type}
|
||||
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:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
onChange={(event) => updateField("type", event.target.value as TerminType)}
|
||||
required
|
||||
>
|
||||
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
|
||||
@@ -97,16 +173,39 @@ export function AdminTerminModal() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="startDate">Start</Label>
|
||||
<Input id="startDate" name="startDate" type="datetime-local" required />
|
||||
<Input
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
type="datetime-local"
|
||||
min={minDateTime}
|
||||
value={formState.startDate}
|
||||
onChange={(event) => updateField("startDate", event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">Ende</Label>
|
||||
<Input id="endDate" name="endDate" type="datetime-local" required />
|
||||
<Input
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
type="datetime-local"
|
||||
min={minEndDateTime}
|
||||
value={formState.endDate}
|
||||
onChange={(event) => updateField("endDate", event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" />
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allDay"
|
||||
name="allDay"
|
||||
checked={formState.allDay}
|
||||
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
onChange={(event) => updateField("allDay", event.target.checked)}
|
||||
/>
|
||||
<Label htmlFor="allDay" className="font-normal cursor-pointer">
|
||||
Ganztägiger Termin
|
||||
</Label>
|
||||
@@ -126,3 +225,10 @@ export function AdminTerminModal() {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function getLocalDateTimeInputValue(date: Date) {
|
||||
const localDate = new Date(date);
|
||||
localDate.setSeconds(0, 0);
|
||||
const offset = localDate.getTimezoneOffset() * 60_000;
|
||||
return new Date(localDate.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
@@ -55,23 +55,25 @@ export function MitbringselList({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-md bg-muted/50 p-3">
|
||||
<div className="flex items-center gap-2 font-medium text-sm mb-1">
|
||||
<Utensils className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-3 rounded-lg bg-[var(--surface-2)] p-3">
|
||||
<div className="mb-1 flex items-center gap-2 text-sm font-medium text-[var(--ink)]">
|
||||
<Utensils className="h-4 w-4 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
||||
Mitbring-Liste
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">Noch keine Einträge vorhanden.</p>
|
||||
<p className="text-xs italic text-[var(--ink-faint)]">
|
||||
Noch keine Einträge vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-2 rounded-sm bg-background p-2 text-sm shadow-sm"
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-[var(--hairline-soft)] bg-[var(--surface)] p-2 text-sm text-[var(--ink)]"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-xs text-primary">
|
||||
<span className="text-xs font-medium text-[var(--accent-deep)]">
|
||||
{item.user.firstName} {item.user.lastName}
|
||||
</span>
|
||||
<span>{item.content}</span>
|
||||
@@ -80,11 +82,11 @@ export function MitbringselList({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0"
|
||||
className="h-6 w-6 shrink-0 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -112,7 +114,7 @@ export function MitbringselList({
|
||||
onClick={handleAdd}
|
||||
disabled={!content.trim() || isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus className="mr-1 h-4 w-4 [stroke-width:1.7]" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
import { useTransition } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Check, X, Clock, CalendarIcon } from "lucide-react";
|
||||
import { CalendarIcon, Check, Clock, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { approveTermin, rejectTermin } from "../actions";
|
||||
|
||||
export type PendingTerminDto = {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: Date;
|
||||
createdAt: Date;
|
||||
ageInDays: number;
|
||||
allDay: boolean;
|
||||
createdBy: { firstName: string; lastName: string } | null;
|
||||
};
|
||||
@@ -21,22 +24,28 @@ export type PendingTerminDto = {
|
||||
export function PendingAnfragen({ termine }: { termine: PendingTerminDto[] }) {
|
||||
if (termine.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
|
||||
<CalendarIcon className="h-10 w-10 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold">Keine ausstehenden Anfragen</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Es gibt aktuell keine Terminanfragen, die bestätigt werden müssen.
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center p-8 text-center animate-in fade-in-50">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<CalendarIcon className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-[var(--ink)]">
|
||||
Keine ausstehenden Anfragen
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-[var(--ink-soft)]">
|
||||
Es gibt aktuell keine Terminanfragen, die bestätigt werden müssen.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="overflow-hidden p-0">
|
||||
{termine.map((termin) => (
|
||||
<PendingTerminCard key={termin.id} termin={termin} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,40 +76,57 @@ function PendingTerminCard({ termin }: { termin: PendingTerminDto }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold">{termin.title}</div>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{format(termin.startDate, "PP", { locale: de })}
|
||||
{!termin.allDay && ` • ${format(termin.startDate, "p", { locale: de })}`}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-primary mt-1">
|
||||
Angefragt von: {termin.createdBy?.firstName} {termin.createdBy?.lastName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 border-b border-[var(--hairline-soft)] px-5 py-4 last:border-b-0 hover:bg-[var(--surface-2)]">
|
||||
<div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
|
||||
<span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
|
||||
{format(termin.startDate, "d", { locale: de })}
|
||||
</span>
|
||||
<span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
|
||||
{format(termin.startDate, "EEE", { locale: de })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground border-destructive/20"
|
||||
onClick={handleReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
<X className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Ablehnen</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={handleApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Freigeben</span>
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||
{termin.title}
|
||||
</h3>
|
||||
{termin.ageInDays > 7 && (
|
||||
<Badge variant="warning">Älter als 7 Tage</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="mt-1 flex items-center gap-1 text-sm text-[var(--ink-soft)]">
|
||||
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{format(termin.startDate, "PP", { locale: de })}
|
||||
{!termin.allDay &&
|
||||
` · ${format(termin.startDate, "p", { locale: de })}`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--accent-deep)]">
|
||||
Angefragt von: {termin.createdBy?.firstName}{" "}
|
||||
{termin.createdBy?.lastName} · vor {termin.ageInDays} Tagen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={handleReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
<X className="h-4 w-4 [stroke-width:1.7]" />
|
||||
Ablehnen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 [stroke-width:1.7]" />
|
||||
Genehmigen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,18 @@
|
||||
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { AlertTriangle, AlignLeft, Calendar, Clock, WashingMachine } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
Clock,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
WashingMachine,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MitbringselList } from "./mitbringsel-list";
|
||||
import { toggleMitbringselList } from "../actions";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { AdminTerminModal } from "./admin-termin-modal";
|
||||
import { TerminRequestModal } from "./termin-request-modal";
|
||||
|
||||
export type TerminListItemDto = {
|
||||
kind: "termin";
|
||||
@@ -59,7 +62,6 @@ export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
|
||||
|
||||
export function TerminList({
|
||||
termine,
|
||||
userId,
|
||||
isAdmin,
|
||||
}: {
|
||||
termine: CalendarListItemDto[];
|
||||
@@ -68,169 +70,130 @@ export function TerminList({
|
||||
}) {
|
||||
if (termine.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<Calendar className="h-10 w-10 text-muted-foreground mb-4" />
|
||||
<h3 className="mt-4 text-lg font-semibold">Keine Termine</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
Es stehen aktuell keine Termine an.
|
||||
<Card>
|
||||
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Calendar className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<h3 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Keine Termine in diesem Zeitraum
|
||||
</h3>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Lege den ersten Termin an, damit Eltern planen können.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
{isAdmin ? (
|
||||
<AdminTerminModal triggerLabel="+ Termin anlegen" triggerClassName="" />
|
||||
) : (
|
||||
<TerminRequestModal
|
||||
triggerLabel="+ Termin anlegen"
|
||||
triggerVariant="default"
|
||||
triggerClassName=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = groupByMonth(termine);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{termine.map((termin) => (
|
||||
termin.kind === "duty" ? (
|
||||
<DutyCard key={`duty-${termin.id}`} duty={termin} />
|
||||
) : (
|
||||
<TerminCard
|
||||
key={`termin-${termin.id}`}
|
||||
termin={termin}
|
||||
userId={userId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)
|
||||
<div className="space-y-8">
|
||||
{groups.map((group) => (
|
||||
<section key={group.key}>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
{group.label}
|
||||
</h2>
|
||||
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)] tabular-nums">
|
||||
{group.items.length} {group.items.length === 1 ? "Termin" : "Termine"}
|
||||
</span>
|
||||
</div>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||
{group.items.map((item) =>
|
||||
item.kind === "duty" ? (
|
||||
<DutyRow key={`duty-${item.id}`} duty={item} />
|
||||
) : (
|
||||
<TerminRow key={`termin-${item.id}`} termin={item} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DutyCard({ duty }: { duty: DutyCalendarItemDto }) {
|
||||
function DutyRow({ duty }: { duty: DutyCalendarItemDto }) {
|
||||
const color = "var(--good)";
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden border-emerald-200 bg-emerald-50/60">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<Badge className="border-transparent bg-emerald-600 text-white hover:bg-emerald-700">
|
||||
Elterndienst
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="line-clamp-2 leading-tight">
|
||||
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
|
||||
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
|
||||
<DateTile date={duty.startDate} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||
{duty.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 flex items-center gap-1 text-sm">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
</h3>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
|
||||
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{format(duty.startDate, "dd.MM.", { locale: de })} -{" "}
|
||||
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 pb-4">
|
||||
<div className="flex items-start gap-2 text-sm text-emerald-900">
|
||||
<WashingMachine className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
Eingeteilt: <span className="font-medium">{duty.familyName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<WashingMachine className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{duty.familyName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CategoryPill color={color}>Elterndienst</CategoryPill>
|
||||
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
|
||||
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminCard({
|
||||
termin,
|
||||
userId,
|
||||
isAdmin,
|
||||
}: {
|
||||
termin: TerminListItemDto;
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
function TerminRow({ termin }: { termin: TerminListItemDto }) {
|
||||
const color = getTypeColor(termin.type);
|
||||
|
||||
const handleToggleMitbringsel = (enabled: boolean) => {
|
||||
startTransition(async () => {
|
||||
const result = await toggleMitbringselList(termin.id, enabled);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success(
|
||||
enabled
|
||||
? "Mitbring-Liste aktiviert"
|
||||
: "Mitbring-Liste deaktiviert"
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden relative">
|
||||
{termin.status === TerminStatus.PENDING && (
|
||||
<div className="absolute top-0 right-0 bg-yellow-500 text-white text-xs font-bold px-2 py-1 rounded-bl-lg z-10">
|
||||
Ausstehend
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start mb-2 gap-2">
|
||||
<Badge variant={getBadgeVariant(termin.type)} className={getBadgeColor(termin.type)}>
|
||||
{getTypeLabel(termin.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="line-clamp-2 leading-tight">{termin.title}</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1 mt-1 text-sm">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{format(termin.startDate, "PP", { locale: de })}
|
||||
{!termin.allDay && (
|
||||
<>
|
||||
{" • "}
|
||||
{format(termin.startDate, "p", { locale: de })} -{" "}
|
||||
{format(termin.endDate, "p", { locale: de })}
|
||||
</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 pb-4">
|
||||
{termin.description && (
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground mt-2">
|
||||
<AlignLeft className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<p className="line-clamp-3 whitespace-pre-wrap">{termin.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TerminDetailDialog termin={termin} />
|
||||
|
||||
{/* Admin Toggle for Mitbringsel-List */}
|
||||
{isAdmin && termin.status === TerminStatus.CONFIRMED && (
|
||||
<div className="flex items-center space-x-2 mt-6 pt-4 border-t">
|
||||
<Switch
|
||||
id={`mitbringsel-${termin.id}`}
|
||||
checked={termin.mitbringselListEnabled}
|
||||
onCheckedChange={handleToggleMitbringsel}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Label htmlFor={`mitbringsel-${termin.id}`} className="text-xs">
|
||||
Mitbring-Liste aktiv
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mitbringsel-List UI */}
|
||||
{termin.mitbringselListEnabled && termin.status === TerminStatus.CONFIRMED && (
|
||||
<div className="mt-4">
|
||||
<MitbringselList
|
||||
terminId={termin.id}
|
||||
items={termin.mitbringselItems || []}
|
||||
currentUserId={userId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminDetailDialog({ termin }: { termin: TerminListItemDto }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="mt-4">
|
||||
Details
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
|
||||
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
|
||||
<DateTile date={termin.startDate} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||
{termin.title}
|
||||
</h3>
|
||||
{termin.status === TerminStatus.PENDING && (
|
||||
<Badge variant="warning">Ausstehend</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
|
||||
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{formatTerminTime(termin)}
|
||||
<MapPin className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
Kita
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
Details
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
|
||||
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{termin.title}</DialogTitle>
|
||||
@@ -246,24 +209,22 @@ function TerminDetailDialog({ termin }: { termin: TerminListItemDto }) {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Badge variant={getBadgeVariant(termin.type)} className={getBadgeColor(termin.type)}>
|
||||
{getTypeLabel(termin.type)}
|
||||
</Badge>
|
||||
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
|
||||
|
||||
{termin.description ? (
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
<p className="whitespace-pre-wrap text-sm leading-6 text-[var(--ink-soft)]">
|
||||
{termin.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-sm italic text-[var(--ink-faint)]">
|
||||
Keine Beschreibung hinterlegt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{termin.participantInfo && termin.showParticipantInfo && (
|
||||
<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)]">
|
||||
<div className="mb-1 flex items-center gap-2 font-medium">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-700" />
|
||||
<AlertTriangle className="h-4 w-4 text-[var(--warn)] [stroke-width:1.7]" />
|
||||
Wichtiger Hinweis für Teilnehmer
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{termin.participantInfo}</p>
|
||||
@@ -275,42 +236,77 @@ function TerminDetailDialog({ termin }: { termin: TerminListItemDto }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getBadgeVariant(type: TerminType): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (type) {
|
||||
case TerminType.KITA_FEST:
|
||||
case TerminType.MITMACH_TAG:
|
||||
return "default";
|
||||
case TerminType.SCHLIESSTAG:
|
||||
case TerminType.TEAMTAG:
|
||||
return "destructive";
|
||||
case TerminType.GEBURTSTAG_INTERN:
|
||||
case TerminType.GEBURTSTAG_EXTERN:
|
||||
case TerminType.ELTERNABEND:
|
||||
case TerminType.MITGLIEDERVERSAMMLUNG:
|
||||
case TerminType.ELTERNCAFE:
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
function DateTile({ date }: { date: Date }) {
|
||||
return (
|
||||
<div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
|
||||
<span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
|
||||
{format(date, "d", { locale: de })}
|
||||
</span>
|
||||
<span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
|
||||
{format(date, "EEE", { locale: de })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getBadgeColor(type: TerminType): string {
|
||||
function CategoryPill({
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
color: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)]"
|
||||
style={{ color }}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByMonth(items: CalendarListItemDto[]) {
|
||||
const groups = new Map<string, { key: string; label: string; items: CalendarListItemDto[] }>();
|
||||
|
||||
items.forEach((item) => {
|
||||
const key = format(item.startDate, "yyyy-MM");
|
||||
const label = format(item.startDate, "MMMM yyyy", { locale: de });
|
||||
const group = groups.get(key) ?? { key, label, items: [] };
|
||||
group.items.push(item);
|
||||
groups.set(key, group);
|
||||
});
|
||||
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
function formatTerminTime(termin: TerminListItemDto) {
|
||||
if (termin.allDay) return "ganztägig";
|
||||
return `${format(termin.startDate, "HH:mm", { locale: de })} - ${format(
|
||||
termin.endDate,
|
||||
"HH:mm",
|
||||
{ locale: de },
|
||||
)}`;
|
||||
}
|
||||
|
||||
function getTypeColor(type: TerminType): string {
|
||||
switch (type) {
|
||||
case TerminType.KITA_FEST:
|
||||
case TerminType.MITMACH_TAG:
|
||||
return "bg-amber-500 hover:bg-amber-600 text-white border-transparent";
|
||||
return "var(--good)";
|
||||
case TerminType.SCHLIESSTAG:
|
||||
case TerminType.TEAMTAG:
|
||||
return "bg-rose-500 hover:bg-rose-600 text-white border-transparent";
|
||||
return "var(--danger)";
|
||||
case TerminType.GEBURTSTAG_INTERN:
|
||||
case TerminType.GEBURTSTAG_EXTERN:
|
||||
return "bg-blue-500 hover:bg-blue-600 text-white border-transparent";
|
||||
return "oklch(0.50 0.10 320)";
|
||||
case TerminType.ELTERNABEND:
|
||||
case TerminType.MITGLIEDERVERSAMMLUNG:
|
||||
case TerminType.ELTERNCAFE:
|
||||
return "bg-purple-500 hover:bg-purple-600 text-white border-transparent";
|
||||
return "oklch(0.50 0.10 280)";
|
||||
default:
|
||||
return "bg-slate-200 text-slate-800 border-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-700";
|
||||
return "var(--ink-muted)";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +319,7 @@ function getTypeLabel(type: TerminType): string {
|
||||
case TerminType.SCHLIESSTAG:
|
||||
return "Schließtag";
|
||||
case TerminType.TEAMTAG:
|
||||
return "Teamtag (Kita geschlossen)";
|
||||
return "Teamtag";
|
||||
case TerminType.ELTERNABEND:
|
||||
return "Elternabend";
|
||||
case TerminType.MITGLIEDERVERSAMMLUNG:
|
||||
@@ -331,9 +327,9 @@ function getTypeLabel(type: TerminType): string {
|
||||
case TerminType.ELTERNCAFE:
|
||||
return "Elterncafe";
|
||||
case TerminType.GEBURTSTAG_INTERN:
|
||||
return "Geburtstag (Intern)";
|
||||
return "Geburtstag";
|
||||
case TerminType.GEBURTSTAG_EXTERN:
|
||||
return "Raumanfrage (Extern)";
|
||||
return "Raumanfrage";
|
||||
default:
|
||||
return "Sonstiges";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import type { ComponentProps, FormEvent } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { TerminType } from "@prisma/client";
|
||||
import { CalendarPlus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -19,17 +20,74 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { createTerminRequest } from "../actions";
|
||||
|
||||
export function TerminRequestModal() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
type TerminRequestFormState = {
|
||||
title: string;
|
||||
type: TerminType;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
allDay: boolean;
|
||||
};
|
||||
|
||||
const initialFormState: TerminRequestFormState = {
|
||||
title: "",
|
||||
type: TerminType.KITA_FEST,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
allDay: false,
|
||||
};
|
||||
|
||||
type TerminRequestModalProps = {
|
||||
triggerLabel?: string;
|
||||
triggerVariant?: ComponentProps<typeof Button>["variant"];
|
||||
triggerClassName?: string;
|
||||
};
|
||||
|
||||
export function TerminRequestModal({
|
||||
triggerLabel = "Termin anfragen",
|
||||
triggerVariant = "ghost",
|
||||
triggerClassName = "gap-2",
|
||||
}: TerminRequestModalProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formState, setFormState] = useState<TerminRequestFormState>(initialFormState);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
|
||||
const minEndDateTime = formState.startDate || minDateTime;
|
||||
|
||||
const updateField = <TKey extends keyof TerminRequestFormState>(
|
||||
key: TKey,
|
||||
value: TerminRequestFormState[TKey],
|
||||
) => {
|
||||
setFormState((current) => {
|
||||
const next = { ...current, [key]: value };
|
||||
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
|
||||
next.endDate = value;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!formState.startDate || !formState.endDate) {
|
||||
toast.error("Bitte Start und Ende ausfüllen.");
|
||||
return;
|
||||
}
|
||||
if (formState.startDate < minDateTime) {
|
||||
toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen.");
|
||||
return;
|
||||
}
|
||||
if (formState.endDate < formState.startDate) {
|
||||
toast.error("Das Ende darf nicht vor dem Start liegen.");
|
||||
return;
|
||||
}
|
||||
|
||||
const handleAction = (formData: FormData) => {
|
||||
const data = {
|
||||
title: formData.get("title") as string,
|
||||
type: formData.get("type") as TerminType,
|
||||
startDate: new Date(formData.get("startDate") as string).toISOString(),
|
||||
endDate: new Date(formData.get("endDate") as string).toISOString(),
|
||||
allDay: formData.get("allDay") === "on",
|
||||
title: formState.title,
|
||||
type: formState.type,
|
||||
startDate: new Date(formState.startDate).toISOString(),
|
||||
endDate: new Date(formState.endDate).toISOString(),
|
||||
allDay: formState.allDay,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
@@ -38,6 +96,7 @@ export function TerminRequestModal() {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("Terminanfrage wurde erfolgreich gestellt!");
|
||||
setFormState(initialFormState);
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
@@ -46,13 +105,13 @@ export function TerminRequestModal() {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
Termin anfragen
|
||||
<Button variant={triggerVariant} className={triggerClassName}>
|
||||
<CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form action={handleAction}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin anfragen</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -64,7 +123,14 @@ export function TerminRequestModal() {
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Titel</Label>
|
||||
<Input id="title" name="title" required placeholder="z.B. Geburtstag von Mia" />
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="z.B. Geburtstag von Mia"
|
||||
value={formState.title}
|
||||
onChange={(event) => updateField("title", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
@@ -72,7 +138,9 @@ export function TerminRequestModal() {
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={formState.type}
|
||||
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:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onChange={(event) => updateField("type", event.target.value as TerminType)}
|
||||
required
|
||||
>
|
||||
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
|
||||
@@ -91,16 +159,39 @@ export function TerminRequestModal() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="startDate">Start</Label>
|
||||
<Input id="startDate" name="startDate" type="datetime-local" required />
|
||||
<Input
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
type="datetime-local"
|
||||
min={minDateTime}
|
||||
value={formState.startDate}
|
||||
onChange={(event) => updateField("startDate", event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">Ende</Label>
|
||||
<Input id="endDate" name="endDate" type="datetime-local" required />
|
||||
<Input
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
type="datetime-local"
|
||||
min={minEndDateTime}
|
||||
value={formState.endDate}
|
||||
onChange={(event) => updateField("endDate", event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" />
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allDay"
|
||||
name="allDay"
|
||||
checked={formState.allDay}
|
||||
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
onChange={(event) => updateField("allDay", event.target.checked)}
|
||||
/>
|
||||
<Label htmlFor="allDay" className="font-normal cursor-pointer">
|
||||
Ganztägiger Termin
|
||||
</Label>
|
||||
@@ -120,3 +211,10 @@ export function TerminRequestModal() {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function getLocalDateTimeInputValue(date: Date) {
|
||||
const localDate = new Date(date);
|
||||
localDate.setSeconds(0, 0);
|
||||
const offset = localDate.getTimezoneOffset() * 60_000;
|
||||
return new Date(localDate.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user