-
e.preventDefault()}
- />
- {loading && (
-
-
-
- )}
- {error && (
-
- )}
-
- )
-}
-
-// โโ Viewer Dialog โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-interface ViewerAppInfo {
- clientId: string
- name: string
- launchUrl: string
-}
-
-export function DicomViewerDialog({
- target,
- open,
- onOpenChange,
- viewerApp = null,
-}: {
- target: ViewerTarget | null
- open: boolean
- onOpenChange: (open: boolean) => void
- viewerApp?: ViewerAppInfo | null
-}) {
- const [imageInfo, setImageInfo] = useState<{ current: number; total: number } | null>(null)
- const { t } = useTranslation()
-
- const handleImageChange = useCallback((current: number, total: number) => {
- setImageInfo({ current, total })
- }, [])
-
- const handleOpenChange = useCallback((isOpen: boolean) => {
- if (!isOpen) setImageInfo(null)
- onOpenChange(isOpen)
- }, [onOpenChange])
-
- const modalityInfo = target?.modality ? getModalityInfo(target.modality) : null
-
- return (
-
-
-
- e.preventDefault()}
- aria-describedby={undefined}
- >
-
- {target?.seriesDescription || t("dicomViewer.title")}
-
-
- {/* Header */}
-
-
-
- {target?.seriesDescription || t("dicomViewer.title")}
-
- {modalityInfo && (
-
- {modalityInfo.emoji} {modalityInfo.label}
-
- )}
- {imageInfo && (
-
- {imageInfo.current} / {imageInfo.total}
-
- )}
-
-
-
- {/* Controls legend */}
-
- {t("dicomViewer.controlWL")}
- {t("dicomViewer.controlZoom")}
- {t("dicomViewer.controlScroll")}
-
- {target && viewerApp && (
-
- )}
-
-
- Close
-
-
-
-
- {/* Viewport */}
-
- {target && open && (
-
- )}
-
-
-
-
- )
-}
diff --git a/apps/patient-portal/src/components/DocumentImport.tsx b/apps/patient-portal/src/components/DocumentImport.tsx
deleted file mode 100644
index 98d26b80e..000000000
--- a/apps/patient-portal/src/components/DocumentImport.tsx
+++ /dev/null
@@ -1,446 +0,0 @@
-import { useState, useCallback, useRef } from "react"
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
- Button,
- Spinner,
-} from "@proxy-smart/shared-ui"
-import {
- Upload,
- FileText,
- CheckCircle2,
- XCircle,
- AlertTriangle,
- Check,
- X,
-} from "lucide-react"
-import { smartAuth } from "@/lib/smart-auth"
-import {
- importDocument,
- createResource,
- type FailedResource,
- type DocumentImportResponse,
-} from "@/lib/fhir-client"
-import { ResourceReviewCard } from "./ResourceReviewCard"
-import { useTranslation } from "react-i18next"
-
-// โโ State machine โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-type ImportStep = "upload" | "processing" | "review" | "saving" | "done"
-
-interface ResourceSelection {
- resource: import("@/lib/fhir-client").ImportedResource
- selected: boolean
- editedResource?: Record
-}
-
-// โโ Component โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-export function DocumentImport({ onClose, onSaved }: { onClose: () => void; onSaved?: () => void }) {
- const [step, setStep] = useState("upload")
- const [dragOver, setDragOver] = useState(false)
- const [error, setError] = useState(null)
- const [result, setResult] = useState(null)
- const [selections, setSelections] = useState([])
- const [saveProgress, setSaveProgress] = useState({ saved: 0, total: 0 })
- const [saveErrors, setSaveErrors] = useState([])
- const fileInputRef = useRef(null)
-
- const patientId = smartAuth.getToken()?.patient as string | undefined
- const { t } = useTranslation()
-
- const processFile = useCallback(async (file: File) => {
- if (!patientId) {
- setError(t("documentImport.noPatientContext"))
- return
- }
-
- if (file.type !== "application/pdf") {
- setError(t("documentImport.onlyPdf"))
- return
- }
-
- setError(null)
- setStep("processing")
-
- try {
- const res = await importDocument(file, patientId)
- setResult(res)
- setSelections(res.resources.map(r => ({ resource: r, selected: true })))
- setStep("review")
- } catch (err) {
- setError(err instanceof Error ? err.message : "Import failed")
- setStep("upload")
- }
- }, [patientId])
-
- const handleDrop = useCallback((e: React.DragEvent) => {
- e.preventDefault()
- setDragOver(false)
- const file = e.dataTransfer.files[0]
- if (file) processFile(file)
- }, [processFile])
-
- const handleFileSelect = useCallback((e: React.ChangeEvent) => {
- const file = e.target.files?.[0]
- if (file) processFile(file)
- }, [processFile])
-
- const toggleSelection = useCallback((index: number) => {
- setSelections(prev => prev.map((s, i) =>
- i === index ? { ...s, selected: !s.selected } : s
- ))
- }, [])
-
- const toggleAll = useCallback((selected: boolean) => {
- setSelections(prev => prev.map(s => ({ ...s, selected })))
- }, [])
-
- const handleResourceEdited = useCallback((index: number, updated: Record) => {
- setSelections(prev => prev.map((s, i) =>
- i === index ? { ...s, editedResource: updated } : s
- ))
- }, [])
-
- const handleConfirm = useCallback(async () => {
- const toSave = selections.filter(s => s.selected)
- const rejected = selections.filter(s => !s.selected)
- if (toSave.length === 0) return
-
- const totalSteps = toSave.length
- + (result?.documentReference ? 1 : 0)
- + (rejected.length > 0 ? 1 : 0) // audit step
- setStep("saving")
- setSaveProgress({ saved: 0, total: totalSteps })
- const errors: string[] = []
-
- // Save accepted resources (use edited version if available) and collect references
- const savedRefs: { reference: string }[] = []
- for (let i = 0; i < toSave.length; i++) {
- const resourceData = toSave[i].editedResource ?? toSave[i].resource.resource
- try {
- const saved = await createResource(resourceData as { resourceType: string })
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const id = (saved as any).id as string | undefined
- if (id) savedRefs.push({ reference: `${toSave[i].resource.resourceType}/${id}` })
- setSaveProgress(p => ({ ...p, saved: p.saved + 1 }))
- } catch (err) {
- errors.push(
- `${toSave[i].resource.resourceType}: ${err instanceof Error ? err.message : "Failed"}`
- )
- }
- }
-
- // Save DocumentReference with context.related linking to saved resources
- if (result?.documentReference) {
- try {
- const docRef = { ...result.documentReference } as Record
- if (savedRefs.length > 0) {
- docRef.context = { ...(docRef.context as object ?? {}), related: savedRefs }
- }
- await createResource(docRef as { resourceType: string })
- setSaveProgress(p => ({ ...p, saved: p.saved + 1 }))
- } catch (err) {
- errors.push(`DocumentReference: ${err instanceof Error ? err.message : "Failed"}`)
- }
- }
-
- // Audit trail for rejected resources
- if (rejected.length > 0 && patientId) {
- try {
- const auditEvent = {
- resourceType: "AuditEvent",
- type: {
- system: "http://terminology.hl7.org/CodeSystem/audit-event-type",
- code: "rest",
- display: "RESTful Operation",
- },
- subtype: [{
- system: "http://proxy-smart.dev/audit",
- code: "document-import-rejection",
- display: "Document Import Resource Rejection",
- }],
- action: "C",
- recorded: new Date().toISOString(),
- outcome: "0",
- outcomeDesc: `Patient declined ${rejected.length} extracted resource(s) during document import of ${result?.fileName ?? "unknown"}`,
- agent: [{
- who: { reference: `Patient/${patientId}` },
- requestor: true,
- }],
- source: {
- observer: { display: "Patient Portal - Document Import" },
- },
- entity: rejected.map(r => ({
- what: { display: `${r.resource.resourceType} (rejected)` },
- detail: [{
- type: "resource-json",
- valueString: JSON.stringify(r.resource.resource),
- }],
- })),
- }
- await createResource(auditEvent as { resourceType: string })
- setSaveProgress(p => ({ ...p, saved: p.saved + 1 }))
- } catch (err) {
- errors.push(`Audit trail: ${err instanceof Error ? err.message : "Failed"}`)
- }
- }
-
- setSaveErrors(errors)
- setStep("done")
- onSaved?.()
- }, [selections, result, patientId])
-
- const selectedCount = selections.filter(s => s.selected).length
-
- // โโ Upload step โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
- if (step === "upload") {
- return (
-
-
-
-
- {t("documentImport.title")}
-
-
-
- { e.preventDefault(); setDragOver(true) }}
- onDragLeave={() => setDragOver(false)}
- onDrop={handleDrop}
- onClick={() => fileInputRef.current?.click()}
- >
-
-
{t("documentImport.dropZone")}
-
- {t("documentImport.dropZoneHint")}
-
-
-
-
- {error && (
-
- )}
-
-
-
-
-
-
- )
- }
-
- // โโ Processing step โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
- if (step === "processing") {
- return (
-
-
-
-
- {t("documentImport.processing")}
-
-
-
-
-
- {t("documentImport.processingHint")}
-
-
- {t("documentImport.processingLarge")}
-
-
-
-
- )
- }
-
- // โโ Review step โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
- if (step === "review" && result) {
- return (
-
- {/* Summary banner */}
-
-
-
-
-
{result.fileName}
-
- {t("common.nPagesProcessed", { n: result.pagesProcessed })}
- {" ยท "}
- {t("common.nResourcesExtracted", { n: result.resources.length })}
- {result.failed.length > 0 && (
-
- {" ยท "}{result.failed.length} failed
-
- )}
- {" ยท "}{(result.processingTimeMs / 1000).toFixed(1)}s
-
-
-
-
-
-
-
-
-
- {/* Resource cards */}
-
- {selections.map((sel, i) => (
- toggleSelection(i)}
- onResourceEdited={(updated) => handleResourceEdited(i, updated)}
- />
- ))}
-
-
- {/* Failed resources */}
- {result.failed.length > 0 && (
-
-
-
-
- {t("common.failedToExtract", { n: result.failed.length })}
-
-
-
-
- {result.failed.map((f: FailedResource, i: number) => (
- -
- {f.resourceType}
-
- โ {f.errors[0] || "validation failed"}
-
-
- ))}
-
-
-
- )}
-
- {/* Actions */}
-
-
-
-
- {t("common.nSelected", { n: selectedCount, total: selections.length })}
-
-
-
-
-
- )
- }
-
- // โโ Saving step โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
- if (step === "saving") {
- return (
-
-
-
-
- {t("common.savingResources")}
-
-
-
-
-
-
- {t("common.nOfNSaved", { n: saveProgress.saved, total: saveProgress.total })}
-
-
-
-
- )
- }
-
- // โโ Done step โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
- if (step === "done") {
- const hasErrors = saveErrors.length > 0
-
- return (
-
-
-
- {hasErrors ? (
-
- ) : (
-
- )}
- {hasErrors ? t("common.completedWithErrors") : t("documentImport.importComplete")}
-
-
-
-
- {t("common.nResourcesSaved", { n: saveProgress.saved - saveErrors.length })}
-
-
- {hasErrors && (
-
- {saveErrors.map((err, i) => (
- -
-
- {err}
-
- ))}
-
- )}
-
-
-
-
-
-
- )
- }
-
- return null
-}
diff --git a/apps/patient-portal/src/components/DocumentsCard.tsx b/apps/patient-portal/src/components/DocumentsCard.tsx
deleted file mode 100644
index 2e839c6c5..000000000
--- a/apps/patient-portal/src/components/DocumentsCard.tsx
+++ /dev/null
@@ -1,192 +0,0 @@
-import { useState, useCallback } from "react"
-import {
- Card, CardContent, CardHeader, CardTitle, Badge,
- Dialog, DialogContent, DialogHeader, DialogTitle,
-} from "@proxy-smart/shared-ui"
-import { FileText, ExternalLink } from "lucide-react"
-import { format } from "date-fns"
-import { fetchBinaryUrl, type DocumentReference } from "@/lib/fhir-client"
-import { RecordName, type AnyResource } from "@/lib/ips-display-helpers"
-import { useTranslation } from "react-i18next"
-
-// โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-function getDocTitle(doc: DocumentReference): string {
- return (
- doc.content?.[0]?.attachment?.title ??
- doc.description ??
- doc.type?.coding?.[0]?.display ??
- "Untitled Document"
- )
-}
-
-function getDocDate(doc: DocumentReference): string | undefined {
- return doc.date ?? doc.meta?.lastUpdated
-}
-
-function getContentType(doc: DocumentReference): string | undefined {
- return doc.content?.[0]?.attachment?.contentType
-}
-
-const STATUS_VARIANT: Record = {
- current: "default",
- superseded: "secondary",
- "entered-in-error": "destructive",
-}
-
-// โโ Utilities for document viewing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-/** Determine document link type without resolving URLs yet */
-type DocLink =
- | { kind: "external"; url: string }
- | { kind: "fhir-binary"; relativeUrl: string }
- | { kind: "inline-text"; text: string }
- | { kind: "inline-binary"; dataUrl: string }
- | undefined
-
-function classifyDocLink(doc: DocumentReference): DocLink {
- const att = doc.content?.[0]?.attachment
- if (!att) return undefined
- if (att.url) {
- // Absolute URLs (http/https) โ external link
- if (/^https?:\/\//i.test(att.url)) return { kind: "external", url: att.url }
- // Relative FHIR reference (e.g. Binary/lab-report-2026-04) โ fetch through proxy
- return { kind: "fhir-binary", relativeUrl: att.url }
- }
- if (att.data && att.contentType) {
- // Inline text/plain โ show in modal
- if (att.contentType.startsWith("text/")) {
- return { kind: "inline-text", text: atob(att.data) }
- }
- return { kind: "inline-binary", dataUrl: `data:${att.contentType};base64,${att.data}` }
- }
- return undefined
-}
-
-// โโ Component โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-interface DocumentsCardProps {
- documents: DocumentReference[]
- onOpenDetail: (title: string, resource: AnyResource) => void
-}
-
-export function DocumentsCard({ documents, onOpenDetail }: DocumentsCardProps) {
- const { t } = useTranslation()
- const [textModal, setTextModal] = useState<{ title: string; text: string } | null>(null)
-
- /** Handle click on the view icon โ async-fetches FHIR Binaries, opens text in modal */
- const handleView = useCallback(async (doc: DocumentReference) => {
- const link = classifyDocLink(doc)
- if (!link) return
- const title = getDocTitle(doc)
- switch (link.kind) {
- case "external":
- window.open(link.url, "_blank", "noopener,noreferrer")
- break
- case "fhir-binary": {
- const blobUrl = await fetchBinaryUrl(link.relativeUrl)
- window.open(blobUrl, "_blank", "noopener,noreferrer")
- break
- }
- case "inline-text":
- setTextModal({ title, text: link.text })
- break
- case "inline-binary":
- window.open(link.dataUrl, "_blank", "noopener,noreferrer")
- break
- }
- }, [])
-
- return (
- <>
-
-
-
-
- {t("documents.title")}
-
-
-
- {documents.length === 0 ? (
- {t("documents.noDocuments")}
- ) : (
-
- )}
-
-
-
- {/* Text document modal */}
-
- >
- )
-}
-
-// โโ Cross-reference helper (used by RecordDetailModal) โโโโโโโโโโโโโโโโโโโโโโโ
-
-/** Find documents that reference a given resource via context.related */
-export function findLinkedDocuments(
- documents: DocumentReference[],
- resourceType: string | undefined,
- resourceId: string | undefined,
-): DocumentReference[] {
- if (!resourceType || !resourceId) return []
- const ref = `${resourceType}/${resourceId}`
- return documents.filter(doc =>
- doc.context?.related?.some(r => r.reference === ref),
- )
-}
diff --git a/apps/patient-portal/src/components/EncountersCard.tsx b/apps/patient-portal/src/components/EncountersCard.tsx
deleted file mode 100644
index 78d09a7c9..000000000
--- a/apps/patient-portal/src/components/EncountersCard.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { Card, CardContent, CardHeader, CardTitle, Badge } from "@proxy-smart/shared-ui"
-import { CalendarDays } from "lucide-react"
-import { format } from "date-fns"
-import type { Encounter } from "@/lib/fhir-client"
-import { RecordName, type AnyResource } from "@/lib/ips-display-helpers"
-import { useTranslation } from "react-i18next"
-
-// โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-function getEncounterTitle(enc: Encounter): string {
- return enc.type?.[0]?.text ?? enc.type?.[0]?.coding?.[0]?.display ?? "Visit"
-}
-
-function getEncounterClass(enc: Encounter): string | undefined {
- return enc.class?.display ?? enc.class?.code
-}
-
-function getParticipantName(enc: Encounter): string | undefined {
- return enc.participant?.[0]?.individual?.display
-}
-
-const STATUS_VARIANT: Record = {
- finished: "secondary",
- "in-progress": "default",
- planned: "outline",
- arrived: "outline",
- cancelled: "destructive",
- "entered-in-error": "destructive",
-}
-
-// โโ Component โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-interface EncountersCardProps {
- encounters: Encounter[]
- onOpenDetail: (title: string, resource: AnyResource) => void
-}
-
-export function EncountersCard({ encounters, onOpenDetail }: EncountersCardProps) {
- const { t } = useTranslation()
- return (
-
-
-
-
- {t("encounters.title", "Visit History")}
-
-
-
- {encounters.length === 0 ? (
- {t("encounters.noEncounters", "No visits on record")}
- ) : (
-
- {encounters.map((enc, i) => {
- const title = getEncounterTitle(enc)
- const encClass = getEncounterClass(enc)
- const participant = getParticipantName(enc)
- return (
- -
-
-
- {title}
-
- {enc.status && (
-
- {enc.status}
-
- )}
- {encClass && (
- ({encClass})
- )}
-
-
- {enc.period?.start && format(new Date(enc.period.start), "MMM d, yyyy")}
- {participant && ` \u00b7 ${participant}`}
-
-
- )
- })}
-
- )}
-
-
- )
-}
diff --git a/apps/patient-portal/src/components/GenomicsCard.tsx b/apps/patient-portal/src/components/GenomicsCard.tsx
deleted file mode 100644
index a39f8fc08..000000000
--- a/apps/patient-portal/src/components/GenomicsCard.tsx
+++ /dev/null
@@ -1,328 +0,0 @@
-import { useState } from "react"
-import { useTranslation } from "react-i18next"
-import { Card, CardContent, CardHeader, CardTitle, Badge } from "@proxy-smart/shared-ui"
-import { Dna, ChevronDown, ChevronUp, FileText, AlertTriangle, Pill } from "lucide-react"
-import { format } from "date-fns"
-import type {
- GenomicReport,
- Variant,
- DiagnosticImplication,
- TherapeuticImplication,
-} from "@/lib/fhir-client"
-import type { DiagnosticReportStatusUvIpsCode } from "hl7.fhir.uv.ips-generated/valuesets/ValueSet-DiagnosticReportStatusUvIps"
-import { RecordName, type AnyResource } from "@/lib/ips-display-helpers"
-
-// โโ LOINC component code helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-/** Extract the display text for a specific LOINC component from an Observation */
-function getComponent(obs: { component?: Array<{ code?: { coding?: Array<{ code?: string; system?: string }>; text?: string }; valueCodeableConcept?: { coding?: Array<{ display?: string }>; text?: string }; valueString?: string; valueQuantity?: { value?: number; unit?: string } }> }, loincCode: string) {
- return obs.component?.find((c) =>
- c.code?.coding?.some((cd) => cd.system === "http://loinc.org" && cd.code === loincCode),
- )
-}
-
-function componentDisplay(comp: ReturnType): string | undefined {
- if (!comp) return undefined
- if (comp.valueCodeableConcept?.coding?.[0]?.display) return comp.valueCodeableConcept.coding[0].display
- if (comp.valueCodeableConcept?.text) return comp.valueCodeableConcept.text
- if (comp.valueString) return comp.valueString
- if (comp.valueQuantity) return `${comp.valueQuantity.value ?? ""} ${comp.valueQuantity.unit ?? ""}`.trim()
- return undefined
-}
-
-// โโ Well-known LOINC codes for genomics components โโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-const LOINC = {
- GENE_STUDIED: "48018-6",
- HGVS_CODING: "48004-6",
- HGVS_PROTEIN: "48005-3",
- CLINICAL_SIGNIFICANCE: "53037-8",
- ASSOCIATED_DISEASE: "81259-4",
- DRUG_ASSESSED: "51963-7",
- ALLELIC_STATE: "53034-5",
- GENOMIC_REF_SEQ: "48013-7",
- TRANSCRIPT_REF_SEQ: "51958-7",
- AMINO_ACID_CHANGE: "48005-3",
- DNA_CHANGE_TYPE: "48019-4",
-} as const
-
-// โโ Clinical significance color mapping โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-function significanceBadge(text: string) {
- const lower = text.toLowerCase()
- if (lower.includes("pathogenic") && !lower.includes("benign") && !lower.includes("likely")) {
- return {text}
- }
- if (lower.includes("likely pathogenic")) {
- return {text}
- }
- if (lower.includes("benign")) {
- return {text}
- }
- if (lower.includes("uncertain") || lower.includes("vus")) {
- return {text}
- }
- return {text}
-}
-
-// โโ Variant row โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-function VariantRow({ variant }: { variant: Variant }) {
- const { t } = useTranslation()
- const [expanded, setExpanded] = useState(false)
-
- const gene = componentDisplay(getComponent(variant, LOINC.GENE_STUDIED))
- const hgvsCoding = componentDisplay(getComponent(variant, LOINC.HGVS_CODING))
- const hgvsProtein = componentDisplay(getComponent(variant, LOINC.HGVS_PROTEIN))
- const clinSig = componentDisplay(getComponent(variant, LOINC.CLINICAL_SIGNIFICANCE))
- const allelicState = componentDisplay(getComponent(variant, LOINC.ALLELIC_STATE))
- const dnaChangeType = componentDisplay(getComponent(variant, LOINC.DNA_CHANGE_TYPE))
- const refSeq = componentDisplay(getComponent(variant, LOINC.TRANSCRIPT_REF_SEQ))
-
- const title = gene
- ? `${gene}${hgvsCoding ? ` โ ${hgvsCoding}` : ""}`
- : variant.code?.coding?.[0]?.display || t("genomics.variant")
-
- const hasDetails = hgvsProtein || allelicState || dnaChangeType || refSeq
-
- return (
-
-
- {expanded && (
-
- {hgvsProtein && (
-
- )}
- {allelicState && (
-
- )}
- {dnaChangeType && (
-
- )}
- {refSeq && (
-
- )}
-
- )}
-
- )
-}
-
-// โโ Detail row helper โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-function Detail({ label, value }: { label: string; value: string }) {
- return (
-
- {label}:
- {value}
-
- )
-}
-
-// โโ Diagnostic implication row โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-function DiagnosticImplicationRow({ impl, onOpenDetail }: { impl: DiagnosticImplication; onOpenDetail?: (title: string, resource: AnyResource) => void }) {
- const { t } = useTranslation()
- const disease = componentDisplay(getComponent(impl, LOINC.ASSOCIATED_DISEASE))
- const clinSig = componentDisplay(getComponent(impl, LOINC.CLINICAL_SIGNIFICANCE))
- const label = disease || impl.code?.coding?.[0]?.display || t("genomics.diagnosticImplication")
-
- return (
-
-
- {onOpenDetail ? (
-
- {label}
-
- ) : (
- {label}
- )}
- {clinSig && significanceBadge(clinSig)}
-
- )
-}
-
-// โโ Therapeutic implication row โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-function TherapeuticImplicationRow({ impl, onOpenDetail }: { impl: TherapeuticImplication; onOpenDetail?: (title: string, resource: AnyResource) => void }) {
- const { t } = useTranslation()
- const drug = componentDisplay(getComponent(impl, LOINC.DRUG_ASSESSED))
- const clinSig = componentDisplay(getComponent(impl, LOINC.CLINICAL_SIGNIFICANCE))
- const label = drug || impl.code?.coding?.[0]?.display || t("genomics.therapeuticImplication")
-
- return (
-
-
- {onOpenDetail ? (
-
- {label}
-
- ) : (
- {label}
- )}
- {clinSig && significanceBadge(clinSig)}
-
- )
-}
-
-// โโ Main GenomicsCard โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-export function GenomicsCard({
- reports,
- variants,
- diagnosticImplications,
- therapeuticImplications,
- onOpenDetail,
- defaultCollapsed = false,
-}: {
- reports: GenomicReport[]
- variants: Variant[]
- diagnosticImplications: DiagnosticImplication[]
- therapeuticImplications: TherapeuticImplication[]
- onOpenDetail?: (title: string, resource: AnyResource) => void
- defaultCollapsed?: boolean
-}) {
- const { t } = useTranslation()
- const [collapsed, setCollapsed] = useState(defaultCollapsed)
- const isEmpty =
- reports.length === 0 &&
- variants.length === 0 &&
- diagnosticImplications.length === 0 &&
- therapeuticImplications.length === 0
-
- return (
-
- setCollapsed(!collapsed)}>
-
-
- {t("genomics.title")}
- {!isEmpty && (
-
- {variants.length > 0 && t("genomics.variants", { n: variants.length })}
- {reports.length > 0 && ` ยท ${reports.length} ${t("genomics.reports").toLowerCase()}`}
-
- )}
- {collapsed ? : }
-
-
- {!collapsed && (
-
- {isEmpty ? (
- {t("genomics.noRecords")}
- ) : (
-
- {/* Genomic Reports */}
- {reports.length > 0 && (
-
-
-
- {t("genomics.reports")}
-
-
-
- )}
-
- {/* Variants */}
- {variants.length > 0 && (
-
-
-
- {t("genomics.variants", { n: variants.length })}
-
-
- {variants.map((v, i) => (
-
- ))}
-
-
- )}
-
- {/* Diagnostic Implications + Pharmacogenomics side by side on desktop */}
- {(diagnosticImplications.length > 0 || therapeuticImplications.length > 0) && (
-
- {/* Diagnostic Implications */}
- {diagnosticImplications.length > 0 && (
-
-
-
- {t("genomics.diagnosticImplications")}
-
-
- {diagnosticImplications.map((di, i) => (
-
- ))}
-
-
- )}
-
- {/* Therapeutic Implications (Pharmacogenomics) */}
- {therapeuticImplications.length > 0 && (
-
-
-
- {t("genomics.pharmacogenomics")}
-
-
- {therapeuticImplications.map((ti, i) => (
-
- ))}
-
-
- )}
-
- )}
-
- )}
-
- )}
-
- )
-}
diff --git a/apps/patient-portal/src/components/HealthChartsCard.tsx b/apps/patient-portal/src/components/HealthChartsCard.tsx
deleted file mode 100644
index e43078338..000000000
--- a/apps/patient-portal/src/components/HealthChartsCard.tsx
+++ /dev/null
@@ -1,251 +0,0 @@
-import { lazy, Suspense, useMemo, useState } from "react"
-import { useTranslation } from "react-i18next"
-import {
- Card, CardContent, CardHeader, CardTitle, Badge, Button, Spinner,
- Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
-} from "@proxy-smart/shared-ui"
-import { Activity, Plus, Minus } from "lucide-react"
-import { format } from "date-fns"
-import type { Observation } from "@/lib/fhir-client"
-import type { ObservationResultsLaboratoryPathologyUvIps as LabResult } from "hl7.fhir.uv.ips-generated"
-
-const ChartRenderer = lazy(() => import("@/components/ChartRenderer").then(m => ({ default: m.ChartRenderer })))
-
-// โโ Metric definitions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-export interface MetricDef {
- label: string
- loinc: string[]
- unit?: string
- color: string
- componentLoinc?: string
-}
-
-const METRIC_DEFS: MetricDef[] = [
- { label: "Systolic BP", loinc: ["8480-6", "85354-9"], unit: "mmHg", color: "#ef4444", componentLoinc: "8480-6" },
- { label: "Diastolic BP", loinc: ["8462-4", "85354-9"], unit: "mmHg", color: "#f97316", componentLoinc: "8462-4" },
- { label: "Heart Rate", loinc: ["8867-4"], unit: "bpm", color: "#ec4899" },
- { label: "Body Weight", loinc: ["29463-7"], unit: "kg", color: "#8b5cf6" },
- { label: "BMI", loinc: ["39156-5"], unit: "kg/mยฒ", color: "#6366f1" },
- { label: "SpOโ", loinc: ["2708-6", "59408-5"], unit: "%", color: "#06b6d4" },
- { label: "Body Temperature", loinc: ["8310-5"], unit: "ยฐC", color: "#f59e0b" },
- { label: "Respiratory Rate", loinc: ["9279-1"], unit: "/min", color: "#10b981" },
- { label: "Hours of Sleep", loinc: ["93832-4"], unit: "h", color: "#64748b" },
-]
-
-// โโ Value extraction โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-function extractValue(obs: Observation | LabResult, metric: MetricDef): number | null {
- if (metric.componentLoinc && "component" in obs && Array.isArray(obs.component)) {
- const comp = obs.component.find(c =>
- c.code?.coding?.some(cd => cd.code === metric.componentLoinc)
- )
- if (comp?.valueQuantity?.value != null) return comp.valueQuantity.value
- }
- if ("valueQuantity" in obs && obs.valueQuantity?.value != null) {
- return obs.valueQuantity.value
- }
- return null
-}
-
-function getEffectiveDate(obs: Observation | LabResult): string | null {
- if ("effectiveDateTime" in obs && obs.effectiveDateTime) return obs.effectiveDateTime
- if ("effectivePeriod" in obs && obs.effectivePeriod?.start) return obs.effectivePeriod.start
- if ("issued" in obs && obs.issued) return obs.issued
- return null
-}
-
-function obsMatchesMetric(obs: Observation | LabResult, metric: MetricDef): boolean {
- const codes = obs.code?.coding?.map(c => c.code) ?? []
- return metric.loinc.some(l => codes.includes(l))
-}
-
-// โโ Build merged chart data for one or two metrics โโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-export interface ChartPoint {
- date: string
- ts: number
- primary?: number
- secondary?: number
-}
-
-function buildChartData(
- allObs: (Observation | LabResult)[],
- primaryDef: MetricDef,
- secondaryDef: MetricDef | undefined,
-): ChartPoint[] {
- const byTs = new Map()
-
- for (const obs of allObs) {
- const dateStr = getEffectiveDate(obs)
- if (!dateStr) continue
- const ts = new Date(dateStr).getTime()
- if (isNaN(ts)) continue
-
- if (obsMatchesMetric(obs, primaryDef)) {
- const val = extractValue(obs, primaryDef)
- if (val !== null) {
- const existing = byTs.get(ts) ?? { date: format(new Date(ts), "MMM d, yyyy"), ts }
- existing.primary = Math.round(val * 100) / 100
- byTs.set(ts, existing)
- }
- }
-
- if (secondaryDef && obsMatchesMetric(obs, secondaryDef)) {
- const val = extractValue(obs, secondaryDef)
- if (val !== null) {
- const existing = byTs.get(ts) ?? { date: format(new Date(ts), "MMM d, yyyy"), ts }
- existing.secondary = Math.round(val * 100) / 100
- byTs.set(ts, existing)
- }
- }
- }
-
- return Array.from(byTs.values()).sort((a, b) => a.ts - b.ts)
-}
-
-// โโ Component โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-interface HealthChartsCardProps {
- vitals: Observation[]
- labs: LabResult[]
-}
-
-export function HealthChartsCard({ vitals, labs }: HealthChartsCardProps) {
- const { t } = useTranslation()
- const allObs = useMemo(() => [...vitals, ...labs], [vitals, labs])
-
- const availableMetrics = useMemo(() => {
- return METRIC_DEFS.filter(m =>
- allObs.some(o => obsMatchesMetric(o, m) && extractValue(o, m) !== null)
- )
- }, [allObs])
-
- const [selectedMetric, setSelectedMetric] = useState("")
- const [secondaryMetric, setSecondaryMetric] = useState(null)
-
- const activeMetricKey = selectedMetric || availableMetrics[0]?.label || ""
- const primaryDef = METRIC_DEFS.find(m => m.label === activeMetricKey)
- const secondaryDef = secondaryMetric ? METRIC_DEFS.find(m => m.label === secondaryMetric) : undefined
-
- // Metrics available for secondary (exclude the primary)
- const secondaryOptions = useMemo(
- () => availableMetrics.filter(m => m.label !== activeMetricKey),
- [availableMetrics, activeMetricKey],
- )
-
- const chartData = useMemo(() => {
- if (!primaryDef) return []
- return buildChartData(allObs, primaryDef, secondaryDef)
- }, [allObs, primaryDef, secondaryDef])
-
- const hasPrimaryData = chartData.some(p => p.primary != null)
- const hasSecondaryData = chartData.some(p => p.secondary != null)
- const hasEnoughData = hasPrimaryData && chartData.filter(p => p.primary != null).length >= 2
-
- const totalObs = vitals.length + labs.length
-
- // Clear secondary if it matches the newly selected primary
- const handlePrimaryChange = (val: string) => {
- setSelectedMetric(val)
- if (secondaryMetric === val) setSecondaryMetric(null)
- }
-
- const handleAddSecondary = () => {
- if (secondaryOptions.length > 0) {
- setSecondaryMetric(secondaryOptions[0].label)
- }
- }
-
- return (
-
-
-
-
-
- {t("healthCharts.title")}
- {totalObs > 0 && (
-
- {t("healthCharts.nObservations", { n: totalObs })}
-
- )}
-
-
- {availableMetrics.length > 0 && (
-
- {/* Primary metric selector */}
-
-
- {/* Add / secondary metric */}
- {secondaryMetric == null ? (
- secondaryOptions.length > 0 && (
-
- )
- ) : (
- <>
-
-
- >
- )}
-
- )}
-
-
-
-
- {allObs.length === 0 ? (
- {t("healthCharts.noData")}
- ) : availableMetrics.length === 0 ? (
- {t("healthCharts.noChartable")}
- ) : !hasEnoughData ? (
-
- {t("healthCharts.notEnoughData", { metric: activeMetricKey })}
-
- ) : (
- }>
-