Add educator leave management and working time accounts
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, Pencil, Search, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -40,7 +41,9 @@ type EducatorDto = {
|
||||
|
||||
export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
const [openCreate, setOpenCreate] = useState(false);
|
||||
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null);
|
||||
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(
|
||||
null,
|
||||
);
|
||||
const [educatorToDelete, setEducatorToDelete] = useState<EducatorDto | null>(
|
||||
null,
|
||||
);
|
||||
@@ -132,8 +135,16 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpenCreate(false)}>Abbrechen</Button>
|
||||
<Button type="submit" disabled={isPending}>Speichern</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpenCreate(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
Speichern
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
@@ -153,67 +164,88 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-6">Vorname</TableHead>
|
||||
<TableHead>Nachname</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="pr-6 text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{educators.length === 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-32 text-center">
|
||||
<div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Plus className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
Keine Einträge gefunden
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege die erste Person im Team an.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableHead className="pl-6">Vorname</TableHead>
|
||||
<TableHead>Nachname</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="pr-6 text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
educators.map((ed) => (
|
||||
<TableRow key={ed.id}>
|
||||
<TableCell className="pl-6 font-medium">{ed.firstName}</TableCell>
|
||||
<TableCell>{ed.lastName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ed.active ? "success" : "secondary"}>
|
||||
{ed.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}>
|
||||
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => setEducatorToDelete(ed)}
|
||||
disabled={isPending}
|
||||
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{educators.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-32 text-center">
|
||||
<div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Plus className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
Keine Einträge gefunden
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege die erste Person im Team an.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
educators.map((ed) => (
|
||||
<TableRow key={ed.id}>
|
||||
<TableCell className="pl-6 font-medium">
|
||||
{ed.firstName}
|
||||
</TableCell>
|
||||
<TableCell>{ed.lastName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ed.active ? "success" : "secondary"}>
|
||||
{ed.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="mr-2"
|
||||
>
|
||||
<Link
|
||||
href={`/dashboard/personal?tab=verwaltung&educator=${ed.id}`}
|
||||
>
|
||||
Personalprofil
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditingEducator(ed)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => setEducatorToDelete(ed)}
|
||||
disabled={isPending}
|
||||
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}>
|
||||
<Dialog
|
||||
open={!!editingEducator}
|
||||
onOpenChange={(open) => !open && setEditingEducator(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<form action={handleUpdate}>
|
||||
<DialogHeader>
|
||||
@@ -223,21 +255,43 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-firstName">Vorname</Label>
|
||||
<Input id="edit-firstName" name="firstName" defaultValue={editingEducator.firstName} required />
|
||||
<Input
|
||||
id="edit-firstName"
|
||||
name="firstName"
|
||||
defaultValue={editingEducator.firstName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-lastName">Nachname</Label>
|
||||
<Input id="edit-lastName" name="lastName" defaultValue={editingEducator.lastName} required />
|
||||
<Input
|
||||
id="edit-lastName"
|
||||
name="lastName"
|
||||
defaultValue={editingEducator.lastName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch id="edit-active" name="active" defaultChecked={editingEducator.active} />
|
||||
<Switch
|
||||
id="edit-active"
|
||||
name="active"
|
||||
defaultChecked={editingEducator.active}
|
||||
/>
|
||||
<Label htmlFor="edit-active">Aktiv</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setEditingEducator(null)}>Abbrechen</Button>
|
||||
<Button type="submit" disabled={isPending}>Aktualisieren</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingEducator(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -19,12 +19,12 @@ export async function createEducator(rawPayload: unknown) {
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
|
||||
const parsed = educatorSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await prisma.educator.create({
|
||||
data: {
|
||||
@@ -34,7 +34,7 @@ export async function createEducator(rawPayload: unknown) {
|
||||
active: parsed.data.active,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -49,12 +49,12 @@ export async function updateEducator(id: string, rawPayload: unknown) {
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
|
||||
const parsed = educatorSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await prisma.educator.update({
|
||||
where: {
|
||||
@@ -67,7 +67,7 @@ export async function updateEducator(id: string, rawPayload: unknown) {
|
||||
active: parsed.data.active,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -82,15 +82,23 @@ export async function deleteEducator(id: string) {
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const profile = await prisma.staffProfile.findFirst({
|
||||
where: { educatorId: id, kitaId },
|
||||
});
|
||||
if (profile)
|
||||
return {
|
||||
error:
|
||||
"Diese Fachkraft hat ein Personalprofil. Bitte auf inaktiv setzen, damit Zeiten und Konten erhalten bleiben.",
|
||||
};
|
||||
await prisma.educator.delete({
|
||||
where: {
|
||||
id,
|
||||
kitaId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -36,8 +36,8 @@ export default async function ErzieherPage() {
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Stammdaten des Kita-Personals für interne Planung und spätere
|
||||
Notdienst-Alarmierung.
|
||||
Stammdaten des Teams und Zugang zu Beschäftigungsprofilen,
|
||||
Urlaubsansprüchen und Arbeitszeitmodellen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
|
||||
+34
-10
@@ -191,8 +191,7 @@ export default async function DashboardPage() {
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Heute, {format(now, "EEEE, d. MMMM yyyy", { locale: de })}. Hier
|
||||
siehst du auf einen Blick, was bei {kita.name} ansteht und kannst
|
||||
dein Kind schnell für Abwesenheiten melden.
|
||||
siehst du auf einen Blick, was bei {kita.name} ansteht.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-9 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
@@ -203,6 +202,14 @@ export default async function DashboardPage() {
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
|
||||
<div className="min-w-0">
|
||||
{session.user.role === UserRole.ERZIEHER && (
|
||||
<QuickCard
|
||||
href="/dashboard/personal"
|
||||
icon={Clock3}
|
||||
title="Meine Arbeitszeit & Urlaub"
|
||||
sub="Stunden erfassen, Urlaub beantragen und dein Zeitkonto ansehen."
|
||||
/>
|
||||
)}
|
||||
{canReportAbsences ? (
|
||||
<AbsenceCard
|
||||
today={format(today, "yyyy-MM-dd")}
|
||||
@@ -237,7 +244,11 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<section className="mt-5 grid grid-cols-4 gap-3.5 max-[1100px]:grid-cols-2 max-[640px]:grid-cols-1">
|
||||
<Stat label="Familien" value={familyCount} hint="registrierte Familien" />
|
||||
<Stat
|
||||
label="Familien"
|
||||
value={familyCount}
|
||||
hint="registrierte Familien"
|
||||
/>
|
||||
<Stat label="Kinder" value={childCount} hint="in der Einrichtung" />
|
||||
<Stat
|
||||
label={`Notdienst ${targetData.monthName}`}
|
||||
@@ -260,12 +271,22 @@ export default async function DashboardPage() {
|
||||
Häufige Aktionen für Vorstand und Koordination.
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3 max-[1100px]:grid-cols-1">
|
||||
<QuickCard
|
||||
href="/dashboard/families"
|
||||
icon={Users}
|
||||
title="Familien verwalten"
|
||||
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
|
||||
/>
|
||||
{session.user.role === UserRole.ADMIN && (
|
||||
<QuickCard
|
||||
href="/dashboard/personal"
|
||||
icon={Clock3}
|
||||
title="Personal & Freigaben"
|
||||
sub="Urlaub, Arbeitswochen und Beschäftigungsprofile verwalten."
|
||||
/>
|
||||
)}
|
||||
{session.user.role === UserRole.ADMIN && (
|
||||
<QuickCard
|
||||
href="/dashboard/families"
|
||||
icon={Users}
|
||||
title="Familien verwalten"
|
||||
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
|
||||
/>
|
||||
)}
|
||||
{kita.terminModuleEnabled ? (
|
||||
<QuickCard
|
||||
href="/dashboard/kalender"
|
||||
@@ -274,7 +295,10 @@ export default async function DashboardPage() {
|
||||
sub="Schließtage, Elternabende und Feste planen."
|
||||
/>
|
||||
) : null}
|
||||
{kita.notdienstModuleEnabled ? (
|
||||
{kita.notdienstModuleEnabled &&
|
||||
[UserRole.ADMIN, UserRole.KOORDINATOR].includes(
|
||||
session.user.role as "ADMIN" | "KOORDINATOR",
|
||||
) ? (
|
||||
<QuickCard
|
||||
href="/dashboard/notdienst/plan"
|
||||
icon={ListTodo}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { requireStaffActor } from "@/lib/staff/auth";
|
||||
import { executeStaffCommand } from "@/lib/staff/service";
|
||||
|
||||
export type StaffActionResult = {
|
||||
error?: string;
|
||||
success?: boolean;
|
||||
invitePath?: string;
|
||||
};
|
||||
|
||||
export async function staffAction(
|
||||
command: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<StaffActionResult> {
|
||||
const actor = await requireStaffActor();
|
||||
try {
|
||||
const result = await executeStaffCommand(actor, { command, data });
|
||||
revalidatePath("/dashboard/personal");
|
||||
revalidatePath("/dashboard/profil");
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
revalidatePath("/dashboard");
|
||||
// Returned only to the authenticated admin who generated this single-use link.
|
||||
return {
|
||||
success: true,
|
||||
...(result.invite
|
||||
? { invitePath: `/invite/${result.invite.token}` }
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError)
|
||||
return {
|
||||
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
|
||||
};
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!error.message.includes("prisma") &&
|
||||
!error.message.includes("Invalid `")
|
||||
)
|
||||
return { error: error.message };
|
||||
return { error: "Speichern fehlgeschlagen. Bitte erneut versuchen." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState, useTransition, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DAY_NAMES,
|
||||
clockMinute,
|
||||
minuteClock,
|
||||
todayKey,
|
||||
} from "@/lib/staff/calculations";
|
||||
import { staffAction, type StaffActionResult } from "./actions";
|
||||
|
||||
const control =
|
||||
"w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-mid)]";
|
||||
export function Field({
|
||||
label,
|
||||
name,
|
||||
type = "text",
|
||||
value,
|
||||
required = true,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
value?: string | number;
|
||||
required?: boolean;
|
||||
min?: string | number;
|
||||
max?: string | number;
|
||||
step?: string | number;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<Input
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={value}
|
||||
required={required}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function transform(command: string, form: FormData) {
|
||||
const text = (key: string) => String(form.get(key) ?? "");
|
||||
const num = (key: string) => Number(text(key));
|
||||
const hours = (key: string) => Math.round(num(key) * 60);
|
||||
switch (command) {
|
||||
case "setup":
|
||||
return {
|
||||
educatorId: text("educatorId"),
|
||||
employmentStart: text("employmentStart"),
|
||||
employmentEnd: text("employmentEnd") || null,
|
||||
trackingStart: text("trackingStart"),
|
||||
openingMinutes: hours("openingHours"),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "invite":
|
||||
return { email: text("email") };
|
||||
case "employmentEnd":
|
||||
return {
|
||||
employmentEnd: text("employmentEnd") || null,
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "pattern":
|
||||
return {
|
||||
effectiveFrom: text("effectiveFrom"),
|
||||
dailyMinutes: DAY_NAMES.map((_, i) => hours(`day${i}`)),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "account":
|
||||
return {
|
||||
year: num("year"),
|
||||
entitlementUnits: Math.round(num("entitlement") * 2),
|
||||
carryUnits: Math.round(num("carry") * 2),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "override":
|
||||
return {
|
||||
date: text("date"),
|
||||
minutes: hours("hours"),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "adjust":
|
||||
return {
|
||||
date: text("date"),
|
||||
minutes: hours("hours"),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "holiday":
|
||||
return { date: text("date"), name: text("name") };
|
||||
case "time":
|
||||
return {
|
||||
date: text("date"),
|
||||
startMinute: clockMinute(text("start")),
|
||||
endMinute: clockMinute(text("end")),
|
||||
breakMinutes: num("breakMinutes"),
|
||||
note: text("note"),
|
||||
};
|
||||
case "leave":
|
||||
return {
|
||||
kind: text("kind"),
|
||||
startDate: text("startDate"),
|
||||
endDate: text("endDate"),
|
||||
halfDay: form.get("halfDay") === "on",
|
||||
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
|
||||
note: text("note"),
|
||||
};
|
||||
case "leaveReview":
|
||||
case "weekReview":
|
||||
return { reason: text("reason") };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function StaffForm({
|
||||
command,
|
||||
profileId,
|
||||
extra = {},
|
||||
children,
|
||||
label = "Speichern",
|
||||
onSuccess,
|
||||
}: {
|
||||
command: string;
|
||||
profileId?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
onSuccess?: (result: StaffActionResult) => void;
|
||||
}) {
|
||||
const [pending, start] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
setError("");
|
||||
start(async () => {
|
||||
try {
|
||||
const result = await staffAction(command, {
|
||||
...transform(command, form),
|
||||
...extra,
|
||||
...(profileId ? { profileId } : {}),
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success("Gespeichert.");
|
||||
onSuccess?.(result);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Speichern fehlgeschlagen.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
|
||||
{children}
|
||||
<div>
|
||||
<Button type="submit">
|
||||
{pending ? "Wird gespeichert …" : label}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
export function CommandButton({
|
||||
command,
|
||||
profileId,
|
||||
data,
|
||||
children,
|
||||
variant = "outline",
|
||||
}: {
|
||||
command: string;
|
||||
profileId: string;
|
||||
data: Record<string, unknown>;
|
||||
children: ReactNode;
|
||||
variant?: "outline" | "ghost" | "default";
|
||||
}) {
|
||||
const [pending, start] = useTransition();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
start(async () => {
|
||||
const result = await staffAction(command, { ...data, profileId });
|
||||
if (result.error) toast.error(result.error);
|
||||
else {
|
||||
toast.success("Gespeichert.");
|
||||
router.refresh();
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Bitte warten …" : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
export function ReviewButton({
|
||||
command,
|
||||
profileId,
|
||||
data,
|
||||
label,
|
||||
approve,
|
||||
}: {
|
||||
command: "leaveReview" | "weekReview";
|
||||
profileId: string;
|
||||
data: Record<string, unknown>;
|
||||
label: string;
|
||||
approve: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant={approve ? "default" : "outline"}>
|
||||
{label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die Entscheidung wird im Personalverlauf dokumentiert.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StaffForm
|
||||
command={command}
|
||||
profileId={profileId}
|
||||
extra={{ ...data, approve }}
|
||||
label={label}
|
||||
onSuccess={() => setOpen(false)}
|
||||
>
|
||||
<Field label="Begründung / Rückmeldung" name="reason" />
|
||||
</StaffForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export function SetupForm({
|
||||
educatorId,
|
||||
values,
|
||||
}: {
|
||||
educatorId: string;
|
||||
values?: {
|
||||
employmentStart: string;
|
||||
employmentEnd: string | null;
|
||||
trackingStart: string;
|
||||
openingMinutes: number;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<StaffForm
|
||||
command="setup"
|
||||
extra={{ educatorId }}
|
||||
label="Beschäftigungsprofil speichern"
|
||||
>
|
||||
<input type="hidden" name="educatorId" value={educatorId} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label="Beschäftigung ab"
|
||||
name="employmentStart"
|
||||
type="date"
|
||||
value={values?.employmentStart ?? todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Beschäftigung bis (optional)"
|
||||
name="employmentEnd"
|
||||
type="date"
|
||||
value={values?.employmentEnd ?? ""}
|
||||
required={false}
|
||||
/>
|
||||
<Field
|
||||
label="Zeiterfassung ab"
|
||||
name="trackingStart"
|
||||
type="date"
|
||||
value={values?.trackingStart ?? todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Anfangssaldo in Stunden"
|
||||
name="openingHours"
|
||||
type="number"
|
||||
step="any"
|
||||
value={(values?.openingMinutes ?? 0) / 60}
|
||||
/>
|
||||
</div>
|
||||
<Field label="Begründung" name="reason" value="Ersteinrichtung" />
|
||||
</StaffForm>
|
||||
);
|
||||
}
|
||||
export function TimeForm({
|
||||
profileId,
|
||||
initial,
|
||||
}: {
|
||||
profileId: string;
|
||||
initial?: {
|
||||
id: string;
|
||||
date: string;
|
||||
startMinute: number;
|
||||
endMinute: number;
|
||||
breakMinutes: number;
|
||||
note: string | null;
|
||||
};
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant={initial ? "outline" : "default"}
|
||||
size={initial ? "sm" : "default"}
|
||||
>
|
||||
{initial ? "Bearbeiten" : "+ Arbeitszeit erfassen"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{initial ? "Arbeitszeit bearbeiten" : "Arbeitszeit erfassen"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Erfasse Beginn, Ende und die gesamte Pause dieses Arbeitsblocks.
|
||||
Weitere Blöcke am selben Tag sind möglich.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StaffForm
|
||||
command="time"
|
||||
profileId={profileId}
|
||||
extra={initial ? { id: initial.id } : {}}
|
||||
label="Arbeitszeit speichern"
|
||||
onSuccess={() => setOpen(false)}
|
||||
>
|
||||
<Field
|
||||
label="Datum"
|
||||
name="date"
|
||||
type="date"
|
||||
value={initial?.date ?? todayKey()}
|
||||
max={todayKey()}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="Beginn"
|
||||
name="start"
|
||||
type="time"
|
||||
value={minuteClock(initial?.startMinute ?? 480)}
|
||||
/>
|
||||
<Field
|
||||
label="Ende"
|
||||
name="end"
|
||||
type="time"
|
||||
value={minuteClock(initial?.endMinute ?? 960)}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="Pause in Minuten"
|
||||
name="breakMinutes"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={initial?.breakMinutes ?? 30}
|
||||
/>
|
||||
<Field
|
||||
label="Notiz (optional)"
|
||||
name="note"
|
||||
value={initial?.note ?? ""}
|
||||
required={false}
|
||||
/>
|
||||
</StaffForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export function LeaveForm({
|
||||
profileId,
|
||||
admin,
|
||||
initial,
|
||||
}: {
|
||||
profileId: string;
|
||||
admin: boolean;
|
||||
initial?: {
|
||||
id: string;
|
||||
kind: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
halfDay: boolean;
|
||||
minutes: number | null;
|
||||
note: string | null;
|
||||
};
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [kind, setKind] = useState(initial?.kind ?? "VACATION");
|
||||
const id = useId();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size={initial ? "sm" : "default"} variant="outline">
|
||||
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{initial ? "Antrag ändern" : "Abwesenheit beantragen"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Urlaub verbraucht nur deine vorgesehenen Arbeitstage.
|
||||
Freizeitausgleich wird in Stunden für einen einzelnen Tag beantragt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StaffForm
|
||||
command="leave"
|
||||
profileId={profileId}
|
||||
extra={initial ? { id: initial.id } : {}}
|
||||
label={
|
||||
kind === "SICKNESS" ? "Krankheit hinterlegen" : "Antrag speichern"
|
||||
}
|
||||
onSuccess={() => setOpen(false)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor={`${id}-kind`} className="text-sm font-medium">
|
||||
Art
|
||||
</label>
|
||||
<select
|
||||
id={`${id}-kind`}
|
||||
name="kind"
|
||||
className={control}
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
<option value="VACATION">Urlaub</option>
|
||||
<option value="TIME_OFF">Freizeitausgleich</option>
|
||||
{admin && <option value="SICKNESS">Krankheit</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="Von"
|
||||
name="startDate"
|
||||
type="date"
|
||||
value={initial?.startDate ?? todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Bis"
|
||||
name="endDate"
|
||||
type="date"
|
||||
value={initial?.endDate ?? todayKey()}
|
||||
/>
|
||||
</div>
|
||||
{kind === "VACATION" && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
name="halfDay"
|
||||
type="checkbox"
|
||||
defaultChecked={initial?.halfDay}
|
||||
/>
|
||||
Halber Urlaubstag (Von = Bis)
|
||||
</label>
|
||||
)}
|
||||
{kind === "TIME_OFF" && (
|
||||
<Field
|
||||
label="Freizeitausgleich in Stunden (Von = Bis)"
|
||||
name="hours"
|
||||
type="number"
|
||||
min={1 / 60}
|
||||
max={16}
|
||||
step="any"
|
||||
value={(initial?.minutes ?? 60) / 60}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Notiz (optional)"
|
||||
name="note"
|
||||
value={initial?.note ?? ""}
|
||||
required={false}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
Der Antrag wird anhand deines Wochenmodells, der Tagesausnahmen und
|
||||
der Feiertage geprüft. Ein halber Tag entspricht der Hälfte der
|
||||
Sollzeit.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
function SettingsSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<details className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-5">
|
||||
<summary className="cursor-pointer font-semibold">{title}</summary>
|
||||
<div className="mt-5">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
export function SettingsPanel({
|
||||
profileId,
|
||||
educatorId,
|
||||
employment,
|
||||
email,
|
||||
verified,
|
||||
pattern,
|
||||
year,
|
||||
account,
|
||||
}: {
|
||||
profileId: string;
|
||||
educatorId: string;
|
||||
employment: {
|
||||
employmentStart: string;
|
||||
employmentEnd: string | null;
|
||||
trackingStart: string;
|
||||
openingMinutes: number;
|
||||
};
|
||||
email?: string;
|
||||
verified: boolean;
|
||||
pattern?: { effectiveFrom: string; dailyMinutes: number[] };
|
||||
year: number;
|
||||
account?: { entitlementUnits: number; carryUnits: number };
|
||||
}) {
|
||||
const [invitePath, setInvitePath] = useState("");
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<SettingsSection title="Zugang zur Zeiterfassung">
|
||||
{verified ? (
|
||||
<p className="text-sm">Aktives Konto: {email}</p>
|
||||
) : (
|
||||
<StaffForm
|
||||
command="invite"
|
||||
profileId={profileId}
|
||||
label="Aktivierungslink erstellen"
|
||||
onSuccess={(r) => setInvitePath(r.invitePath ?? "")}
|
||||
>
|
||||
<Field
|
||||
label="E-Mail-Adresse"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email ?? ""}
|
||||
/>
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
Erstellt oder verknüpft einen ErzieherInnen-Account. Der
|
||||
persönliche Link ist sieben Tage gültig. Ein neuer Link ersetzt
|
||||
den vorherigen.
|
||||
</p>
|
||||
</StaffForm>
|
||||
)}
|
||||
{invitePath && (
|
||||
<div className="mt-4 grid gap-2 rounded-lg bg-[var(--accent-soft)] p-4">
|
||||
<label htmlFor="activation-link" className="text-sm font-medium">
|
||||
Persönlicher Aktivierungslink
|
||||
</label>
|
||||
<Input
|
||||
id="activation-link"
|
||||
readOnly
|
||||
value={`${window.location.origin}${invitePath}`}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`${window.location.origin}${invitePath}`,
|
||||
);
|
||||
toast.success("Link kopiert.");
|
||||
}}
|
||||
>
|
||||
Link kopieren
|
||||
</Button>
|
||||
<p className="text-xs">
|
||||
Bitte persönlich an die Fachkraft weitergeben. Es wird keine
|
||||
E-Mail versendet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Feste Wochentage & Sollstunden">
|
||||
<StaffForm
|
||||
command="pattern"
|
||||
profileId={profileId}
|
||||
label="Wochenmodell speichern"
|
||||
>
|
||||
<Field
|
||||
label="Gültig ab"
|
||||
name="effectiveFrom"
|
||||
type="date"
|
||||
value={pattern?.effectiveFrom ?? employment.trackingStart}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{DAY_NAMES.map((day, i) => (
|
||||
<Field
|
||||
key={day}
|
||||
label={`${day} · Stunden`}
|
||||
name={`day${i}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={16}
|
||||
step="any"
|
||||
value={(pattern?.dailyMinutes[i] ?? 0) / 60}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Field label="Begründung" name="reason" />
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
0 Stunden = frei. Stunden sind Nettozeiten ohne Pause. Änderungen
|
||||
gelten ab dem gewählten Datum; ältere Modelle bleiben erhalten.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={`Urlaubsanspruch & Übertrag · ${year}`}>
|
||||
<StaffForm command="account" profileId={profileId}>
|
||||
<Field
|
||||
label="Jahr"
|
||||
name="year"
|
||||
type="number"
|
||||
min={2000}
|
||||
max={2100}
|
||||
value={year}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="Jahresanspruch in Tagen"
|
||||
name="entitlement"
|
||||
type="number"
|
||||
min={0}
|
||||
max={400}
|
||||
step={0.5}
|
||||
value={(account?.entitlementUnits ?? 0) / 2}
|
||||
/>
|
||||
<Field
|
||||
label="Übertrag in Tagen"
|
||||
name="carry"
|
||||
type="number"
|
||||
min={0}
|
||||
max={400}
|
||||
step={0.5}
|
||||
value={(account?.carryUnits ?? 0) / 2}
|
||||
/>
|
||||
</div>
|
||||
<Field label="Begründung" name="reason" />
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Anspruch individuell für die Arbeitstage dieser Person hinterlegen.
|
||||
Der Übertrag wird ausdrücklich festgelegt.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Ausnahme für einen einzelnen Tag">
|
||||
<StaffForm command="override" profileId={profileId}>
|
||||
<Field label="Datum" name="date" type="date" value={todayKey()} />
|
||||
<Field
|
||||
label="Abweichende Sollstunden (0 = frei)"
|
||||
name="hours"
|
||||
type="number"
|
||||
min={0}
|
||||
max={16}
|
||||
step="any"
|
||||
value={0}
|
||||
/>
|
||||
<Field label="Begründung" name="reason" />
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Bei einem Tausch beide Tage erfassen: den ursprünglichen Tag auf 0
|
||||
setzen und den neuen Arbeitstag mit Sollstunden hinterlegen.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Zeitkonto korrigieren">
|
||||
<StaffForm command="adjust" profileId={profileId}>
|
||||
<Field
|
||||
label="Buchungsdatum"
|
||||
name="date"
|
||||
type="date"
|
||||
value={todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Korrektur in Stunden (mit Vorzeichen)"
|
||||
name="hours"
|
||||
type="number"
|
||||
step="any"
|
||||
/>
|
||||
<Field label="Begründung" name="reason" />
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Korrekturen bleiben im Verlauf erhalten. Falsche Buchungen durch
|
||||
eine Gegenbuchung berichtigen.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Beschäftigungsbeginn & Anfangssaldo">
|
||||
<SetupForm educatorId={educatorId} values={employment} />
|
||||
<p className="mt-3 text-xs text-[var(--ink-soft)]">
|
||||
Die Grunddaten sind geschützt, sobald Zeiten, Wochen oder Anträge
|
||||
erfasst wurden.
|
||||
</p>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Beschäftigungsende ändern">
|
||||
<StaffForm command="employmentEnd" profileId={profileId}>
|
||||
<Field
|
||||
label="Beschäftigung bis (leer = unbefristet)"
|
||||
name="employmentEnd"
|
||||
type="date"
|
||||
value={employment.employmentEnd ?? ""}
|
||||
required={false}
|
||||
/>
|
||||
<Field label="Begründung" name="reason" />
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function HolidayForm() {
|
||||
return (
|
||||
<StaffForm command="holiday" label="Feiertag speichern">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Datum" name="date" type="date" />
|
||||
<Field label="Bezeichnung" name="name" />
|
||||
</div>
|
||||
</StaffForm>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import Link from "next/link";
|
||||
import { UserRole } from "@prisma/client";
|
||||
import {
|
||||
AtSign,
|
||||
Baby,
|
||||
@@ -96,7 +98,9 @@ export default async function ProfilPage() {
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen.
|
||||
{user.role === UserRole.ERZIEHER
|
||||
? "Deine persönlichen Daten, dein Arbeitsbereich und Datenschutzeinstellungen."
|
||||
: "Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen."}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -124,25 +128,31 @@ export default async function ProfilPage() {
|
||||
label="Telefon"
|
||||
value={user.phone ? user.phone : <EmptyDefValue />}
|
||||
/>
|
||||
<DefItem icon={Shield} label="Rolle" value={<RoleBadge role={user.role} />} />
|
||||
<DefItem
|
||||
icon={Home}
|
||||
label="Haushalt"
|
||||
value={
|
||||
user.family ? (
|
||||
user.family.name
|
||||
) : (
|
||||
<div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
|
||||
<p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
|
||||
Dein Account ist noch keinem Haushalt zugeordnet.
|
||||
</p>
|
||||
<CreateFamilyDialog
|
||||
defaultFamilyName={`Familie ${user.lastName}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
icon={Shield}
|
||||
label="Rolle"
|
||||
value={<RoleBadge role={user.role} />}
|
||||
/>
|
||||
{user.role !== UserRole.ERZIEHER && (
|
||||
<DefItem
|
||||
icon={Home}
|
||||
label="Haushalt"
|
||||
value={
|
||||
user.family ? (
|
||||
user.family.name
|
||||
) : (
|
||||
<div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
|
||||
<p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
|
||||
Dein Account ist noch keinem Haushalt zugeordnet.
|
||||
</p>
|
||||
<CreateFamilyDialog
|
||||
defaultFamilyName={`Familie ${user.lastName}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<DefItem
|
||||
icon={CalendarHeart}
|
||||
label="Mitglied seit"
|
||||
@@ -153,6 +163,24 @@ export default async function ProfilPage() {
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{user.role === UserRole.ERZIEHER && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Meine Arbeitszeit & Urlaub</CardTitle>
|
||||
<CardDescription>
|
||||
Wochenmodell, Urlaubsanspruch, Zeiterfassung und Überstunden.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link
|
||||
href="/dashboard/personal"
|
||||
className="font-medium text-[var(--accent-deep)] underline underline-offset-4"
|
||||
>
|
||||
Personalbereich öffnen →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -180,87 +208,91 @@ export default async function ProfilPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Baby className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Meine Kinder</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Kinder deines Haushalts verwalten.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MyChildrenManager
|
||||
items={(user.family?.children ?? []).map((child) => ({
|
||||
id: child.id,
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
dateOfBirth: child.dateOfBirth
|
||||
? child.dateOfBirth.toISOString().slice(0, 10)
|
||||
: null,
|
||||
}))}
|
||||
canManage={!!user.familyId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Shield className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Meine Ämter & Dienste</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Feste Aufgaben und laufende Zuständigkeiten.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.dutyAssignments.length === 0 ? (
|
||||
<div className="flex min-h-32 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Shield className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
|
||||
Keine festen Ämter übernommen
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-soft)]">
|
||||
Dienste und Rollen erscheinen hier, sobald sie zugewiesen
|
||||
sind.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{user.dutyAssignments.map((assignment) => (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--ink)]">
|
||||
{assignment.duty.name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
laufender Dienst
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]">
|
||||
Aktiv
|
||||
</span>
|
||||
{user.role !== UserRole.ERZIEHER && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Baby className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<CardTitle>Meine Kinder</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Kinder deines Haushalts verwalten.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MyChildrenManager
|
||||
items={(user.family?.children ?? []).map((child) => ({
|
||||
id: child.id,
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
dateOfBirth: child.dateOfBirth
|
||||
? child.dateOfBirth.toISOString().slice(0, 10)
|
||||
: null,
|
||||
}))}
|
||||
canManage={!!user.familyId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Shield className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Meine Ämter & Dienste</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Feste Aufgaben und laufende Zuständigkeiten.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.dutyAssignments.length === 0 ? (
|
||||
<div className="flex min-h-32 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Shield className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
|
||||
Keine festen Ämter übernommen
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-soft)]">
|
||||
Dienste und Rollen erscheinen hier, sobald sie
|
||||
zugewiesen sind.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{user.dutyAssignments.map((assignment) => (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--ink)]">
|
||||
{assignment.duty.name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
laufender Dienst
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]">
|
||||
Aktiv
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user