# Patient Records UI Patterns (Reference) Dual-platform patterns for patient lookup, profiles, history, records, documents, and risk scoring. Web: Bootstrap 5/Tabler + PHP. Android: Jetpack Compose + Material 3. ## 1. Patient Lookup List **Search:** Full name, partial name, MRN, phone, DOB, room number. Debounced 300ms, min 2 chars. Display differentiating data (age, MRN last 4) to prevent misidentification. Store last 10 lookups per session. **Filters** (collapsible sidebar web / bottom sheet Android): Care Program, Medical Condition, Appointment Status (Scheduled|Checked-In|In-Progress|Completed|No-Show), Provider, Ward/Department. **Tabs:** `[ All ] [ Current ] [ New ] [ Discharged ] [ High Priority(3) ]` -- server-side filtered. **Favorites:** Star icon per item, persisted in `user_patient_favorites`. Starred section above results. **List item anatomy:** ``` [Avatar] Jane Doe, F, 42 | MRN: 7823 | [Allergy: Penicillin] Dr. Smith (Primary) | Next: Feb 25, 10:00 AM | Risk: [HIGH pill] ``` Avatar: initials fallback, color-coded by gender. Allergy: red=severe, amber=moderate, hidden=none. ### Web: DataTables + Tabler Card ```html

Patient Lookup

PatientMRNProviderNext ApptRisk
``` ```javascript // Debounced search (300ms) let t; document.getElementById('patientSearch').addEventListener('input', function() { clearTimeout(t); t = setTimeout(() => { $('#patientTable').DataTable().search(this.value).draw(); }, 300); }); ``` ### Android: LazyColumn + Sticky Headers + Pull-to-Refresh ```kotlin @Composable fun PatientListScreen(viewModel: PatientListViewModel = hiltViewModel()) { val patients by viewModel.patients.collectAsStateWithLifecycle() val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() var showFilters by remember { mutableStateOf(false) } PullToRefreshBox(state = rememberPullToRefreshState(), onRefresh = { viewModel.refresh() }) { Column(Modifier.fillMaxSize()) { SearchBar(query = searchQuery, onQueryChange = { viewModel.updateSearch(it) }, placeholder = "Name, MRN, phone, DOB...", trailingIcon = { IconButton(onClick = { showFilters = true }) { Icon(Icons.Default.FilterList, "Filters") } }) ClassificationTabRow(viewModel) LazyColumn { patients.groupBy { it.ward }.forEach { (ward, wardPatients) -> stickyHeader { WardHeader(ward) } items(wardPatients, key = { it.mrn }) { patient -> PatientListItem(patient, onStarClick = { viewModel.toggleFavorite(it) }) } } } } } if (showFilters) FilterBottomSheet(onDismiss = { showFilters = false }) } @Composable fun PatientListItem(patient: Patient, onStarClick: (Patient) -> Unit) { Card(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { PatientAvatar(patient.name, patient.photoUrl, patient.gender) Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text("${patient.name}, ${patient.genderShort}, ${patient.age}", style = MaterialTheme.typography.titleSmall) Spacer(Modifier.width(8.dp)) Text("MRN: ${patient.mrn}", style = MaterialTheme.typography.bodySmall) } if (patient.allergies.isNotEmpty()) AllergyBadge(patient.allergies) Row { Text(patient.primaryProvider, style = MaterialTheme.typography.bodySmall) Spacer(Modifier.weight(1f)); RiskLevelChip(patient.riskLevel) } } IconButton(onClick = { onStarClick(patient) }) { Icon(if (patient.isFavorite) Icons.Filled.Star else Icons.Outlined.StarBorder, tint = if (patient.isFavorite) Color(0xFFF59F00) else Color.Gray, contentDescription = "Favorite") } } } } ``` ## 2. Patient Profile / Summary ``` +------------------------------------------------------------------+ | [Photo] Jane Doe DOB: 1984-03-15 | Age: 42 | F | | MRN: 7823 Blood: O+ | Lang: English | +------------------------------------------------------------------+ | !! ALLERGY: Penicillin (SEVERE) | Sulfa (Moderate) !! | +------------------------------------------------------------------+ | [Last Visit] [Active Meds] [Open Orders] [Risk Score] | | Feb 20, 2026 5 medications 2 pending 72 (HIGH) | +------------------------------------------------------------------+ | Overview | Vitals | Records | Meds | Labs | Documents | Billing | +------------------------------------------------------------------+ ``` **Rules:** Allergy banner ALWAYS visible, never scrolls off. Red (`bg-danger`) = severe, amber (`bg-warning`) = moderate. Quick-stat cards are clickable to navigate to detail tabs. ### Web: Tabler Card + Tabs ```html

Jane Doe

DOB: 1984-03-15 | Age: 42 | Female | MRN: 7823 | Blood: O+ | English
``` ### Android: Collapsing Toolbar + TabRow + HorizontalPager ```kotlin @Composable fun PatientProfileScreen(patientId: String, vm: PatientProfileViewModel = hiltViewModel()) { val patient by vm.patient.collectAsStateWithLifecycle() val pagerState = rememberPagerState(pageCount = { 7 }) val tabs = listOf("Overview","Vitals","Records","Meds","Labs","Documents","Billing") val scope = rememberCoroutineScope() Scaffold(topBar = { CollapsingToolbarLayout(patient) }) { padding -> Column(Modifier.padding(padding)) { patient?.allergies?.let { AllergyBanner(it) } QuickStatRow(patient) ScrollableTabRow(selectedTabIndex = pagerState.currentPage) { tabs.forEachIndexed { i, title -> Tab(selected = pagerState.currentPage == i, onClick = { scope.launch { pagerState.animateScrollToPage(i) } }, text = { Text(title) }) } } HorizontalPager(state = pagerState) { page -> when (page) { 0 -> OverviewTab(patient); 1 -> VitalsTab(patientId) 2 -> RecordsTab(patientId); 3 -> MedicationsTab(patientId) 4 -> LabResultsTab(patientId); 5 -> DocumentsTab(patientId) 6 -> BillingTab(patientId) }} } } } @Composable fun AllergyBanner(allergies: List) { val hasSevere = allergies.any { it.severity == Severity.SEVERE } Surface(color = if (hasSevere) MaterialTheme.colorScheme.error else Color(0xFFF59F00), modifier = Modifier.fillMaxWidth()) { Text("ALLERGY: " + allergies.joinToString(" | ") { "${it.name} (${it.severity})" }, color = Color.White, fontWeight = FontWeight.Bold, modifier = Modifier.padding(8.dp), textAlign = TextAlign.Center) } } ``` ## 3. Medical History Timeline Chronological vertical timeline (newest first). Year-based grouping with filter dropdown. **Event types with color-coded icons:** | Type | Color | Icon | Type | Color | Icon | |------|-------|------|------|-------|------| | Visit | `#206bc4` Blue | Stethoscope | Procedure | `#f76707` Orange | Scalpel | | Lab | `#0ca678` Teal | Flask | Medication | `#f59f00` Amber | Pill | | Imaging | `#ae3ec9` Purple | Camera | Discharge | `#2fb344` Green | Door-open | ``` 2026 ----+ Feb 20 o---- [Visit] Dr. Smith - Annual checkup | BP 120/80, Weight 165 lbs. [Expand] Feb 10 o---- [Lab] CBC Panel - Results normal | Hemoglobin 14.2, WBC 7.5k. [View Report] Jan 15 o---- [Medication] Lisinopril 10mg started 2025 ----+ Dec 01 o---- [Procedure] Knee arthroscopy (right) ``` ### Web: CSS Timeline ```html
2026
Visit Feb 20, 2026

Dr. Smith - Annual Checkup

BP 120/80, Weight 165 lbs.

Expand
``` ### Android: Canvas Timeline Connector ```kotlin @Composable fun MedicalTimeline(events: List) { LazyColumn(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { events.groupBy { it.date.year }.forEach { (year, yearEvents) -> item { Text("$year", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(vertical = 8.dp)) } itemsIndexed(yearEvents) { i, event -> TimelineItem(event, i == yearEvents.lastIndex) } } } } @Composable fun TimelineItem(event: TimelineEvent, isLast: Boolean) { val color = when (event.type) { EventType.VISIT -> Color(0xFF206BC4); EventType.LAB -> Color(0xFF0CA678) EventType.IMAGING -> Color(0xFFAE3EC9); EventType.PROCEDURE -> Color(0xFFF76707) EventType.MEDICATION -> Color(0xFFF59F00); EventType.DISCHARGE -> Color(0xFF2FB344) } Row(Modifier.fillMaxWidth()) { Box(Modifier.width(32.dp).height(IntrinsicSize.Min)) { Canvas(Modifier.fillMaxSize()) { drawCircle(color, 7.dp.toPx(), Offset(size.width / 2, 20.dp.toPx())) if (!isLast) drawLine(Color.LightGray, Offset(size.width / 2, 34.dp.toPx()), Offset(size.width / 2, size.height), 2.dp.toPx()) } } ElevatedCard(Modifier.weight(1f).padding(bottom = 8.dp)) { Column(Modifier.padding(12.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { SuggestionChip(onClick = {}, label = { Text(event.type.label) }, colors = SuggestionChipDefaults.suggestionChipColors( containerColor = color.copy(alpha = 0.1f))) Text(event.date.format(), style = MaterialTheme.typography.labelSmall) } Text(event.title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 4.dp)) Text(event.summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } } ``` ## 4. Patient Record Cards Card-based modular layout: Diagnoses (ICD codes, active/resolved), Insurance (provider, policy, coverage, co-pay), Emergency Contacts (name, relationship, phone, priority), Care Team (primary, specialists, nurses). **Grid:** Web 3-col lg / 2-col md / 1-col sm. Android `GridCells.Adaptive(300.dp)`. ``` +-------------------+-------------------+-------------------+ | DIAGNOSES | INSURANCE | EMERGENCY CONTACTS| | E11.9 T2 Diabetes | BlueCross PPO | John Doe (Spouse) | | Active, 2023-06 | BC-445521 | (555) 123-4567 | +-------------------+-------------------+-------------------+ ``` ### Web ```html

Diagnoses

E11.9 Type 2 Diabetes
Active

Insurance

Provider
BlueCross PPO
Policy #
BC-445521
Coverage
01/2026 - 12/2026
Co-pay
$30
``` ### Android ```kotlin @Composable fun PatientRecordCards(patient: Patient) { LazyVerticalGrid(columns = GridCells.Adaptive(300.dp), contentPadding = PaddingValues(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { item { DiagnosesCard(patient.diagnoses) } item { InsuranceCard(patient.insurance) } item { EmergencyContactsCard(patient.emergencyContacts) } item { CareTeamCard(patient.careTeam) } } } @Composable fun DiagnosesCard(diagnoses: List) { ElevatedCard(Modifier.fillMaxWidth()) { Column { Text("Diagnoses", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(16.dp)) HorizontalDivider() diagnoses.forEach { dx -> ListItem( headlineContent = { Text("${dx.icdCode} ${dx.name}") }, supportingContent = { Text("Diagnosed: ${dx.dateFormatted}") }, trailingContent = { SuggestionChip(onClick = {}, label = { Text(if (dx.isActive) "Active" else "Resolved") }, colors = SuggestionChipDefaults.suggestionChipColors(containerColor = (if (dx.isActive) Color(0xFF2FB344) else Color.Gray).copy(alpha = 0.12f))) }) } }} } ``` ## 5. Document Management **Categories:** Clinical Notes | Lab Results | Imaging | Consent Forms | Referrals. Each row: type icon, filename, category, date, uploader, size. Version history per row. Inline preview panel. Upload: drag-drop (web) or camera/file picker (Android). Max 25MB. Accepted: PDF, JPG, PNG, DICOM. ### Web ```html

Drag files here or browse

PDF, JPG, PNG, DICOM. Max 25MB.
``` ### Android ```kotlin @Composable fun DocumentsTab(patientId: String, vm: DocumentsViewModel = hiltViewModel()) { val docs by vm.documents.collectAsStateWithLifecycle() val launcher = rememberLauncherForActivityResult( ActivityResultContracts.OpenMultipleDocuments()) { uris -> vm.uploadDocuments(uris) } Column(Modifier.fillMaxSize()) { LazyRow(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(DocumentCategory.entries) { cat -> FilterChip(selected = vm.selectedCategory == cat, onClick = { vm.filterByCategory(cat) }, label = { Text(cat.label) }) } } OutlinedButton(onClick = { launcher.launch(arrayOf("application/pdf","image/*")) }, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)) { Icon(Icons.Default.CloudUpload, null); Spacer(Modifier.width(8.dp)); Text("Upload") } LazyColumn { items(docs, key = { it.id }) { doc -> DocumentListItem(doc, onPreview = { vm.preview(doc) }, onVersionHistory = { vm.showVersions(doc) }) } } } } ``` ## 6. Acuity / Risk Scoring Display | Level | Background | Text | Meaning | |-------|------------|------|---------| | Low | `#d4edda` | `#155724` | Routine care | | Moderate | `#fff3cd` | `#856404` | Needs monitoring | | High | `#f8d7da` | `#721c24` | Requires intervention | | Critical | `#721c24` | `#ffffff` | Immediate attention | **Trend:** Improving (down arrow, green) | Stable (right arrow, gray) | Worsening (up arrow, red). **Breakdown card:** Score + level pill + trend + rows: HCC Score, Chronic Count, ER Visits/yr, Med Adherence with delta values. **Web badges:** `bg-success`=LOW, `bg-warning text-dark`=MODERATE, `bg-danger`=HIGH, `style="background:#721c24;color:#fff"`=CRITICAL. ### Android Risk Composables ```kotlin @Composable fun RiskLevelChip(level: RiskLevel) { val (bg, fg) = when (level) { RiskLevel.LOW -> Color(0xFFD4EDDA) to Color(0xFF155724) RiskLevel.MODERATE -> Color(0xFFFFF3CD) to Color(0xFF856404) RiskLevel.HIGH -> Color(0xFFF8D7DA) to Color(0xFF721C24) RiskLevel.CRITICAL -> Color(0xFF721C24) to Color.White } SuggestionChip(onClick = {}, label = { Text(level.name, fontWeight = FontWeight.Bold) }, colors = SuggestionChipDefaults.suggestionChipColors(containerColor = bg, labelColor = fg)) } @Composable fun RiskScoreCard(score: RiskScore) { ElevatedCard(Modifier.fillMaxWidth().padding(12.dp)) { Column(Modifier.padding(16.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text("Risk Score: ${score.value}", style = MaterialTheme.typography.headlineSmall) RiskLevelChip(score.level) } TrendIndicator(score.trend) HorizontalDivider(Modifier.padding(vertical = 8.dp)) score.breakdown.forEach { (label, value, delta) -> Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(label, Modifier.weight(1f)); Text(value, Modifier.width(60.dp)); DeltaText(delta) } } }} } ``` ## 7. Search Best Practices - **Two-patient verification:** Similar names trigger side-by-side comparison before selection - **Differentiators:** Always show age, MRN last 4, DOB alongside name - **Barcode/QR:** Web accepts scanner input in text field; Android uses CameraX + ML Kit - **Audit:** Log every access with user ID, timestamp, patient MRN, access reason ### Debounce Pattern (Android ViewModel) ```kotlin private val _searchQuery = MutableStateFlow("") val patients = _searchQuery.debounce(300).filter { it.length >= 2 } .flatMapLatest { repository.searchPatients(it) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) ``` ### Server-Side DataTables (Web) ```javascript $('#patientTable').DataTable({ processing: true, serverSide: true, ajax: { url: '/api/patients/search', type: 'POST' }, columns: [{ data:'star', orderable:false }, { data:'name' }, { data:'mrn' }, { data:'provider' }, { data:'next_appointment' }, { data:'risk_level', render: riskBadgeRenderer }], pageLength: 25, deferRender: true }); ``` ### Barcode Scanner (Android) ```kotlin @Composable fun BarcodeScanButton(onScanned: (String) -> Unit) { val launcher = rememberLauncherForActivityResult(ScanContract()) { result -> result.contents?.let { onScanned(it) } } IconButton(onClick = { launcher.launch(ScanOptions().apply { setDesiredBarcodeFormats(ScanOptions.CODE_128, ScanOptions.QR_CODE) setPrompt("Scan patient wristband"); setBeepEnabled(true) }) }) { Icon(Icons.Default.QrCodeScanner, "Scan wristband") } } ```