diff --git a/package.json b/package.json index ba01a0c..ebca8f6 100644 --- a/package.json +++ b/package.json @@ -46,13 +46,14 @@ "dependencies": { "@carbon/react": "^1.33.1", "@openmrs/openmrs-form-engine-lib": "^1.0.0-pre.44", + "classnames": "^2.3.2", + "dayjs": "^1.11.9", "lodash-es": "^4.17.21", "react-image-annotate": "^1.8.0" }, "peerDependencies": { "@openmrs/esm-framework": "*", "@openmrs/openmrs-form-engine-lib": "5.x", - "dayjs": "1.x", "react": "18.x", "react-i18next": "11.x", "react-router-dom": "6.x", diff --git a/src/bed-admission/active-patients/active-patients-table.component.tsx b/src/bed-admission/active-patients/active-patients-table.component.tsx new file mode 100644 index 0000000..2aed706 --- /dev/null +++ b/src/bed-admission/active-patients/active-patients-table.component.tsx @@ -0,0 +1,367 @@ +import { + DataTable, + DataTableSkeleton, + DefinitionTooltip, + Pagination, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, + Tag, + Layer, + TableToolbar, + TableToolbarContent, + TableToolbarSearch, + TableExpandedRow, + TableExpandHeader, + TableExpandRow, +} from "@carbon/react"; + +import { + isDesktop, + useLayoutType, + usePagination, + useSession, +} from "@openmrs/esm-framework"; +import React, { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + formatWaitTime, + getOriginFromPathName, + getTagColor, + getTagType, + trimVisitNumber, +} from "../helpers/functions"; +import styles from "./styles.scss"; +import { usePatientQueuesList } from "./patient-queues.resource"; +import EmptyState from "../../empty-state/empty-state.component"; +import AssignBedWorkSpace from "../../workspace/allocate-bed-workspace.component"; +import AdmissionActionButton from "./admission-action-button.component"; +import { patientDetailsProps } from "../types"; +import ViewActionsMenu from "./view-action-menu.component"; + +interface ActiveVisitsTableProps { + status: string; + setPatientCount?: (value: number) => void; +} + +const ActivePatientsTable: React.FC = ({ + status, + setPatientCount, +}) => { + const { t } = useTranslation(); + const session = useSession(); + const currentPathName: string = window.location.pathname; + const fromPage: string = getOriginFromPathName(currentPathName); + const pageSizes = [10, 20, 30, 40, 50]; + const [currentPageSize, setPageSize] = useState(10); + const [showOverlay, setShowOverlay] = useState(false); + const [selectedPatientDetails, setSelectedPatientDetails] = + useState(); + + const layout = useLayoutType(); + const { patientQueueEntries, isLoading } = usePatientQueuesList( + session?.sessionLocation?.uuid, + status + ); + + const handleBedAssigmentModal = useCallback( + (entry) => { + setSelectedPatientDetails({ + name: entry.name, + patientUuid: entry.patientUuid, + encounter: entry.encounter, + locationUuid: session?.sessionLocation?.uuid, + locationTo: entry.locationTo, + locationFrom: entry.locationFrom, + queueUuid: entry.uuid, + }); + setShowOverlay(true); + }, + [session?.sessionLocation?.uuid] + ); + + const renderActionButton = useCallback( + (entry) => { + const buttonTexts = { + pending: "Assign Bed", + completed: "Transfer", + }; + const buttonText = buttonTexts[status] || "Un-assign"; + + return ( + + ); + }, + [handleBedAssigmentModal, status] + ); + + const { + goTo, + results: paginatedQueueEntries, + currentPage, + } = usePagination(patientQueueEntries, currentPageSize); + + const tableHeaders = useMemo( + () => [ + { + id: 0, + header: t("visitNumber", "Visit Number"), + key: "visitNumber", + }, + { + id: 1, + header: t("name", "Name"), + key: "name", + }, + { + id: 2, + header: t("locationFrom", "Location From"), + key: "locationFrom", + }, + { + id: 3, + header: t("priority", "Priority"), + key: "priority", + }, + { + id: 4, + header: t("priorityLevel", "Priority Level"), + key: "priorityLevel", + }, + { + id: 5, + header: t("waitTime", "Wait time"), + key: "waitTime", + }, + { + id: 6, + header: t("actions", "Actions"), + key: "actions", + }, + ], + [t] + ); + + const tableRows = useMemo(() => { + return paginatedQueueEntries?.map((entry) => ({ + ...entry, + visitNumber: { + content: {trimVisitNumber(entry.visitNumber)}, + }, + name: { + content: entry.name, + }, + locationFrom: { + content: entry.locationFromName, + }, + priority: { + content: ( + <> + {entry?.priorityComment ? ( + + + {entry.priority} + + + ) : ( + + {entry.priority} + + )} + + ), + }, + priorityLevel: { + content: {entry.priorityLevel}, + }, + waitTime: { + content: ( + + + {formatWaitTime(entry.waitTime, t)} + + + ), + }, + actions: { + content: ( + <> + {renderActionButton(entry)} + {status === "completed" && ( + + )} + + ), + }, + notes: { + content: entry.comment, + }, + })); + }, [paginatedQueueEntries, status, t, renderActionButton, fromPage]); + + if (isLoading) { + return ; + } + + if ( + (!isLoading && patientQueueEntries && status === "pending") || + status === "completed" || + status === "" + ) { + setPatientCount(patientQueueEntries.length); + } + + if (patientQueueEntries?.length) { + return ( +
+
+ + + {({ rows, headers, getTableProps, getRowProps, onInputChange }) => ( + + + + + + + + + + + + + {headers.map((header) => ( + + {header.header?.content ?? header.header} + + ))} + + + + {rows.map((row, index) => { + return ( + <> + + {row.cells.map((cell) => ( + + {cell.value?.content ?? cell.value} + + ))} + + + {row.isExpanded ? ( + + <> + {tableRows[index]?.comment ?? ""} + + + ) : ( + + )} + + ); + })} + +
+ { + if (pageSize !== currentPageSize) { + setPageSize(pageSize); + } + if (page !== currentPage) { + goTo(page); + } + }} + /> +
+ )} +
+ {showOverlay && ( + setShowOverlay(false)} + queueStatus={status} + headerTitle={t( + "assignBedToPatient", + `Assign Bed to Patient ${selectedPatientDetails.name} in the ${session?.sessionLocation?.display} Ward` + )} + /> + )} +
+ ); + } + + return ( + + ); +}; +export default ActivePatientsTable; diff --git a/src/bed-admission/active-patients/admission-action-button-styles.scss b/src/bed-admission/active-patients/admission-action-button-styles.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/bed-admission/active-patients/admission-action-button.component.tsx b/src/bed-admission/active-patients/admission-action-button.component.tsx new file mode 100644 index 0000000..483f7c8 --- /dev/null +++ b/src/bed-admission/active-patients/admission-action-button.component.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Button, Tooltip } from "@carbon/react"; +import { HospitalBed } from "@carbon/react/icons"; +import { useTranslation } from "react-i18next"; +import styles from "./admission-action-button-styles.scss"; + +const AdmissionActionButton = ({ + entry, + handleBedAssigmentModal, + buttonText, +}) => { + const { t } = useTranslation(); + return ( + + + + ); +}; + +export default AdmissionActionButton; diff --git a/src/bed-admission/active-patients/index.tsx b/src/bed-admission/active-patients/index.tsx new file mode 100644 index 0000000..852b406 --- /dev/null +++ b/src/bed-admission/active-patients/index.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import Header from "../../header/header.component"; +import styles from "./styles.scss"; +import BedAdmissionTabs from "../bed-admission-tabs.component"; + +const ActivePatientsHome: React.FC = () => { + return ( +
+
+ +
+ ); +}; + +export default ActivePatientsHome; diff --git a/src/bed-admission/active-patients/patient-queues.resource.ts b/src/bed-admission/active-patients/patient-queues.resource.ts new file mode 100644 index 0000000..3fa14b7 --- /dev/null +++ b/src/bed-admission/active-patients/patient-queues.resource.ts @@ -0,0 +1,85 @@ +import dayjs from "dayjs"; +import useSWR from "swr"; + +import { formatDate, openmrsFetch, parseDate } from "@openmrs/esm-framework"; +import { PatientQueue, UuidDisplay } from "../types"; + +export interface MappedPatientQueueEntry { + id: string; + name: string; + patientAge: number; + patientSex: string; + patientDob: string; + patientUuid: string; + priority: string; + priorityComment: string; + status: string; + waitTime: string; + locationFrom?: string; + locationToName?: string; + visitNumber: string; + identifiers: Array; + dateCreated: string; + creatorUuid: string; + creatorUsername: string; + creatorDisplay: string; +} + +export function usePatientQueuesList( + currentQueueRoomLocationUuid: string, + status: string +) { + const apiUrl = `/ws/rest/v1/patientqueue?v=full&locationFrom=${currentQueueRoomLocationUuid}&status=${status}`; + return usePatientQueueRequest(apiUrl); +} + +export function usePatientQueueRequest(apiUrl: string) { + const { data, error, isLoading, isValidating, mutate } = useSWR< + { data: { results: Array } }, + Error + >(apiUrl, openmrsFetch, { refreshInterval: 3000 }); + + const mapppedQueues = data?.data?.results.map((queue: PatientQueue) => { + return { + ...queue, + id: queue.uuid, + name: queue.patient?.person.display, + patientUuid: queue.patient?.uuid, + priorityComment: queue.priorityComment, + priority: + queue.priorityComment === "Urgent" ? "Priority" : queue.priorityComment, + priorityLevel: queue.priority, + waitTime: queue.dateCreated + ? `${dayjs().diff(dayjs(queue.dateCreated), "minutes")}` + : "--", + status: queue.status, + patientAge: queue.patient?.person?.age, + patientSex: queue.patient?.person?.gender === "M" ? "MALE" : "FEMALE", + patientDob: queue.patient?.person?.birthdate + ? formatDate(parseDate(queue.patient.person.birthdate), { time: false }) + : "--", + identifiers: queue.patient?.identifiers, + locationFrom: queue.locationFrom?.uuid, + locationTo: queue.locationTo?.uuid, + locationFromName: queue.locationFrom?.name, + locationToName: queue.locationTo?.name, + queueRoom: queue.locationTo?.display, + visitNumber: queue.visitNumber, + dateCreated: queue.dateCreated + ? formatDate(parseDate(queue.dateCreated), { time: false }) + : "--", + creatorUuid: queue.creator?.uuid, + creatorUsername: queue.creator?.username, + creatorDisplay: queue.creator?.display, + }; + }); + + return { + patientQueueEntries: mapppedQueues || [], + patientQueueCount: mapppedQueues?.length, + isLoading, + isError: error, + isValidating, + mutate, + }; +} diff --git a/src/bed-admission/active-patients/styles.scss b/src/bed-admission/active-patients/styles.scss new file mode 100644 index 0000000..e951ca3 --- /dev/null +++ b/src/bed-admission/active-patients/styles.scss @@ -0,0 +1,280 @@ +@use '@carbon/type'; +@use '@carbon/colors'; +@use '@carbon/styles/scss/spacing'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.container { + background-color: $ui-01; +} + +.section { + border-right: 1px solid colors.$gray-20; +} + +.activePatientsTable tr:last-of-type { + td { + border-bottom: none; + } +} + +.headerContainer { + display: flex; + justify-content: space-between; + align-items: center; + background-color: $ui-background; +} + +.headerButtons { + display: flex; + flex-flow: column; +} + +.heading { + font-size: 20px; + font-weight: bold; +} + +.filterContainer { + :global(.cds--dropdown__wrapper--inline) { + gap: 0; + } + + :global(.cds--list-box__menu-icon) { + height: 1rem; + } + + :global(.cds--dropdown--inline) :global(.cds--list-box__field) { + min-width: 12rem; + } +} + +.tooltip :global(.cds--tooltip__trigger.cds--tooltip__trigger--definition) { + border-bottom: none; +} + +.tag { + margin: 0.25rem 0; +} + +.priorityTag { + @extend .tag; + @include type.type-style('label-01'); + color: #943d00; + background-color: #ffc9a3; +} + +.backgroundDataFetchingIndicator { + align-items: center; + display: flex; + flex: 1 1 0%; + justify-content: center; +} + +.search { + max-width: 16rem; + + input { + background-color: $ui-02 !important; + } +} + +.tableContainer { + background-color: $ui-01; + margin: 0 spacing.$spacing-05; + padding: 0; + + a { + text-decoration: none; + } + + th { + color: $text-02; + } + + :global(.cds--data-table) { + background-color: $ui-03; + } + + :global(.cds--data-table-content) { + display: contents; + } + + .toolbarContent { + height: spacing.$spacing-07; + margin-bottom: spacing.$spacing-02; + } +} + +.emptyRow { + padding: 0 1rem; + display: flex; + align-items: center; +} + +.activeVisitsTable tr:last-of-type { + td { + border-bottom: none; + } +} + +.expandedActiveVisitRow { + :global(.cds--tab-content) { + padding: 0.5rem 0; + } + + td { + padding: 0.5rem; + + >div { + max-height: max-content !important; + background-color: $ui-02; + } + } + + th[colspan] td[colspan]>div:first-child { + padding: 0 1rem; + } +} + +.hiddenRow { + display: none; +} + +.content { + @include type.type-style('heading-compact-02'); + color: $text-02; + margin-bottom: 0.5rem; +} + +.helper { + @include type.type-style('body-compact-01'); + color: $text-02; +} + +.separator { + @include type.type-style('body-compact-02'); + color: $text-02; + width: 80%; + margin: 1.5rem auto; + overflow: hidden; + text-align: center; + + &::before, + &::after { + background-color: $text-03; + content: ''; + display: inline-block; + height: 1px; + position: relative; + vertical-align: middle; + width: 50%; + } + + &::before { + right: 0.5rem; + margin-left: -50%; + } + + &::after { + left: 0.5rem; + margin-right: -50%; + } +} + +.tileContainer { + background-color: $ui-02; + border-top: 1px solid $ui-03; + padding: 5rem 0; +} + +.tile { + margin: auto; + width: fit-content; +} + +.tileContent { + display: flex; + flex-direction: column; + align-items: center; +} + +.menuItem { + max-width: none; +} + +.desktopHeading { + h4 { + @include type.type-style('heading-compact-02'); + color: $text-02; + } +} + +.tabletHeading { + h4 { + @include type.type-style('heading-03'); + color: $text-02; + } +} + +.desktopHeading, +.tabletHeading { + text-align: left; + text-transform: capitalize; + margin-bottom: spacing.$spacing-05; + + h4:after { + content: ''; + display: block; + width: 2rem; + padding-top: 3px; + border-bottom: 0.375rem solid var(--brand-03); + } +} + +.statusContainer { + display: flex; + align-items: center; + + svg { + margin-right: 0.5rem; + } +} + +.visitType { + @include type.type-style('heading-compact-02'); +} + +.headerBtnContainer { + background-color: $ui-background; + text-align: right; +} + +.addPatientToListBtn { + margin-left: spacing.$spacing-05; + height: spacing.$spacing-09; +} + +.editIcon { + color: $interactive-01; + margin-top: 0.5rem; + cursor: pointer; +} + +.expandedLabQueueVisitRow { + :global(.cds--tab-content) { + padding: 0.5rem 0; + } + + td { + padding: 0.5rem; + + >div { + max-height: max-content !important; + background-color: $ui-02; + } + } + + th[colspan] td[colspan]>div:first-child { + padding: 0 1rem; + } +} \ No newline at end of file diff --git a/src/bed-admission/active-patients/view-action-menu.component.tsx b/src/bed-admission/active-patients/view-action-menu.component.tsx new file mode 100644 index 0000000..6a5a368 --- /dev/null +++ b/src/bed-admission/active-patients/view-action-menu.component.tsx @@ -0,0 +1,33 @@ +import { Button, Tooltip } from "@carbon/react"; +import { Dashboard } from "@carbon/react/icons"; +import React, { AnchorHTMLAttributes } from "react"; +import { useTranslation } from "react-i18next"; +import { interpolateUrl, navigate } from "@openmrs/esm-framework"; + +interface NameLinkProps extends AnchorHTMLAttributes { + to: string; + from: string; +} + +const ViewActionsMenu: React.FC = ({ from, to }) => { + const { t } = useTranslation(); + + const handleNameClick = (event: MouseEvent, to: string) => { + event.preventDefault(); + navigate({ to }); + localStorage.setItem("fromPage", from); + }; + + return ( + + + + + + + ); +}; + +export default AllocateBedWorkSpace; diff --git a/src/workspace/allocate-bed.scss b/src/workspace/allocate-bed.scss new file mode 100644 index 0000000..19539d1 --- /dev/null +++ b/src/workspace/allocate-bed.scss @@ -0,0 +1,117 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.container { + // margin: spacing.$spacing-05 0rem; + background-color: $ui-background; + max-height: 100%; + + + & section { + margin: spacing.$spacing-09 0 0; + // overflow-y: auto; + + } + + :global(.cds--text-input) { + &:focus, + &:hover { + outline: 2px solid var(--cds-focus,#ff832b); + } + } +} + +.admitPatientInfo { + padding: 2rem; + background-color: #fff; +} + +.heading { + @include type.type-style('heading-03'); + margin: spacing.$spacing-05; +} + +.sectionTitle { + @include type.type-style('heading-compact-02'); + color: $text-02; + margin-bottom: spacing.$spacing-05; +} + +.dateTimeSection { + display: flex; +} + +.radioButton { + margin: spacing.$spacing-05 0; +} + +.headerGridRow { + border-bottom: 0.0625rem solid $grey-2; + margin: 0; +} + +.dataGridRow { + display: grid; + grid-template-columns: 50% 10% 1fr; + margin: spacing.$spacing-03 spacing.$spacing-05; + width: 100%; +} + +.form { + background-color: $ui-background; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.button { + height: 4rem; + display: flex; + align-content: flex-start; + align-items: baseline; + min-width: 50%; +} + +.tablet { + padding: spacing.$spacing-06 spacing.$spacing-05; + background-color: $ui-02; +} + +.desktop { + padding: 0rem; +} + +@media screen and (max-width: 600px) { + .dateTimeSection { + flex-direction: column; + } +} + +.backButton { + align-items: center; + display: flex; + justify-content: flex-start; + margin: spacing.$spacing-03 0; + padding: 0; + @include type.type-style('body-compact-01'); + + button { + display: flex; + + svg { + order: 1; + margin-right: spacing.$spacing-03; + margin-left: 0rem !important; + } + + span { + order: 2; + } + } +} + +.disabled { + opacity: 0.8; + pointer-events: none; +} \ No newline at end of file diff --git a/src/workspace/overlay.component.tsx b/src/workspace/overlay.component.tsx new file mode 100644 index 0000000..ab19ef5 --- /dev/null +++ b/src/workspace/overlay.component.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Button, Header } from "@carbon/react"; +import { ArrowLeft, Close } from "@carbon/react/icons"; +import { isDesktop, useLayoutType } from "@openmrs/esm-framework"; +import styles from "./overlay.scss"; +import { useTranslation } from "react-i18next"; + +interface OverlayProps { + closePanel: () => void; + header: string; + children?: React.ReactNode; +} + +const Overlay: React.FC = ({ closePanel, children, header }) => { + const layout = useLayoutType(); + const { t } = useTranslation(); + + return ( +
+ {isDesktop(layout) ? ( +
+
{header}
+
+ ) : ( +
+
+ )} + {children} +
+ ); +}; + +export default Overlay; diff --git a/src/workspace/overlay.scss b/src/workspace/overlay.scss new file mode 100644 index 0000000..78d174d --- /dev/null +++ b/src/workspace/overlay.scss @@ -0,0 +1,91 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; +@import '~@openmrs/esm-styleguide/src/vars'; +@import '../root.scss'; + +.desktopOverlay { + position: fixed; + top: spacing.$spacing-09; + width: 37rem; + right: 0; + bottom: 0; + border-left: 1px solid $text-03; + background-color: $ui-01; + overflow-y: auto; + height: calc(100vh - 3rem); + z-index: 99999; + display: grid; + grid-template-rows: 1fr auto; +} + +.desktopOverlay::after { + height: 100%; + border-left: 1px solid $text-03; +} + +.tabletOverlay { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 9999; + background-color: $ui-01; + overflow: hidden; + padding-top: spacing.$spacing-09; + display: grid; + grid-template-rows: 1fr auto; + overflow-y: auto; + height: 100vh; +} + +.tabletOverlayHeader { + button { + @include brand-01(background-color); + } + + .headerContent { + color: $ui-02; + } +} + +.desktopHeader { + display: flex; + justify-content: space-between; + align-items: center; + background-color: $ui-03; + border-bottom: 1px solid $text-03; + position: absolute; + position: -webkit-sticky; + width: 100%; + z-index: 1000; + top: 0; +} + +.headerContent { + @include type.type-style('heading-compact-02'); + padding: 0 spacing.$spacing-05; + color: $ui-05; +} + +.closePanelButton { + background-color: $ui-background; + color: $ui-05; + fill: $ui-05; +} + +/* Desktop */ +:global(.omrs-breakpoint-gt-tablet) { + .overlayContent { + padding: 0 0 0 0; + overflow-y: auto; + } +} + +/* Tablet */ +:global(.omrs-breakpoint-lt-desktop) { + .overlayContent { + padding: 0 0 0 0; + overflow-y: auto; + } +} diff --git a/yarn.lock b/yarn.lock index 5078304..ed8db9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5330,7 +5330,9 @@ __metadata: "@types/webpack-env": ^1.18.1 "@typescript-eslint/eslint-plugin": ^5.61.0 "@typescript-eslint/parser": ^5.61.0 + classnames: ^2.3.2 css-loader: ^6.8.1 + dayjs: ^1.11.9 eslint: ^8.44.0 eslint-config-prettier: ^8.8.0 eslint-config-ts-react-important-stuff: ^3.0.0 @@ -5360,7 +5362,6 @@ __metadata: peerDependencies: "@openmrs/esm-framework": "*" "@openmrs/openmrs-form-engine-lib": 5.x - dayjs: 1.x react: 18.x react-i18next: 11.x react-router-dom: 6.x @@ -7654,7 +7655,7 @@ __metadata: languageName: node linkType: hard -"classnames@npm:2.3.2, classnames@npm:^2.2.6": +"classnames@npm:2.3.2, classnames@npm:^2.2.6, classnames@npm:^2.3.2": version: 2.3.2 resolution: "classnames@npm:2.3.2" checksum: 2c62199789618d95545c872787137262e741f9db13328e216b093eea91c85ef2bfb152c1f9e63027204e2559a006a92eb74147d46c800a9f96297ae1d9f96f4e @@ -9013,7 +9014,7 @@ __metadata: languageName: node linkType: hard -"dayjs@npm:^1.10.4, dayjs@npm:^1.10.7": +"dayjs@npm:^1.10.4, dayjs@npm:^1.10.7, dayjs@npm:^1.11.9": version: 1.11.9 resolution: "dayjs@npm:1.11.9" checksum: a4844d83dc87f921348bb9b1b93af851c51e6f71fa259604809cfe1b49d1230e6b0212dab44d1cb01994c096ad3a77ea1cf18fa55154da6efcc9d3610526ac38