feat: show planning availability and manage Kita access roles

This commit is contained in:
t.indorf
2026-10-12 16:28:56 +02:00
parent 99d3a63eed
commit 061024d901
29 changed files with 2347 additions and 404 deletions
@@ -1,5 +1,8 @@
"use client";
import type { UserRole } from "@prisma/client";
import { RoleChangeButton } from "../../zugriffsrechte/role-change-button";
import { RoleBadge } from "@/components/ui/role-badge";
import { useState, useTransition } from "react";
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
import { toast } from "sonner";
@@ -29,6 +32,8 @@ interface FamilyUser {
email: string;
emailVerifiedAt: Date | null;
invitation: FamilyInvitationView;
role: UserRole;
staffProfile: { id: string } | null;
dutyAssignments: {
id: string;
dutyId: string;
@@ -60,12 +65,14 @@ interface DutyData {
}
interface FamiliesListClientProps {
currentUserId: string;
initialFamilies: FamilyData[];
allDuties: DutyData[];
canManageDuties: boolean;
}
export function FamiliesListClient({
currentUserId,
initialFamilies,
allDuties,
canManageDuties,
@@ -85,7 +92,7 @@ export function FamiliesListClient({
(user) =>
user.firstName.toLowerCase().includes(q) ||
user.lastName.toLowerCase().includes(q) ||
user.email.toLowerCase().includes(q)
user.email.toLowerCase().includes(q),
);
if (matchesParent) return true;
@@ -93,20 +100,20 @@ export function FamiliesListClient({
const matchesChild = family.children.some(
(child) =>
child.firstName.toLowerCase().includes(q) ||
child.lastName.toLowerCase().includes(q)
child.lastName.toLowerCase().includes(q),
);
return matchesChild;
});
const childCount = filteredFamilies.reduce(
(acc, family) => acc + family.children.length,
0
0,
);
const pendingInviteCount = filteredFamilies.reduce(
(acc, family) =>
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
0
0,
);
return (
@@ -123,14 +130,17 @@ export function FamiliesListClient({
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort pflegen.
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort
pflegen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setViewMode(viewMode === "table" ? "cards" : "table")}
onClick={() =>
setViewMode(viewMode === "table" ? "cards" : "table")
}
className="flex items-center gap-1.5"
>
{viewMode === "table" ? (
@@ -152,7 +162,9 @@ export function FamiliesListClient({
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{filteredFamilies.length}</span>
<span className="text-[var(--ink)] tabular-nums">
{filteredFamilies.length}
</span>
Familien
</span>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
@@ -160,7 +172,9 @@ export function FamiliesListClient({
Kinder
</span>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{pendingInviteCount}</span>
<span className="text-[var(--ink)] tabular-nums">
{pendingInviteCount}
</span>
Offene Einladungen
</span>
</div>
@@ -196,7 +210,8 @@ export function FamiliesListClient({
<div>
<CardTitle>Haushalte</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{filteredFamilies.length} {filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
{filteredFamilies.length}{" "}
{filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
{childCount} Kinder · {pendingInviteCount} offene Einladungen
</p>
</div>
@@ -218,7 +233,7 @@ export function FamiliesListClient({
<TableBody>
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt
(user) => !user.emailVerifiedAt,
);
return (
@@ -250,10 +265,29 @@ export function FamiliesListClient({
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
{!parent.emailVerifiedAt && (
<ResendInviteButton userId={parent.id} />
)}
</span>
</div>
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} />
<InvitationStatus
userId={parent.id}
name={`${parent.firstName} ${parent.lastName}`}
invitation={parent.invitation}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<RoleBadge role={parent.role} />
<RoleChangeButton
ownAccount={parent.id === currentUserId}
person={{
id: parent.id,
name: `${parent.firstName} ${parent.lastName}`,
role: parent.role,
familyId: family.id,
hasStaffProfile: !!parent.staffProfile,
}}
/>
</div>
</div>
))}
</div>
@@ -277,7 +311,9 @@ export function FamiliesListClient({
)}
</TableCell>
<TableCell>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
<Badge
variant={hasPendingInvite ? "warning" : "success"}
>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</TableCell>
@@ -323,7 +359,7 @@ export function FamiliesListClient({
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt
(user) => !user.emailVerifiedAt,
);
return (
<Card key={family.id} className="flex flex-col h-full">
@@ -333,9 +369,12 @@ export function FamiliesListClient({
{family.name.slice(0, 1)}
</div>
<div>
<CardTitle className="text-lg font-semibold">{family.name}</CardTitle>
<CardTitle className="text-lg font-semibold">
{family.name}
</CardTitle>
<p className="text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil{family.users.length === 1 ? "" : "e"}
{family.users.length} Elternteil
{family.users.length === 1 ? "" : "e"}
</p>
</div>
</div>
@@ -359,10 +398,29 @@ export function FamiliesListClient({
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
{!parent.emailVerifiedAt && (
<ResendInviteButton userId={parent.id} />
)}
</span>
</div>
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} />
<InvitationStatus
userId={parent.id}
name={`${parent.firstName} ${parent.lastName}`}
invitation={parent.invitation}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<RoleBadge role={parent.role} />
<RoleChangeButton
ownAccount={parent.id === currentUserId}
person={{
id: parent.id,
name: `${parent.firstName} ${parent.lastName}`,
role: parent.role,
familyId: family.id,
hasStaffProfile: !!parent.staffProfile,
}}
/>
</div>
</div>
))}
</div>
@@ -430,8 +488,21 @@ export function FamiliesListClient({
function ResendInviteButton({ userId }: { userId: string }) {
const [pending, startTransition] = useTransition();
return <Button type="button" size="sm" variant="ghost" disabled={pending} onClick={() => startTransition(async () => {
const result = await resendParentInvite(userId);
if (result.error) toast.error(result.error); else toast.success("Einladung erneut gesendet.");
})}>{pending ? "Wird gesendet…" : "Einladung erneut senden"}</Button>;
return (
<Button
type="button"
size="sm"
variant="ghost"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await resendParentInvite(userId);
if (result.error) toast.error(result.error);
else toast.success("Einladung erneut gesendet.");
})
}
>
{pending ? "Wird gesendet…" : "Einladung erneut senden"}
</Button>
);
}