mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
+24
-1
@@ -5119,7 +5119,23 @@
|
||||
"passphraseHint": "Die Empfängerin oder der Empfänger benötigt Link und Passphrase. Der QR-Code enthält beides.",
|
||||
"copyLink": "Link kopieren",
|
||||
"copyPassphrase": "Passphrase kopieren",
|
||||
"protected": "Passphrase"
|
||||
"protected": "Passphrase",
|
||||
"attachTitle": "Dokumente anhängen",
|
||||
"attachButton": "Dokumente auswählen",
|
||||
"attachCount": "{count} von {max} ausgewählt",
|
||||
"attachFrozen": "Die Dokumentenauswahl ist nach dem Erstellen des Links fest — Dokumente lassen sich danach weder hinzufügen noch entfernen.",
|
||||
"attachRemove": "{title} entfernen",
|
||||
"exifNote": "Kamera-Metadaten (EXIF/GPS) werden beim Teilen aus JPEG-, PNG- und WebP-Bildern entfernt. PDF-, TIFF-, HEIC- und Office-Dateien werden unverändert übernommen.",
|
||||
"documentCount": "Angehängte Dokumente: {count}",
|
||||
"qrScanTitle": "Zum Öffnen auf einem anderen Gerät scannen",
|
||||
"qrScanHint": "Richte eine Handykamera auf den Code, um die geteilte Akte zu öffnen — die Passphrase ist bereits enthalten.",
|
||||
"pickerTitle": "Dokumente anhängen",
|
||||
"pickerDescription": "Durchsuche deinen Tresor und wähle Dokumente zum Teilen. Bis zu {max}.",
|
||||
"pickerSearch": "Dokumente suchen",
|
||||
"pickerEmpty": "Keine Dokumente gefunden.",
|
||||
"pickerDone": "Fertig",
|
||||
"pickerSelectedCount": "{count} von {max} ausgewählt",
|
||||
"pickerMaxReached": "Maximum von {max} Dokumenten erreicht."
|
||||
},
|
||||
"genderOther": "Divers",
|
||||
"cycleTracking": {
|
||||
@@ -6591,6 +6607,13 @@
|
||||
"title": "Diese geteilte Ansicht konnte nicht geladen werden",
|
||||
"description": "Beim Öffnen dieser geteilten Gesundheitsdaten ist ein Fehler aufgetreten. Der Link ist möglicherweise abgelaufen, oder es lag ein vorübergehendes Problem vor. Versuche es erneut oder bitte die Person, die den Link geteilt hat, um einen neuen.",
|
||||
"retry": "Erneut versuchen"
|
||||
},
|
||||
"documents": {
|
||||
"title": "Dokumente",
|
||||
"untitled": "Unbenanntes Dokument",
|
||||
"download": "Herunterladen",
|
||||
"attachmentHint": "Diese Datei wird auf dein Gerät heruntergeladen (keine Vorschau in der Seite).",
|
||||
"exifNote": "Kamera-Metadaten (EXIF/GPS) wurden aus geteilten JPEG-, PNG- und WebP-Bildern entfernt."
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
|
||||
+24
-1
@@ -5119,7 +5119,23 @@
|
||||
"passphraseHint": "The recipient needs both the link and this passphrase. The QR code carries both.",
|
||||
"copyLink": "Copy link",
|
||||
"copyPassphrase": "Copy passphrase",
|
||||
"protected": "Passphrase"
|
||||
"protected": "Passphrase",
|
||||
"attachTitle": "Attach documents",
|
||||
"attachButton": "Choose documents",
|
||||
"attachCount": "{count} of {max} selected",
|
||||
"attachFrozen": "The document set is fixed once you create the link — documents can't be added or removed afterwards.",
|
||||
"attachRemove": "Remove {title}",
|
||||
"exifNote": "Camera metadata (EXIF/GPS) is removed from JPEG, PNG and WebP images when shared. PDF, TIFF, HEIC and Office files pass through unchanged.",
|
||||
"documentCount": "Attached documents: {count}",
|
||||
"qrScanTitle": "Scan to open on another device",
|
||||
"qrScanHint": "Point a phone camera at the code to open the shared record — the passphrase is already included.",
|
||||
"pickerTitle": "Attach documents",
|
||||
"pickerDescription": "Search your vault and select documents to share. Up to {max}.",
|
||||
"pickerSearch": "Search documents",
|
||||
"pickerEmpty": "No documents found.",
|
||||
"pickerDone": "Done",
|
||||
"pickerSelectedCount": "{count} of {max} selected",
|
||||
"pickerMaxReached": "You've reached the maximum of {max} documents."
|
||||
},
|
||||
"genderOther": "Other",
|
||||
"cycleTracking": {
|
||||
@@ -6591,6 +6607,13 @@
|
||||
"title": "This shared view could not be loaded",
|
||||
"description": "Something went wrong while opening this shared health record. The link may have expired, or there was a temporary problem. Try again, or ask the person who shared it for a new link.",
|
||||
"retry": "Try again"
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documents",
|
||||
"untitled": "Untitled document",
|
||||
"download": "Download",
|
||||
"attachmentHint": "This file downloads to your device (no in-page preview).",
|
||||
"exifNote": "Camera metadata (EXIF/GPS) has been removed from shared JPEG, PNG and WebP images."
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
|
||||
+24
-1
@@ -5119,7 +5119,23 @@
|
||||
"passphraseHint": "El destinatario necesita el enlace y esta frase de acceso. El código QR contiene ambos.",
|
||||
"copyLink": "Copiar enlace",
|
||||
"copyPassphrase": "Copiar frase de acceso",
|
||||
"protected": "Frase de acceso"
|
||||
"protected": "Frase de acceso",
|
||||
"attachTitle": "Adjuntar documentos",
|
||||
"attachButton": "Elegir documentos",
|
||||
"attachCount": "{count} de {max} seleccionados",
|
||||
"attachFrozen": "El conjunto de documentos queda fijo al crear el enlace: después no se pueden añadir ni quitar documentos.",
|
||||
"attachRemove": "Quitar {title}",
|
||||
"exifNote": "Los metadatos de la cámara (EXIF/GPS) se eliminan de las imágenes JPEG, PNG y WebP al compartir. Los archivos PDF, TIFF, HEIC y Office se comparten sin cambios.",
|
||||
"documentCount": "Documentos adjuntos: {count}",
|
||||
"qrScanTitle": "Escanéalo para abrirlo en otro dispositivo",
|
||||
"qrScanHint": "Apunta la cámara del teléfono al código para abrir el registro compartido: la frase de acceso ya está incluida.",
|
||||
"pickerTitle": "Adjuntar documentos",
|
||||
"pickerDescription": "Busca en tu archivo y selecciona documentos para compartir. Hasta {max}.",
|
||||
"pickerSearch": "Buscar documentos",
|
||||
"pickerEmpty": "No se encontraron documentos.",
|
||||
"pickerDone": "Listo",
|
||||
"pickerSelectedCount": "{count} de {max} seleccionados",
|
||||
"pickerMaxReached": "Has alcanzado el máximo de {max} documentos."
|
||||
},
|
||||
"genderOther": "Otro",
|
||||
"cycleTracking": {
|
||||
@@ -6591,6 +6607,13 @@
|
||||
"title": "No se pudo cargar esta vista compartida",
|
||||
"description": "Se produjo un error al abrir este registro de salud compartido. Puede que el enlace haya caducado o que haya habido un problema temporal. Inténtalo de nuevo o pide un enlace nuevo a la persona que lo compartió.",
|
||||
"retry": "Intentar de nuevo"
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documentos",
|
||||
"untitled": "Documento sin título",
|
||||
"download": "Descargar",
|
||||
"attachmentHint": "Este archivo se descarga en tu dispositivo (sin vista previa en la página).",
|
||||
"exifNote": "Los metadatos de la cámara (EXIF/GPS) se han eliminado de las imágenes JPEG, PNG y WebP compartidas."
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
|
||||
+24
-1
@@ -5119,7 +5119,23 @@
|
||||
"passphraseHint": "Le destinataire a besoin du lien et de cette phrase secrète. Le code QR contient les deux.",
|
||||
"copyLink": "Copier le lien",
|
||||
"copyPassphrase": "Copier la phrase secrète",
|
||||
"protected": "Phrase secrète"
|
||||
"protected": "Phrase secrète",
|
||||
"attachTitle": "Joindre des documents",
|
||||
"attachButton": "Choisir des documents",
|
||||
"attachCount": "{count} sur {max} sélectionnés",
|
||||
"attachFrozen": "La sélection de documents est figée à la création du lien — impossible d'ajouter ou de retirer des documents ensuite.",
|
||||
"attachRemove": "Retirer {title}",
|
||||
"exifNote": "Les métadonnées de l'appareil photo (EXIF/GPS) sont supprimées des images JPEG, PNG et WebP lors du partage. Les fichiers PDF, TIFF, HEIC et Office sont transmis tels quels.",
|
||||
"documentCount": "Documents joints : {count}",
|
||||
"qrScanTitle": "Scannez pour ouvrir sur un autre appareil",
|
||||
"qrScanHint": "Pointez l'appareil photo d'un téléphone vers le code pour ouvrir le dossier partagé — la phrase secrète est déjà incluse.",
|
||||
"pickerTitle": "Joindre des documents",
|
||||
"pickerDescription": "Recherchez dans votre coffre et sélectionnez les documents à partager. Jusqu'à {max}.",
|
||||
"pickerSearch": "Rechercher des documents",
|
||||
"pickerEmpty": "Aucun document trouvé.",
|
||||
"pickerDone": "Terminé",
|
||||
"pickerSelectedCount": "{count} sur {max} sélectionnés",
|
||||
"pickerMaxReached": "Vous avez atteint le maximum de {max} documents."
|
||||
},
|
||||
"genderOther": "Autre",
|
||||
"cycleTracking": {
|
||||
@@ -6591,6 +6607,13 @@
|
||||
"title": "Impossible de charger cette vue partagée",
|
||||
"description": "Une erreur s'est produite lors de l'ouverture de ce dossier de santé partagé. Le lien a peut-être expiré, ou un problème temporaire est survenu. Réessayez ou demandez un nouveau lien à la personne qui l'a partagé.",
|
||||
"retry": "Réessayer"
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documents",
|
||||
"untitled": "Document sans titre",
|
||||
"download": "Télécharger",
|
||||
"attachmentHint": "Ce fichier se télécharge sur votre appareil (pas d'aperçu dans la page).",
|
||||
"exifNote": "Les métadonnées de l'appareil photo (EXIF/GPS) ont été supprimées des images JPEG, PNG et WebP partagées."
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
|
||||
+24
-1
@@ -5119,7 +5119,23 @@
|
||||
"passphraseHint": "Il destinatario ha bisogno del link e di questa passphrase. Il codice QR contiene entrambi.",
|
||||
"copyLink": "Copia link",
|
||||
"copyPassphrase": "Copia passphrase",
|
||||
"protected": "Passphrase"
|
||||
"protected": "Passphrase",
|
||||
"attachTitle": "Allega documenti",
|
||||
"attachButton": "Scegli documenti",
|
||||
"attachCount": "{count} di {max} selezionati",
|
||||
"attachFrozen": "La selezione dei documenti è fissata alla creazione del link: dopo non è possibile aggiungere o rimuovere documenti.",
|
||||
"attachRemove": "Rimuovi {title}",
|
||||
"exifNote": "I metadati della fotocamera (EXIF/GPS) vengono rimossi dalle immagini JPEG, PNG e WebP durante la condivisione. I file PDF, TIFF, HEIC e Office vengono condivisi invariati.",
|
||||
"documentCount": "Documenti allegati: {count}",
|
||||
"qrScanTitle": "Scansiona per aprire su un altro dispositivo",
|
||||
"qrScanHint": "Inquadra il codice con la fotocamera del telefono per aprire la cartella condivisa: la passphrase è già inclusa.",
|
||||
"pickerTitle": "Allega documenti",
|
||||
"pickerDescription": "Cerca nel tuo archivio e seleziona i documenti da condividere. Fino a {max}.",
|
||||
"pickerSearch": "Cerca documenti",
|
||||
"pickerEmpty": "Nessun documento trovato.",
|
||||
"pickerDone": "Fatto",
|
||||
"pickerSelectedCount": "{count} di {max} selezionati",
|
||||
"pickerMaxReached": "Hai raggiunto il massimo di {max} documenti."
|
||||
},
|
||||
"genderOther": "Altro",
|
||||
"cycleTracking": {
|
||||
@@ -6591,6 +6607,13 @@
|
||||
"title": "Impossibile caricare questa vista condivisa",
|
||||
"description": "Si è verificato un errore durante l'apertura di questo referto sanitario condiviso. Il link potrebbe essere scaduto oppure si è verificato un problema temporaneo. Riprova o chiedi un nuovo link alla persona che lo ha condiviso.",
|
||||
"retry": "Riprova"
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documenti",
|
||||
"untitled": "Documento senza titolo",
|
||||
"download": "Scarica",
|
||||
"attachmentHint": "Questo file viene scaricato sul tuo dispositivo (nessuna anteprima nella pagina).",
|
||||
"exifNote": "I metadati della fotocamera (EXIF/GPS) sono stati rimossi dalle immagini JPEG, PNG e WebP condivise."
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
|
||||
+24
-1
@@ -5119,7 +5119,23 @@
|
||||
"passphraseHint": "Odbiorca potrzebuje linku i tego hasła. Kod QR zawiera oba.",
|
||||
"copyLink": "Kopiuj link",
|
||||
"copyPassphrase": "Kopiuj hasło",
|
||||
"protected": "Hasło"
|
||||
"protected": "Hasło",
|
||||
"attachTitle": "Dołącz dokumenty",
|
||||
"attachButton": "Wybierz dokumenty",
|
||||
"attachCount": "Wybrano {count} z {max}",
|
||||
"attachFrozen": "Zestaw dokumentów jest ustalany przy tworzeniu linku — później nie można dodawać ani usuwać dokumentów.",
|
||||
"attachRemove": "Usuń {title}",
|
||||
"exifNote": "Metadane aparatu (EXIF/GPS) są usuwane z obrazów JPEG, PNG i WebP podczas udostępniania. Pliki PDF, TIFF, HEIC i Office są przekazywane bez zmian.",
|
||||
"documentCount": "Dołączone dokumenty: {count}",
|
||||
"qrScanTitle": "Zeskanuj, aby otworzyć na innym urządzeniu",
|
||||
"qrScanHint": "Skieruj aparat telefonu na kod, aby otworzyć udostępnioną dokumentację — hasło jest już dołączone.",
|
||||
"pickerTitle": "Dołącz dokumenty",
|
||||
"pickerDescription": "Przeszukaj swój sejf i wybierz dokumenty do udostępnienia. Maksymalnie {max}.",
|
||||
"pickerSearch": "Szukaj dokumentów",
|
||||
"pickerEmpty": "Nie znaleziono dokumentów.",
|
||||
"pickerDone": "Gotowe",
|
||||
"pickerSelectedCount": "Wybrano {count} z {max}",
|
||||
"pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów."
|
||||
},
|
||||
"genderOther": "Inna",
|
||||
"cycleTracking": {
|
||||
@@ -6591,6 +6607,13 @@
|
||||
"title": "Nie udało się załadować udostępnionego widoku",
|
||||
"description": "Podczas otwierania udostępnionej dokumentacji zdrowotnej wystąpił błąd. Link mógł wygasnąć lub wystąpił chwilowy problem. Spróbuj ponownie albo poproś osobę, która go udostępniła, o nowy link.",
|
||||
"retry": "Spróbuj ponownie"
|
||||
},
|
||||
"documents": {
|
||||
"title": "Dokumenty",
|
||||
"untitled": "Dokument bez tytułu",
|
||||
"download": "Pobierz",
|
||||
"attachmentHint": "Ten plik zostanie pobrany na Twoje urządzenie (bez podglądu na stronie).",
|
||||
"exifNote": "Metadane aparatu (EXIF/GPS) zostały usunięte z udostępnionych obrazów JPEG, PNG i WebP."
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
|
||||
@@ -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