diff --git a/packages/esm-patient-banner-app/src/banner-tags/print-identifier-sticker.component.tsx b/packages/esm-patient-banner-app/src/banner-tags/print-identifier-sticker.component.tsx new file mode 100644 index 0000000000..221b3128cf --- /dev/null +++ b/packages/esm-patient-banner-app/src/banner-tags/print-identifier-sticker.component.tsx @@ -0,0 +1,198 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, ModalBody, ModalFooter, ModalHeader } from '@carbon/react'; +import styles from './print-identifier-sticker.scss'; +import { useReactToPrint } from 'react-to-print'; +import { Column } from '@carbon/react'; +import { Grid } from '@carbon/react'; +import { age, displayName, showSnackbar, useConfig } from '@openmrs/esm-framework'; + +interface PrintIdentifierStickerProps { + patient: fhir.Patient; + closeModal: () => void; +} + +const PrintIdentifierSticker: React.FC = ({ patient, closeModal }) => { + const { t } = useTranslation(); + const [isPrinting, setIsPrinting] = useState(false); + const contentToPrintRef = useRef(null); + const [printRetryCount, setPrintRetryCount] = useState(0); + const { printIdentifierStickerFields, printIdentifierStickerSize, excludePatientIdentifierCodeTypes } = useConfig(); + + const headerTitle = t('patientIdentifierSticker', 'Patient Identifier Sticker'); + + const onBeforeGetContentResolve = useRef(null); + + useEffect(() => { + if (isPrinting && onBeforeGetContentResolve.current) { + onBeforeGetContentResolve.current(); + } + }, [isPrinting]); + + const patientDetails = useMemo(() => { + const getGender = (gender: string): string => { + switch (gender) { + case 'male': + return t('male', 'Male'); + case 'female': + return t('female', 'Female'); + case 'other': + return t('other', 'Other'); + case 'unknown': + return t('unknown', 'Unknown'); + default: + return gender; + } + }; + + const identifiers = + patient?.identifier?.filter( + (identifier) => !excludePatientIdentifierCodeTypes?.uuids.includes(identifier.type.coding[0].code), + ) ?? []; + + return { + id: patient?.id, + photo: patient?.photo, + name: patient ? displayName(patient) : '', + dateOfBirth: patient.birthDate, + age: age(patient?.birthDate), + gender: getGender(patient?.gender), + address: patient?.address, + identifiers: identifiers?.length ? identifiers.map(({ value, type }) => ({ value, type })) : [], + }; + }, [patient, t, excludePatientIdentifierCodeTypes?.uuids]); + const handleBeforeGetContent = useCallback(() => { + return new Promise((resolve) => { + if (patient && headerTitle) { + onBeforeGetContentResolve.current = resolve; + setIsPrinting(true); + const printStyles = `@media print { @page { size: ${printIdentifierStickerSize}; } }`; + + const style = document.createElement('style'); + style.appendChild(document.createTextNode(printStyles)); + + document.head.appendChild(style); + } + }); + }, [patient, printIdentifierStickerSize, headerTitle]); + + const handleAfterPrint = useCallback(() => { + onBeforeGetContentResolve.current = null; + setIsPrinting(false); + closeModal(); + }, [closeModal]); + + const handlePrint = useReactToPrint({ + content: () => contentToPrintRef.current, + documentTitle: `${patientDetails.name} - ${headerTitle}`, + onBeforeGetContent: handleBeforeGetContent, + onAfterPrint: handleAfterPrint, + onPrintError: (errorLocation, error) => { + new Promise((resolve) => { + if (error) { + if (printRetryCount < 2) { + setPrintRetryCount(printRetryCount + 1); + handleBeforeGetContent().then(() => { + handlePrint(); + }); + } else { + showSnackbar({ + title: t('printFailed', 'Print Failed'), + subtitle: `${t('printingFailed', 'Printing has failed after 3 attempts')}`, + kind: 'error', + isLowContrast: true, + }); + setPrintRetryCount(1); + } + resolve(); + } + }); + }, + }); + + const renderElementsInPairs = (elements) => { + const pairs = []; + let currentPair = []; + + const getKey = (element) => { + if (element?.props?.children?.key?.startsWith('identifier-text')) { + return 'identifier'; + } + return element?.key; + }; + + const filteredElements = elements.filter((element) => { + return printIdentifierStickerFields.includes(getKey(element)); + }); + + filteredElements.forEach((element, index) => { + if (element) { + currentPair.push(element); + if (currentPair.length === 2) { + pairs.push(currentPair); + currentPair = []; + } + } + }); + + if (currentPair.length === 1) { + pairs.push(currentPair); + } + + return pairs; + }; + + return ( +
+ + +
+ {printIdentifierStickerFields.includes('name') && ( +
+ {patientDetails?.name} +
+ )} + {renderElementsInPairs( + [ + patientDetails?.identifiers?.map((identifier, index) => ( +
+

+ {identifier?.type?.text}: {identifier?.value} +

+
+ )), +

+ {t('sex', 'Sex')}: {patientDetails?.gender} +

, +

+ {t('bod', 'DOB')}: {patientDetails?.dateOfBirth} +

, +

+ {t('age', 'Age')}: {patientDetails?.age} +

, + ].flat(), + ).map((pair, index) => ( + + +
{pair[0]}
+
+ +
{pair[1] ||
}
+ + + ))} +
+ + + + + +
+ ); +}; + +export default PrintIdentifierSticker; diff --git a/packages/esm-patient-banner-app/src/banner-tags/print-identifier-sticker.scss b/packages/esm-patient-banner-app/src/banner-tags/print-identifier-sticker.scss new file mode 100644 index 0000000000..859fcab0b7 --- /dev/null +++ b/packages/esm-patient-banner-app/src/banner-tags/print-identifier-sticker.scss @@ -0,0 +1,31 @@ +@use '@carbon/layout'; +@use '@carbon/type'; + +.stickerContainer { + padding: 1rem 1rem 0 1rem; +} + +.row { + margin: layout.$spacing-03 0rem 0rem; + display: flex; + flex-flow: row wrap; + gap: layout.$spacing-05; +} + +.gridRow { + padding: 0px; + display: flex; + flex-direction: row; + justify-content: space-between; + + div { + margin-left: 0px; + margin-bottom: 5px; + } +} + +.patientName { + font-size: 1.5rem; + font-weight: bolder; + margin-bottom: 5px; +} diff --git a/packages/esm-patient-banner-app/src/banner-tags/print-identifier-sticker.test.tsx b/packages/esm-patient-banner-app/src/banner-tags/print-identifier-sticker.test.tsx new file mode 100644 index 0000000000..8609b313a8 --- /dev/null +++ b/packages/esm-patient-banner-app/src/banner-tags/print-identifier-sticker.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { mockPatient } from 'tools'; +import { useReactToPrint } from 'react-to-print'; +import PrintIdentifierSticker from './print-identifier-sticker.component'; + +const mockedCloseModal = jest.fn(); +const mockedUseReactToPrint = jest.mocked(useReactToPrint); + +jest.mock('react-to-print', () => { + const originalModule = jest.requireActual('react-to-print'); + + return { + ...originalModule, + useReactToPrint: jest.fn(), + }; +}); + +jest.mock('@openmrs/esm-framework', () => { + const originalModule = jest.requireActual('@openmrs/esm-framework'); + + return { + ...originalModule, + useConfig: jest.fn().mockImplementation(() => ({ + printIdentifierStickerFields: ['name', 'identifier', 'age', 'dateOfBirth', 'gender'], + })), + }; +}); + +describe('PrintIdentifierSticker', () => { + test('renders the component', () => { + renderPrintIdentifierSticker(); + + expect(screen.getByText(/Print Identifier Sticker/i)).toBeInTheDocument(); + expect(screen.getByText('John Wilson')).toBeInTheDocument(); + expect(screen.getByText('100GEJ')).toBeInTheDocument(); + expect(screen.getByText('1972-04-04')).toBeInTheDocument(); + }); + + test('calls closeModal when cancel button is clicked', async () => { + const user = userEvent.setup(); + + renderPrintIdentifierSticker(); + + const cancelButton = screen.getByRole('button', { name: /Cancel/i }); + expect(cancelButton).toBeInTheDocument(); + + await user.click(cancelButton); + expect(mockedCloseModal).toHaveBeenCalled(); + }); + + test('calls the print function when print button is clicked', async () => { + const handlePrint = jest.fn(); + mockedUseReactToPrint.mockReturnValue(handlePrint); + + const user = userEvent.setup(); + + renderPrintIdentifierSticker(); + + const printButton = screen.getByRole('button', { name: /Print/i }); + expect(printButton).toBeInTheDocument(); + + await user.click(printButton); + expect(handlePrint).toHaveBeenCalled(); + }); +}); +function renderPrintIdentifierSticker() { + render(); +} diff --git a/packages/esm-patient-banner-app/src/banner/patient-banner.component.tsx b/packages/esm-patient-banner-app/src/banner/patient-banner.component.tsx index e39e4b5bc6..f3e220c56e 100644 --- a/packages/esm-patient-banner-app/src/banner/patient-banner.component.tsx +++ b/packages/esm-patient-banner-app/src/banner/patient-banner.component.tsx @@ -7,8 +7,14 @@ import { PatientBannerPatientInfo, PatientBannerToggleContactDetailsButton, PatientPhoto, + showModal, + useConfig, } from '@openmrs/esm-framework'; +import { Printer } from '@carbon/react/icons'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@carbon/react'; import styles from './patient-banner.scss'; +import { type ConfigObject } from '../config-schema'; interface PatientBannerProps { patient: fhir.Patient; @@ -17,6 +23,7 @@ interface PatientBannerProps { } const PatientBanner: React.FC = ({ patient, patientUuid, hideActionsOverflow }) => { + const { t } = useTranslation(); const patientBannerRef = useRef(null); const [isTabletViewport, setIsTabletViewport] = useState(false); @@ -46,6 +53,15 @@ const PatientBanner: React.FC = ({ patient, patientUuid, hid const maxDesktopWorkspaceWidthInPx = 520; const showDetailsButtonBelowHeader = patientBannerRef.current?.scrollWidth <= maxDesktopWorkspaceWidthInPx; + const { showPrintIdentifierStickerButton } = useConfig(); + + const openModal = useCallback(() => { + const dispose = showModal('print-identifier-sticker', { + closeModal: () => dispose(), + patient, + }); + }, [patient]); + return (
= ({ patient, patientUuid, hid
- {!hideActionsOverflow ? ( - - ) : null} +
+ {showPrintIdentifierStickerButton && ( +
{!showDetailsButtonBelowHeader ? ( ; - excludePatientIdentifierCodeTypes: Array; + excludePatientIdentifierCodeTypes: Array; customAddressLabels: Object; useRelationshipNameLink: boolean; + showPrintIdentifierStickerButton: boolean; + printIdentifierStickerFields: Array; + printIdentifierStickerSize: string; } diff --git a/packages/esm-patient-banner-app/src/index.ts b/packages/esm-patient-banner-app/src/index.ts index ccc6530b50..f6bdb885b8 100644 --- a/packages/esm-patient-banner-app/src/index.ts +++ b/packages/esm-patient-banner-app/src/index.ts @@ -1,9 +1,9 @@ -import { defineConfigSchema, getSyncLifecycle, messageOmrsServiceWorker, restBaseUrl } from '@openmrs/esm-framework'; +import { defineConfigSchema, getAsyncLifecycle, getSyncLifecycle, messageOmrsServiceWorker, restBaseUrl } from '@openmrs/esm-framework'; import { configSchema } from './config-schema'; import visitTagComponent from './banner-tags/visit-tag.extension'; import deceasedPatientTagComponent from './banner-tags/deceased-patient-tag.extension'; import patientBannerComponent from './banner/patient-banner.component'; - +import printIdentifierStickerComponent from './banner-tags/print-identifier-sticker.component'; const moduleName = '@openmrs/esm-patient-banner-app'; const options = { @@ -28,6 +28,10 @@ export const deceasedPatientTag = getSyncLifecycle(deceasedPatientTagComponent, export const patientBanner = getSyncLifecycle(patientBannerComponent, options); +// export const printIdentifierSticker = getSyncLifecycle(printIdentifierStickerComponent, options); + +export const printIdentifierSticker = getAsyncLifecycle(() => import('./banner-tags/print-identifier-sticker.component'), options); + /* The translations for built-in address fields are kept here in patient-banner. This comment ensures that they are included in the translations files. diff --git a/packages/esm-patient-banner-app/src/routes.json b/packages/esm-patient-banner-app/src/routes.json index 290d07c549..7630dcc650 100644 --- a/packages/esm-patient-banner-app/src/routes.json +++ b/packages/esm-patient-banner-app/src/routes.json @@ -25,5 +25,11 @@ "online": true, "offline": true } + ], + "modals": [ + { + "name": "print-identifier-sticker", + "component": "printIdentifierSticker" + } ] } \ No newline at end of file diff --git a/packages/esm-patient-banner-app/translations/en.json b/packages/esm-patient-banner-app/translations/en.json index da032812d1..2846d2e7ff 100644 --- a/packages/esm-patient-banner-app/translations/en.json +++ b/packages/esm-patient-banner-app/translations/en.json @@ -1,12 +1,25 @@ { "address1": "Address line 1", "address2": "Address line 2", + "age": "Age", + "bod": "DOB", + "cancel": "Cancel", "city": "City", "cityVillage": "city", "country": "Country", "countyDistrict": "District", "district": "District", + "female": "Female", + "male": "Male", + "other": "Other", + "patientIdentifierSticker": "Patient identifier sticker", "postalCode": "Postal code", + "print": "Print", + "printFailed": "Print Failed", + "printIdentifierSticker": "Print identifier sticker", + "printingFailed": "Printing has failed after 3 attempts", + "sex": "Sex", "state": "State", - "stateProvince": "State" + "stateProvince": "State", + "unknown": "Unknown" }