Files
HealthLog/src/components/settings/report-selection/selection-panel.tsx
T
Marc-André Bombeck bc1633db40 fix(export): make the standard report a click, not a preselection
The scope picker arrived with the standard doctor's report already ticked,
so someone who opened the panel and pressed Generate sent material they
never chose. Nothing is selected now until a person selects it.

The template keeps its definition and loses its automatic application: it
sits on a button at the top of the picker, named, on both surfaces that
mount the picker, and it stays there so the standard set can be restored
after experimenting. One click still gets a complete doctor's report.

Generate and the share-link create refuse an empty scope, with the scope
line beside them saying what to do rather than reporting a failure after
the press; the line also describes the control, so the disabled state is
not silent for a screen reader.
2026-07-26 00:46:03 +02:00

394 lines
15 KiB
TypeScript

"use client";
/**
* Settings → Export → the health-record export panel.
*
* One surface driving `POST /api/export/health-record`: a format radio, the
* reporting window, the practice line, the scope picker, and the generate
* action that streams the artefact as a download.
*
* Three states, by design:
*
* 1. First run (no saved selection): NOTHING is selected. The picker opens
* expanded so the standard-report button and the empty fenced tier are
* both in view, and Generate is disabled until something is ticked. One
* click on that button still produces a complete doctor's report, so the
* fast path costs one interaction, not a scope nobody chose.
* 2. Repeat run: the picker is collapsed behind its disclosure, the saved
* selection is loaded, and the scope line under the button says what will
* be in the document. Two interactions after navigation, and the reading
* is the consent check.
* 3. Editing: a group row is one control for up to sixteen leaves; the leaf
* grid opens only when the scope actually changes, which is rare.
*
* The saved selection seeds per-user during render with a stored-id guard —
* `useAuth` resolves asynchronously, so a `useState` initialiser would read
* `undefined` and stick — and the per-user gate means a late re-resolve never
* clobbers edits the user is in the middle of.
*/
import Link from "next/link";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
ChevronDown,
Download,
FileText,
FolderOpen,
Loader2,
} from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
import {
parseSavedProfile,
SAVED_PROFILE_FALLBACK,
} from "@/lib/report-selection/profile-shape";
import { orderLeaves } from "@/lib/report-selection/selection";
import { ReportScopePicker } from "./report-scope-picker";
import { ScopeSummary } from "./scope-summary";
type ExportFormat = "pdf" | "fhir" | "package";
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
const PRESET_RANGES = [30, 90, 180, 365] as const;
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
const [format, setFormat] = useState<ExportFormat>(
SAVED_PROFILE_FALLBACK.format,
);
const [days, setDays] = useState<number>(SAVED_PROFILE_FALLBACK.rangeDays);
const [customRange, setCustomRange] = useState(false);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [practiceName, setPracticeName] = useState("");
const [includeCharts, setIncludeCharts] = useState<boolean>(
SAVED_PROFILE_FALLBACK.includeCharts,
);
// Empty until a human ticks something. There is no default selection to get
// wrong, because there is no default selection.
const [selected, setSelected] = useState<ReadonlySet<ReportLeafId>>(
() => new Set<ReportLeafId>(),
);
const [seededUserId, setSeededUserId] = useState<string | null>(null);
// A first run opens the picker: with nothing selected, the standard-report
// button and the empty groups are the two things the person needs to see.
const [pickerOpen, setPickerOpen] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const pickerPanelId = useId();
const scopeSummaryId = useId();
if (user && user.id !== seededUserId) {
setSeededUserId(user.id);
setPracticeName(user.lastReportPracticeName ?? "");
const saved = parseSavedProfile(user.reportSelection);
if (saved) {
setSelected(new Set(saved.leaves as ReportLeafId[]));
setFormat(saved.format);
setDays(saved.rangeDays);
setIncludeCharts(saved.includeCharts);
setPickerOpen(false);
}
}
const isPdfLike = format === "pdf" || format === "package";
const isFhirLike = format === "fhir" || format === "package";
const { getRadioProps: getFormatRadioProps } = useRovingRadioGroup({
count: EXPORT_FORMATS.length,
selectedIndex: EXPORT_FORMATS.indexOf(format),
onSelect: (index) => setFormat(EXPORT_FORMATS[index]!),
});
async function handleGenerate() {
setBusy(true);
setError(null);
try {
const range =
customRange && startDate && endDate
? {
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
}
: { days };
const res = await apiFetchRaw("/api/export/health-record", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
format,
// Carry the active in-app locale so the artefact matches the UI
// language instead of falling back to Accept-Language on the server.
locale,
range,
practiceName: practiceName.trim() || undefined,
includeCharts,
selection: { v: 2, leaves: orderLeaves(selected) },
}),
});
if (!res.ok) {
setError(
res.status === 429
? t("settings.healthRecord.errorRateLimit")
: res.status === 403
? t("settings.healthRecord.errorModuleDisabled")
: t("settings.healthRecord.errorGeneric"),
);
return;
}
const blob = await res.blob();
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `healthlog-health-record-${new Date()
.toISOString()
.slice(0, 10)}.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
// The route just persisted the practice name and the selection, so the
// cached `/me` payload is stale. Refresh it, or a remount would seed from
// the previous values.
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<SettingsCard
as="section"
aria-labelledby="health-record-export-title"
data-testid="health-record-export-panel"
>
<SettingsCardHeader
className="mb-4"
icon={FileText}
titleId="health-record-export-title"
title={t("settings.healthRecord.title")}
description={t("settings.healthRecord.description")}
/>
<div className="space-y-4">
{/* Format + range share a row on desktop; on mobile they stack. */}
<div className="grid gap-4 sm:grid-cols-2">
<fieldset className="space-y-1.5">
<legend className="mb-1 text-sm font-medium">
{t("settings.healthRecord.format")}
</legend>
<div className="flex flex-wrap gap-2" role="radiogroup">
{EXPORT_FORMATS.map((f, index) => (
<Button
key={f}
type="button"
role="radio"
aria-checked={format === f}
variant={format === f ? "default" : "outline"}
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setFormat(f)}
{...getFormatRadioProps(index)}
>
{t(`settings.healthRecord.format_${f}`)}
</Button>
))}
</div>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor="hr-range">{t("settings.healthRecord.range")}</Label>
<NativeSelect
id="hr-range"
value={customRange ? "custom" : String(days)}
onChange={(e) => {
if (e.target.value === "custom") {
setCustomRange(true);
return;
}
setCustomRange(false);
setDays(Number(e.target.value));
}}
>
{PRESET_RANGES.map((preset) => (
<option key={preset} value={String(preset)}>
{t(`settings.healthRecord.range${preset}`)}
</option>
))}
<option value="custom">
{t("settings.healthRecord.rangeCustom")}
</option>
</NativeSelect>
{/* Entry point into the document vault for the report period
(navigation only). Only rendered when the module is enabled. */}
{user?.modules?.inboundDocuments ? (
<Link
href={`/documents?year=${new Date().getFullYear()}`}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<FolderOpen className="size-3.5" aria-hidden />
{t("settings.healthRecord.documentsLink")}
</Link>
) : null}
</div>
</div>
{customRange ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="hr-range-start">
{t("settings.healthRecord.rangeCustomStart")}
</Label>
<DateField
id="hr-range-start"
value={startDate}
onChange={setStartDate}
max={endDate || undefined}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hr-range-end">
{t("settings.healthRecord.rangeCustomEnd")}
</Label>
<DateField
id="hr-range-end"
value={endDate}
onChange={setEndDate}
min={startDate || undefined}
/>
</div>
</div>
) : null}
{/* Practice name — PDF only */}
{isPdfLike && (
<div className="space-y-1.5">
<Label htmlFor="hr-practice">
{t("settings.healthRecord.practiceName")}
</Label>
<Input
id="hr-practice"
value={practiceName}
onChange={(e) => setPracticeName(e.target.value)}
maxLength={120}
placeholder={t("settings.healthRecord.practiceNamePlaceholder")}
/>
</div>
)}
<fieldset className="space-y-3">
<legend className="sr-only">
{t("settings.healthRecord.includedData")}
</legend>
<button
type="button"
data-testid="health-record-included-data-toggle"
aria-expanded={pickerOpen}
aria-controls={pickerPanelId}
onClick={() => setPickerOpen((v) => !v)}
className="text-foreground hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-3 rounded-lg px-1 py-1 text-left text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span>{t("settings.healthRecord.includedData")}</span>
<ChevronDown
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
pickerOpen && "rotate-180",
)}
aria-hidden="true"
/>
</button>
{pickerOpen && (
<div
id={pickerPanelId}
data-testid="health-record-included-data-panel"
className="animate-insight-in space-y-3"
style={{ animationDuration: "200ms" }}
>
<ReportScopePicker
surface="export"
selected={selected}
onChange={setSelected}
/>
{isPdfLike ? (
<label className="flex min-h-11 items-center justify-between gap-3 text-sm sm:min-h-9">
<span className="text-foreground">
{t("settings.healthRecord.includeCharts")}
</span>
<Switch
checked={includeCharts}
onCheckedChange={() => setIncludeCharts((v) => !v)}
/>
</label>
) : null}
</div>
)}
</fieldset>
{/* The FHIR note and the generate action share the footer row. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<ScopeSummary
t={t}
id={scopeSummaryId}
surface="export"
selected={selected}
/>
{isFhirLike ? (
<p className="text-muted-foreground max-w-md text-xs">
{t("settings.healthRecord.fhirNote")}
</p>
) : null}
</div>
{/* An empty scope cannot be generated: there is no document to make.
The scope line beside the button carries the reason and the way
out of it, and describes the button so the disabled state is not
silent for a screen reader. */}
<Button
type="button"
onClick={handleGenerate}
disabled={busy || selected.size === 0}
aria-describedby={scopeSummaryId}
data-testid="health-record-generate"
className="ml-auto min-h-11 shrink-0 sm:min-h-9"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Download className="h-4 w-4" />
)}
{t("settings.healthRecord.generate")}
</Button>
</div>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
</div>
</SettingsCard>
);
}