Skip to content

Commit

Permalink
[Closes #115] Display a table of all patients (#146)
Browse files Browse the repository at this point in the history
* Create starter patients list page

* Format files

* Add a route to list all patients based on search term

* Format files

* Add tests for listing patients route

* Provide default value for patient as empty string

* Destructure options so include can be used with function

The option include causes an error if passed as an argument for count() so it needs to be extracted from options

* Update route to return additional necessary fields

* Update tests

* Add an API call to get patients

* Basic display of all patients in a table format

* Add some styling to table

* Add a rounded border around table

* Add action menu button at end of each row

* Add header section

* Link view profile button to patient profile page

* Add icons to action menu options

* Adjust rounding of border to avoid blank space

* Utilize size prop from tabler icons

* Refactor table component into separate component

* Refactor table rows to be more dynamic

* Adjust key value and style menu

* Add JSDoc comment

* Refactor API call for patients into separate file

* Add search patients functionality

* Add pagination to patients page

* Return total pages from api results for pagination

* Apply loading overlay to table component rather than page

* Add an empty table state

* Remove renewal and create buttons

* Add a space between user first and last name

* Add margin spacing between table and pagination

* Add spacing between bottom of page and aain content

* Add a scrollable container to the table

* Increase responsiveness of grid layout on smaller screens

* Change to native type so scroll area is hidden when not used

* Reduce complexity of grid template column styling

* Add a modal that confirms patient deletion if ADMIN user

* Add a DELETE patient route

* Remove use of array for role verification

* Refactor usePatients hook

* Add delete patient API calls

* Add delete patient functionality from table

* Explicitly return mutate function from useDeletePatient

* Move table rows into its own component

* Utilize async mutate fn and export isPending to help with loading states

* Refactor table and utilize memoization

* Remove unnecessary useMemo

* Replace empty name fields with a dash

* Fix typo

* Allow api to return empty name fields from database

* Style modal buttons

* Format files

* Fix typo in whereClause

* Change use of p tag to Mantine Text component for consistency

* Restyle modal text to be more appealing

---------

Co-authored-by: Francis Li <[email protected]>
  • Loading branch information
samau3 and francisli authored Nov 1, 2024
1 parent 1bee132 commit 85ab24e
Show file tree
Hide file tree
Showing 15 changed files with 926 additions and 5 deletions.
2 changes: 2 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import AuthLayout from './stories/AuthLayout/AuthLayout';
import Verify from './pages/verify/verify';
import PatientRegistration from './pages/patients/register/PatientRegistration';
import PatientDetails from './pages/patients/patient-details/PatientDetails';
import Patients from './pages/patients/Patients';

const RedirectProps = {
isLoading: PropTypes.bool.isRequired,
Expand Down Expand Up @@ -140,6 +141,7 @@ function App() {
element={<AdminPatientsGenerate />}
/>
<Route element={<Layout />}>
<Route path="/patients" element={<Patients />} />
<Route path="/patients/:patientId" element={<PatientDetails />} />
<Route
path="/patients/register/:patientId"
Expand Down
6 changes: 5 additions & 1 deletion client/src/components/Sidebar/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ const sections = [
label: 'Management',
icon: null,
links: [
{ label: 'Patient', href: '/', icon: <IconEmergencyBed stroke={2} /> },
{
label: 'Patients',
href: '/patients',
icon: <IconEmergencyBed stroke={2} />,
},
{
label: 'Team Member',
href: '/admin/users',
Expand Down
14 changes: 14 additions & 0 deletions client/src/pages/patients/LifelineAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export default class LifelineAPI {
return response;
}

static async getPatients(query, page) {
const response = await fetch(
`${SERVER_BASE_URL}/patients?patient=${query}&page=${page}`,
);
return response;
}

static async registerPatient(data, patientId) {
const response = await fetch(`${SERVER_BASE_URL}/patients`, {
method: 'POST',
Expand All @@ -71,6 +78,13 @@ export default class LifelineAPI {
return response;
}

static async deletePatient(patientId) {
const response = await fetch(`${SERVER_BASE_URL}/patients/${patientId}`, {
method: 'DELETE',
});
return response;
}

static async getMedicalData(path, pathInfo, query) {
const response = await fetch(
`${SERVER_BASE_URL}/${path}?${pathInfo}=${query}`,
Expand Down
89 changes: 89 additions & 0 deletions client/src/pages/patients/PatientTableRow.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import PropTypes from 'prop-types';

import { Link } from 'react-router-dom';

import { Table, Menu, ActionIcon } from '@mantine/core';
import {
IconDotsVertical,
IconUser,
IconQrcode,
IconTrash,
} from '@tabler/icons-react';

const patientTableRowProps = {
headers: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string.isRequired,
text: PropTypes.node,
}),
),
patient: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
createdBy: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
updatedBy: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired,
}),

onDelete: PropTypes.func.isRequired,
showDeleteMenu: PropTypes.bool.isRequired,
};

/**
* Patient table row component
* @param {PropTypes.InferProps<typeof patientTableRowProps>} props
*/
export default function PatientTableRow({
headers,
patient,
onDelete,
showDeleteMenu,
}) {
return (
<Table.Tr key={patient.id}>
{headers.map((header) => (
<Table.Td key={patient[header.key] + header.key}>
{patient[header.key]}
</Table.Td>
))}
<Table.Td>
<Menu shadow="md">
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDotsVertical size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconUser size={18} />}
component={Link}
to={`/patients/${patient.id}`}
>
View/Edit
</Menu.Item>
<Menu.Item leftSection={<IconQrcode size={18} />}>
Reprint QR Code
</Menu.Item>
{showDeleteMenu && (
<Menu.Item
leftSection={<IconTrash size={18} />}
color="red"
onClick={() =>
onDelete({
id: patient.id,
name: patient.name,
})
}
>
Delete
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
);
}

PatientTableRow.propTypes = patientTableRowProps;
63 changes: 63 additions & 0 deletions client/src/pages/patients/Patients.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {
Container,
Group,
TextInput,
Divider,
Pagination,
LoadingOverlay,
Text,
} from '@mantine/core';
import { useDebouncedCallback } from '@mantine/hooks';
import { useState } from 'react';

import { IconSearch } from '@tabler/icons-react';

import classes from './Patients.module.css';
import PatientsTable from './PatientsTable';
import { usePatients } from './usePatients';

/**
* Patients page component
*
*/
export default function Patients() {
const [inputValue, setInputValue] = useState('');
const { patients, headers, isFetching, page, pages, setPage, setSearch } =
usePatients();

const handleSearch = useDebouncedCallback((query) => {
setSearch(query);
}, 500);

return (
<Container>
<div className={classes.header}>
<Text fw={600} size="xl" mr="md">
Patients
</Text>
<Group>
<TextInput
leftSectionPointerEvents="none"
leftSection={<IconSearch stroke={2} />}
placeholder="Search"
onChange={(event) => {
setInputValue(event.currentTarget.value);
handleSearch(event.currentTarget.value);
}}
value={inputValue}
/>
</Group>
</div>
<Divider mb="xl" />
<Container className={classes.patientsContainer}>
<LoadingOverlay
visible={isFetching}
zIndex={1000}
overlayProps={{ radius: 'sm', blur: 2 }}
/>
<PatientsTable headers={headers} data={patients} />
<Pagination total={pages} value={page} onChange={setPage} />
</Container>
</Container>
);
}
29 changes: 29 additions & 0 deletions client/src/pages/patients/Patients.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.tableWrapper {
margin-bottom: var(--mantine-spacing-xs);
}

.table {
border-radius: 7px;
overflow: hidden;
}

.title {
font-size: 1.2rem;
font-weight: 600;
color: var(--mantine-color-red-8);
}

.header {
display: flex;
justify-content: space-between;
margin: 1rem 1rem;
}

.patientsContainer {
position: relative;
margin-bottom: var(--mantine-spacing-lg);
}

.button {
margin-top: var(--mantine-spacing-lg);
}
152 changes: 152 additions & 0 deletions client/src/pages/patients/PatientsTable.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import PropTypes from 'prop-types';

import { useState, useContext, useMemo, useCallback } from 'react';
import { Paper, Table, Modal, Button, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useDeletePatient } from './useDeletePatient';
import { notifications } from '@mantine/notifications';
import classes from './Patients.module.css';
import Context from '../../Context';

import PatientTableRow from './PatientTableRow';

const patientTableProps = {
headers: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string.isRequired,
text: PropTypes.node,
}),
),
data: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
createdBy: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
updatedBy: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired,
}),
),
};

/**
* Patients table component
* @param {PropTypes.InferProps<typeof patientTableProps>} props
*/
export default function PatientsTable({ headers, data }) {
const [opened, { open, close }] = useDisclosure(false);
const [selectedPatient, setSelectedPatient] = useState(null);
const { mutateAsync: deletePatient, isPending } = useDeletePatient();
const { user } = useContext(Context);

const showDeleteConfirmation = useCallback(
(patient) => {
setSelectedPatient(patient);
open();
},
[open],
);
const confirmPatientDeletion = async () => {
try {
await deletePatient(selectedPatient.id);
notifications.show({
title: 'Success',
message: 'Patient deleted successfully.',
color: 'green',
});
} catch (error) {
console.error('Failed to delete patient:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete patient.',
color: 'red',
});
}
if (!isPending) {
setSelectedPatient(null);
close();
}
};

const cancelPatientDeletion = () => {
setSelectedPatient(null);
close();
};

const emptyStateRow = useMemo(
() => (
<Table.Tr>
<Table.Td colSpan={headers.length}>No patients found.</Table.Td>
</Table.Tr>
),
[headers.length],
);

const patientRows = useMemo(() => {
return data?.map((patient) => (
<PatientTableRow
key={patient.id}
patient={patient}
headers={headers}
onDelete={showDeleteConfirmation}
showDeleteMenu={user?.role === 'ADMIN'}
/>
));
}, [data, headers, user.role, showDeleteConfirmation]);

return (
<>
<Paper withBorder className={classes.tableWrapper}>
<Table.ScrollContainer minWidth={500} type="native">
<Table
stickyHeader
highlightOnHover
verticalSpacing="lg"
classNames={{ table: classes.table }}
>
<Table.Thead>
<Table.Tr>
{headers.map((header) => (
<Table.Th key={header.key}>{header.text}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.length > 0 ? patientRows : emptyStateRow}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Paper>
<Modal
opened={opened}
onClose={close}
title="Delete Patient"
classNames={{ title: classes.title }}
>
<Text fw={600}>
Are you sure you want to delete this patient record?
</Text>
<Button
classNames={{ root: classes.button }}
color="red"
fullWidth
onClick={confirmPatientDeletion}
loading={isPending}
>
Yes
</Button>
<Button
classNames={{ root: classes.button }}
color="blue"
fullWidth
onClick={cancelPatientDeletion}
disabled={isPending}
>
No
</Button>
</Modal>
</>
);
}

PatientsTable.propTypes = patientTableProps;
Loading

0 comments on commit 85ab24e

Please sign in to comment.