mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(links): offer the whole vault when linking a document to a record
The visit and vaccination forms asked the document list for a page of 200 while the route accepts at most 100. The refusal read as an empty list, so the picker said "No documents yet." to anyone with a full vault, and the only way a scan reached a dose was the seven-day suggestion offered at upload. The visit form's condition picker had the same problem against the episode list. The document pickers now walk the vault page by page at the route's own limit, so any document can be linked from a vaccination or a visit, and one document can sit on as many records as it covers. Documents dated within seven days of the record stay on top under their own heading, the rest follow by month and are searchable. A failed read now shows an error with a retry instead of passing for an empty list. Refs #1024, #1005
This commit is contained in:
1 parent
8f889cf942
commit
f46ed99def
17 files changed
+443
-85
No files matched your search
+3
-1
@@ -10825,7 +10825,9 @@
|
||||
"selectAll": "Alle auswählen",
|
||||
"searchPlaceholder": "Suchen",
|
||||
"searchNoMatch": "Kein Treffer",
|
||||
"remove": "Entfernen"
|
||||
"remove": "Entfernen",
|
||||
"suggested": "Um dieses Datum",
|
||||
"loadError": "Die Liste konnte nicht geladen werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -10825,7 +10825,9 @@
|
||||
"selectAll": "Select all",
|
||||
"searchPlaceholder": "Search",
|
||||
"searchNoMatch": "No match",
|
||||
"remove": "Remove"
|
||||
"remove": "Remove",
|
||||
"suggested": "Around this date",
|
||||
"loadError": "The list could not be loaded."
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -10825,7 +10825,9 @@
|
||||
"selectAll": "Seleccionar todo",
|
||||
"searchPlaceholder": "Buscar",
|
||||
"searchNoMatch": "Sin coincidencias",
|
||||
"remove": "Quitar"
|
||||
"remove": "Quitar",
|
||||
"suggested": "En torno a esta fecha",
|
||||
"loadError": "No se pudo cargar la lista."
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -10825,7 +10825,9 @@
|
||||
"selectAll": "Tout sélectionner",
|
||||
"searchPlaceholder": "Rechercher",
|
||||
"searchNoMatch": "Aucun résultat",
|
||||
"remove": "Retirer"
|
||||
"remove": "Retirer",
|
||||
"suggested": "Autour de cette date",
|
||||
"loadError": "Impossible de charger la liste."
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -10825,7 +10825,9 @@
|
||||
"selectAll": "Seleziona tutto",
|
||||
"searchPlaceholder": "Cerca",
|
||||
"searchNoMatch": "Nessun risultato",
|
||||
"remove": "Rimuovi"
|
||||
"remove": "Rimuovi",
|
||||
"suggested": "Intorno a questa data",
|
||||
"loadError": "Impossibile caricare l’elenco."
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -10825,7 +10825,9 @@
|
||||
"selectAll": "전체 선택",
|
||||
"searchPlaceholder": "검색",
|
||||
"searchNoMatch": "결과 없음",
|
||||
"remove": "제거"
|
||||
"remove": "제거",
|
||||
"suggested": "이 날짜 전후",
|
||||
"loadError": "목록을 불러오지 못했습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -10825,7 +10825,9 @@
|
||||
"selectAll": "Zaznacz wszystko",
|
||||
"searchPlaceholder": "Szukaj",
|
||||
"searchNoMatch": "Brak wyników",
|
||||
"remove": "Usuń"
|
||||
"remove": "Usuń",
|
||||
"suggested": "Wokół tej daty",
|
||||
"loadError": "Nie udało się wczytać listy."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness";
|
||||
import { listLabResultsSchema } from "@/lib/validations/labs";
|
||||
import {
|
||||
EPISODE_FETCH_LIMIT,
|
||||
PICKER_FETCH_LIMIT,
|
||||
} from "../encounter-link-pickers";
|
||||
|
||||
/**
|
||||
* Each picker's page size must be one its list route accepts. A refused size
|
||||
* comes back as a 422, and a picker that treats a failed read as an empty
|
||||
* list tells the person there is nothing to link. That is how the visit
|
||||
* form's condition picker and both document pickers showed nothing at all.
|
||||
*/
|
||||
describe("visit link picker page sizes", () => {
|
||||
it("the condition picker asks for a page the episode route accepts", () => {
|
||||
expect(
|
||||
illnessEpisodeListQuerySchema.safeParse({
|
||||
limit: String(EPISODE_FETCH_LIMIT),
|
||||
includeResolved: "true",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("the lab picker asks for a page the lab route accepts", () => {
|
||||
expect(
|
||||
listLabResultsSchema.safeParse({ limit: String(PICKER_FETCH_LIMIT) })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -273,6 +273,7 @@ export function EncounterForm({
|
||||
|
||||
<EncounterLinkPickers
|
||||
modules={user?.modules}
|
||||
anchor={draft.occurredAt || null}
|
||||
documentIds={draft.documentIds}
|
||||
labResultIds={draft.labResultIds}
|
||||
episodeIds={draft.episodeIds}
|
||||
|
||||
@@ -27,14 +27,20 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
EntityLinkPicker,
|
||||
type EntityLinkOption,
|
||||
} from "@/components/links/entity-link-picker";
|
||||
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
|
||||
|
||||
/** Fetch cap for the picker — the grouped sheet needs enough rows to group. */
|
||||
const PICKER_FETCH_LIMIT = 200;
|
||||
export const PICKER_FETCH_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* The episode list route caps `limit` at 100 and answers anything above it
|
||||
* with a 422, which the picker used to read as "nothing here to link".
|
||||
*/
|
||||
export const EPISODE_FETCH_LIMIT = 100;
|
||||
|
||||
interface LabListPage {
|
||||
results: Array<{
|
||||
@@ -45,18 +51,17 @@ interface LabListPage {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DocumentListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
}
|
||||
|
||||
export function EncounterLinkPickers({
|
||||
modules,
|
||||
anchor,
|
||||
documentIds,
|
||||
labResultIds,
|
||||
episodeIds,
|
||||
onChange,
|
||||
}: {
|
||||
modules: Partial<Record<ModuleKey, boolean>> | undefined;
|
||||
/** The visit's own date, for the document suggestions on top. */
|
||||
anchor: string | null;
|
||||
documentIds: string[];
|
||||
labResultIds: string[];
|
||||
episodeIds: string[];
|
||||
@@ -73,13 +78,12 @@ export function EncounterLinkPickers({
|
||||
const labsOn = modules?.labs === true;
|
||||
const illnessOn = modules?.illness === true;
|
||||
|
||||
const documents = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentPicker("encounter-form"),
|
||||
// The whole vault, suggestions near the visit's date on top — see
|
||||
// `useVaultDocumentOptions` for why this no longer asks for one oversized
|
||||
// page.
|
||||
const documents = useVaultDocumentOptions({
|
||||
enabled: documentsOn,
|
||||
queryFn: () =>
|
||||
apiGet<DocumentListPage>(
|
||||
`/api/documents/inbound?sort=documentDate&order=desc&limit=${PICKER_FETCH_LIMIT}`,
|
||||
),
|
||||
anchor,
|
||||
});
|
||||
|
||||
const labs = useQuery({
|
||||
@@ -103,30 +107,12 @@ export function EncounterLinkPickers({
|
||||
enabled: illnessOn,
|
||||
queryFn: () =>
|
||||
apiGet<IllnessEpisodeDTO[]>(
|
||||
`/api/illness/episodes?includeResolved=true&limit=${PICKER_FETCH_LIMIT}`,
|
||||
`/api/illness/episodes?includeResolved=true&limit=${EPISODE_FETCH_LIMIT}`,
|
||||
),
|
||||
});
|
||||
|
||||
if (!documentsOn && !labsOn && !illnessOn) return null;
|
||||
|
||||
// Documents group by month; the caller localizes the heading.
|
||||
const documentOptions: EntityLinkOption[] = (
|
||||
documents.data?.documents ?? []
|
||||
).map((doc) => {
|
||||
const date = doc.documentDate ?? doc.reportDate ?? doc.createdAt;
|
||||
return {
|
||||
id: doc.id,
|
||||
label: doc.title ?? doc.filename ?? doc.id,
|
||||
dateLabel: date ? format.date(date) : null,
|
||||
group: date
|
||||
? {
|
||||
key: date.slice(0, 7),
|
||||
label: `${format.monthShort(date)} ${date.slice(0, 4)}`,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
// Labs group by sample date + panel: "Blutbild · 12.05.2026", the analyte
|
||||
// underneath. A visit links the whole panel from a day in one "select all".
|
||||
const labOptions: EntityLinkOption[] = (labs.data?.results ?? []).map(
|
||||
@@ -163,10 +149,13 @@ export function EncounterLinkPickers({
|
||||
icon={FolderOpen}
|
||||
title={t("encounters.form.linkDocuments")}
|
||||
slot="encounter-link-documents"
|
||||
pending={documents.isPending}
|
||||
pending={documents.pending}
|
||||
error={documents.error}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={documents.retry}
|
||||
selected={documentIds}
|
||||
onChange={(ids) => onChange({ documentIds: ids })}
|
||||
options={documentOptions}
|
||||
options={documents.options}
|
||||
searchPlaceholder={t("links.picker.searchPlaceholder")}
|
||||
emptyLabel={t("encounters.form.linkNothingToOffer")}
|
||||
/>
|
||||
@@ -178,6 +167,9 @@ export function EncounterLinkPickers({
|
||||
title={t("encounters.form.linkLabResults")}
|
||||
slot="encounter-link-labs"
|
||||
pending={labs.isPending}
|
||||
error={labs.isError}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={() => void labs.refetch()}
|
||||
selected={labResultIds}
|
||||
onChange={(ids) => onChange({ labResultIds: ids })}
|
||||
options={labOptions}
|
||||
@@ -192,6 +184,9 @@ export function EncounterLinkPickers({
|
||||
title={t("encounters.form.linkConditions")}
|
||||
slot="encounter-link-conditions"
|
||||
pending={episodes.isPending}
|
||||
error={episodes.isError}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={() => void episodes.refetch()}
|
||||
selected={episodeIds}
|
||||
onChange={(ids) => onChange({ episodeIds: ids })}
|
||||
options={episodeOptions}
|
||||
|
||||
@@ -140,6 +140,24 @@ describe("<EntityLinkPicker> inline summary", () => {
|
||||
expect(html).not.toContain('data-slot="test-link-add"');
|
||||
});
|
||||
|
||||
it("a failed read shows an error row with retry, never the empty label", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={[]}
|
||||
pending={true}
|
||||
error
|
||||
errorLabel="Could not load documents"
|
||||
onRetry={() => undefined}
|
||||
selected={[]}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="test-link-error"');
|
||||
expect(html).toContain("Could not load documents");
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).not.toContain("Nothing to link");
|
||||
});
|
||||
|
||||
it("renders a skeleton while the options load", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker {...base} options={[]} pending={true} selected={[]} />,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
documentOptionsWithSuggestions,
|
||||
isNearAnchor,
|
||||
type VaultDocument,
|
||||
} from "../vault-document-options";
|
||||
|
||||
function doc(
|
||||
id: string,
|
||||
documentDate: string | null,
|
||||
extra: Partial<VaultDocument> = {},
|
||||
): VaultDocument {
|
||||
return {
|
||||
id,
|
||||
title: `Doc ${id}`,
|
||||
filename: null,
|
||||
documentDate,
|
||||
reportDate: null,
|
||||
createdAt: "2026-01-01T10:00:00.000Z",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
suggested: "Suggested",
|
||||
month: (iso: string) => iso.slice(0, 7),
|
||||
date: (iso: string) => iso.slice(0, 10),
|
||||
};
|
||||
|
||||
describe("isNearAnchor", () => {
|
||||
const anchor = "2026-05-10T00:00:00.000Z";
|
||||
it("is inclusive at seven days either side", () => {
|
||||
expect(isNearAnchor("2026-05-03", anchor)).toBe(true);
|
||||
expect(isNearAnchor("2026-05-17", anchor)).toBe(true);
|
||||
expect(isNearAnchor("2026-05-02", anchor)).toBe(false);
|
||||
expect(isNearAnchor("2026-05-18", anchor)).toBe(false);
|
||||
});
|
||||
it("never matches without an anchor or a date", () => {
|
||||
expect(isNearAnchor(null, anchor)).toBe(false);
|
||||
expect(isNearAnchor("2026-05-10", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("documentOptionsWithSuggestions", () => {
|
||||
it("offers the WHOLE vault, not only the documents near the record's date", () => {
|
||||
// A childhood record dated years before the dose it also covers is the
|
||||
// case the old picker could not reach.
|
||||
const options = documentOptionsWithSuggestions(
|
||||
[doc("near", "2026-05-12"), doc("old", "1991-03-01")],
|
||||
"2026-05-10T00:00:00.000Z",
|
||||
LABELS,
|
||||
);
|
||||
expect(options.map((o) => o.id)).toEqual(["near", "old"]);
|
||||
});
|
||||
|
||||
it("puts the documents within the window on top under one suggestion group", () => {
|
||||
const options = documentOptionsWithSuggestions(
|
||||
[
|
||||
doc("recent", "2026-09-01"),
|
||||
doc("near", "2026-05-12"),
|
||||
doc("old", "1991-03-01"),
|
||||
],
|
||||
"2026-05-10T00:00:00.000Z",
|
||||
LABELS,
|
||||
);
|
||||
expect(options[0]).toMatchObject({
|
||||
id: "near",
|
||||
group: { key: "suggested", label: "Suggested" },
|
||||
});
|
||||
expect(options.slice(1).map((o) => o.id)).toEqual(["recent", "old"]);
|
||||
expect(options[1]!.group).toEqual({ key: "2026-09", label: "2026-09" });
|
||||
});
|
||||
|
||||
it("reads a report date when the filing date is missing", () => {
|
||||
const options = documentOptionsWithSuggestions(
|
||||
[doc("lab", null, { reportDate: "2026-05-09" })],
|
||||
"2026-05-10T00:00:00.000Z",
|
||||
LABELS,
|
||||
);
|
||||
expect(options[0]!.group?.key).toBe("suggested");
|
||||
});
|
||||
|
||||
it("without an anchor keeps the plain month grouping", () => {
|
||||
const options = documentOptionsWithSuggestions(
|
||||
[doc("a", "2026-05-12")],
|
||||
null,
|
||||
LABELS,
|
||||
);
|
||||
expect(options[0]!.group?.key).toBe("2026-05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchWholeVault", () => {
|
||||
it("asks only for pages the list route accepts, and walks every cursor", async () => {
|
||||
const { documentListQuerySchema } =
|
||||
await import("@/lib/validations/inbound-documents");
|
||||
const apiFetch = await import("@/lib/api/api-fetch");
|
||||
const urls: string[] = [];
|
||||
const pages = [
|
||||
{ documents: [doc("a", "2026-05-01")], nextCursor: "a" },
|
||||
{ documents: [doc("b", "1991-03-01")], nextCursor: null },
|
||||
];
|
||||
const spy = vi
|
||||
.spyOn(apiFetch, "apiGet")
|
||||
.mockImplementation(async (url: string) => {
|
||||
urls.push(url);
|
||||
return pages.shift() as never;
|
||||
});
|
||||
try {
|
||||
const { fetchWholeVault } = await import("../vault-document-options");
|
||||
const documents = await fetchWholeVault();
|
||||
expect(documents.map((d) => d.id)).toEqual(["a", "b"]);
|
||||
expect(urls).toHaveLength(2);
|
||||
for (const url of urls) {
|
||||
// The route parses its query with this schema and answers a 422 to
|
||||
// anything it refuses; the picker used to read that 422 as an empty
|
||||
// vault.
|
||||
const params = Object.fromEntries(
|
||||
new URL(url, "http://local").searchParams,
|
||||
);
|
||||
expect(documentListQuerySchema.safeParse(params).success).toBe(true);
|
||||
}
|
||||
expect(urls[1]).toContain("cursor=a");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -131,6 +132,9 @@ export function EntityLinkPicker({
|
||||
onChange,
|
||||
searchPlaceholder,
|
||||
emptyLabel,
|
||||
error = false,
|
||||
errorLabel,
|
||||
onRetry,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
@@ -142,6 +146,14 @@ export function EntityLinkPicker({
|
||||
onChange: (ids: string[]) => void;
|
||||
searchPlaceholder: string;
|
||||
emptyLabel: string;
|
||||
/**
|
||||
* The option read failed. Renders an error row with Retry instead of the
|
||||
* empty label: a failed read must never pass for "nothing to link", which
|
||||
* is exactly how a refused page size once hid a whole vault.
|
||||
*/
|
||||
error?: boolean;
|
||||
errorLabel?: string;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -175,8 +187,14 @@ export function EntityLinkPicker({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{pending ? (
|
||||
{pending && !error ? (
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
) : error ? (
|
||||
<QueryErrorRow
|
||||
message={errorLabel}
|
||||
onRetry={onRetry}
|
||||
slot={`${slot}-error`}
|
||||
/>
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The vault as a link-picker option list, with the documents near the
|
||||
* record's own date on top.
|
||||
*
|
||||
* The visit and vaccination forms used to fetch their document options with a
|
||||
* page size the list route refuses (`limit=200` against a ceiling of 100). The
|
||||
* 422 read as an empty list, so the picker said "No documents yet." to a
|
||||
* person with a full vault, and the only way a scan ever got attached was the
|
||||
* ±7-day suggestion offered at upload. A childhood record covering eight doses
|
||||
* from one sheet of paper could therefore be filed against exactly one of
|
||||
* them.
|
||||
*
|
||||
* Now the whole vault is offered, paged through at the route's own ceiling,
|
||||
* and a read failure says so instead of pretending the vault is empty. The
|
||||
* documents dated within the same ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day
|
||||
* window the upload suggestion uses stay on top under their own heading, so
|
||||
* the common case is still one tap; everything else follows, grouped by month
|
||||
* and searchable.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { EntityLinkOption } from "@/components/links/entity-link-picker";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
DOCUMENT_LIST_MAX_LIMIT,
|
||||
type InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* How many pages the picker walks. At the route's ceiling of 100 that is a
|
||||
* thousand documents, past which the vault's own search is the better tool.
|
||||
*/
|
||||
const VAULT_PICKER_MAX_PAGES = 10;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** The fields the option list reads off a document. */
|
||||
export type VaultDocument = Pick<
|
||||
InboundDocumentDto,
|
||||
"id" | "title" | "filename" | "documentDate" | "reportDate" | "createdAt"
|
||||
>;
|
||||
|
||||
interface ListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
/** Walk the vault newest filing date first, at the route's own page ceiling. */
|
||||
export async function fetchWholeVault(): Promise<VaultDocument[]> {
|
||||
const documents: VaultDocument[] = [];
|
||||
let cursor: string | null = null;
|
||||
for (let page = 0; page < VAULT_PICKER_MAX_PAGES; page++) {
|
||||
const params = new URLSearchParams({
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
limit: String(DOCUMENT_LIST_MAX_LIMIT),
|
||||
});
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
const result: ListPage = await apiGet<ListPage>(
|
||||
`/api/documents/inbound?${params.toString()}`,
|
||||
);
|
||||
documents.push(...result.documents);
|
||||
cursor = result.nextCursor;
|
||||
if (!cursor) break;
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a document dated within the suggestion window of the record's anchor?
|
||||
* Day-granular and inclusive on both edges, like the upload suggestion. A
|
||||
* document with no date of its own is never "near" anything.
|
||||
*/
|
||||
export function isNearAnchor(
|
||||
documentDay: string | null,
|
||||
anchorIso: string | null,
|
||||
): boolean {
|
||||
if (!documentDay || !anchorIso) return false;
|
||||
const day = Date.parse(`${documentDay.slice(0, 10)}T00:00:00.000Z`);
|
||||
const anchor = Date.parse(`${anchorIso.slice(0, 10)}T00:00:00.000Z`);
|
||||
if (Number.isNaN(day) || Number.isNaN(anchor)) return false;
|
||||
return Math.abs(day - anchor) <= ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the option list: suggestions first, under one heading, then the rest
|
||||
* of the vault in the route's order (newest filing date first), by month.
|
||||
*
|
||||
* `labels` carries the already-localised strings, so this stays a pure
|
||||
* function a test can call without a provider.
|
||||
*/
|
||||
export function documentOptionsWithSuggestions(
|
||||
documents: readonly VaultDocument[],
|
||||
anchorIso: string | null,
|
||||
labels: {
|
||||
suggested: string;
|
||||
month: (iso: string) => string;
|
||||
date: (iso: string) => string;
|
||||
},
|
||||
): EntityLinkOption[] {
|
||||
const suggested: EntityLinkOption[] = [];
|
||||
const rest: EntityLinkOption[] = [];
|
||||
for (const doc of documents) {
|
||||
const own = doc.documentDate ?? doc.reportDate;
|
||||
const date = own ?? doc.createdAt;
|
||||
const base = {
|
||||
id: doc.id,
|
||||
label: doc.title ?? doc.filename ?? doc.id,
|
||||
dateLabel: date ? labels.date(date) : null,
|
||||
};
|
||||
if (isNearAnchor(own, anchorIso)) {
|
||||
suggested.push({
|
||||
...base,
|
||||
group: { key: "suggested", label: labels.suggested },
|
||||
});
|
||||
} else {
|
||||
rest.push({
|
||||
...base,
|
||||
group: date
|
||||
? { key: date.slice(0, 7), label: labels.month(date) }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...suggested, ...rest];
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's data: the whole vault as options, the suggestion group on top
|
||||
* when the record has a date, and the query state an error row needs.
|
||||
*/
|
||||
export function useVaultDocumentOptions({
|
||||
enabled,
|
||||
anchor,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
/** The record's own date (ISO), or null when it has none yet. */
|
||||
anchor: string | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentVaultPicker(),
|
||||
enabled,
|
||||
queryFn: fetchWholeVault,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
documentOptionsWithSuggestions(query.data ?? [], anchor, {
|
||||
suggested: t("links.picker.suggested"),
|
||||
month: (iso) => `${format.monthShort(iso)} ${iso.slice(0, 4)}`,
|
||||
date: (iso) => format.date(iso),
|
||||
}),
|
||||
[query.data, anchor, t, format],
|
||||
);
|
||||
|
||||
return {
|
||||
options,
|
||||
pending: query.isPending,
|
||||
error: query.isError,
|
||||
retry: () => void query.refetch(),
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,11 @@
|
||||
* transcribed from. The shared {@link EntityLinkPicker} — an inline summary
|
||||
* (removable chips + an add button) over a searchable, month-grouped sheet.
|
||||
*
|
||||
* The whole vault is offered, not only the pages dated near the dose: one
|
||||
* childhood record commonly covers a dozen doses given years apart, and it
|
||||
* belongs on every one of them. The pages dated within the upload
|
||||
* suggestion's window of this dose sit on top as suggestions.
|
||||
*
|
||||
* **The gate blanks the block, it does not post-filter it.** When the
|
||||
* `inboundDocuments` module is off, this renders nothing — no heading, no empty
|
||||
* list — because an empty picker for a switched-off module advertises a feature
|
||||
@@ -13,75 +18,43 @@
|
||||
* Nothing here can block a save: the list starts empty and stays valid empty,
|
||||
* and linking is optional, capped and idempotent behind the link facade.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
EntityLinkPicker,
|
||||
type EntityLinkOption,
|
||||
} from "@/components/links/entity-link-picker";
|
||||
|
||||
const PICKER_FETCH_LIMIT = 200;
|
||||
|
||||
interface DocumentListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
}
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { EntityLinkPicker } from "@/components/links/entity-link-picker";
|
||||
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
|
||||
|
||||
export function VaccinationDocumentPicker({
|
||||
enabled,
|
||||
anchor,
|
||||
documentIds,
|
||||
onChange,
|
||||
}: {
|
||||
/** The `inboundDocuments` module flag — false blanks the block entirely. */
|
||||
enabled: boolean;
|
||||
/** The dose's own date (ISO), for the suggestions on top. */
|
||||
anchor: string | null;
|
||||
documentIds: string[];
|
||||
onChange: (documentIds: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
|
||||
const documents = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentPicker("vaccination-form"),
|
||||
enabled,
|
||||
queryFn: () =>
|
||||
apiGet<DocumentListPage>(
|
||||
`/api/documents/inbound?sort=documentDate&order=desc&limit=${PICKER_FETCH_LIMIT}`,
|
||||
),
|
||||
});
|
||||
const vault = useVaultDocumentOptions({ enabled, anchor });
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const options: EntityLinkOption[] = (documents.data?.documents ?? []).map(
|
||||
(doc) => {
|
||||
const date = doc.documentDate ?? doc.reportDate ?? doc.createdAt;
|
||||
return {
|
||||
id: doc.id,
|
||||
label: doc.title ?? doc.filename ?? doc.id,
|
||||
dateLabel: date ? format.date(date) : null,
|
||||
group: date
|
||||
? {
|
||||
key: date.slice(0, 7),
|
||||
label: `${format.monthShort(date)} ${date.slice(0, 4)}`,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-t pt-4" data-slot="vaccination-document-picker">
|
||||
<EntityLinkPicker
|
||||
icon={FolderOpen}
|
||||
title={t("vaccinations.form.linkDocuments")}
|
||||
slot="vaccination-document"
|
||||
pending={documents.isPending}
|
||||
pending={vault.pending}
|
||||
error={vault.error}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={vault.retry}
|
||||
selected={documentIds}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
options={vault.options}
|
||||
searchPlaceholder={t("links.picker.searchPlaceholder")}
|
||||
emptyLabel={t("vaccinations.form.linkNothingToOffer")}
|
||||
/>
|
||||
|
||||
@@ -305,6 +305,7 @@ export function VaccinationForm({
|
||||
|
||||
<VaccinationDocumentPicker
|
||||
enabled={user?.modules?.inboundDocuments === true}
|
||||
anchor={anchor}
|
||||
documentIds={draft.documentIds}
|
||||
onChange={(documentIds) => patch({ documentIds })}
|
||||
/>
|
||||
|
||||
@@ -54,6 +54,13 @@ export const documentKeys = {
|
||||
*/
|
||||
inboundDocumentPicker: (q: string) =>
|
||||
["documents", "inbound", "picker", q] as const,
|
||||
/**
|
||||
* The whole vault as a link-picker option list (every page, walked at the
|
||||
* list route's ceiling), shared by the visit and vaccination forms. Own
|
||||
* segment because the shape is a flat document array, not a page.
|
||||
*/
|
||||
inboundDocumentVaultPicker: () =>
|
||||
["documents", "inbound", "vault-picker"] as const,
|
||||
/**
|
||||
* Storage usage + effective limits (`GET /api/documents/inbound/usage`).
|
||||
* Invalidated through the `["documents"]` prefix after every upload /
|
||||
|
||||
Reference in new issue
Block a user