509 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|