"use client"; /** * "What is this page filed against?" — the document side of the visit and * vaccination links, on the vault's detail sheet. * * Both links already existed as tables, but only one end could write them: * a visit's form picked its documents, a dose's form picked its pages, and * the document itself could show its visits read-only and its doses not at * all. A childhood record covering a dozen doses had to be attached dose by * dose, from twelve forms. Here the page lists what it is filed against and, * for someone who may manage the vault, links and unlinks it in one place. * Both ends write the SAME table through the link service, so the dose's * form and this sheet cannot disagree about what is filed where. * * Every name and date comes from the server: the visit kind as its enum * constant for the reader's bundle, the dose as its catalogue slug or the * person's own wording. The pick lists come from the visit and vaccination * list routes, with the records dated near the document on top under the * same seven-day window the upload suggestion uses. * * A module that is off leaves no block behind, and a grant that does not * reach the doses (`vaccinationLinks: null`) shows no dose block rather than * an empty one that would claim the page records none. */ import { useMemo, useState } from "react"; import Link from "next/link"; import { CalendarDays, Syringe } from "lucide-react"; import { EntityLinkPicker, serverPagingFrom, } from "@/components/links/entity-link-picker"; import type { EntityLinkOption } from "@/components/links/entity-link-picker"; import { isNearAnchor } from "@/components/links/vault-document-options"; import { encounterPickerUrl } from "@/components/links/picker-requests"; import { PICKER_PAGE_SIZE, pickerPivot, useLinkPickerPages, type PickerPage, type PickerPageRequest, } from "@/components/links/use-link-picker-pages"; import { encounterKindText } from "@/components/encounters/encounter-labels"; import { useVaccinations } from "@/components/vaccinations/use-vaccinations"; import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto"; import type { EncounterKind } from "@/generated/prisma/client"; import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; type Translate = ReturnType["t"]; /** Records near the document first, then the rest by year, newest first. */ export function recordOptions( records: ReadonlyArray<{ id: string; label: string; meta?: string | null; occurredAt: string; href?: string | null; }>, anchor: string | null, labels: { suggested: string; date: (iso: string) => string }, ): EntityLinkOption[] { const near: EntityLinkOption[] = []; const rest: EntityLinkOption[] = []; for (const record of records) { const option = { id: record.id, label: record.label, meta: record.meta ?? null, dateLabel: labels.date(record.occurredAt), href: record.href ?? null, }; if (isNearAnchor(record.occurredAt, anchor)) { near.push({ ...option, group: { key: "suggested", label: labels.suggested }, }); } else { const year = record.occurredAt.slice(0, 4); rest.push({ ...option, group: { key: year, label: year } }); } } return [...near, ...rest]; } /** * One picker page of visits. The route answers in two arms, each paged by the * same offset; another page exists while either arm came back full. */ export async function fetchEncounterPage( request: PickerPageRequest, ): Promise> { const list = await apiGet(encounterPickerUrl(request)); const full = list.upcoming.length >= PICKER_PAGE_SIZE || list.past.length >= PICKER_PAGE_SIZE; return { items: [...list.upcoming, ...list.past], next: full ? String(Number(request.cursor ?? "0") + PICKER_PAGE_SIZE) : null, }; } /** Where a visit opens: its sheet on the checkups page. */ function visitHref(encounterId: string): string { return `/checkups?visit=${encodeURIComponent(encounterId)}`; } /** Where a dose opens: its sheet on the vaccinations page. */ function doseHref(vaccinationId: string): string { return `/vaccinations?dose=${encodeURIComponent(vaccinationId)}`; } /** A dose named the way the vaccination page names it. */ function doseName( t: Translate, catalogSlug: string | null, vaccineName: string | null, ): string { if (catalogSlug) return t(`vaccinations.catalog.${catalogSlug}`); return vaccineName ?? t("vaccinations.suggestion.unnamed"); } /** * The caller mounts this keyed on the document and on its write-failure * count, so the local selection below starts over from the server's answer * whenever the sheet moves to another document or a write fails. */ export function DocumentRecordLinks({ doc, canManage, seedFresh, seedError = false, onRetrySeed, onChange, }: { doc: InboundDocumentDetailDto; canManage: boolean; /** * Whether `doc` was read from the server since the sheet opened. Every * change is a replace-set write seeded from `doc`'s links, so a seed from a * cached copy that predates a link made on the dose's or the visit's own * form would delete that link on the first tap. The pickers wait for a * fresh read instead. */ seedFresh: boolean; /** That fresh read failed; the pickers show the retry row instead. */ seedError?: boolean; onRetrySeed?: () => void; onChange: (part: { encounterIds?: string[]; vaccinationIds?: string[]; }) => void; }) { const { t } = useTranslations(); const format = useFormatters(); const { user } = useAuth(); const vaccinationsOn = user?.modules?.vaccinations !== false; const anchor = doc.documentDate ?? doc.reportDate; // The visits around the document's date, paged older and newer from // there. The visit route has no text search (its free text is ciphertext // at rest), so the term filters what is loaded. const encounters = useLinkPickerPages({ enabled: canManage, pivot: pickerPivot(anchor), queryKey: queryKeys.encounterPicker, fetchPage: fetchEncounterPage, serverSearch: false, }); const vaccinations = useVaccinations( null, canManage && vaccinationsOn && doc.vaccinationLinks !== null, ); // The selection the person is editing, ahead of the server. Each change is // sent as the whole set (a replace-set PATCH), so a quick second tap never // computes from a list the first tap has not refreshed yet. const [visitIds, setVisitIds] = useState(null); const [doseIds, setDoseIds] = useState(null); const labels = { suggested: t("links.picker.suggested"), date: (iso: string) => format.date(iso), }; const visitOptions = recordOptions( [...encounters.items] // Two arms per page arrive in two orders; the list reads newest first. .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)) .map((visit) => ({ id: visit.id, label: encounterKindText(t, visit.kind as EncounterKind), meta: visit.practitioner?.name ?? null, occurredAt: visit.occurredAt, href: visitHref(visit.id), })), anchor, labels, ); const doseOptions = recordOptions( (vaccinations.data?.vaccinations ?? []).map((dose) => ({ id: dose.id, label: doseName(t, dose.catalogEntry?.slug ?? null, dose.vaccineName), occurredAt: dose.occurredAt, href: doseHref(dose.id), })), anchor, labels, ); const showDoses = vaccinationsOn && doc.vaccinationLinks !== null; // The visits the document is filed against, as its own read names them, // so their chips show whatever page of visits is loaded. const knownVisits = useMemo( () => doc.encounterLinks.map((link) => ({ id: link.encounterId, label: encounterKindText(t, link.kind as EncounterKind), dateLabel: link.occurredAt ? format.date(link.occurredAt) : null, href: visitHref(link.encounterId), })), [doc.encounterLinks, t, format], ); return ( <> {canManage ? ( { if (encounters.error) encounters.retry(); if (seedError) onRetrySeed?.(); }} selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)} onChange={(ids) => { setVisitIds(ids); onChange({ encounterIds: ids }); }} options={visitOptions} server={serverPagingFrom(encounters)} knownOptions={knownVisits} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("documents.detail.noVisitsToLink")} /> ) : doc.encounterLinks.length > 0 ? (

{t("documents.detail.visitsLabel")}

{doc.encounterLinks.map((link) => ( {encounterKindText(t, link.kind as EncounterKind)} {link.occurredAt ? ( {format.date(link.occurredAt)} ) : null} ))}
) : null} {showDoses && canManage ? ( { if (vaccinations.isError) void vaccinations.refetch(); if (seedError) onRetrySeed?.(); }} selected={ doseIds ?? (doc.vaccinationLinks ?? []).map((link) => link.vaccinationId) } onChange={(ids) => { setDoseIds(ids); onChange({ vaccinationIds: ids }); }} options={doseOptions} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("documents.detail.noVaccinationsToLink")} /> ) : showDoses && (doc.vaccinationLinks?.length ?? 0) > 0 ? (

{t("documents.detail.vaccinationsLabel")}

{(doc.vaccinationLinks ?? []).map((link) => ( {doseName(t, link.catalogSlug, link.vaccineName)} {link.occurredAt ? ( {format.date(link.occurredAt)} ) : null} ))}
) : null} ); }