Skip to main content
Glama
PatientList.tsx9.51 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { Badge, Button, Group, Modal, MultiSelect, Stack, Table, Text, TextInput } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; import '@mantine/notifications/styles.css'; import { normalizeErrorString } from '@medplum/core'; import type { Organization, Patient } from '@medplum/fhirtypes'; import { useMedplum, useMedplumNavigate } from '@medplum/react'; import { IconPlus, IconRefresh, IconSearch } from '@tabler/icons-react'; import { useCallback, useEffect, useState } from 'react'; import type { JSX } from 'react'; import { enrollPatient, getEnrolledPatients, unEnrollPatient } from '../utils/enrollment'; interface PatientListProps { organization: Organization; } /** * A component that displays a list of patients for an organization. * Supports searching, filtering, enrollment, and unenrollment actions. * * @param props - The component props * @param props.organization - The organization to show patients for * @returns A table displaying the patients with their names and actions */ export function PatientList({ organization }: PatientListProps): JSX.Element { const medplum = useMedplum(); const [patients, setPatients] = useState<Patient[]>([]); const [filteredPatients, setFilteredPatients] = useState<Patient[]>([]); const [isLoading, setIsLoading] = useState<boolean>(false); const [searchFilter, setSearchFilter] = useState<string>(''); const [enrollModalOpen, setEnrollModalOpen] = useState<boolean>(false); const [selectedPatients, setSelectedPatients] = useState<Patient[]>([]); const [availablePatients, setAvailablePatients] = useState<{ value: string; label: string }[]>([]); const [selectedPatientIds, setSelectedPatientIds] = useState<string[]>([]); const navigate = useMedplumNavigate(); // Load enrolled Patients to display for the current clinic const loadPatients = useCallback(async (): Promise<void> => { try { setIsLoading(true); const patients = await getEnrolledPatients(medplum, organization); setPatients(patients); setFilteredPatients(patients); } catch (error) { showNotification({ title: 'Error', message: normalizeErrorString(error), color: 'red', }); } finally { setIsLoading(false); } }, [medplum, organization]); // Load the available patients for enrollment (patients not already enrolled in the clinic) const loadAvailablePatients = useCallback(async (): Promise<void> => { try { setIsLoading(true); const searchResult = await medplum.search('Patient', { _count: 100, _fields: 'name,meta', }); const allPatients = searchResult.entry?.map((e) => e.resource as Patient) ?? []; const enrolledPatients = await getEnrolledPatients(medplum, organization); const availablePatients = allPatients.filter((patient) => !enrolledPatients.some((p) => p.id === patient.id)); const options = availablePatients.map((patient) => ({ value: patient.id as string, label: getName(patient), })); setAvailablePatients(options); } catch (error) { showNotification({ title: 'Error', message: normalizeErrorString(error), color: 'red', }); } finally { setIsLoading(false); } }, [medplum, organization]); useEffect(() => { loadPatients().catch(console.error); }, [loadPatients]); useEffect(() => { if (enrollModalOpen) { loadAvailablePatients().catch(console.error); } }, [enrollModalOpen, loadAvailablePatients]); useEffect(() => { if (!searchFilter) { setFilteredPatients(patients); return; } const filtered = patients.filter((patient) => { const name = getName(patient).toLowerCase(); return name.includes(searchFilter.toLowerCase()); }); setFilteredPatients(filtered); }, [searchFilter, patients]); // Get the name of a patient const getName = (patient: Patient): string => { if (!patient.name?.[0]) { return 'Unknown'; } const name = patient.name[0]; return `${name.given?.[0] ?? ''} ${name.family ?? ''}`.trim(); }; // Unenroll a Patient from the organization const handleUnenroll = async (patient: Patient): Promise<void> => { try { await unEnrollPatient(medplum, patient, organization); // Refresh the list after unenrollment const updatedPatients = patients.filter((p) => p.id !== patient.id); setPatients(updatedPatients); setFilteredPatients(updatedPatients); showNotification({ title: 'Success', message: `Successfully unenrolled ${getName(patient)} from ${organization.name}`, color: 'green', }); loadPatients().catch(console.error); } catch (error) { showNotification({ title: 'Error', message: normalizeErrorString(error), color: 'red', }); } }; // Handle selection change in MultiSelect const handleSelectionChange = async (selectedIds: string[]): Promise<void> => { setSelectedPatientIds(selectedIds); try { const patients: Patient[] = []; for (const id of selectedIds) { const patient = await medplum.readResource('Patient', id); patients.push(patient); } setSelectedPatients(patients); } catch (error) { console.error('Error loading selected patients:', normalizeErrorString(error)); } }; const handleEnroll = async (): Promise<void> => { try { let successCount = 0; if (selectedPatients.length > 0) { for (const patient of selectedPatients) { await enrollPatient(medplum, patient, organization); successCount++; } showNotification({ title: 'Success', message: `${successCount} patient${successCount !== 1 ? 's' : ''} enrolled in ${organization.name}`, color: 'green', }); } // Reset form, close modal, and refresh the list setSelectedPatients([]); setSelectedPatientIds([]); setEnrollModalOpen(false); loadPatients().catch(console.error); } catch (error) { showNotification({ title: 'Error', message: normalizeErrorString(error), color: 'red', }); } }; return ( <Stack gap="lg"> <Group justify="space-between" align="center"> <Group> <TextInput placeholder="Search patients..." value={searchFilter} onChange={(e) => setSearchFilter(e.target.value)} leftSection={<IconSearch size={16} />} style={{ width: '300px' }} /> <Badge size="lg" variant="light"> {patients.length} Patients </Badge> </Group> <Group> <Button leftSection={<IconPlus size={16} />} onClick={() => setEnrollModalOpen(true)}> Enroll New Patient </Button> </Group> </Group> <Table> <Table.Thead> <Table.Tr> <Table.Th style={{ width: '80%' }}>Name</Table.Th> <Table.Th> <Group justify="flex-end"> <Button size="xs" variant="subtle" color="gray" onClick={() => { loadPatients().catch(console.error); }} loading={isLoading} p={6} aria-label="Refresh list" > <IconRefresh size={16} /> </Button> </Group> </Table.Th> </Table.Tr> </Table.Thead> <Table.Tbody> {filteredPatients.map((patient) => ( <Table.Tr key={patient.id}> <Table.Td> <Text style={{ cursor: 'pointer' }} onClick={() => navigate(`/Patient/${patient.id}`)}> {getName(patient)} </Text> </Table.Td> <Table.Td> <Group justify="flex-end"> <Button size="xs" color="red" onClick={() => handleUnenroll(patient)}> Unenroll </Button> </Group> </Table.Td> </Table.Tr> ))} </Table.Tbody> </Table> <Modal opened={enrollModalOpen} onClose={() => { setEnrollModalOpen(false); setSelectedPatients([]); setSelectedPatientIds([]); }} title="Enroll New Patients" size="lg" > <Stack> <Group align="flex-end"> <MultiSelect data={availablePatients} placeholder="Search for patients..." searchable nothingFoundMessage="No patients found" value={selectedPatientIds} onChange={handleSelectionChange} maxDropdownHeight={200} disabled={isLoading} description={isLoading ? 'Loading available patients...' : 'Select patients to enroll'} style={{ flex: 1 }} /> <Button onClick={handleEnroll} disabled={selectedPatients.length === 0 || isLoading} loading={isLoading}> Enroll </Button> </Group> </Stack> </Modal> </Stack> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server