mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(documents): share document picker, clinician document list, mobile QR
This commit is contained in:
1 parent
9ff39b5846
commit
fdc0f83a47
12 files changed
+649
-17
No files matched your search
@@ -103,7 +103,7 @@ export default async function ClinicianSharePage({
|
||||
const context = await resolveShareToken(token);
|
||||
if (!context) notFound();
|
||||
|
||||
const [{ report, sections }, locale] = await Promise.all([
|
||||
const [{ report, sections, documents }, locale] = await Promise.all([
|
||||
loadShareViewData(context),
|
||||
resolveLocale(),
|
||||
]);
|
||||
@@ -116,6 +116,9 @@ export default async function ClinicianSharePage({
|
||||
expiresAt={context.expiresAt.toISOString()}
|
||||
report={report}
|
||||
sections={sections}
|
||||
documents={documents}
|
||||
token={token}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,14 @@
|
||||
* a FENCED, muted wellness card carrying the load-bearing "descriptive, not a
|
||||
* clinical assessment / not a diagnosis" disclaimer.
|
||||
*/
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
|
||||
import { formatBytes } from "@/components/documents/vault-utils";
|
||||
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
|
||||
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
type Translate = (
|
||||
@@ -27,6 +33,16 @@ interface ClinicianViewProps {
|
||||
expiresAt: string;
|
||||
report: DoctorReportData;
|
||||
sections: DoctorReportPrefs;
|
||||
/**
|
||||
* The frozen document set on this link (metadata only — never bytes). Each
|
||||
* entry points at the token-scoped serve route (`/c/<token>/d/<id>`), the
|
||||
* one decrypt path. Empty when the owner shared no documents.
|
||||
*/
|
||||
documents?: ShareViewDocument[];
|
||||
/** The raw share token from the path — used to build serve-route URLs. */
|
||||
token?: string;
|
||||
/** Viewer locale (byte formatting only). Defaults to English. */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
|
||||
@@ -74,12 +90,102 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One shared document. Class A (inline) renders a preview straight at the
|
||||
* token-scoped serve route — an `<img>` for raster images, a framed PDF
|
||||
* otherwise; the proxy's share-serve CSP (`default-src 'none';
|
||||
* frame-ancestors 'self'`) fences the framed context. Class B (attachment)
|
||||
* offers a plain download link — the serve route answers it as an opaque
|
||||
* `application/octet-stream` attachment. No bytes reach this component; only
|
||||
* the metadata and the URL.
|
||||
*/
|
||||
function DocumentEntry({
|
||||
t,
|
||||
doc,
|
||||
token,
|
||||
locale,
|
||||
}: {
|
||||
t: Translate;
|
||||
doc: ShareViewDocument;
|
||||
token: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const serveUrl = `/c/${encodeURIComponent(token)}/d/${encodeURIComponent(doc.id)}`;
|
||||
const title = doc.title ?? t("clinicianView.documents.untitled");
|
||||
const Icon =
|
||||
DOCUMENT_KIND_ICONS[doc.kind as InboundDocumentKindValue] ?? undefined;
|
||||
const meta = [
|
||||
doc.documentDate,
|
||||
formatBytes(doc.byteSize, locale),
|
||||
].filter(Boolean) as string[];
|
||||
const isImage = doc.mimeType.startsWith("image/");
|
||||
const isInline = doc.servingClass === "inline";
|
||||
|
||||
return (
|
||||
<li className="border-border bg-card rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
{Icon ? (
|
||||
<Icon className="text-foreground mt-0.5 size-5 shrink-0" />
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium break-words">{title}</p>
|
||||
{meta.length > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{meta.join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Download is always available (inline or attachment) so the recipient
|
||||
can keep a copy; the anchor hits the same token-scoped serve route. */}
|
||||
<a
|
||||
href={serveUrl}
|
||||
download
|
||||
className="border-border hover:bg-muted focus-visible:ring-ring/50 inline-flex min-h-9 shrink-0 items-center gap-1.5 rounded-md border px-3 text-xs font-medium focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<Download className="size-3.5" aria-hidden />
|
||||
{t("clinicianView.documents.download")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{isInline ? (
|
||||
<div className="mt-3">
|
||||
{isImage ? (
|
||||
// The served image is the frozen original with EXIF/GPS stripped on
|
||||
// egress; it renders inline within the Class A carve-out.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={title}
|
||||
className="border-border mx-auto max-h-[70vh] w-auto max-w-full rounded-md border"
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
src={serveUrl}
|
||||
title={title}
|
||||
className="border-border h-[70vh] max-h-[80vh] w-full rounded-md border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("clinicianView.documents.attachmentHint")}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClinicianView({
|
||||
t,
|
||||
label,
|
||||
expiresAt,
|
||||
report,
|
||||
sections,
|
||||
documents = [],
|
||||
token = "",
|
||||
locale = "en",
|
||||
}: ClinicianViewProps) {
|
||||
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString();
|
||||
const fmtNum = (n: number) => Math.round(n * 100) / 100;
|
||||
@@ -230,6 +336,26 @@ export function ClinicianView({
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* ── Shared documents ────────────────────────────────────── */}
|
||||
{documents.length > 0 && token ? (
|
||||
<Section title={t("clinicianView.documents.title")}>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
{t("clinicianView.documents.exifNote")}
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{documents.map((doc) => (
|
||||
<DocumentEntry
|
||||
key={doc.id}
|
||||
t={t}
|
||||
doc={doc}
|
||||
token={token}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="border-border text-muted-foreground mt-8 border-t pt-4 text-center text-xs">
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Owner document picker for the clinician-share create flow. A
|
||||
* `ResponsiveSheet` (bottom sheet on phones) with a debounced search over the
|
||||
* user's own vault — the SAME recall path as the vault page — and one
|
||||
* multi-select toggle row per document. Selection is client-only state; the
|
||||
* chosen ids POST with the share link, which freezes them write-once. The set
|
||||
* is bounded at `max` (mirrors `SHARE_LINK_MAX_DOCUMENTS`): once the cap is
|
||||
* reached, unselected rows disable so the client never posts an oversized set.
|
||||
*
|
||||
* Reuses the vault list endpoint + the `inboundDocumentPicker` query key from
|
||||
* the central factory; reads unwrap `(await res.json()).data` via `apiGet`.
|
||||
* No write happens here — attaching is the create mutation's job.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
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 { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
|
||||
import { documentDateKey } from "@/components/documents/vault-utils";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
/** A picked document, carried by id + a display title (for the owner chips). */
|
||||
export interface PickedDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export function ShareDocumentPicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
selected,
|
||||
onSelectedChange,
|
||||
max,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selected: PickedDocument[];
|
||||
onSelectedChange: (next: PickedDocument[]) => void;
|
||||
max: number;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
|
||||
const [searchDraft, setSearchDraft] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// 200 ms debounce, matching the vault's search feel.
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onSearchChange = (value: string) => {
|
||||
setSearchDraft(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setQuery(value.trim()), 200);
|
||||
};
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentPicker(query),
|
||||
enabled: open,
|
||||
queryFn: () => {
|
||||
const sp = new URLSearchParams();
|
||||
if (query) sp.set("q", query);
|
||||
sp.set("sort", "documentDate");
|
||||
sp.set("order", "desc");
|
||||
sp.set("limit", "50");
|
||||
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedIds = new Set(selected.map((s) => s.id));
|
||||
const atCap = selected.length >= max;
|
||||
|
||||
function toggle(doc: InboundDocumentDto, title: string) {
|
||||
if (selectedIds.has(doc.id)) {
|
||||
onSelectedChange(selected.filter((s) => s.id !== doc.id));
|
||||
return;
|
||||
}
|
||||
if (atCap) return;
|
||||
onSelectedChange([...selected, { id: doc.id, title }]);
|
||||
}
|
||||
|
||||
const documents = list.data?.documents ?? [];
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("settings.sharing.pickerTitle")}
|
||||
description={t("settings.sharing.pickerDescription", { max })}
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground text-xs" aria-live="polite">
|
||||
{t("settings.sharing.pickerSelectedCount", {
|
||||
count: selected.length,
|
||||
max,
|
||||
})}
|
||||
</span>
|
||||
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||
{t("settings.sharing.pickerDone")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={searchDraft}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("settings.sharing.pickerSearch")}
|
||||
aria-label={t("settings.sharing.pickerSearch")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{atCap ? (
|
||||
<p
|
||||
className="text-warning bg-warning/10 rounded-md px-3 py-2 text-xs"
|
||||
role="status"
|
||||
>
|
||||
{t("settings.sharing.pickerMaxReached", { max })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{list.isPending && open ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : list.isError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("documents.list.loadError")}
|
||||
</p>
|
||||
) : documents.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("settings.sharing.pickerEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul
|
||||
className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain"
|
||||
data-testid="share-doc-picker-list"
|
||||
>
|
||||
{documents.map((doc) => {
|
||||
const picked = selectedIds.has(doc.id);
|
||||
const title =
|
||||
doc.title ?? doc.filename ?? t("documents.card.untitled");
|
||||
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
|
||||
const disabled = !picked && atCap;
|
||||
return (
|
||||
<li key={doc.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={picked}
|
||||
disabled={disabled}
|
||||
onClick={() => toggle(doc, title)}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
picked && "border-primary/40 bg-primary/5",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="text-foreground size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
|
||||
</span>
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"text-primary size-4 shrink-0",
|
||||
picked ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,16 @@
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, KeyRound, Loader2, Share2, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Copy,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
ScanLine,
|
||||
Share2,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -44,11 +53,16 @@ import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
ShareDocumentPicker,
|
||||
type PickedDocument,
|
||||
} from "@/components/settings/share-document-picker";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { SHARE_LINK_MAX_DOCUMENTS } from "@/lib/validations/clinician-share-link";
|
||||
|
||||
/** The FHIR resource types a share link may serve — mirrors C4's enum. */
|
||||
const RESOURCE_TYPES = [
|
||||
@@ -73,6 +87,8 @@ interface ShareLinkSummary {
|
||||
allowFhirApi: boolean;
|
||||
/** v1.18.7 — whether a passphrase second factor guards this link. */
|
||||
protected: boolean;
|
||||
/** v1.28 — size of the frozen document set (never the ids, never bytes). */
|
||||
documentCount: number;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
revokedAt: string | null;
|
||||
@@ -120,6 +136,8 @@ function ShareLinksCard() {
|
||||
"Patient",
|
||||
"Observation",
|
||||
]);
|
||||
const [selectedDocs, setSelectedDocs] = useState<PickedDocument[]>([]);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [created, setCreated] = useState<ShareLinkCreated | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<"link" | "passphrase" | null>(null);
|
||||
@@ -140,7 +158,9 @@ function ShareLinksCard() {
|
||||
// needs to ship eagerly.
|
||||
import("qrcode")
|
||||
.then(({ default: QRCode }) =>
|
||||
QRCode.toDataURL(qrUrl, { margin: 1, width: 220 }),
|
||||
// Render at a high pixel density (the display size is capped in CSS) so
|
||||
// the code stays crisp when scaled up for on-the-spot phone scanning.
|
||||
QRCode.toDataURL(qrUrl, { margin: 2, width: 512 }),
|
||||
)
|
||||
.then((url) => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
@@ -177,6 +197,13 @@ function ShareLinksCard() {
|
||||
rangeEnd: null,
|
||||
resourceTypes,
|
||||
allowFhirApi,
|
||||
// v1.28 — the hand-picked, frozen-at-create document set. Omit the key
|
||||
// entirely when nothing is attached (a documents-less share stays the
|
||||
// default). The server re-validates each id as the caller's own live
|
||||
// document before minting the link.
|
||||
...(selectedDocs.length > 0
|
||||
? { documentIds: selectedDocs.map((d) => d.id) }
|
||||
: {}),
|
||||
expiresAt: isoDaysFromNow(expiryDays),
|
||||
});
|
||||
},
|
||||
@@ -186,6 +213,9 @@ function ShareLinksCard() {
|
||||
setCreated(result);
|
||||
setCopied(null);
|
||||
setLabel("");
|
||||
// The document set is frozen onto the link now — reset the picker so the
|
||||
// next link starts clean.
|
||||
setSelectedDocs([]);
|
||||
setFormError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
|
||||
},
|
||||
@@ -333,6 +363,72 @@ function ShareLinksCard() {
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* ── Attach documents ─────────────────────────────────────── */}
|
||||
<div className="border-border space-y-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("settings.sharing.attachTitle")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.attachCount", {
|
||||
count: selectedDocs.length,
|
||||
max: SHARE_LINK_MAX_DOCUMENTS,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
data-testid="share-attach-open"
|
||||
>
|
||||
<FileText className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("settings.sharing.attachButton")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedDocs.length > 0 ? (
|
||||
<ul
|
||||
className="flex flex-wrap gap-1.5"
|
||||
data-testid="share-attached-chips"
|
||||
>
|
||||
{selectedDocs.map((doc) => (
|
||||
<li key={doc.id}>
|
||||
<span className="bg-muted inline-flex max-w-full items-center gap-1 rounded-full py-1 pr-1 pl-2.5 text-xs">
|
||||
<span className="max-w-[12rem] truncate">{doc.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedDocs((prev) =>
|
||||
prev.filter((d) => d.id !== doc.id),
|
||||
)
|
||||
}
|
||||
className="hover:bg-background/80 focus-visible:ring-ring/50 flex size-5 shrink-0 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
aria-label={t("settings.sharing.attachRemove", {
|
||||
title: doc.title,
|
||||
})}
|
||||
>
|
||||
<X className="size-3" aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.attachFrozen")}
|
||||
</p>
|
||||
{selectedDocs.length > 0 ? (
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.exifNote")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{formError}
|
||||
@@ -351,6 +447,14 @@ function ShareLinksCard() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<ShareDocumentPicker
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
selected={selectedDocs}
|
||||
onSelectedChange={setSelectedDocs}
|
||||
max={SHARE_LINK_MAX_DOCUMENTS}
|
||||
/>
|
||||
|
||||
{created && (
|
||||
<div
|
||||
className="bg-success/10 space-y-3 rounded-lg p-3 text-sm"
|
||||
@@ -359,26 +463,49 @@ function ShareLinksCard() {
|
||||
<p className="text-success font-medium">
|
||||
{t("settings.sharing.tokenCreated")}
|
||||
</p>
|
||||
{created.documentCount > 0 && (
|
||||
<p
|
||||
className="text-foreground text-xs font-medium"
|
||||
data-testid="share-created-doc-count"
|
||||
>
|
||||
{t("settings.sharing.documentCount", {
|
||||
count: created.documentCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.shownOnce")}
|
||||
</p>
|
||||
|
||||
{/* QR — carries the passphrase in the URL fragment, so scanning it
|
||||
opens the record. Shown exactly once with the passphrase text. */}
|
||||
opens the record (and its shared documents). Promoted as the
|
||||
primary mobile affordance: a clinician scans it on the spot to
|
||||
open the record on their own device. Shown exactly once. The
|
||||
white quiet-zone is the documented QR exemption (UI-STANDARDS §4)
|
||||
so the code stays scannable in the dark theme too. */}
|
||||
{qrDataUrl && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground text-[11px] font-medium">
|
||||
{t("settings.sharing.qrLabel")}
|
||||
</p>
|
||||
<div
|
||||
className="border-border bg-background flex flex-col items-center gap-2 rounded-lg border p-4 text-center"
|
||||
data-testid="share-qr-block"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ScanLine className="text-foreground size-4" aria-hidden />
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("settings.sharing.qrScanTitle")}
|
||||
</p>
|
||||
</div>
|
||||
{/* The QR is a transient secret render, not stored content. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt={t("settings.sharing.qrAlt")}
|
||||
width={220}
|
||||
height={220}
|
||||
className="bg-background rounded border p-2"
|
||||
width={512}
|
||||
height={512}
|
||||
className="h-auto w-full max-w-[15rem] rounded-md border bg-white p-2 sm:max-w-[13rem]"
|
||||
/>
|
||||
<p className="text-muted-foreground max-w-xs text-xs">
|
||||
{t("settings.sharing.qrScanHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -481,6 +608,19 @@ function ShareLinksCard() {
|
||||
{t("settings.sharing.protected")}
|
||||
</Badge>
|
||||
)}
|
||||
{link.documentCount > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 text-[10px]"
|
||||
data-testid="share-doc-count-badge"
|
||||
aria-label={t("settings.sharing.documentCount", {
|
||||
count: link.documentCount,
|
||||
})}
|
||||
>
|
||||
<FileText className="h-2.5 w-2.5" />
|
||||
{link.documentCount}
|
||||
</Badge>
|
||||
)}
|
||||
{link.allowFhirApi && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
FHIR
|
||||
|
||||
@@ -125,6 +125,7 @@ describe("loadShareViewData — KVNR default OFF", () => {
|
||||
kind: "LAB_REPORT",
|
||||
documentDate: "2026-01-15",
|
||||
byteSize: 12345,
|
||||
mimeType: "application/pdf",
|
||||
servingClass: "inline",
|
||||
},
|
||||
{
|
||||
@@ -133,6 +134,7 @@ describe("loadShareViewData — KVNR default OFF", () => {
|
||||
kind: "OTHER",
|
||||
documentDate: null,
|
||||
byteSize: 6789,
|
||||
mimeType: "application/msword",
|
||||
servingClass: "attachment",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface ShareViewDocument {
|
||||
/** Filing date (YYYY-MM-DD at UTC) or null. */
|
||||
documentDate: string | null;
|
||||
byteSize: number;
|
||||
/**
|
||||
* The stored content type (metadata, never bytes). The recipient view uses
|
||||
* it to pick the inline surface — an image tag for `image/*`, a framed PDF
|
||||
* for `application/pdf` — within the Class A carve-out. Class B types are
|
||||
* download-only regardless.
|
||||
*/
|
||||
mimeType: string;
|
||||
servingClass: DocumentServingClass;
|
||||
}
|
||||
|
||||
@@ -119,6 +126,7 @@ async function loadShareDocuments(
|
||||
? document.documentDate.toISOString().slice(0, 10)
|
||||
: null,
|
||||
byteSize: document.byteSize,
|
||||
mimeType: document.mimeType,
|
||||
servingClass: servingClassFor(document.mimeType),
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user