Implement missing UI button features, filters, search, and view toggles across all modules

This commit is contained in:
t.indorf
2026-05-21 10:44:23 +02:00
parent e7eb43462c
commit 84aae1d72e
18 changed files with 3268 additions and 958 deletions
@@ -0,0 +1,600 @@
"use client";
import { useState, useMemo } from "react";
import {
Baby,
Contact,
Mail,
Phone,
Search,
ShieldCheck,
SlidersHorizontal,
UserRound,
Home,
MapPin,
} from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export type DirectoryParentDto = {
id: string;
firstName: string;
lastName: string;
hasDirectoryOptIn: boolean;
email?: string;
phone?: string;
street?: string;
postalCode?: string;
city?: string;
duties: {
id: string;
name: string;
}[];
};
export type DirectoryFamilyDto = {
id: string;
name: string;
children: {
id: string;
firstName: string;
lastName: string;
dateOfBirth?: Date | null;
}[];
parents: DirectoryParentDto[];
};
export function DirectoryClient({
initialFamilies,
}: {
initialFamilies: DirectoryFamilyDto[];
}) {
const [searchQuery, setSearchQuery] = useState("");
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
const [selectedFamily, setSelectedFamily] = useState<DirectoryFamilyDto | null>(null);
const [privacyDialogOpen, setPrivacyDialogOpen] = useState(false);
const toggleFilter = (filterKey: string) => {
setActiveFilters((prev) => {
const next = new Set(prev);
if (next.has(filterKey)) {
next.delete(filterKey);
} else {
next.add(filterKey);
}
return next;
});
};
const filteredFamilies = useMemo(() => {
return initialFamilies.filter((family) => {
// 1. Search Query filter
const query = searchQuery.trim().toLowerCase();
if (query) {
const familyNameMatch = family.name.toLowerCase().includes(query);
const childMatch = family.children.some(
(c) =>
c.firstName.toLowerCase().includes(query) ||
c.lastName.toLowerCase().includes(query)
);
const parentMatch = family.parents.some(
(p) =>
p.firstName.toLowerCase().includes(query) ||
p.lastName.toLowerCase().includes(query) ||
(p.email && p.email.toLowerCase().includes(query))
);
if (!familyNameMatch && !childMatch && !parentMatch) return false;
}
// 2. Custom filters
if (activeFilters.has("telefon")) {
const hasPhone = family.parents.some((p) => p.hasDirectoryOptIn && p.phone);
if (!hasPhone) return false;
}
if (activeFilters.has("aemter")) {
const hasDuties = family.parents.some((p) => p.duties.length > 0);
if (!hasDuties) return false;
}
if (activeFilters.has("optIn")) {
// Show households where all parents in the system have opted in
const allOptIn = family.parents.every((p) => p.hasDirectoryOptIn);
if (!allOptIn) return false;
}
return true;
});
}, [initialFamilies, searchQuery, activeFilters]);
// Recalculate metrics for filtered subset
const visibleParentCount = useMemo(() => {
return filteredFamilies.reduce(
(count, fam) => count + fam.parents.filter((p) => p.hasDirectoryOptIn).length,
0
);
}, [filteredFamilies]);
const childCount = useMemo(() => {
return filteredFamilies.reduce((count, fam) => count + fam.children.length, 0);
}, [filteredFamilies]);
const totalVisibleParentCount = useMemo(() => {
return initialFamilies.reduce(
(count, fam) => count + fam.parents.filter((p) => p.hasDirectoryOptIn).length,
0
);
}, [initialFamilies]);
const groupedFamilies = useMemo(() => {
const groups = new Map<string, DirectoryFamilyDto[]>();
filteredFamilies.forEach((family) => {
const initial = family.name.trim().slice(0, 1).toUpperCase();
const key = initial || "#";
groups.set(key, [...(groups.get(key) ?? []), family]);
});
return Array.from(groups.entries())
.map(([initial, families]) => ({ initial, families }))
.sort((a, b) => a.initial.localeCompare(b.initial));
}, [filteredFamilies]);
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
{/* Header */}
<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]">
KONTAKTE · ADRESSBUCH
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Freigegebene{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
Kontakte
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte und Kontaktdaten, die explizit für das interne
Kita-Adressbuch freigegeben wurden.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<ShieldCheck className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{totalVisibleParentCount} sichtbar
</span>
<Button variant="ghost" size="sm" onClick={() => setPrivacyDialogOpen(true)}>
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Sichtbarkeit
</Button>
</div>
</header>
{/* Main Household List or Empty State */}
{initialFamilies.length === 0 ? (
<Card className="border-dashed">
<CardContent 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)]">
<Contact className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine freigegebenen Kontakte
</h3>
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Sobald mindestens ein Elternteil eines Haushalts zustimmt,
erscheint die Familie hier.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-5">
{/* Metrics & Search Bar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<DirectoryMetric label="Haushalte" value={filteredFamilies.length} />
<DirectoryMetric label="Kontakte" value={visibleParentCount} />
<DirectoryMetric label="Kinder" value={childCount} />
</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, Namen oder E-Mail suchen"
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Custom Filter Chips */}
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setActiveFilters(new Set())}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.size === 0
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Alle anzeigen
</button>
<button
type="button"
onClick={() => toggleFilter("telefon")}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.has("telefon")
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Mit Telefon
</button>
<button
type="button"
onClick={() => toggleFilter("aemter")}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.has("aemter")
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Mit Ämtern
</button>
<button
type="button"
onClick={() => toggleFilter("optIn")}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.has("optIn")
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Haushalt komplett freigegeben
</button>
</div>
{/* Household list cards wrapper */}
{filteredFamilies.length === 0 ? (
<Card className="border-dashed">
<CardContent 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)]">
<SlidersHorizontal className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine passenden Kontakte gefunden
</h3>
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Passe deine Suche oder die Filterchips an, um Einträge zu sehen.
</p>
</CardContent>
</Card>
) : (
<Card className="overflow-hidden p-0">
{groupedFamilies.map((group) => (
<section key={group.initial}>
<div className="flex items-center justify-between border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3">
<h2 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.14em]">
{group.initial}
</h2>
<span className="text-[11px] font-semibold text-[var(--ink-muted)] tabular-nums">
{group.families.length}
</span>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{group.families.map((family) => {
const visibleParents = family.parents.filter(
(parent) => parent.hasDirectoryOptIn
);
const duties = family.parents.flatMap((parent) => parent.duties);
return (
<div
key={family.id}
className="grid grid-cols-[minmax(220px,0.8fr)_minmax(0,1.4fr)_minmax(220px,1fr)] gap-5 px-5 py-4 transition-colors hover:bg-[var(--surface-2)] max-[1050px]:grid-cols-1"
>
{/* Left: Family Name & Children */}
<div className="flex min-w-0 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 className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{family.name}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-[13px] text-[var(--ink-soft)]">
<Baby className="h-3.5 w-3.5 [stroke-width:1.7]" />
{family.children.length === 0
? "Keine Kinder hinterlegt"
: family.children
.map((child) => `${child.firstName} ${child.lastName}`)
.join(", ")}
</p>
</div>
</div>
{/* Middle: Parents */}
<div className="grid gap-3">
{family.parents.map((user) => (
<div key={user.id} className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<UserRound className="h-3.5 w-3.5 text-[var(--ink-muted)] [stroke-width:1.7]" />
<p className="text-sm font-medium text-[var(--ink)]">
{user.firstName} {user.lastName}
</p>
{user.hasDirectoryOptIn ? (
<Badge variant="success">Opt-in</Badge>
) : (
<span className="text-xs italic text-[var(--ink-faint)]">
nicht freigegeben
</span>
)}
</div>
{user.hasDirectoryOptIn && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 pl-5 text-[13px] text-[var(--ink-soft)]">
{user.email && (
<a
href={`mailto:${user.email}`}
className="inline-flex min-w-0 items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{user.email}
</span>
</a>
)}
{user.phone ? (
<a
href={`tel:${user.phone}`}
className="inline-flex items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Phone className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
{user.phone}
</a>
) : (
<span className="italic text-[var(--ink-faint)]">
Keine Telefonnummer
</span>
)}
</div>
)}
</div>
))}
</div>
{/* Right: Badges & Details Action */}
<div className="flex flex-col items-end justify-between gap-3 max-[1050px]:items-start">
<div className="flex flex-wrap justify-end gap-1.5 max-[1050px]:justify-start">
<Badge variant="secondary">
{visibleParents.length} von {family.parents.length} sichtbar
</Badge>
{duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
>
{duty.name}
</Badge>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedFamily(family)}
>
Kontakt öffnen
</Button>
</div>
</div>
);
})}
</div>
</section>
))}
</Card>
)}
</div>
)}
{/* Household Detail Dialog */}
<Dialog
open={!!selectedFamily}
onOpenChange={(open) => !open && setSelectedFamily(null)}
>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
{selectedFamily && (
<>
<DialogHeader>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)] text-xl font-bold uppercase mb-2">
{selectedFamily.name.slice(0, 1)}
</div>
<DialogTitle className="text-2xl">Familie {selectedFamily.name}</DialogTitle>
<DialogDescription>
Detaillierte Kontaktdaten und Haushalts-Zugehörigkeiten.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-6">
{/* Children Details */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--ink-muted)] mb-3 flex items-center gap-1.5">
<Baby className="h-4 w-4" />
Kinder im Haushalt
</h4>
<div className="grid gap-2.5">
{selectedFamily.children.length === 0 ? (
<p className="text-sm italic text-[var(--ink-soft)]">Keine Kinder hinterlegt.</p>
) : (
selectedFamily.children.map((child) => (
<div
key={child.id}
className="flex items-center justify-between rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3"
>
<div className="flex items-center gap-2">
<Baby className="h-4 w-4 text-[var(--accent-deep)]" />
<span className="font-semibold text-sm text-[var(--ink)]">
{child.firstName} {child.lastName}
</span>
</div>
{child.dateOfBirth && (
<span className="text-xs text-[var(--ink-soft)] font-medium">
Geboren: {format(new Date(child.dateOfBirth), "dd.MM.yyyy", { locale: de })}
</span>
)}
</div>
))
)}
</div>
</div>
{/* Parents/Users Details */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--ink-muted)] mb-3 flex items-center gap-1.5">
<UserRound className="h-4 w-4" />
Eltern & Kontakte
</h4>
<div className="space-y-4">
{selectedFamily.parents.map((parent) => (
<div
key={parent.id}
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm"
>
<div className="flex items-center justify-between gap-2 border-b border-[var(--hairline-soft)] pb-2 mb-3">
<span className="font-semibold text-[15px] text-[var(--ink)] flex items-center gap-1.5">
<UserRound className="h-4 w-4 text-[var(--ink-muted)]" />
{parent.firstName} {parent.lastName}
</span>
{parent.hasDirectoryOptIn ? (
<Badge variant="success">Freigegeben</Badge>
) : (
<Badge variant="secondary">Privat</Badge>
)}
</div>
{parent.hasDirectoryOptIn ? (
<div className="space-y-2.5 text-sm text-[var(--ink-soft)]">
{parent.email && (
<a
href={`mailto:${parent.email}`}
className="flex items-center gap-2.5 hover:text-[var(--accent-deep)]"
>
<Mail className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
<span className="break-all">{parent.email}</span>
</a>
)}
{parent.phone ? (
<a
href={`tel:${parent.phone}`}
className="flex items-center gap-2.5 hover:text-[var(--accent-deep)]"
>
<Phone className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
<span>{parent.phone}</span>
</a>
) : (
<div className="flex items-center gap-2.5 italic text-[var(--ink-faint)]">
<Phone className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
<span>Keine Telefonnummer hinterlegt</span>
</div>
)}
{/* Address details */}
{(parent.street || parent.postalCode || parent.city) && (
<div className="flex items-start gap-2.5 border-t border-[var(--hairline-soft)] pt-2.5 mt-2.5">
<MapPin className="h-4 w-4 text-[var(--ink-muted)] shrink-0 mt-0.5" />
<div>
{parent.street && <p>{parent.street}</p>}
{(parent.postalCode || parent.city) && (
<p>
{parent.postalCode} {parent.city}
</p>
)}
</div>
</div>
)}
</div>
) : (
<p className="text-xs italic text-[var(--ink-faint)]">
Dieser Kontakt hat seine Daten nicht für das Adressbuch freigegeben.
</p>
)}
{parent.duties.length > 0 && (
<div className="mt-3 border-t border-[var(--hairline-soft)] pt-3 flex flex-wrap gap-1.5">
{parent.duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)] text-[10px]"
>
{duty.name}
</Badge>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
{/* Global Privacy settings dialog */}
<Dialog open={privacyDialogOpen} onOpenChange={setPrivacyDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-[var(--accent-deep)]" />
Sichtbarkeit & Datenschutz
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6">
Deine Privatsphäre ist uns wichtig. In diesem Adressbuch werden Kontaktdaten
nur für Haushalte angezeigt, die dem Opt-in für das interne Kita-Adressbuch
explizit zugestimmt haben.
<br />
<br />
Du kannst deine eigene Zustimmung sowie deine E-Mail-Adresse und Telefonnummer
jederzeit in deinen persönlichen Profileinstellungen einsehen und anpassen.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-3 mt-4">
<Button variant="ghost" onClick={() => setPrivacyDialogOpen(false)}>
Schließen
</Button>
<a href="/dashboard/profil">
<Button>Zu den Profileinstellungen</Button>
</a>
</div>
</DialogContent>
</Dialog>
</div>
);
}
function DirectoryMetric({ label, value }: { label: string; value: number }) {
return (
<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">{value}</span>
{label}
</span>
);
}