diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile new file mode 100644 index 0000000..783a2a6 --- /dev/null +++ b/apps/dashboard/Dockerfile @@ -0,0 +1,34 @@ +# =========================================================================== +# AuthScript Dashboard - Multi-stage Docker build +# Stage 1: Build React SPA with Vite +# Stage 2: Serve via nginx with API reverse proxy +# Build context: repository root +# =========================================================================== + +# Build stage +FROM docker.io/node:20-alpine AS build +WORKDIR /app + +# Copy all workspace files (node_modules excluded via .dockerignore) +COPY package.json package-lock.json ./ +COPY shared/ shared/ +COPY apps/dashboard/ apps/dashboard/ + +# Install dependencies and build +RUN npm ci +RUN npm run build --workspace=shared/types && npm run build --workspace=shared/validation +RUN npm run build --workspace=apps/dashboard + +# Serve stage +FROM docker.io/nginx:alpine + +# Copy built SPA assets +COPY --from=build /app/apps/dashboard/dist /usr/share/nginx/html + +# Copy nginx config template (envsubst resolves $GATEWAY_URL at startup) +COPY apps/dashboard/nginx.conf /etc/nginx/templates/default.conf.template + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD wget -q --spider http://localhost/health || exit 1 diff --git a/apps/dashboard/nginx.conf b/apps/dashboard/nginx.conf new file mode 100644 index 0000000..1fcd3a5 --- /dev/null +++ b/apps/dashboard/nginx.conf @@ -0,0 +1,32 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback — serve index.html for client-side routes + location / { + try_files $uri $uri/ /index.html; + } + + # Reverse proxy /api to Gateway service (resolved at startup via envsubst) + location /api/ { + proxy_pass ${GATEWAY_URL}/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Cache static assets + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Health check + location /health { + return 200 'ok'; + add_header Content-Type text/plain; + } +} diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index e5116c3..a48b4b0 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -28,6 +28,7 @@ "@tanstack/react-query": "^5.80.6", "@tanstack/react-router": "1.131.35", "@tanstack/router-plugin": "1.131.35", + "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "graphql": "^16.9.0", @@ -35,6 +36,7 @@ "html2canvas": "^1.4.1", "jspdf": "^4.1.0", "lucide-react": "^0.513.0", + "motion": "^12.36.0", "pdf-lib": "^1.17.1", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/apps/dashboard/src/api/graphqlClient.ts b/apps/dashboard/src/api/graphqlClient.ts index 6034fee..a37df72 100644 --- a/apps/dashboard/src/api/graphqlClient.ts +++ b/apps/dashboard/src/api/graphqlClient.ts @@ -1,15 +1,11 @@ /** * GraphQL client for AuthScript Gateway API - * Uses VITE_GATEWAY_URL (default http://localhost:5000) for direct calls. - * When using Vite dev proxy, /api is proxied to the Gateway. + * Uses relative /api/graphql path — proxied to Gateway by Vite (dev) or nginx (prod). */ import { GraphQLClient } from 'graphql-request'; -import { getApiConfig } from '../config/secrets'; -const GRAPHQL_ENDPOINT = import.meta.env.DEV - ? `${window.location.origin}/api/graphql` - : `${getApiConfig().gatewayUrl}/api/graphql`; +const GRAPHQL_ENDPOINT = `${window.location.origin}/api/graphql`; export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT, { credentials: 'include', diff --git a/apps/dashboard/src/components/case/AnimatedEdge.tsx b/apps/dashboard/src/components/case/AnimatedEdge.tsx new file mode 100644 index 0000000..068e71f --- /dev/null +++ b/apps/dashboard/src/components/case/AnimatedEdge.tsx @@ -0,0 +1,57 @@ +import { getSmoothStepPath, BaseEdge, type EdgeProps } from '@xyflow/react'; + +export interface AnimatedEdgeData { + animationDelay?: number; +} + +/** + * Custom React Flow edge with an animated dot traveling along the path. + * Uses SVG with for the animation effect. + */ +export function AnimatedEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style = {}, + markerEnd, + data, +}: EdgeProps & { data?: AnimatedEdgeData }) { + const [edgePath] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const delay = data?.animationDelay ?? 0; + + return ( + <> + + + + + + ); +} diff --git a/apps/dashboard/src/components/case/CaseGraph.tsx b/apps/dashboard/src/components/case/CaseGraph.tsx new file mode 100644 index 0000000..a5e65ce --- /dev/null +++ b/apps/dashboard/src/components/case/CaseGraph.tsx @@ -0,0 +1,235 @@ +import { useState, useEffect, useCallback } from 'react'; +import { ReactFlow, ReactFlowProvider, Background, Controls } from '@xyflow/react'; +import type { Node, Edge } from '@xyflow/react'; +import { PatientNode } from './PatientNode'; +import { EvidenceNode } from './EvidenceNode'; +import { CriteriaNode } from './CriteriaNode'; +import { DecisionNode } from './DecisionNode'; +import { AnimatedEdge } from './AnimatedEdge'; +import { DEMO_PA_RESULT_SOURCES, LCD_L34220_POLICY } from '@/lib/demoData'; +import type { PARequest } from '@/api/graphqlService'; + +// Register custom node and edge types +const nodeTypes = { + patient: PatientNode, + evidence: EvidenceNode, + criteria: CriteriaNode, + decision: DecisionNode, +}; + +const edgeTypes = { + animated: AnimatedEdge, +}; + +/** + * Maps a criterion's met value (boolean | null) to a node status string. + */ +function toStatus(met: boolean | null): 'met' | 'not_met' | 'indeterminate' { + if (met === true) return 'met'; + if (met === false) return 'not_met'; + return 'indeterminate'; +} + +/** + * Builds graph nodes and edges from a PARequest. + * Exported for testability. + */ +export function buildCaseGraphData(paRequest: PARequest): { + nodes: Node[]; + edges: Edge[]; +} { + const nodes: Node[] = []; + const edges: Edge[] = []; + + // 1. Patient node (top center) + const patientNodeId = 'patient-1'; + nodes.push({ + id: patientNodeId, + type: 'patient', + position: { x: 400, y: 0 }, + data: { + name: paRequest.patient.name, + dob: paRequest.patient.dob, + mrn: paRequest.patient.mrn, + insurance: paRequest.payer, + }, + }); + + // 2. Evidence nodes (left column, stacked) + const evidenceEntries = paRequest.criteria.map((criterion) => { + const sourceInfo = DEMO_PA_RESULT_SOURCES[criterion.label]; + return { + text: sourceInfo?.evidence ?? criterion.reason ?? criterion.label, + source: sourceInfo?.source ?? 'Clinical', + criterionLabel: criterion.label, + }; + }); + + const evidenceYStart = 120; + const evidenceSpacing = 100; + + evidenceEntries.forEach((entry, i) => { + const nodeId = `evidence-${i}`; + nodes.push({ + id: nodeId, + type: 'evidence', + position: { x: 0, y: evidenceYStart + i * evidenceSpacing }, + data: { + text: entry.text, + source: entry.source, + }, + }); + + // Edge: patient -> evidence (subtle) + edges.push({ + id: `edge-patient-evidence-${i}`, + source: patientNodeId, + target: nodeId, + style: { stroke: '#cbd5e1', strokeWidth: 1 }, + animated: false, + }); + }); + + // 3. Criteria nodes (center column, stacked) + const criteriaYStart = 120; + const criteriaSpacing = 100; + + paRequest.criteria.forEach((criterion, i) => { + const nodeId = `criteria-${i}`; + nodes.push({ + id: nodeId, + type: 'criteria', + position: { x: 350, y: criteriaYStart + i * criteriaSpacing }, + data: { + label: criterion.label, + status: toStatus(criterion.met), + reasoning: criterion.reason, + }, + }); + + // Edge: evidence -> criteria (animated dashed) + edges.push({ + id: `edge-evidence-criteria-${i}`, + source: `evidence-${i}`, + target: nodeId, + type: 'animated', + data: { animationDelay: i * 0.3 }, + }); + }); + + // 4. Decision node (right) + const decisionNodeId = 'decision-1'; + const decisionY = + criteriaYStart + + ((paRequest.criteria.length - 1) * criteriaSpacing) / 2; + + nodes.push({ + id: decisionNodeId, + type: 'decision', + position: { x: 700, y: decisionY }, + data: { + payer: paRequest.payer, + policyId: LCD_L34220_POLICY.policyId, + confidence: paRequest.confidence, + status: paRequest.status, + }, + }); + + // Edges: criteria -> decision (solid colored) + paRequest.criteria.forEach((criterion, i) => { + const statusColor = + criterion.met === true + ? '#22c55e' + : criterion.met === false + ? '#ef4444' + : '#f59e0b'; + edges.push({ + id: `edge-criteria-decision-${i}`, + source: `criteria-${i}`, + target: decisionNodeId, + style: { stroke: statusColor, strokeWidth: 2 }, + }); + }); + + return { nodes, edges }; +} + +/** Timing tiers for sequential node reveal animation (ms) */ +const REVEAL_TIERS: Record = { + patient: [0, 300], + evidence: [300, 900], + criteria: [900, 1500], + decision: [1500, 1800], +}; + +/** Apply opacity 0 initially, then reveal nodes sequentially by type */ +function useNodeReveal(baseNodes: Node[]): Node[] { + const [revealedTypes, setRevealedTypes] = useState>(new Set()); + + const scheduleReveals = useCallback(() => { + const types = Object.keys(REVEAL_TIERS); + const timers: ReturnType[] = []; + + for (const type of types) { + const [, end] = REVEAL_TIERS[type]; + timers.push( + setTimeout(() => { + setRevealedTypes((prev) => new Set([...prev, type])); + }, end), + ); + } + + return timers; + }, []); + + useEffect(() => { + const timers = scheduleReveals(); + return () => timers.forEach(clearTimeout); + }, [scheduleReveals]); + + return baseNodes.map((node) => ({ + ...node, + style: { + ...node.style, + opacity: revealedTypes.has(node.type ?? '') ? 1 : 0, + transition: 'opacity 0.3s ease-in', + }, + })); +} + +interface CaseGraphProps { + paRequest: PARequest; +} + +/** + * Full case graph visualization for a PA request. + * Shows patient -> evidence -> criteria -> decision flow. + * Nodes fade in sequentially: Patient -> Evidence -> Criteria -> Decision. + */ +export function CaseGraph({ paRequest }: CaseGraphProps) { + const { nodes: baseNodes, edges } = buildCaseGraphData(paRequest); + const nodes = useNodeReveal(baseNodes); + + return ( + + + + + + + ); +} diff --git a/apps/dashboard/src/components/case/CaseTimeline.tsx b/apps/dashboard/src/components/case/CaseTimeline.tsx new file mode 100644 index 0000000..1a4b48a --- /dev/null +++ b/apps/dashboard/src/components/case/CaseTimeline.tsx @@ -0,0 +1,104 @@ +import { cn } from '@/lib/utils'; + +export interface TimelinePhase { + name: string; + status: 'completed' | 'active' | 'pending'; + timestamp?: string; + duration?: string; +} + +interface CaseTimelineProps { + phases: TimelinePhase[]; +} + +/** + * Vertical timeline showing PA request lifecycle phases. + * Completed phases show a green checkmark, active phase pulses, + * and pending phases are muted with dashed connectors. + */ +export function CaseTimeline({ phases }: CaseTimelineProps) { + return ( +
+ {phases.map((phase, i) => { + const isLast = i === phases.length - 1; + + return ( +
+ {/* Timeline column: icon + connector line */} +
+ {/* Status icon */} + {phase.status === 'completed' && ( +
+ {'\u2713'} +
+ )} + {phase.status === 'active' && ( +
+ + +
+ )} + {phase.status === 'pending' && ( +
+ +
+ )} + + {/* Connector line */} + {!isLast && ( +
+ )} +
+ + {/* Content column */} +
+

+ {phase.name} +

+
+ {phase.timestamp && ( + + {phase.timestamp} + + )} + {phase.duration && ( + + {phase.duration} + + )} +
+
+
+ ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/case/CriteriaNode.tsx b/apps/dashboard/src/components/case/CriteriaNode.tsx new file mode 100644 index 0000000..096ae43 --- /dev/null +++ b/apps/dashboard/src/components/case/CriteriaNode.tsx @@ -0,0 +1,68 @@ +import { Handle, Position } from '@xyflow/react'; +import { NodeCard } from './NodeCard'; +import type { NodeProps } from '@xyflow/react'; + +export interface CriteriaNodeData { + label: string; + status: 'met' | 'not_met' | 'indeterminate'; + reasoning?: string; +} + +const STATUS_CONFIG = { + met: { + border: 'border-green-400', + icon: '\u2713', + iconBg: 'bg-green-500 text-white', + testId: 'criteria-icon-met', + }, + not_met: { + border: 'border-red-400', + icon: '\u2717', + iconBg: 'bg-red-500 text-white', + testId: 'criteria-icon-not_met', + }, + indeterminate: { + border: 'border-amber-400', + icon: '?', + iconBg: 'bg-amber-500 text-white', + testId: 'criteria-icon-indeterminate', + }, +} as const; + +/** + * Custom React Flow node displaying a policy criterion and its status. + * Layout: status icon + criterion label. Border colored by status. + */ +export function CriteriaNode({ + data, +}: NodeProps & { data: CriteriaNodeData }) { + const config = STATUS_CONFIG[data.status]; + + return ( + + + +
+
+ {config.icon} +
+ + {data.label} + +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/case/DecisionNode.tsx b/apps/dashboard/src/components/case/DecisionNode.tsx new file mode 100644 index 0000000..a9fbe1a --- /dev/null +++ b/apps/dashboard/src/components/case/DecisionNode.tsx @@ -0,0 +1,86 @@ +import { Handle, Position } from '@xyflow/react'; +import { NodeCard } from './NodeCard'; +import type { NodeProps } from '@xyflow/react'; + +export interface DecisionNodeData { + payer: string; + policyId: string; + confidence: number; + status: string; +} + +/** + * Custom React Flow node displaying the PA decision outcome. + * Layout: payer + policy ref + confidence % with animated SVG ring + status. + */ +export function DecisionNode({ + data, +}: NodeProps & { data: DecisionNodeData }) { + const size = 64; + const strokeWidth = 5; + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const offset = circumference - (data.confidence / 100) * circumference; + + const colorClass = + data.confidence >= 80 + ? 'text-green-500' + : data.confidence >= 60 + ? 'text-amber-500' + : 'text-red-500'; + + return ( + + + +
+ {/* Animated confidence ring */} +
+ + + + +
+ + {data.confidence}% + +
+
+ +
+

{data.payer}

+

{data.policyId}

+ + {data.status} + +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/case/EvidenceNode.tsx b/apps/dashboard/src/components/case/EvidenceNode.tsx new file mode 100644 index 0000000..e91d700 --- /dev/null +++ b/apps/dashboard/src/components/case/EvidenceNode.tsx @@ -0,0 +1,50 @@ +import { Handle, Position } from '@xyflow/react'; +import { NodeCard } from './NodeCard'; +import type { NodeProps } from '@xyflow/react'; + +export interface EvidenceNodeData { + text: string; + source: string; +} + +const SOURCE_COLORS: Record = { + HPI: 'bg-blue-100 text-blue-700', + Assessment: 'bg-purple-100 text-purple-700', + Orders: 'bg-amber-100 text-amber-700', + 'Imaging History': 'bg-slate-100 text-slate-700', + 'Problem List': 'bg-green-100 text-green-700', + 'Assessment / Plan': 'bg-indigo-100 text-indigo-700', + 'CC / HPI': 'bg-sky-100 text-sky-700', + 'HPI / Orders': 'bg-cyan-100 text-cyan-700', +}; + +/** + * Custom React Flow node displaying a piece of clinical evidence. + * Layout: evidence text + small source badge (colored by type). + */ +export function EvidenceNode({ data }: NodeProps & { data: EvidenceNodeData }) { + const sourceColor = + SOURCE_COLORS[data.source] ?? 'bg-slate-100 text-slate-600'; + + return ( + + + +

{data.text}

+ +
+ + {data.source} + +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/case/NodeCard.tsx b/apps/dashboard/src/components/case/NodeCard.tsx new file mode 100644 index 0000000..2abd6e9 --- /dev/null +++ b/apps/dashboard/src/components/case/NodeCard.tsx @@ -0,0 +1,26 @@ +import { cn } from '@/lib/utils'; +import type { ReactNode } from 'react'; + +interface NodeCardProps { + children: ReactNode; + className?: string; + borderColor?: string; +} + +/** + * Shared card wrapper for React Flow custom nodes. + * Provides consistent border, padding, rounded corners, and shadow. + */ +export function NodeCard({ children, className, borderColor }: NodeCardProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/components/case/PatientNode.tsx b/apps/dashboard/src/components/case/PatientNode.tsx new file mode 100644 index 0000000..c0ec161 --- /dev/null +++ b/apps/dashboard/src/components/case/PatientNode.tsx @@ -0,0 +1,47 @@ +import { Handle, Position } from '@xyflow/react'; +import { NodeCard } from './NodeCard'; +import { getInitials } from '@/lib/formatUtils'; +import type { NodeProps } from '@xyflow/react'; + +export interface PatientNodeData { + name: string; + dob: string; + mrn: string; + insurance: string; +} + +/** + * Custom React Flow node displaying patient demographics. + * Layout: initials avatar (teal ring) + name + DOB + MRN + insurance badge. + */ +export function PatientNode({ data }: NodeProps & { data: PatientNodeData }) { + const initials = getInitials(data.name); + + return ( + +
+ {/* Initials avatar */} +
+ {initials} +
+
+

+ {data.name} +

+

DOB: {data.dob}

+
+
+ +
+ + MRN: {data.mrn} + + + {data.insurance} + +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/case/__tests__/AnimatedEdge.test.tsx b/apps/dashboard/src/components/case/__tests__/AnimatedEdge.test.tsx new file mode 100644 index 0000000..a377e9c --- /dev/null +++ b/apps/dashboard/src/components/case/__tests__/AnimatedEdge.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; + +// Mock @xyflow/react utilities +vi.mock('@xyflow/react', () => ({ + getSmoothStepPath: () => ['M 0 0 L 100 100', 0, 0], + BaseEdge: ({ path }: { path: string }) => , + Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' }, +})); + +import { AnimatedEdge } from '../AnimatedEdge'; + +describe('AnimatedEdge', () => { + const baseEdgeProps = { + id: 'edge-1', + source: 'node-1', + target: 'node-2', + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + sourcePosition: 'bottom' as const, + targetPosition: 'top' as const, + style: {}, + markerEnd: undefined, + data: {}, + }; + + it('AnimatedEdge_RendersBaseSVGPath', () => { + const { container } = render( + + + , + ); + const path = container.querySelector('[data-testid="base-edge-path"]'); + expect(path).not.toBeNull(); + }); +}); diff --git a/apps/dashboard/src/components/case/__tests__/CaseGraph.test.tsx b/apps/dashboard/src/components/case/__tests__/CaseGraph.test.tsx new file mode 100644 index 0000000..b5490c9 --- /dev/null +++ b/apps/dashboard/src/components/case/__tests__/CaseGraph.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +// Mock @xyflow/react to avoid jsdom layout issues +vi.mock('@xyflow/react', () => ({ + ReactFlow: ({ nodes, edges, children }: any) => ( +
+ {children} +
+ ), + ReactFlowProvider: ({ children }: any) => <>{children}, + Background: () => null, + Controls: () => null, + Handle: () => null, + Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' }, + useNodesState: (init: any) => [init, vi.fn(), vi.fn()], + useEdgesState: (init: any) => [init, vi.fn(), vi.fn()], + getSmoothStepPath: () => ['M 0 0', 0, 0], + BaseEdge: () => null, +})); + +// Mock motion to avoid animation issues in tests +vi.mock('motion/react', () => ({ + motion: { + div: ({ children, ...props }: any) =>
{children}
, + }, + AnimatePresence: ({ children }: any) => <>{children}, +})); + +import { CaseGraph, buildCaseGraphData } from '../CaseGraph'; +import { DEMO_PA_RESULT } from '@/lib/demoData'; + +describe('buildCaseGraphData', () => { + const { nodes, edges } = buildCaseGraphData(DEMO_PA_RESULT); + + it('CaseGraph_CreatesPatientNode', () => { + const patientNodes = nodes.filter((n) => n.type === 'patient'); + expect(patientNodes).toHaveLength(1); + }); + + it('CaseGraph_CreatesEvidenceNodes', () => { + const evidenceNodes = nodes.filter((n) => n.type === 'evidence'); + expect(evidenceNodes.length).toBeGreaterThan(0); + }); + + it('CaseGraph_CreatesCriteriaNodes', () => { + const criteriaNodes = nodes.filter((n) => n.type === 'criteria'); + expect(criteriaNodes).toHaveLength(DEMO_PA_RESULT.criteria.length); + }); + + it('CaseGraph_CreatesDecisionNode', () => { + const decisionNodes = nodes.filter((n) => n.type === 'decision'); + expect(decisionNodes).toHaveLength(1); + }); + + it('CaseGraph_CreatesEdges_EvidenceToCriteria', () => { + const evidenceNodes = nodes.filter((n) => n.type === 'evidence'); + const criteriaNodes = nodes.filter((n) => n.type === 'criteria'); + const evidenceToCriteriaEdges = edges.filter( + (e) => + evidenceNodes.some((n) => n.id === e.source) && + criteriaNodes.some((n) => n.id === e.target), + ); + expect(evidenceToCriteriaEdges.length).toBeGreaterThan(0); + }); + + it('CaseGraph_CreatesEdges_CriteriaToDecision', () => { + const criteriaNodes = nodes.filter((n) => n.type === 'criteria'); + const decisionNodes = nodes.filter((n) => n.type === 'decision'); + const criteriaToDecisionEdges = edges.filter( + (e) => + criteriaNodes.some((n) => n.id === e.source) && + decisionNodes.some((n) => n.id === e.target), + ); + expect(criteriaToDecisionEdges.length).toBe(criteriaNodes.length); + }); +}); + +describe('CaseGraph component', () => { + it('CaseGraph_RendersReactFlow', () => { + render(); + expect(screen.getByTestId('react-flow')).toBeInTheDocument(); + }); + + it('CaseGraph_PassesCorrectNodeCount', () => { + render(); + const flowEl = screen.getByTestId('react-flow'); + const nodeCount = parseInt(flowEl.getAttribute('data-nodes')!, 10); + // patient(1) + evidence(N) + criteria(5) + decision(1) = total + expect(nodeCount).toBeGreaterThanOrEqual(7); + }); +}); diff --git a/apps/dashboard/src/components/case/__tests__/CaseTimeline.test.tsx b/apps/dashboard/src/components/case/__tests__/CaseTimeline.test.tsx new file mode 100644 index 0000000..09088e5 --- /dev/null +++ b/apps/dashboard/src/components/case/__tests__/CaseTimeline.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +// Mock motion to avoid animation issues in tests +vi.mock('motion/react', () => ({ + motion: { + div: ({ children, ...props }: any) =>
{children}
, + span: ({ children, ...props }: any) => {children}, + }, + AnimatePresence: ({ children }: any) => <>{children}, +})); + +import { CaseTimeline } from '../CaseTimeline'; + +const mockPhases = [ + { + name: 'Submitted', + status: 'completed' as const, + timestamp: '2:34 PM', + duration: '0.8s', + }, + { + name: 'Analyzing', + status: 'completed' as const, + timestamp: '2:34 PM', + duration: '4.2s', + }, + { + name: 'Review Ready', + status: 'active' as const, + timestamp: '2:35 PM', + }, + { + name: 'Payer Submission', + status: 'pending' as const, + }, + { + name: 'Decision', + status: 'pending' as const, + }, +]; + +describe('CaseTimeline', () => { + it('CaseTimeline_RendersPastPhases_WithCheckmarks', () => { + render(); + // Completed phases should have checkmarks + const checkmarks = screen.getAllByTestId('phase-check'); + expect(checkmarks.length).toBe(2); // "Submitted" and "Analyzing" + }); + + it('CaseTimeline_RendersCurrentPhase_WithActiveStyle', () => { + render(); + const activePhase = screen.getByTestId('phase-active'); + expect(activePhase).toBeInTheDocument(); + // Active phase name should be in bold or have distinct styling + expect(screen.getByText('Review Ready')).toBeInTheDocument(); + }); + + it('CaseTimeline_RendersFuturePhases_AsMuted', () => { + render(); + const pendingPhases = screen.getAllByTestId('phase-pending'); + expect(pendingPhases.length).toBe(2); // "Payer Submission" and "Decision" + }); + + it('CaseTimeline_ShowsTimestamps', () => { + render(); + // Two phases share "2:34 PM", one has "2:35 PM" + const timestamps234 = screen.getAllByText('2:34 PM'); + expect(timestamps234.length).toBe(2); + expect(screen.getByText('2:35 PM')).toBeInTheDocument(); + }); + + it('CaseTimeline_ShowsDuration', () => { + render(); + expect(screen.getByText('0.8s')).toBeInTheDocument(); + expect(screen.getByText('4.2s')).toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/components/case/__tests__/CustomNodes.test.tsx b/apps/dashboard/src/components/case/__tests__/CustomNodes.test.tsx new file mode 100644 index 0000000..3ee3246 --- /dev/null +++ b/apps/dashboard/src/components/case/__tests__/CustomNodes.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +// Mock @xyflow/react Handle since we render nodes outside ReactFlow +vi.mock('@xyflow/react', () => ({ + Handle: () => null, + Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' }, +})); + +import { PatientNode } from '../PatientNode'; +import { EvidenceNode } from '../EvidenceNode'; +import { CriteriaNode } from '../CriteriaNode'; +import { DecisionNode } from '../DecisionNode'; + +// Helper to build mock node props +function mockNodeProps>(data: T) { + return { + id: 'test-node', + data, + type: 'custom', + selected: false, + isConnectable: true, + zIndex: 0, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + } as any; +} + +describe('PatientNode', () => { + const patientData = { + name: 'Rebecca Sandbox', + dob: '09/14/1990', + mrn: '60182', + insurance: 'Aetna', + }; + + it('PatientNode_RendersPatientName', () => { + render(); + expect(screen.getByText('Rebecca Sandbox')).toBeInTheDocument(); + }); + + it('PatientNode_RendersMRN', () => { + render(); + expect(screen.getByText(/60182/)).toBeInTheDocument(); + }); + + it('PatientNode_RendersInsurance', () => { + render(); + expect(screen.getByText('Aetna')).toBeInTheDocument(); + }); +}); + +describe('EvidenceNode', () => { + const evidenceData = { + text: 'Progressive numbness in left foot over past 3 weeks', + source: 'HPI', + }; + + it('EvidenceNode_RendersEvidenceText', () => { + render(); + expect( + screen.getByText('Progressive numbness in left foot over past 3 weeks'), + ).toBeInTheDocument(); + }); + + it('EvidenceNode_RendersSourceBadge', () => { + render(); + expect(screen.getByText('HPI')).toBeInTheDocument(); + }); +}); + +describe('CriteriaNode', () => { + it('CriteriaNode_MetStatus_ShowsCheckIcon', () => { + render( + , + ); + expect(screen.getByTestId('criteria-icon-met')).toBeInTheDocument(); + }); + + it('CriteriaNode_NotMetStatus_ShowsXIcon', () => { + render( + , + ); + expect(screen.getByTestId('criteria-icon-not_met')).toBeInTheDocument(); + }); + + it('CriteriaNode_IndeterminateStatus_ShowsQuestionIcon', () => { + render( + , + ); + expect( + screen.getByTestId('criteria-icon-indeterminate'), + ).toBeInTheDocument(); + }); +}); + +describe('DecisionNode', () => { + const decisionData = { + payer: 'Aetna', + policyId: 'LCD L34220', + confidence: 93, + status: 'ready', + }; + + it('DecisionNode_RendersPayerName', () => { + render(); + expect(screen.getByText('Aetna')).toBeInTheDocument(); + }); + + it('DecisionNode_RendersConfidenceScore', () => { + render(); + expect(screen.getByText('93%')).toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/components/demo/DemoProvider.tsx b/apps/dashboard/src/components/demo/DemoProvider.tsx new file mode 100644 index 0000000..cf4f556 --- /dev/null +++ b/apps/dashboard/src/components/demo/DemoProvider.tsx @@ -0,0 +1,83 @@ +import { + createContext, + useState, + useEffect, + useCallback, + useRef, + type ReactNode, +} from 'react'; + +export type Scene = 'encounter' | 'fleet' | 'case'; + +const SCENE_ORDER: Scene[] = ['encounter', 'fleet', 'case']; +const AUTO_PLAY_INTERVAL_MS = 15_000; + +export interface DemoContextValue { + scene: Scene; + setScene: (scene: Scene) => void; + selectedCaseId: string | null; + setSelectedCaseId: (id: string | null) => void; + autoPlay: boolean; + setAutoPlay: (v: boolean) => void; + resetDemo: () => void; +} + +export const DemoContext = createContext(null); + +interface DemoProviderProps { + children: ReactNode; + autoPlay?: boolean; +} + +export function DemoProvider({ + children, + autoPlay: initialAutoPlay = false, +}: DemoProviderProps) { + const [scene, setScene] = useState('encounter'); + const [selectedCaseId, setSelectedCaseId] = useState(null); + const [autoPlay, setAutoPlay] = useState(initialAutoPlay); + const intervalRef = useRef | null>(null); + + const resetDemo = useCallback(() => { + setScene('encounter'); + setSelectedCaseId(null); + setAutoPlay(false); + }, []); + + // Auto-play: cycle through scenes on interval + useEffect(() => { + if (!autoPlay) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + return; + } + + intervalRef.current = setInterval(() => { + setScene((current) => { + const idx = SCENE_ORDER.indexOf(current); + return SCENE_ORDER[(idx + 1) % SCENE_ORDER.length]; + }); + }, AUTO_PLAY_INTERVAL_MS); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [autoPlay]); + + const value: DemoContextValue = { + scene, + setScene, + selectedCaseId, + setSelectedCaseId, + autoPlay, + setAutoPlay, + resetDemo, + }; + + return {children}; +} diff --git a/apps/dashboard/src/components/demo/SceneNav.tsx b/apps/dashboard/src/components/demo/SceneNav.tsx new file mode 100644 index 0000000..c4465c4 --- /dev/null +++ b/apps/dashboard/src/components/demo/SceneNav.tsx @@ -0,0 +1,98 @@ +import { useContext, useEffect } from 'react'; +import { useNavigate, useLocation } from '@tanstack/react-router'; +import { DemoContext, type Scene } from './DemoProvider'; + +const SCENES: { key: Scene; label: string; route: string }[] = [ + { key: 'encounter', label: 'Encounter', route: '/ehr-demo' }, + { key: 'fleet', label: 'Fleet', route: '/fleet' }, + { key: 'case', label: 'Case Detail', route: '/case/demo' }, +]; + +/** Map pathname to scene key */ +function pathnameToScene(pathname: string): Scene | null { + if (pathname.startsWith('/ehr-demo')) return 'encounter'; + if (pathname.startsWith('/fleet')) return 'fleet'; + if (pathname.startsWith('/case')) return 'case'; + return null; +} + +export function SceneNav() { + const ctx = useContext(DemoContext); + const navigate = useNavigate(); + const location = useLocation(); + + // Sync scene state from current route + useEffect(() => { + if (!ctx) return; + const sceneFromRoute = pathnameToScene(location.pathname); + if (sceneFromRoute && sceneFromRoute !== ctx.scene) { + ctx.setScene(sceneFromRoute); + } + }, [location.pathname]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!ctx) return null; + + const { scene, setScene, autoPlay, setAutoPlay, resetDemo } = ctx; + + const handleSceneClick = (key: Scene) => { + setScene(key); + const target = SCENES.find((s) => s.key === key); + if (target) { + navigate({ to: target.route }); + } + }; + + return ( + + ); +} diff --git a/apps/dashboard/src/components/demo/SceneTransition.tsx b/apps/dashboard/src/components/demo/SceneTransition.tsx new file mode 100644 index 0000000..51d4259 --- /dev/null +++ b/apps/dashboard/src/components/demo/SceneTransition.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { + transitionVariants, + type TransitionDirection, +} from './transitionVariants'; + +interface SceneTransitionProps { + sceneKey: string; + children: ReactNode; + direction?: TransitionDirection; +} + +export function SceneTransition({ + sceneKey, + children, + direction = 'generic', +}: SceneTransitionProps) { + const variants = transitionVariants[direction]; + + return ( + + + {children} + + + ); +} diff --git a/apps/dashboard/src/components/demo/__tests__/DemoProvider.test.tsx b/apps/dashboard/src/components/demo/__tests__/DemoProvider.test.tsx new file mode 100644 index 0000000..1776f64 --- /dev/null +++ b/apps/dashboard/src/components/demo/__tests__/DemoProvider.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { createElement, useContext } from 'react'; +import { DemoProvider, DemoContext } from '../DemoProvider'; +import type { DemoContextValue } from '../DemoProvider'; + +/** Test helper that renders a consumer inside DemoProvider */ +function renderWithProvider(providerProps?: { autoPlay?: boolean }) { + let contextValue: DemoContextValue | null = null; + + function Consumer() { + contextValue = useContext(DemoContext); + if (!contextValue) throw new Error('DemoContext not found'); + return createElement('div', { 'data-testid': 'scene' }, contextValue.scene); + } + + const result = render( + createElement(DemoProvider, { ...providerProps, children: createElement(Consumer) }), + ); + + return { result, getContext: () => contextValue! }; +} + +describe('DemoProvider', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('DemoProvider_Default_StartsWithEncounterScene', () => { + renderWithProvider(); + expect(screen.getByTestId('scene').textContent).toBe('encounter'); + }); + + it('DemoProvider_TransitionToFleet_SetsFleetScene', () => { + const { getContext } = renderWithProvider(); + + act(() => { + getContext().setScene('fleet'); + }); + + expect(screen.getByTestId('scene').textContent).toBe('fleet'); + }); + + it('DemoProvider_AutoPlay_CyclesThroughScenes', () => { + const { getContext } = renderWithProvider({ autoPlay: true }); + + // Starts at encounter + expect(getContext().scene).toBe('encounter'); + + // After 15s, should advance to fleet + act(() => { + vi.advanceTimersByTime(15000); + }); + expect(getContext().scene).toBe('fleet'); + + // After another 15s, should advance to case + act(() => { + vi.advanceTimersByTime(15000); + }); + expect(getContext().scene).toBe('case'); + + // After another 15s, should cycle back to encounter + act(() => { + vi.advanceTimersByTime(15000); + }); + expect(getContext().scene).toBe('encounter'); + }); + + it('DemoProvider_ResetDemo_ResetsAllState', () => { + const { getContext } = renderWithProvider(); + + // Set some state + act(() => { + getContext().setScene('fleet'); + getContext().setSelectedCaseId('case-123'); + getContext().setAutoPlay(true); + }); + + expect(getContext().scene).toBe('fleet'); + expect(getContext().selectedCaseId).toBe('case-123'); + expect(getContext().autoPlay).toBe(true); + + // Reset + act(() => { + getContext().resetDemo(); + }); + + expect(getContext().scene).toBe('encounter'); + expect(getContext().selectedCaseId).toBeNull(); + expect(getContext().autoPlay).toBe(false); + }); +}); diff --git a/apps/dashboard/src/components/demo/__tests__/SceneNav.test.tsx b/apps/dashboard/src/components/demo/__tests__/SceneNav.test.tsx new file mode 100644 index 0000000..053fd62 --- /dev/null +++ b/apps/dashboard/src/components/demo/__tests__/SceneNav.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { DemoProvider } from '../DemoProvider'; +import { SceneNav } from '../SceneNav'; + +// Mock TanStack Router hooks +const mockNavigate = vi.fn(); +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => mockNavigate, + useLocation: () => ({ pathname: '/ehr-demo' }), +})); + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderSceneNav() { + return render(, { wrapper }); +} + +describe('SceneNav', () => { + it('SceneNav_RendersThreePills_EncounterFleetCase', () => { + renderSceneNav(); + + expect(screen.getByRole('button', { name: /encounter/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /fleet/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /case detail/i })).toBeInTheDocument(); + }); + + it('SceneNav_ActiveScene_HasFilledStyle', () => { + renderSceneNav(); + + // Default scene is 'encounter', so it should have aria-current="page" + const encounterBtn = screen.getByRole('button', { name: /encounter/i }); + expect(encounterBtn).toHaveAttribute('aria-current', 'page'); + + const fleetBtn = screen.getByRole('button', { name: /fleet/i }); + expect(fleetBtn).not.toHaveAttribute('aria-current'); + }); + + it('SceneNav_ClickPill_CallsSetScene', () => { + renderSceneNav(); + + const fleetBtn = screen.getByRole('button', { name: /fleet/i }); + fireEvent.click(fleetBtn); + + // After click, fleet should now be active + expect(fleetBtn).toHaveAttribute('aria-current', 'page'); + + // And encounter should be inactive + const encounterBtn = screen.getByRole('button', { name: /encounter/i }); + expect(encounterBtn).not.toHaveAttribute('aria-current'); + }); + + it('SceneNav_ClickPill_NavigatesToRoute', () => { + renderSceneNav(); + + fireEvent.click(screen.getByRole('button', { name: /fleet/i })); + expect(mockNavigate).toHaveBeenCalledWith({ to: '/fleet' }); + + fireEvent.click(screen.getByRole('button', { name: /case detail/i })); + expect(mockNavigate).toHaveBeenCalledWith({ to: '/case/demo' }); + }); + + it('SceneNav_DemoControls_RendersResetButton', () => { + renderSceneNav(); + + expect(screen.getByRole('button', { name: /reset demo/i })).toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/components/demo/__tests__/SceneTransition.test.tsx b/apps/dashboard/src/components/demo/__tests__/SceneTransition.test.tsx new file mode 100644 index 0000000..9685ea8 --- /dev/null +++ b/apps/dashboard/src/components/demo/__tests__/SceneTransition.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { createElement, useState, type ReactNode } from 'react'; +import { SceneTransition } from '../SceneTransition'; + +// Mock motion to avoid animation issues in jsdom +vi.mock('motion/react', () => ({ + AnimatePresence: ({ children }: { children: ReactNode }) => + createElement('div', { 'data-testid': 'animate-presence' }, children), + motion: { + div: ({ children }: { children?: ReactNode }) => + createElement('div', { 'data-testid': 'motion-div' }, children), + }, +})); + +function SceneController({ initialScene = 'encounter' }: { initialScene?: string }) { + const [sceneKey, setSceneKey] = useState(initialScene); + + const sceneContent = + sceneKey === 'encounter' + ? createElement('div', { 'data-testid': 'encounter-content' }, 'Encounter Scene') + : sceneKey === 'fleet' + ? createElement('div', { 'data-testid': 'fleet-content' }, 'Fleet Scene') + : createElement('div', { 'data-testid': 'case-content' }, 'Case Scene'); + + return createElement( + 'div', + null, + createElement('button', { 'data-testid': 'set-fleet', onClick: () => setSceneKey('fleet') }, 'Go Fleet'), + createElement('button', { 'data-testid': 'set-case', onClick: () => setSceneKey('case') }, 'Go Case'), + createElement('button', { 'data-testid': 'set-encounter', onClick: () => setSceneKey('encounter') }, 'Go Encounter'), + createElement(SceneTransition, { sceneKey, children: sceneContent }), + ); +} + +describe('SceneTransition', () => { + it('SceneTransition_Default_RendersEncounterContent', () => { + render(createElement(SceneController)); + expect(screen.getByTestId('encounter-content')).toBeInTheDocument(); + }); + + it('SceneTransition_EncounterToFleet_RendersFleetScene', () => { + render(createElement(SceneController)); + expect(screen.getByTestId('encounter-content')).toBeInTheDocument(); + + act(() => { + screen.getByTestId('set-fleet').click(); + }); + + expect(screen.getByTestId('fleet-content')).toBeInTheDocument(); + expect(screen.queryByTestId('encounter-content')).not.toBeInTheDocument(); + }); + + it('SceneTransition_FleetToCase_RendersCaseScene', () => { + render(createElement(SceneController, { initialScene: 'fleet' })); + expect(screen.getByTestId('fleet-content')).toBeInTheDocument(); + + act(() => { + screen.getByTestId('set-case').click(); + }); + + expect(screen.getByTestId('case-content')).toBeInTheDocument(); + expect(screen.queryByTestId('fleet-content')).not.toBeInTheDocument(); + }); + + it('SceneTransition_PillNav_AllowsNonLinearNavigation', () => { + render(createElement(SceneController)); + expect(screen.getByTestId('encounter-content')).toBeInTheDocument(); + + // Jump directly from encounter to case (skipping fleet) + act(() => { + screen.getByTestId('set-case').click(); + }); + + expect(screen.getByTestId('case-content')).toBeInTheDocument(); + expect(screen.queryByTestId('encounter-content')).not.toBeInTheDocument(); + expect(screen.queryByTestId('fleet-content')).not.toBeInTheDocument(); + }); + + it('SceneTransition_AcceptsDirectionProp', () => { + // Verify component renders without error when direction prop is provided + render( + createElement(SceneTransition, { + sceneKey: 'encounter', + direction: 'zoom-out', + children: createElement('div', null, 'Test content'), + }), + ); + expect(screen.getByText('Test content')).toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/components/demo/transitionVariants.ts b/apps/dashboard/src/components/demo/transitionVariants.ts new file mode 100644 index 0000000..3eb25ac --- /dev/null +++ b/apps/dashboard/src/components/demo/transitionVariants.ts @@ -0,0 +1,28 @@ +/** + * Transition variants for scene changes in the demo. + * + * - zoom-out: Encounter -> Fleet ("zooming out to fleet view") + * - drill-down: Fleet -> Case ("drilling into a case") + * - generic: Pill nav (non-linear jump) + */ +export type TransitionDirection = 'zoom-out' | 'drill-down' | 'generic'; + +const EASING = [0.4, 0, 0.2, 1] as const; + +export const transitionVariants = { + 'zoom-out': { + initial: { opacity: 0, scale: 0.9 }, + animate: { opacity: 1, scale: 1, transition: { duration: 0.8, ease: EASING } }, + exit: { opacity: 0, scale: 0.85, transition: { duration: 0.8, ease: EASING } }, + }, + 'drill-down': { + initial: { opacity: 0, x: 80 }, + animate: { opacity: 1, x: 0, transition: { duration: 0.6, ease: EASING } }, + exit: { opacity: 0, x: -80, transition: { duration: 0.6, ease: EASING } }, + }, + generic: { + initial: { opacity: 0, x: 40 }, + animate: { opacity: 1, x: 0, transition: { duration: 0.5, ease: EASING } }, + exit: { opacity: 0, x: -40, transition: { duration: 0.5, ease: EASING } }, + }, +} as const; diff --git a/apps/dashboard/src/components/ehr/AuthDetectionBanner.tsx b/apps/dashboard/src/components/ehr/AuthDetectionBanner.tsx new file mode 100644 index 0000000..0515a1b --- /dev/null +++ b/apps/dashboard/src/components/ehr/AuthDetectionBanner.tsx @@ -0,0 +1,42 @@ +import { ShieldAlert } from 'lucide-react'; +import { motion, AnimatePresence } from 'motion/react'; + +interface AuthDetectionBannerProps { + visible: boolean; + payer: string; + policyId: string; + cptCode: string; +} + +export function AuthDetectionBanner({ + visible, + payer, + policyId, + cptCode, +}: AuthDetectionBannerProps) { + return ( + + {visible && ( + +
+ + Authorization Determination Engine + +
+
+ + + PA Required — {payer} {policyId} applies to CPT {cptCode} + +
+
+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/ehr/ChartTabPanel.tsx b/apps/dashboard/src/components/ehr/ChartTabPanel.tsx new file mode 100644 index 0000000..a241e90 --- /dev/null +++ b/apps/dashboard/src/components/ehr/ChartTabPanel.tsx @@ -0,0 +1,100 @@ +import type { DEMO_CHART_DATA } from '@/lib/demoData'; + +type ChartData = typeof DEMO_CHART_DATA; + +interface ChartTabPanelProps { + activeTab: string; + chartData: ChartData; +} + +function ProblemsPanel({ problems }: { problems: ChartData['problems'] }) { + return ( +
    + {problems.map((problem) => ( +
  • + {problem.code} + {problem.description} +
  • + ))} +
+ ); +} + +function MedicationsPanel({ medications }: { medications: ChartData['medications'] }) { + return ( +
    + {medications.map((med) => ( +
  • + {med.name} + + {med.dosage} {med.frequency} + +
  • + ))} +
+ ); +} + +function AllergiesPanel({ allergies }: { allergies: ChartData['allergies'] }) { + return ( +
+ {allergies === 'NKDA' ? 'No Known Drug Allergies' : allergies} +
+ ); +} + +function ImagingPanel({ imagingHistory }: { imagingHistory: ChartData['imagingHistory'] }) { + if (imagingHistory.length === 0) { + return
No prior lumbar imaging
; + } + + return ( +
    + {imagingHistory.map((item) => ( +
  • + {item.type} + {item.date} + {item.result} +
  • + ))} +
+ ); +} + +function VitalsPanel() { + return ( +
+
BP: 128/82
+
HR: 72
+
Temp: 98.6°F
+
SpO2: 99%
+
+ ); +} + +function LabsPanel({ labResults }: { labResults: ChartData['labResults'] }) { + return ( +
    + {labResults.map((lab) => ( +
  • + {lab.name} + {lab.value} + {lab.date} +
  • + ))} +
+ ); +} + +export function ChartTabPanel({ activeTab, chartData }: ChartTabPanelProps) { + return ( +
+ {activeTab === 'problems' && } + {activeTab === 'medications' && } + {activeTab === 'allergies' && } + {activeTab === 'imaging' && } + {activeTab === 'vitals' && } + {activeTab === 'labs' && } +
+ ); +} diff --git a/apps/dashboard/src/components/ehr/EhrHeader.tsx b/apps/dashboard/src/components/ehr/EhrHeader.tsx index ec237f5..e7b4809 100644 --- a/apps/dashboard/src/components/ehr/EhrHeader.tsx +++ b/apps/dashboard/src/components/ehr/EhrHeader.tsx @@ -14,6 +14,7 @@ interface EncounterMeta { specialty: string; date: string; type: string; + facility?: string; } interface EhrHeaderProps { @@ -87,7 +88,15 @@ export function EhrHeader({ patient, encounterMeta }: EhrHeaderProps) { · {encounterMeta.date} · - {encounterMeta.type} + + {encounterMeta.type} + + {encounterMeta.facility && ( + <> + · + {encounterMeta.facility} + + )}
)} diff --git a/apps/dashboard/src/components/ehr/EncounterSidebar.tsx b/apps/dashboard/src/components/ehr/EncounterSidebar.tsx index 294d023..e6f8d98 100644 --- a/apps/dashboard/src/components/ehr/EncounterSidebar.tsx +++ b/apps/dashboard/src/components/ehr/EncounterSidebar.tsx @@ -1,16 +1,36 @@ +import { useState } from 'react'; import type { EhrDemoState } from './useEhrDemoFlow'; +import type { DEMO_CHART_DATA } from '@/lib/demoData'; +import { ChartTabPanel } from './ChartTabPanel'; const ENCOUNTER_STAGES = ['Review', 'HPI', 'ROS', 'PE', 'A&P'] as const; +const ENHANCED_ENCOUNTER_STAGES = ['Intake', 'HPI', 'ROS', 'PE', 'A&P', 'Orders', 'Sign'] as const; + const PA_STAGES = ['Analyzing', 'Review', 'Submit', 'Complete'] as const; -export type StageName = typeof ENCOUNTER_STAGES[number]; +const ENHANCED_PA_STAGES = ['PA Review', 'PA Submit'] as const; + +export type StageName = typeof ENCOUNTER_STAGES[number] | typeof ENHANCED_ENCOUNTER_STAGES[number]; + +type ChartData = typeof DEMO_CHART_DATA; + +const CHART_TABS = [ + { id: 'problems', label: 'Problems' }, + { id: 'medications', label: 'Meds' }, + { id: 'allergies', label: 'Allergies' }, + { id: 'vitals', label: 'Vitals' }, + { id: 'imaging', label: 'Imaging' }, + { id: 'labs', label: 'Labs' }, +] as const; interface EncounterSidebarProps { activeStage?: StageName; signed?: boolean; flowState?: EhrDemoState; preCheckCount?: { met: number; total: number }; + chartData?: ChartData; + paDetected?: boolean; } type StageState = 'completed' | 'active' | 'pending'; @@ -113,30 +133,111 @@ function StageList({ ); } +function ChartTabsGrid({ + chartData, + activeTab, + onTabClick, +}: { + chartData: ChartData; + activeTab: string | null; + onTabClick: (tabId: string) => void; +}) { + return ( +
+

+ Chart +

+
+ {CHART_TABS.map((tab) => ( + + ))} +
+ {activeTab && ( +
+ +
+ )} +
+ ); +} + export function EncounterSidebar({ activeStage = 'A&P', signed = false, flowState = 'idle', preCheckCount, + chartData, + paDetected, }: EncounterSidebarProps) { + const [activeChartTab, setActiveChartTab] = useState(null); + + const isEnhanced = !!chartData; + const stages = isEnhanced ? ENHANCED_ENCOUNTER_STAGES : ENCOUNTER_STAGES; + // When signed, all encounter stages are completed (activeIndex past last stage) - const activeIndex = signed ? ENCOUNTER_STAGES.length : ENCOUNTER_STAGES.indexOf(activeStage); + const activeIndex = signed ? stages.length : (stages as readonly string[]).indexOf(activeStage); const isFlagged = flowState === 'flagged'; const showPAStages = flowState !== 'idle' && flowState !== 'error' && flowState !== 'flagged'; + + // In legacy mode, use PA_STAGES; in enhanced mode, use ENHANCED_PA_STAGES + const paStages = isEnhanced ? ENHANCED_PA_STAGES : PA_STAGES; const paActiveIndex = getPAActiveIndex(flowState); + const handleTabClick = (tabId: string) => { + setActiveChartTab((prev) => (prev === tabId ? null : tabId)); + }; + return (