"use client"; /** * The account's own address book of doctors and practices. * * **Record content, not configuration**, so it does not live in Settings: a * settings surface configures behaviour, and a list of one's own doctors * configures nothing. It sits beside the visits that name it. * * Deleting is a soft delete and the visits that referenced the entry keep it. * The confirmation says so in one sentence, because a person deleting a doctor * has every reason to fear their history goes with it. */ import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { usePractitionerMutations, usePractitioners, type Practitioner, } from "@/hooks/use-practitioners"; import { useTranslations } from "@/lib/i18n/context"; import { PractitionerSheet } from "./practitioner-sheet"; export function PractitionerList({ enabled = true }: { enabled?: boolean }) { const { t } = useTranslations(); const { canWriteDomain, canManageDomain } = useRecordCapabilities(); // `POST /api/practitioners` answers at WRITE; editing and deleting at MANAGE. const canAddPractitioner = canWriteDomain("profile"); const canManageProfile = canManageDomain("profile"); const [term, setTerm] = useState(""); const debounced = useDebouncedValue(term, 250); const list = usePractitioners(debounced, enabled); const { remove } = usePractitionerMutations(); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); // Bumped on every open so the sheet remounts with a fresh draft rather than // copying props into state from an effect. const [session, setSession] = useState(0); const openSheet = (target: Practitioner | "new") => { setEditing(target); setSession((n) => n + 1); }; const rows = useMemo(() => list.data ?? [], [list.data]); // Group the flat list by specialty so a long address book reads by field // ("Zahnmedizin", "Hausarzt", …) instead of one undifferentiated column. // Specialty is exact free-text: each distinct string is its own group, // named groups sorted alphabetically, the unspecified rows collected into // one group that always sits last. When nothing carries a specialty the // headers are suppressed — a lone "no specialty" heading over every row is // noise, so the list degrades to the flat form it had before. const groups = useMemo(() => { const bySpecialty = new Map(); for (const row of rows) { const key = row.specialty?.trim() ? row.specialty.trim() : null; const bucket = bySpecialty.get(key); if (bucket) bucket.push(row); else bySpecialty.set(key, [row]); } const named = [...bySpecialty.entries()] .filter((entry): entry is [string, Practitioner[]] => entry[0] !== null) .sort((a, b) => a[0].localeCompare(b[0])); const unspecified = bySpecialty.get(null); return unspecified ? [...named, [null, unspecified] as [null, Practitioner[]]] : named; }, [rows]); const showGroupHeadings = groups.some(([specialty]) => specialty !== null); const addButton = canAddPractitioner ? ( ) : null; return (
setTerm(event.target.value)} placeholder={t("practitioners.searchPlaceholder")} aria-label={t("practitioners.searchPlaceholder")} className="pl-9" data-slot="practitioner-search" />
{list.isError ? ( void list.refetch()} /> ) : list.isPending ? (
{Array.from({ length: 3 }, (_, index) => ( ))}
) : rows.length === 0 ? ( } title={t( debounced ? "practitioners.noMatchTitle" : "practitioners.emptyTitle", )} description={t( debounced ? "practitioners.noMatchDescription" : "practitioners.emptyDescription", )} action={debounced ? undefined : (addButton ?? undefined)} /> ) : (
{groups.map(([specialty, groupRows]) => (
{showGroupHeadings ? (

{specialty ?? t("practitioners.noSpecialtyGroup")}

) : null}
    {groupRows.map((row) => (
  • {row.name}

    {row.practice || row.specialty ? (

    {[row.practice, row.specialty] .filter(Boolean) .join(" · ")}

    ) : null} {row.location ? (

    {row.location}

    ) : null}
    {canManageProfile ? (
    ) : null}
  • ))}
))}
)} { if (!open) setEditing(null); }} practitioner={editing === "new" ? null : editing} /> { if (!open) setDeleting(null); }} > {t("practitioners.deleteTitle")} {/* The one sentence that matters: the old visits stay. */} {t("practitioners.deleteDescription")} {t("common.cancel")} { if (!deleting) return; const id = deleting.id; setDeleting(null); remove.mutate(id, { onError: (failure) => showErrorToast(t("practitioners.deleteFailed"), { cause: failure, }), }); }} > {t("common.delete")}
); }