mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge remote-tracking branch 'origin/feat/documents-procedure-filter' into release/v1.40.2
This commit is contained in:
commit
03c43ef70a
24 files changed
+1308
-54
No files matched your search
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* The vault filter rail's procedure facet, pinned via single-pass static
|
||||
* renders (the project's component-test convention; the open-and-pick path is
|
||||
* the vault e2e). The facet mirrors the condition facet beside it: absent when
|
||||
* nothing is filed against a procedure, an icon-only trigger on a phone while
|
||||
* inactive, the procedure's own heading once one is picked, placed between the
|
||||
* condition and the year, and counted by the one clear control.
|
||||
*
|
||||
* Mutation checks (each run, each seen red):
|
||||
* - render the trigger with no choices → "renders no procedure control"
|
||||
* goes red;
|
||||
* - drop the active tint → "shows the picked procedure" goes red.
|
||||
*/
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DocumentFilterBar } from "../document-filter-bar";
|
||||
import type { ProcedureFilterChoice } from "../vault-utils";
|
||||
|
||||
const KNEE: ProcedureFilterChoice = {
|
||||
encounterId: "enc-knee",
|
||||
name: "Knee arthroscopy",
|
||||
detail: "Knee (left) · 2 Oct 2025",
|
||||
};
|
||||
|
||||
function render({
|
||||
procedures = [KNEE],
|
||||
activeEncounterId,
|
||||
activeCount = 0,
|
||||
}: {
|
||||
procedures?: ProcedureFilterChoice[];
|
||||
activeEncounterId?: string;
|
||||
activeCount?: number;
|
||||
} = {}) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentFilterBar
|
||||
searchValue=""
|
||||
onSearchChange={() => {}}
|
||||
searchInputRef={{ current: null }}
|
||||
activeKinds={new Set()}
|
||||
onToggleKind={() => {}}
|
||||
conditionChips={[{ episodeId: "ep-1", name: "Knee" }]}
|
||||
activeEpisodeId={undefined}
|
||||
onToggleEpisode={() => {}}
|
||||
procedureChips={procedures}
|
||||
activeEncounterId={activeEncounterId}
|
||||
onToggleEncounter={() => {}}
|
||||
years={[2025]}
|
||||
activeYear={undefined}
|
||||
onToggleYear={() => {}}
|
||||
activeCount={activeCount}
|
||||
onClearAll={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The opening tag of the element carrying `data-slot="<slot>"`. */
|
||||
function triggerTag(html: string, slot: string): string {
|
||||
const at = html.indexOf(`data-slot="${slot}"`);
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
return html.slice(html.lastIndexOf("<", at), html.indexOf(">", at) + 1);
|
||||
}
|
||||
|
||||
describe("<DocumentFilterBar> procedure facet", () => {
|
||||
it("renders no procedure control when nothing is filed against a procedure", () => {
|
||||
const html = render({ procedures: [] });
|
||||
expect(html).not.toContain('data-slot="document-procedure-filter"');
|
||||
// The positive control: the neighbouring facets still render.
|
||||
expect(html).toContain('data-slot="document-condition-filter"');
|
||||
expect(html).toContain('data-slot="document-year-filter"');
|
||||
});
|
||||
|
||||
it("sits between the condition and the year facet on the one row", () => {
|
||||
const html = render();
|
||||
const condition = html.indexOf('data-slot="document-condition-filter"');
|
||||
const procedure = html.indexOf('data-slot="document-procedure-filter"');
|
||||
const year = html.indexOf('data-slot="document-year-filter"');
|
||||
expect(condition).toBeGreaterThan(-1);
|
||||
expect(procedure).toBeGreaterThan(condition);
|
||||
expect(year).toBeGreaterThan(procedure);
|
||||
expect(html).toContain("flex flex-nowrap items-center gap-2");
|
||||
});
|
||||
|
||||
it("is an icon-only trigger on a phone while inactive, named for the facet", () => {
|
||||
const html = render();
|
||||
const tag = triggerTag(html, "document-procedure-filter");
|
||||
expect(tag).toContain('aria-label="All procedures"');
|
||||
expect(tag).not.toContain("border-primary/40");
|
||||
// Same trigger chrome as the condition facet: the label hides below `sm`.
|
||||
const conditionTag = triggerTag(html, "document-condition-filter");
|
||||
expect(tag.match(/class="([^"]*)"/)?.[1]).toBe(
|
||||
conditionTag.match(/class="([^"]*)"/)?.[1],
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<span class="max-w-28 min-w-0 truncate hidden sm:inline">All procedures</span>',
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the picked procedure's heading, tinted, and visible on a phone", () => {
|
||||
const html = render({ activeEncounterId: "enc-knee", activeCount: 1 });
|
||||
const tag = triggerTag(html, "document-procedure-filter");
|
||||
expect(tag).toContain('aria-label="Knee arthroscopy"');
|
||||
expect(tag).toContain("border-primary/40 bg-primary/10 text-foreground");
|
||||
expect(html).toContain(
|
||||
'<span class="max-w-28 min-w-0 truncate inline">Knee arthroscopy</span>',
|
||||
);
|
||||
// The one clear control appears for it like for any other facet.
|
||||
expect(html).toContain('aria-label="Clear filters"');
|
||||
});
|
||||
|
||||
it("leaves the trigger at rest for a filtered visit that is not a choice", () => {
|
||||
// A routine visit reached from its own row filters the list but is not a
|
||||
// procedure: the facet must not claim it.
|
||||
const html = render({ activeEncounterId: "enc-routine", activeCount: 1 });
|
||||
const tag = triggerTag(html, "document-procedure-filter");
|
||||
expect(tag).toContain('aria-label="All procedures"');
|
||||
expect(tag).not.toContain("border-primary/40");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
buildProcedureChoices,
|
||||
buildTimelineItems,
|
||||
buildVaultListApiSearch,
|
||||
classifyUploadFailure,
|
||||
@@ -457,3 +458,63 @@ describe("documentCardKeyAction", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProcedureChoices", () => {
|
||||
const labels = {
|
||||
kindName: "Procedure or surgery",
|
||||
siteText: (site: string | null, side: string | null) =>
|
||||
site ? (side ? `${site} (${side.toLowerCase()})` : site) : null,
|
||||
formatDate: (iso: string) => iso.slice(0, 10),
|
||||
};
|
||||
const knee = {
|
||||
encounterId: "enc-knee",
|
||||
occurredAt: "2025-10-02T08:00:00.000Z",
|
||||
reason: "Knee arthroscopy",
|
||||
bodySite: "Knee",
|
||||
laterality: "LEFT" as const,
|
||||
practitionerName: "Day clinic",
|
||||
};
|
||||
|
||||
it("heads each choice like the visits page: reason, else practice, else kind", () => {
|
||||
const choices = buildProcedureChoices(
|
||||
[
|
||||
knee,
|
||||
{ ...knee, encounterId: "enc-2", reason: null, bodySite: null },
|
||||
{
|
||||
...knee,
|
||||
encounterId: "enc-3",
|
||||
reason: " ",
|
||||
practitionerName: null,
|
||||
laterality: null,
|
||||
},
|
||||
],
|
||||
null,
|
||||
labels,
|
||||
);
|
||||
expect(choices).toEqual([
|
||||
{
|
||||
encounterId: "enc-knee",
|
||||
name: "Knee arthroscopy",
|
||||
detail: "Knee (left) · 2025-10-02",
|
||||
},
|
||||
{ encounterId: "enc-2", name: "Day clinic", detail: "2025-10-02" },
|
||||
{
|
||||
encounterId: "enc-3",
|
||||
name: "Procedure or surgery",
|
||||
detail: "Knee · 2025-10-02",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an active procedure the usage list no longer holds, once", () => {
|
||||
const gone = { ...knee, encounterId: "enc-gone", reason: "Appendectomy" };
|
||||
const choices = buildProcedureChoices([knee], gone, labels);
|
||||
expect(choices.map((c) => c.encounterId)).toEqual(["enc-knee", "enc-gone"]);
|
||||
// Already listed: not added a second time.
|
||||
expect(buildProcedureChoices([knee], knee, labels)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("offers nothing when no procedure carries a document", () => {
|
||||
expect(buildProcedureChoices([], null, labels)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The vault's sticky filter rail: debounced title/filename search plus three
|
||||
* The vault's sticky filter rail: debounced title/filename search plus four
|
||||
* compact dropdown facets — type (multi-select, OR inside the facet),
|
||||
* condition (episodes that actually carry links, single-select), and year
|
||||
* (years present in the corpus, single-select) — and a one-tap clear with
|
||||
* the active-facet count.
|
||||
* condition (episodes that actually carry links, single-select), procedure
|
||||
* (procedures and surgeries that actually carry links, single-select), and
|
||||
* year (years present in the corpus, single-select) — and a one-tap clear
|
||||
* with the active-facet count.
|
||||
*
|
||||
* The controls sit on ONE row at every width: the search flexes and can
|
||||
* shrink to nothing while each dropdown trigger stays a fixed compact
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
ChevronDown,
|
||||
ListFilter,
|
||||
ScanSearch,
|
||||
Scissors,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -43,14 +45,21 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
|
||||
import type { ProcedureFilterChoice } from "./vault-utils";
|
||||
|
||||
/** Active-facet tint on a dropdown trigger — mirrors the app pill vocabulary. */
|
||||
const TRIGGER_ACTIVE = "border-primary/40 bg-primary/10 text-foreground";
|
||||
/**
|
||||
* Active-facet tint on a dropdown trigger — mirrors the app pill vocabulary.
|
||||
* An active trigger may also shrink (its label truncates further), so on a
|
||||
* phone with several facets set the row gives up label width before it gives
|
||||
* up the search field.
|
||||
*/
|
||||
const TRIGGER_ACTIVE =
|
||||
"border-primary/40 bg-primary/10 text-foreground min-w-0 shrink";
|
||||
/** Shared trigger chrome: compact, fixed, never shrinks below its label. */
|
||||
const TRIGGER_CLASSES = "shrink-0 gap-1.5 font-normal";
|
||||
|
||||
/**
|
||||
* The facet label is the whole point of the trigger — but three text labels
|
||||
* The facet label is the whole point of the trigger — but four text labels
|
||||
* plus the search will not fit a 360px phone. So the label shows from `sm`
|
||||
* up, and on a phone only when the facet is ACTIVE (truncated), while the
|
||||
* leading icon always carries the facet identity. The trailing chevron is
|
||||
@@ -59,7 +68,10 @@ const TRIGGER_CLASSES = "shrink-0 gap-1.5 font-normal";
|
||||
* pinned via `aria-label` so an icon-only trigger still announces its state.
|
||||
*/
|
||||
function facetLabelClass(active: boolean): string {
|
||||
return cn("max-w-28 truncate", active ? "inline" : "hidden sm:inline");
|
||||
return cn(
|
||||
"max-w-28 min-w-0 truncate",
|
||||
active ? "inline" : "hidden sm:inline",
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConditionChip {
|
||||
@@ -76,6 +88,9 @@ export function DocumentFilterBar({
|
||||
conditionChips,
|
||||
activeEpisodeId,
|
||||
onToggleEpisode,
|
||||
procedureChips,
|
||||
activeEncounterId,
|
||||
onToggleEncounter,
|
||||
years,
|
||||
activeYear,
|
||||
onToggleYear,
|
||||
@@ -94,6 +109,9 @@ export function DocumentFilterBar({
|
||||
conditionChips: ConditionChip[];
|
||||
activeEpisodeId: string | undefined;
|
||||
onToggleEpisode: (episodeId: string) => void;
|
||||
procedureChips: ProcedureFilterChoice[];
|
||||
activeEncounterId: string | undefined;
|
||||
onToggleEncounter: (encounterId: string) => void;
|
||||
years: number[];
|
||||
activeYear: number | undefined;
|
||||
onToggleYear: (year: number) => void;
|
||||
@@ -120,6 +138,12 @@ export function DocumentFilterBar({
|
||||
const conditionLabel =
|
||||
activeCondition?.name ?? t("documents.filter.conditionAll");
|
||||
|
||||
const activeProcedure = procedureChips.find(
|
||||
(chip) => chip.encounterId === activeEncounterId,
|
||||
);
|
||||
const procedureLabel =
|
||||
activeProcedure?.name ?? t("documents.filter.procedureAll");
|
||||
|
||||
const yearLabel =
|
||||
activeYear !== undefined
|
||||
? String(activeYear)
|
||||
@@ -132,10 +156,12 @@ export function DocumentFilterBar({
|
||||
>
|
||||
{/* One row at every width: the search flexes (min-w-0 → it can shrink to
|
||||
nothing), every dropdown trigger stays a fixed compact control, and
|
||||
the clear pins to the trailing edge. `flex-nowrap` guarantees the bar
|
||||
the clear pins to the trailing edge. The search keeps at least its
|
||||
icon plus its padding (the `/` hint and its padding are desktop
|
||||
only), so it never bleeds under the next control. `flex-nowrap` guarantees the bar
|
||||
never breaks to a second line. */}
|
||||
<div className="flex flex-nowrap items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<div className="relative min-w-12 flex-1">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
@@ -147,7 +173,7 @@ export function DocumentFilterBar({
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("documents.filter.searchPlaceholder")}
|
||||
aria-label={t("documents.filter.searchLabel")}
|
||||
className="pr-8 pl-9"
|
||||
className="min-w-0 pr-3 pl-9 sm:pr-8"
|
||||
aria-keyshortcuts="/"
|
||||
/>
|
||||
<kbd
|
||||
@@ -252,6 +278,58 @@ export function DocumentFilterBar({
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{/* Procedure — single-select; only rendered when procedures carry
|
||||
links. Same cadence as the condition facet beside it. */}
|
||||
{procedureChips.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="document-procedure-filter"
|
||||
aria-label={procedureLabel}
|
||||
className={cn(
|
||||
TRIGGER_CLASSES,
|
||||
activeProcedure && TRIGGER_ACTIVE,
|
||||
)}
|
||||
>
|
||||
<Scissors className="size-4 shrink-0" aria-hidden />
|
||||
<span className={facetLabelClass(Boolean(activeProcedure))}>
|
||||
{procedureLabel}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="hidden size-3.5 opacity-60 sm:inline"
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
aria-label={t("documents.filter.procedureGroup")}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{t("documents.filter.procedureGroup")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{procedureChips.map((chip) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={chip.encounterId}
|
||||
checked={chip.encounterId === activeEncounterId}
|
||||
onCheckedChange={() => onToggleEncounter(chip.encounterId)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="max-w-56 truncate">{chip.name}</span>
|
||||
<span className="text-muted-foreground max-w-56 truncate text-xs">
|
||||
{chip.detail}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{/* Year — single-select; only rendered when the corpus spans years. */}
|
||||
{years.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/**
|
||||
* The Dokumente vault — one wide, calm, filing-cabinet-fast surface.
|
||||
*
|
||||
* Filter state lives in the URL (`?q&kind&episode&year`) so every view is
|
||||
* Filter state lives in the URL (`?q&kind&episode&encounter&year`) so every view is
|
||||
* shareable, back-button-safe, and deep-linkable from the illness page and
|
||||
* labs. Search debounces 200 ms and `/` focuses it. The timeline below is
|
||||
* virtualized; uploads appear optimistically above it in < 100 ms.
|
||||
@@ -44,7 +44,10 @@ import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import {
|
||||
bodySiteText,
|
||||
encounterKindText,
|
||||
} from "@/components/encounters/encounter-labels";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
@@ -73,12 +76,14 @@ import { useReindexAll } from "./use-content-index";
|
||||
import { useDocumentUpload } from "./use-document-upload";
|
||||
import { usePageFileDrop } from "./use-page-file-drop";
|
||||
import {
|
||||
buildProcedureChoices,
|
||||
buildVaultListApiSearch,
|
||||
countActiveFilters,
|
||||
documentDateKey,
|
||||
expandRangeSelection,
|
||||
hasProcessingDocument,
|
||||
parseVaultSearchParams,
|
||||
type ProcedureFilterChoice,
|
||||
resolveBulkShareDocuments,
|
||||
SHARE_LINK_MAX_DOCUMENTS,
|
||||
vaultFiltersToSearch,
|
||||
@@ -345,6 +350,17 @@ export function DocumentsView() {
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEncounter = (encounterId: string) => {
|
||||
applyFilters(
|
||||
{
|
||||
...filters,
|
||||
encounterId:
|
||||
filters.encounterId === encounterId ? undefined : encounterId,
|
||||
},
|
||||
"push",
|
||||
);
|
||||
};
|
||||
|
||||
const toggleYear = (year: number) => {
|
||||
applyFilters(
|
||||
{ ...filters, year: filters.year === year ? undefined : year },
|
||||
@@ -418,15 +434,19 @@ export function DocumentsView() {
|
||||
enqueueRef.current = upload.enqueue;
|
||||
}, [upload.enqueue]);
|
||||
const episodeIdFilter = filters.episodeId;
|
||||
const encounterIdFilter = filters.encounterId;
|
||||
const enqueueFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
enqueueRef.current(files, { episodeId: episodeIdFilter });
|
||||
enqueueRef.current(files, {
|
||||
episodeId: episodeIdFilter,
|
||||
encounterId: encounterIdFilter,
|
||||
});
|
||||
setUploadAnnouncement(
|
||||
t("documents.upload.queuedAnnouncement", { count: files.length }),
|
||||
);
|
||||
},
|
||||
[episodeIdFilter, t],
|
||||
[episodeIdFilter, encounterIdFilter, t],
|
||||
);
|
||||
const { dropActive } = usePageFileDrop(
|
||||
canManageDocuments ? enqueueFiles : undefined,
|
||||
@@ -504,6 +524,42 @@ export function DocumentsView() {
|
||||
[visits.data, t, format],
|
||||
);
|
||||
|
||||
// Procedure choices: every procedure carrying at least one live document
|
||||
// link, served by the usage endpoint for the same reason as the condition
|
||||
// chips, plus the actively filtered visit when it is a procedure (see
|
||||
// `buildProcedureChoices`).
|
||||
const procedureChips = useMemo<ProcedureFilterChoice[]>(() => {
|
||||
const activeVisit = filters.encounterId
|
||||
? [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])].find(
|
||||
(v) => v.id === filters.encounterId && v.kind === "PROCEDURE",
|
||||
)
|
||||
: undefined;
|
||||
return buildProcedureChoices(
|
||||
usage.data?.linkedProcedures ?? [],
|
||||
activeVisit
|
||||
? {
|
||||
encounterId: activeVisit.id,
|
||||
occurredAt: activeVisit.occurredAt,
|
||||
reason: activeVisit.reason,
|
||||
bodySite: activeVisit.bodySite,
|
||||
laterality: activeVisit.laterality,
|
||||
practitionerName: activeVisit.practitioner?.name ?? null,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
kindName: encounterKindText(t, "PROCEDURE"),
|
||||
siteText: (site, laterality) => bodySiteText(t, site, laterality),
|
||||
formatDate: (iso) => format.date(iso),
|
||||
},
|
||||
);
|
||||
}, [
|
||||
usage.data?.linkedProcedures,
|
||||
filters.encounterId,
|
||||
visits.data,
|
||||
t,
|
||||
format,
|
||||
]);
|
||||
|
||||
// Year segmenter: years present in the loaded corpus (+ the active year).
|
||||
const years = useMemo(() => {
|
||||
const set = new Set<number>();
|
||||
@@ -921,6 +977,9 @@ export function DocumentsView() {
|
||||
conditionChips={conditionChips}
|
||||
activeEpisodeId={filters.episodeId}
|
||||
onToggleEpisode={toggleEpisode}
|
||||
procedureChips={procedureChips}
|
||||
activeEncounterId={filters.encounterId}
|
||||
onToggleEncounter={toggleEncounter}
|
||||
years={years}
|
||||
activeYear={filters.year}
|
||||
onToggleYear={toggleYear}
|
||||
|
||||
@@ -59,6 +59,11 @@ export interface UploadQueueItem {
|
||||
export interface EnqueueOptions {
|
||||
/** Pre-link every uploaded document to this episode (deep-link uploads). */
|
||||
episodeId?: string;
|
||||
/**
|
||||
* Pre-link every uploaded document to this visit — the active procedure
|
||||
* filter, so a file dropped into a filtered view stays in it.
|
||||
*/
|
||||
encounterId?: string;
|
||||
}
|
||||
|
||||
interface UploadLimits {
|
||||
@@ -139,6 +144,7 @@ export function uploadViaXhr(
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (options.episodeId) fd.append("episodeIds", options.episodeId);
|
||||
if (options.encounterId) fd.append("encounterIds", options.encounterId);
|
||||
xhr.send(fd);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import {
|
||||
DOCUMENT_LIST_DEFAULT_LIMIT,
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
type DocumentLinkedProcedureDto,
|
||||
type InboundDocumentDto,
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
@@ -455,3 +456,53 @@ export function documentCardKeyAction(key: string): DocumentCardKeyAction {
|
||||
if (key === "Delete") return "delete";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One procedure choice in the filter bar, labelled for the reader. */
|
||||
export interface ProcedureFilterChoice {
|
||||
encounterId: string;
|
||||
/** The heading the visits page shows for it. */
|
||||
name: string;
|
||||
/** Body site and date, the line that tells two same-named ones apart. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The procedure filter's choices: the usage read's linked procedures, plus the
|
||||
* actively filtered visit when it is a procedure that list does not hold (its
|
||||
* last link was just removed, or a deep link names it). A filtered visit that
|
||||
* is not a procedure adds nothing — it is not a procedure choice.
|
||||
*
|
||||
* Each is labelled the way the visits page heads the row: the reason, else the
|
||||
* practice, else the kind's name. The body site (with its side) and the date
|
||||
* ride the second line. Order is the server's, newest first, with the
|
||||
* appended active visit last.
|
||||
*/
|
||||
export function buildProcedureChoices(
|
||||
linked: readonly DocumentLinkedProcedureDto[],
|
||||
active: DocumentLinkedProcedureDto | null,
|
||||
labels: {
|
||||
kindName: string;
|
||||
siteText: (
|
||||
bodySite: string | null,
|
||||
laterality: DocumentLinkedProcedureDto["laterality"],
|
||||
) => string | null;
|
||||
formatDate: (iso: string) => string;
|
||||
},
|
||||
): ProcedureFilterChoice[] {
|
||||
const sources = [...linked];
|
||||
if (active && !sources.some((p) => p.encounterId === active.encounterId)) {
|
||||
sources.push(active);
|
||||
}
|
||||
return sources.map((procedure) => {
|
||||
const site = labels.siteText(procedure.bodySite, procedure.laterality);
|
||||
const date = labels.formatDate(procedure.occurredAt);
|
||||
return {
|
||||
encounterId: procedure.encounterId,
|
||||
name:
|
||||
procedure.reason?.trim() ||
|
||||
procedure.practitionerName?.trim() ||
|
||||
labels.kindName,
|
||||
detail: site ? `${site} · ${date}` : date,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user