mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
About forty compact rows and seventy cards still offered a retry on a spent read budget and drew an error card on a refusal, while a third primitive (ReadFailureRow) did the right thing at eight places. QueryErrorRow and QueryErrorCard now take the query's `error` and read it through readFailureKind, which moves to src/lib/api next to ApiError: a refusal (403) draws the surface's refused state or nothing, a spent budget (429) says so without a retry, an ended session (401) says so and offers sign-in instead of going blank, and only a real failure offers the retry. ReadFailureRow is gone and every site passes its error; a guard test fails on a site that does not. The coach read strip keeps its card in the error state and holds one body height across loading, failure and a one-line read.
188 lines
7.0 KiB
TypeScript
188 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* "Termine" — the visits half of the checkups page.
|
|
*
|
|
* Two lists rather than one, and the split is the server's: upcoming reads
|
|
* soonest-first, past reads newest-first, and the API resolves both so the
|
|
* ordering lives in one place. A client that split and re-sorted one array
|
|
* would be the second place it lives.
|
|
*
|
|
* No new nav entry and no new route. `/checkups` already owns exactly the right
|
|
* sentence — when do I have to do what, where — and is deliberately not module
|
|
* gated, which is also why a visit carries no module key of its own.
|
|
*/
|
|
import { CalendarClock, ContactRound, History, Plus } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { Fragment, useState } from "react";
|
|
|
|
import { SinceLastVisit } from "@/components/day/since-last-visit";
|
|
import { Button } from "@/components/ui/button";
|
|
import { EmptyState } from "@/components/ui/empty-state";
|
|
import { PageHeader } from "@/components/ui/page-header";
|
|
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
|
import { SectionHeading } from "@/components/ui/section-heading";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record";
|
|
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
|
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
|
|
import { apiGet } from "@/lib/api/api-fetch";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { EncounterSheet } from "./encounter-sheet";
|
|
import { VisitCard } from "./visit-card";
|
|
|
|
/** One visit by id, for a link to a visit older than the list reads. */
|
|
function fetchVisit(id: string): Promise<Encounter | null> {
|
|
return apiGet<Encounter>(`/api/encounters/${encodeURIComponent(id)}`).catch(
|
|
() => null,
|
|
);
|
|
}
|
|
|
|
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
|
const { t } = useTranslations();
|
|
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
|
// `POST /api/encounters` answers at WRITE under the profile section; the
|
|
// edit and the delete behind the row are both MANAGE. Only the add was
|
|
// asked, so every row stayed tappable into a sheet whose Save and Delete
|
|
// the server refuses.
|
|
const canAddVisit = canWriteDomain("profile");
|
|
const canManageVisits = canManageDomain("profile");
|
|
const list = useEncounters(enabled);
|
|
|
|
// `null` closed, `"new"` create, a row edits it.
|
|
const [editing, setEditing] = useState<Encounter | "new" | null>(null);
|
|
const [session, setSession] = useState(0);
|
|
const openSheet = (target: Encounter | "new") => {
|
|
setEditing(target);
|
|
setSession((n) => n + 1);
|
|
};
|
|
|
|
const upcoming = list.data?.upcoming ?? [];
|
|
const past = list.data?.past ?? [];
|
|
|
|
// `?visit=<id>` — a document's link chip names the visit it is filed
|
|
// against. Scroll to it, and open it for someone who may edit it.
|
|
useDeepLinkedRecord({
|
|
param: "visit",
|
|
records: list.data ? [...upcoming, ...past] : undefined,
|
|
anchorAttribute: "data-encounter-id",
|
|
open: canManageVisits ? openSheet : undefined,
|
|
// The list reads the newest hundred of each arm; a search hit can name an
|
|
// older visit, which is read on its own.
|
|
resolve: fetchVisit,
|
|
});
|
|
const empty = upcoming.length === 0 && past.length === 0;
|
|
// The last visit that took place: the start of the visit preparation.
|
|
const lastDone = past.find((encounter) => encounter.status === "DONE");
|
|
|
|
const addButton = canAddVisit ? (
|
|
<Button
|
|
type="button"
|
|
className="min-h-11"
|
|
data-slot="visits-add"
|
|
onClick={() => openSheet("new")}
|
|
>
|
|
<Plus className="size-4" aria-hidden />
|
|
{t("encounters.add")}
|
|
</Button>
|
|
) : null;
|
|
|
|
return (
|
|
<section aria-labelledby="visits-section-title" className="space-y-4">
|
|
<PageHeader
|
|
titleId="visits-section-title"
|
|
title={t("encounters.sectionTitle")}
|
|
description={t("encounters.sectionDescription")}
|
|
actions={
|
|
<>
|
|
<Button asChild variant="outline" className="min-h-11">
|
|
<Link href="/checkups/practitioners">
|
|
<ContactRound className="size-4" aria-hidden />
|
|
{t("practitioners.linkLabel")}
|
|
</Link>
|
|
</Button>
|
|
{/* The empty state carries the add action while there is no
|
|
visit. */}
|
|
{!list.isPending && !list.isError && empty ? null : addButton}
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{list.isError ? (
|
|
<QueryErrorCard
|
|
error={list.error}
|
|
title={t("encounters.loadError")}
|
|
onRetry={() => void list.refetch()}
|
|
/>
|
|
) : list.isPending ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 3 }, (_, index) => (
|
|
<Skeleton key={index} className="h-20 w-full rounded-xl" />
|
|
))}
|
|
</div>
|
|
) : empty ? (
|
|
<EmptyState
|
|
icon={<CalendarClock className="size-6" aria-hidden />}
|
|
title={t("encounters.emptyTitle")}
|
|
description={t("encounters.emptyDescription")}
|
|
action={addButton ?? undefined}
|
|
/>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{upcoming.length > 0 ? (
|
|
<div className="space-y-2" data-slot="visits-upcoming">
|
|
<SectionHeading
|
|
icon={CalendarClock}
|
|
title={t("encounters.upcomingHeading")}
|
|
/>
|
|
{upcoming.map((encounter, i) => (
|
|
<Fragment key={encounter.id}>
|
|
<VisitCard
|
|
encounter={encounter}
|
|
onOpen={canManageVisits ? openSheet : undefined}
|
|
/>
|
|
{/* v1.42 — the next appointment carries what happened
|
|
since the last one, each date opening its day. The
|
|
component draws its own card, and none when it has
|
|
nothing to list. */}
|
|
{i === 0 && lastDone ? (
|
|
<SinceLastVisit next={encounter} last={lastDone} />
|
|
) : null}
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{past.length > 0 ? (
|
|
<div className="space-y-2" data-slot="visits-past">
|
|
<SectionHeading
|
|
icon={History}
|
|
title={t("encounters.pastHeading")}
|
|
/>
|
|
{past.map((encounter) => (
|
|
<VisitCard
|
|
key={encounter.id}
|
|
encounter={encounter}
|
|
onOpen={canManageVisits ? openSheet : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{/* `key` changes on every open, so the sheet remounts with a fresh draft
|
|
instead of copying props into state from an effect. It stays mounted
|
|
while closing, which is what keeps the exit animation. */}
|
|
<EncounterSheet
|
|
key={session}
|
|
open={editing !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setEditing(null);
|
|
}}
|
|
encounter={editing === "new" ? null : editing}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|