Files
kita-planer/src/app/dashboard/families/_components/families-list-client.tsx
T

509 lines
21 KiB
TypeScript

"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";
import { resendParentInvite } from "../actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Table as UITable,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AddFamilyDialog } from "../add-family-dialog";
import { DutyManager } from "../duty-manager";
import { EditFamilyDialog } from "../edit-family-dialog";
import { InvitationStatus } from "../invitation-status";
import type { FamilyInvitationView } from "@/lib/family-invitation-view";
interface FamilyUser {
id: string;
firstName: string;
lastName: string;
email: string;
emailVerifiedAt: Date | null;
invitation: FamilyInvitationView;
role: UserRole;
staffProfile: { id: string } | null;
dutyAssignments: {
id: string;
dutyId: string;
duty: {
name: string;
};
}[];
}
interface FamilyChild {
id: string;
firstName: string;
lastName: string;
}
interface FamilyData {
id: string;
name: string;
users: FamilyUser[];
children: FamilyChild[];
}
interface DutyData {
id: string;
name: string;
assignments: {
id: string;
}[];
}
interface FamiliesListClientProps {
currentUserId: string;
initialFamilies: FamilyData[];
allDuties: DutyData[];
canManageDuties: boolean;
}
export function FamiliesListClient({
currentUserId,
initialFamilies,
allDuties,
canManageDuties,
}: FamiliesListClientProps) {
const [viewMode, setViewMode] = useState<"table" | "cards">("table");
const [searchQuery, setSearchQuery] = useState("");
const filteredFamilies = initialFamilies.filter((family) => {
const q = searchQuery.toLowerCase().trim();
if (!q) return true;
// Check family name
if (family.name.toLowerCase().includes(q)) return true;
// Check parents
const matchesParent = family.users.some(
(user) =>
user.firstName.toLowerCase().includes(q) ||
user.lastName.toLowerCase().includes(q) ||
user.email.toLowerCase().includes(q),
);
if (matchesParent) return true;
// Check children
const matchesChild = family.children.some(
(child) =>
child.firstName.toLowerCase().includes(q) ||
child.lastName.toLowerCase().includes(q),
);
return matchesChild;
});
const childCount = filteredFamilies.reduce(
(acc, family) => acc + family.children.length,
0,
);
const pendingInviteCount = filteredFamilies.reduce(
(acc, family) =>
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
0,
);
return (
<div>
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
VERWALTUNG · FAMILIEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Familien{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
organisieren
</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.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
setViewMode(viewMode === "table" ? "cards" : "table")
}
className="flex items-center gap-1.5"
>
{viewMode === "table" ? (
<>
<LayoutGrid className="h-4 w-4 [stroke-width:1.7]" />
Kartenansicht
</>
) : (
<>
<Table className="h-4 w-4 [stroke-width:1.7]" />
Tabellenansicht
</>
)}
</Button>
<AddFamilyDialog />
</div>
</header>
<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>
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)]">
<span className="text-[var(--ink)] tabular-nums">{childCount}</span>
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>
Offene Einladungen
</span>
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Familien, Eltern oder Kinder suchen"
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{filteredFamilies.length === 0 ? (
<Card className="border-dashed">
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Users className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="mb-1 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine Familien gefunden
</h3>
<p className="mb-5 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Passe deine Suche an oder erstelle eine neue Familie.
</p>
{initialFamilies.length === 0 && <AddFamilyDialog />}
</div>
</Card>
) : viewMode === "table" ? (
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div>
<CardTitle>Haushalte</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{filteredFamilies.length}{" "}
{filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
{childCount} Kinder · {pendingInviteCount} offene Einladungen
</p>
</div>
<Badge variant="secondary" className="tabular-nums">
{filteredFamilies.length}
</Badge>
</CardHeader>
<CardContent className="px-0 pb-0 overflow-x-auto">
<UITable>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Familie</TableHead>
<TableHead>Elternteile</TableHead>
<TableHead>Kinder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt,
);
return (
<TableRow key={family.id}>
<TableCell className="pl-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[13px] font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div>
<div className="font-semibold text-[var(--ink)]">
{family.name}
</div>
<div className="mt-0.5 text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil
{family.users.length === 1 ? "" : "e"}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
{family.users.map((parent) => (
<div key={parent.id} className="min-w-0">
<div className="font-medium text-[var(--ink)]">
{parent.firstName} {parent.lastName}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-[var(--ink-muted)]">
<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} />
)}
</span>
</div>
<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>
</TableCell>
<TableCell>
{family.children.length === 0 ? (
<span className="text-sm italic text-[var(--ink-faint)]">
Noch keine Kinder
</span>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs font-medium text-[var(--ink-soft)]"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge
variant={hasPendingInvite ? "warning" : "success"}
>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</TableCell>
<TableCell className="pr-6 text-right">
<div className="flex flex-wrap justify-end gap-1">
<EditFamilyDialog
family={{
id: family.id,
name: family.name,
parents: family.users.map((parent) => ({
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
invitation: parent.invitation,
})),
children: family.children,
}}
/>
{canManageDuties &&
family.users.map((parent) => (
<DutyManager
key={parent.id}
user={{
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
}}
allDuties={allDuties}
userAssignments={parent.dutyAssignments}
/>
))}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</UITable>
</CardContent>
</Card>
) : (
<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,
);
return (
<Card key={family.id} className="flex flex-col h-full">
<CardHeader className="flex flex-row items-center justify-between pb-3 space-y-0">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-sm font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div>
<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"}
</p>
</div>
</div>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4 pt-0">
<div className="space-y-4">
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-[var(--ink-muted)] mb-1">
Eltern
</p>
<div className="space-y-2.5">
{family.users.map((parent) => (
<div key={parent.id} className="text-sm">
<div className="font-semibold text-[var(--ink)]">
{parent.firstName} {parent.lastName}
</div>
<div className="flex items-center gap-1.5 text-xs text-[var(--ink-muted)] mt-0.5">
<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} />
)}
</span>
</div>
<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>
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-[var(--ink-muted)] mb-1">
Kinder
</p>
{family.children.length === 0 ? (
<span className="text-xs italic text-[var(--ink-faint)]">
Noch keine Kinder
</span>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs text-[var(--ink-soft)] font-medium"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</div>
</div>
<div className="border-t border-[var(--hairline)] pt-3 mt-auto flex flex-wrap gap-1 justify-end">
<EditFamilyDialog
family={{
id: family.id,
name: family.name,
parents: family.users.map((parent) => ({
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
invitation: parent.invitation,
})),
children: family.children,
}}
/>
{canManageDuties &&
family.users.map((parent) => (
<DutyManager
key={parent.id}
user={{
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
}}
allDuties={allDuties}
userAssignments={parent.dutyAssignments}
/>
))}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}
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>
);
}