continued the kita-planer

This commit is contained in:
t.indorf
2026-05-08 14:32:14 +02:00
parent b686e714ff
commit 7aff691803
85 changed files with 9434 additions and 588 deletions
+173
View File
@@ -0,0 +1,173 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import {
ArrowRight,
CheckCircle2,
Home,
MapPin,
Phone,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
type UserRoleDto =
| "SUPERADMIN"
| "ADMIN"
| "KOORDINATOR"
| "ERZIEHER"
| "ELTERN";
type OnboardingDialogProps = {
user: {
phone: string | null;
street: string | null;
postalCode: string | null;
city: string | null;
familyId: string | null;
role: UserRoleDto;
};
family: {
childrenCount: number;
} | null;
};
const HIDE_ONBOARDING_DIALOG_KEY = "hideOnboardingDialog";
export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
const [open, setOpen] = useState(false);
const status = useMemo(() => {
const hasPhone = !!user.phone?.trim();
const hasAddress =
!!user.street?.trim() && !!user.postalCode?.trim() && !!user.city?.trim();
const needsFamilyData = user.role !== "ERZIEHER";
const hasFamilyAndChildren =
!needsFamilyData || (!!user.familyId && !!family?.childrenCount);
const missingItems = [
!hasPhone ? { label: "Telefonnummer fehlt", icon: Phone } : null,
!hasAddress ? { label: "Adresse fehlt", icon: MapPin } : null,
!hasFamilyAndChildren
? { label: "Haushalts-/Kinderdaten unvollständig", icon: Home }
: null,
].filter(Boolean) as { label: string; icon: typeof Phone }[];
const completedCount = [hasPhone, hasAddress, hasFamilyAndChildren].filter(
Boolean,
).length;
const progress = completedCount === 3 ? 100 : completedCount * 33;
return {
completedCount,
progress,
missingItems,
hasFamilyAndChildren,
isComplete: completedCount === 3,
};
}, [family?.childrenCount, user]);
useEffect(() => {
if (status.isComplete) return;
const hidden = localStorage.getItem(HIDE_ONBOARDING_DIALOG_KEY);
if (hidden !== "true") {
const timer = window.setTimeout(() => setOpen(true), 0);
return () => window.clearTimeout(timer);
}
}, [status.isComplete]);
if (status.isComplete) {
return null;
}
const ctaLabel = status.hasFamilyAndChildren
? "Kontaktdaten hinterlegen"
: "Haushalt & Kinder einrichten";
function hidePermanently() {
localStorage.setItem(HIDE_ONBOARDING_DIALOG_KEY, "true");
setOpen(false);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="overflow-hidden p-0 sm:max-w-xl">
<div className="bg-emerald-950 px-6 py-6 text-white">
<div className="mb-5 flex h-11 w-11 items-center justify-center rounded-full bg-emerald-300 text-emerald-950">
<Sparkles className="h-5 w-5" />
</div>
<DialogHeader className="space-y-2 text-left">
<DialogTitle className="text-2xl leading-tight">
Dein Kita-Profil ist fast bereit
</DialogTitle>
<DialogDescription className="text-emerald-50/80">
Vervollständige die wichtigsten Daten, damit Notdienst,
Adressbuch und Familienverwaltung reibungslos funktionieren.
</DialogDescription>
</DialogHeader>
</div>
<div className="space-y-5 px-6 py-6">
<div>
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
<span className="font-medium">Profil-Fortschritt</span>
<span className="text-muted-foreground">
{status.completedCount}/3 erledigt
</span>
</div>
<Progress value={status.progress} />
<p className="mt-2 text-xs text-muted-foreground">
{status.progress}% vollständig
</p>
</div>
<div className="rounded-md border bg-muted/30 p-4">
<p className="mb-3 text-sm font-medium">Noch offen</p>
<ul className="space-y-2">
{status.missingItems.map(({ label, icon: Icon }) => (
<li key={label} className="flex items-center gap-2 text-sm">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<Icon className="h-3.5 w-3.5" />
</span>
{label}
</li>
))}
</ul>
</div>
<div className="flex items-start gap-2 rounded-md bg-emerald-50 p-3 text-sm text-emerald-950">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Du kannst die App weiter nutzen. Diese Erinnerung hilft nur beim
sauberen Einrichten der wichtigsten MVP-Daten.
</p>
</div>
</div>
<DialogFooter className="gap-2 border-t px-6 py-4 sm:justify-between sm:space-x-0">
<Button type="button" variant="ghost" onClick={hidePermanently}>
Nicht mehr anzeigen
</Button>
<Button asChild onClick={() => setOpen(false)}>
<Link href="/dashboard/profil">
{ctaLabel}
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+34 -2
View File
@@ -2,7 +2,18 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, Users, CalendarCheck2, CalendarDays, Contact, GraduationCap, UserCircle } from "lucide-react";
import {
LayoutDashboard,
Users,
CalendarCheck2,
CalendarDays,
Contact,
GraduationCap,
UserCircle,
ClipboardList,
Stethoscope,
Megaphone,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -45,7 +56,28 @@ const navItems = [
label: "Familienverwaltung",
icon: Users,
exact: false,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
allowedRoles: [UserRole.ADMIN],
},
{
href: "/dashboard/admin/dienste",
label: "Dienstplan",
icon: ClipboardList,
exact: false,
allowedRoles: [UserRole.ADMIN],
},
{
href: "/dashboard/admin/news",
label: "Schwarzes Brett",
icon: Megaphone,
exact: false,
allowedRoles: [UserRole.ADMIN],
},
{
href: "/dashboard/admin/abwesenheiten",
label: "Abwesenheiten",
icon: Stethoscope,
exact: false,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ERZIEHER],
},
{
href: "/dashboard/erzieher",
+49
View File
@@ -0,0 +1,49 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export function MarkdownContent({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a
{...props}
className="font-medium text-primary underline underline-offset-4"
target="_blank"
rel="noreferrer"
>
{children}
</a>
),
p: ({ children }) => (
<p className="mb-3 leading-7 last:mb-0">{children}</p>
),
ul: ({ children }) => (
<ul className="mb-3 list-disc space-y-1 pl-5">{children}</ul>
),
ol: ({ children }) => (
<ol className="mb-3 list-decimal space-y-1 pl-5">{children}</ol>
),
h1: ({ children }) => (
<h1 className="mb-3 text-2xl font-semibold">{children}</h1>
),
h2: ({ children }) => (
<h2 className="mb-3 text-xl font-semibold">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 text-lg font-semibold">{children}</h3>
),
blockquote: ({ children }) => (
<blockquote className="mb-3 border-l-4 pl-4 text-muted-foreground">
{children}
</blockquote>
),
}}
>
{content}
</ReactMarkdown>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div className={cn("grid gap-2", className)} {...props} />
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
id={formMessageId}
className={cn("text-sm text-destructive", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ComponentRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-transform"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
+122
View File
@@ -0,0 +1,122 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
};
+37
View File
@@ -0,0 +1,37 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type SwitchProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"type" | "onChange"
> & {
onCheckedChange?: (checked: boolean) => void;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
};
const Switch = React.forwardRef<
HTMLInputElement,
SwitchProps
>(({ className, onCheckedChange, onChange, ...props }, ref) => (
<input
ref={ref}
type="checkbox"
role="switch"
onChange={(event) => {
onChange?.(event);
onCheckedChange?.(event.currentTarget.checked);
}}
className={cn(
"peer h-5 w-9 shrink-0 cursor-pointer appearance-none rounded-full border border-transparent bg-input shadow-sm transition-colors checked:bg-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"before:block before:h-4 before:w-4 before:translate-x-0 before:rounded-full before:bg-background before:shadow before:transition-transform checked:before:translate-x-4",
className,
)}
{...props}
/>
));
Switch.displayName = "Switch";
export { Switch };
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"flex min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
));
Textarea.displayName = "Textarea";
export { Textarea };