From 79fb24f4bedbbc3ae058802f9a10f32e68d39b04 Mon Sep 17 00:00:00 2001 From: Rugute Date: Thu, 20 Jun 2024 19:46:26 +0300 Subject: [PATCH] added billing modules --- packages/esm-billing-app/README.md | 4 + .../esm-billing-app/__mocks__/visit.mock.ts | 151 ++++++++ packages/esm-billing-app/jest.config.js | 8 + packages/esm-billing-app/package.json | 54 +++ .../bill-history/bill-history.component.tsx | 202 +++++++++++ .../src/bill-history/bill-history.scss | 165 +++++++++ ...ble-services-admin-card-link.component.tsx | 24 ++ .../bill-waiver/bill-selection.component.tsx | 74 ++++ .../bill-waiver-form.component.tsx | 105 ++++++ .../bill-waiver/bill-waiver-form.scss | 34 ++ .../bill-waiver/bill-waiver.component.tsx | 33 ++ .../bill-waiver/bill-waiver.scss | 10 + .../bill-waiver/patient-bills.component.tsx | 136 ++++++++ .../billable-services/bill-waiver/utils.ts | 38 ++ .../billable-service.resource.tsx | 51 +++ .../billable-services-home.component.tsx | 50 +++ .../billable-services.component.tsx | 255 ++++++++++++++ .../billable-services/billable-services.scss | 219 ++++++++++++ .../billiable-item/drug-order.component.tsx | 56 +++ .../billiable-item/drug-order.scss | 26 ++ .../billiable-item/lab-order.component.tsx | 34 ++ .../billiable-item/useBilliableItem.tsx | 50 +++ .../add-billable-service.component.tsx | 327 ++++++++++++++++++ .../create-edit/add-billable-service.scss | 132 +++++++ .../dashboard/dashboard.component.tsx | 18 + .../dashboard/dashboard.scss | 27 ++ .../dashboard/service-metrics.component.tsx | 40 +++ .../billing-dashboard.component.tsx | 20 ++ .../billing-dashboard/billing-dashboard.scss | 27 ++ .../billing-checkin-form.component.tsx | 137 ++++++++ .../billing-form/billing-checkin-form.scss | 14 + .../billing-form/billing-form.component.tsx | 297 ++++++++++++++++ .../src/billing-form/billing-form.scss | 28 ++ .../src/billing-form/helper.ts | 2 + .../sha-number-validity.component.tsx | 77 +++++ .../visit-attributes-form.component.tsx | 198 +++++++++++ .../visit-attributes-form.scss | 18 + .../billing-header.component.tsx | 43 +++ .../src/billing-header/billing-header.scss | 83 +++++ .../billing-illustration.component.tsx | 30 ++ .../billing-prompt.resource.tsx | 45 +++ .../esm-billing-app/src/billing.resource.ts | 224 ++++++++++++ .../src/bills-table/bills-table.component.tsx | 288 +++++++++++++++ .../src/bills-table/bills-table.scss | 181 ++++++++++ packages/esm-billing-app/src/config-schema.ts | 115 ++++++ packages/esm-billing-app/src/constants.ts | 3 + .../esm-billing-app/src/dashboard.meta.ts | 6 + .../esm-billing-app/src/declarations.d.ts | 4 + .../esm-billing-app/src/helpers/functions.ts | 83 +++++ packages/esm-billing-app/src/helpers/index.ts | 1 + .../esm-billing-app/src/hooks/getMflCode.tsx | 19 + packages/esm-billing-app/src/index.ts | 54 +++ .../claims-breakdown.component.tsx | 19 + .../claims-breakdown/claims-breakdown.scss | 35 ++ .../claims-dashboard.component.tsx | 15 + .../claims-form/claims-form.component.tsx | 129 +++++++ .../claims-form/claims-form.scss | 118 +++++++ .../claims-header/claims-header.component.tsx | 63 ++++ .../claims-header/claims-header.scss | 51 +++ .../claims-table.component.tsx | 188 ++++++++++ .../claims-table.scss | 103 ++++++ .../invoice/claims/make-claims.component.tsx | 27 ++ .../src/invoice/claims/make-claims.scss | 7 + .../src/invoice/invoice-table.component.tsx | 193 +++++++++++ .../src/invoice/invoice-table.scss | 91 +++++ .../src/invoice/invoice.component.tsx | 157 +++++++++ .../esm-billing-app/src/invoice/invoice.scss | 97 ++++++ .../initiate-payment.component.tsx | 149 ++++++++ .../initiate-payment/initiate-payment.scss | 33 ++ .../invoice-breakdown.component.tsx | 17 + .../invoice-breakdown/invoice-breakdown.scss | 29 ++ .../payment-form/payment-form.component.tsx | 105 ++++++ .../payments/payment-form/payment-form.scss | 54 +++ .../payment-history.component.tsx | 68 ++++ .../invoice/payments/payments.component.tsx | 138 ++++++++ .../src/invoice/payments/payments.scss | 46 +++ .../src/invoice/payments/utils.ts | 84 +++++ .../visit-tags/visit-attribute.component.tsx | 21 ++ .../print-receipt.component.tsx | 28 ++ .../printable-invoice/print-receipt.scss | 14 + .../printable-footer.component.tsx | 18 + .../printable-invoice/printable-footer.scss | 17 + .../printable-footer.test.tsx | 30 ++ .../printable-invoice-header.component.tsx | 63 ++++ .../printable-invoice-header.scss | 61 ++++ .../printable-invoice-header.test.tsx | 58 ++++ .../printable-invoice.component.tsx | 146 ++++++++ .../printable-invoice/printable-invoice.scss | 50 +++ .../src/left-panel-link.component.tsx | 40 +++ .../src/m-pesa/mpesa-resource.tsx | 74 ++++ .../src/metrics-cards/card.component.tsx | 11 + .../src/metrics-cards/card.scss | 20 ++ .../metrics-cards/metrics-cards.component.tsx | 42 +++ .../src/metrics-cards/metrics-cards.scss | 12 + .../src/metrics-cards/metrics.resource.ts | 45 +++ .../modal/require-payment-modal.component.tsx | 112 ++++++ .../src/modal/require-payment.scss | 18 + .../esm-billing-app/src/root.component.tsx | 21 ++ packages/esm-billing-app/src/root.scss | 30 ++ packages/esm-billing-app/src/routes.json | 94 +++++ packages/esm-billing-app/src/types/index.ts | 259 ++++++++++++++ packages/esm-billing-app/translations/en.json | 144 ++++++++ packages/esm-billing-app/tsconfig.json | 5 + packages/esm-billing-app/webpack.config.js | 1 + .../amrs-link/amrs-chart-link.component.tsx | 2 - packages/esm-report-app/translations/en.json | 4 +- yarn.lock | 17 + 107 files changed, 7788 insertions(+), 5 deletions(-) create mode 100644 packages/esm-billing-app/README.md create mode 100644 packages/esm-billing-app/__mocks__/visit.mock.ts create mode 100644 packages/esm-billing-app/jest.config.js create mode 100644 packages/esm-billing-app/package.json create mode 100644 packages/esm-billing-app/src/bill-history/bill-history.component.tsx create mode 100644 packages/esm-billing-app/src/bill-history/bill-history.scss create mode 100644 packages/esm-billing-app/src/billable-services-admin-card-link.component.tsx create mode 100644 packages/esm-billing-app/src/billable-services/bill-waiver/bill-selection.component.tsx create mode 100644 packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver-form.component.tsx create mode 100644 packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver-form.scss create mode 100644 packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver.component.tsx create mode 100644 packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver.scss create mode 100644 packages/esm-billing-app/src/billable-services/bill-waiver/patient-bills.component.tsx create mode 100644 packages/esm-billing-app/src/billable-services/bill-waiver/utils.ts create mode 100644 packages/esm-billing-app/src/billable-services/billable-service.resource.tsx create mode 100644 packages/esm-billing-app/src/billable-services/billable-services-home.component.tsx create mode 100644 packages/esm-billing-app/src/billable-services/billable-services.component.tsx create mode 100644 packages/esm-billing-app/src/billable-services/billable-services.scss create mode 100644 packages/esm-billing-app/src/billable-services/billiable-item/drug-order.component.tsx create mode 100644 packages/esm-billing-app/src/billable-services/billiable-item/drug-order.scss create mode 100644 packages/esm-billing-app/src/billable-services/billiable-item/lab-order.component.tsx create mode 100644 packages/esm-billing-app/src/billable-services/billiable-item/useBilliableItem.tsx create mode 100644 packages/esm-billing-app/src/billable-services/create-edit/add-billable-service.component.tsx create mode 100644 packages/esm-billing-app/src/billable-services/create-edit/add-billable-service.scss create mode 100644 packages/esm-billing-app/src/billable-services/dashboard/dashboard.component.tsx create mode 100644 packages/esm-billing-app/src/billable-services/dashboard/dashboard.scss create mode 100644 packages/esm-billing-app/src/billable-services/dashboard/service-metrics.component.tsx create mode 100644 packages/esm-billing-app/src/billing-dashboard/billing-dashboard.component.tsx create mode 100644 packages/esm-billing-app/src/billing-dashboard/billing-dashboard.scss create mode 100644 packages/esm-billing-app/src/billing-form/billing-checkin-form.component.tsx create mode 100644 packages/esm-billing-app/src/billing-form/billing-checkin-form.scss create mode 100644 packages/esm-billing-app/src/billing-form/billing-form.component.tsx create mode 100644 packages/esm-billing-app/src/billing-form/billing-form.scss create mode 100644 packages/esm-billing-app/src/billing-form/helper.ts create mode 100644 packages/esm-billing-app/src/billing-form/social-health-authority/sha-number-validity.component.tsx create mode 100644 packages/esm-billing-app/src/billing-form/visit-attributes/visit-attributes-form.component.tsx create mode 100644 packages/esm-billing-app/src/billing-form/visit-attributes/visit-attributes-form.scss create mode 100644 packages/esm-billing-app/src/billing-header/billing-header.component.tsx create mode 100644 packages/esm-billing-app/src/billing-header/billing-header.scss create mode 100644 packages/esm-billing-app/src/billing-header/billing-illustration.component.tsx create mode 100644 packages/esm-billing-app/src/billing-prompt/billing-prompt.resource.tsx create mode 100644 packages/esm-billing-app/src/billing.resource.ts create mode 100644 packages/esm-billing-app/src/bills-table/bills-table.component.tsx create mode 100644 packages/esm-billing-app/src/bills-table/bills-table.scss create mode 100644 packages/esm-billing-app/src/config-schema.ts create mode 100644 packages/esm-billing-app/src/constants.ts create mode 100644 packages/esm-billing-app/src/dashboard.meta.ts create mode 100644 packages/esm-billing-app/src/declarations.d.ts create mode 100644 packages/esm-billing-app/src/helpers/functions.ts create mode 100644 packages/esm-billing-app/src/helpers/index.ts create mode 100644 packages/esm-billing-app/src/hooks/getMflCode.tsx create mode 100644 packages/esm-billing-app/src/index.ts create mode 100644 packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-breakdown/claims-breakdown.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-breakdown/claims-breakdown.scss create mode 100644 packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-dashboard.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-form/claims-form.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-form/claims-form.scss create mode 100644 packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-header/claims-header.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-header/claims-header.scss create mode 100644 packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-table.component.tsx/claims-table.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-table.component.tsx/claims-table.scss create mode 100644 packages/esm-billing-app/src/invoice/claims/make-claims.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/claims/make-claims.scss create mode 100644 packages/esm-billing-app/src/invoice/invoice-table.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/invoice-table.scss create mode 100644 packages/esm-billing-app/src/invoice/invoice.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/invoice.scss create mode 100644 packages/esm-billing-app/src/invoice/payments/initiate-payment/initiate-payment.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/payments/initiate-payment/initiate-payment.scss create mode 100644 packages/esm-billing-app/src/invoice/payments/invoice-breakdown/invoice-breakdown.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/payments/invoice-breakdown/invoice-breakdown.scss create mode 100644 packages/esm-billing-app/src/invoice/payments/payment-form/payment-form.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/payments/payment-form/payment-form.scss create mode 100644 packages/esm-billing-app/src/invoice/payments/payment-history/payment-history.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/payments/payments.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/payments/payments.scss create mode 100644 packages/esm-billing-app/src/invoice/payments/utils.ts create mode 100644 packages/esm-billing-app/src/invoice/payments/visit-tags/visit-attribute.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/printable-invoice/print-receipt.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/printable-invoice/print-receipt.scss create mode 100644 packages/esm-billing-app/src/invoice/printable-invoice/printable-footer.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/printable-invoice/printable-footer.scss create mode 100644 packages/esm-billing-app/src/invoice/printable-invoice/printable-footer.test.tsx create mode 100644 packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice-header.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice-header.scss create mode 100644 packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice-header.test.tsx create mode 100644 packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice.component.tsx create mode 100644 packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice.scss create mode 100644 packages/esm-billing-app/src/left-panel-link.component.tsx create mode 100644 packages/esm-billing-app/src/m-pesa/mpesa-resource.tsx create mode 100644 packages/esm-billing-app/src/metrics-cards/card.component.tsx create mode 100644 packages/esm-billing-app/src/metrics-cards/card.scss create mode 100644 packages/esm-billing-app/src/metrics-cards/metrics-cards.component.tsx create mode 100644 packages/esm-billing-app/src/metrics-cards/metrics-cards.scss create mode 100644 packages/esm-billing-app/src/metrics-cards/metrics.resource.ts create mode 100644 packages/esm-billing-app/src/modal/require-payment-modal.component.tsx create mode 100644 packages/esm-billing-app/src/modal/require-payment.scss create mode 100644 packages/esm-billing-app/src/root.component.tsx create mode 100644 packages/esm-billing-app/src/root.scss create mode 100644 packages/esm-billing-app/src/routes.json create mode 100644 packages/esm-billing-app/src/types/index.ts create mode 100644 packages/esm-billing-app/translations/en.json create mode 100644 packages/esm-billing-app/tsconfig.json create mode 100644 packages/esm-billing-app/webpack.config.js diff --git a/packages/esm-billing-app/README.md b/packages/esm-billing-app/README.md new file mode 100644 index 00000000..37fc4c24 --- /dev/null +++ b/packages/esm-billing-app/README.md @@ -0,0 +1,4 @@ + +# ESM Billing App + +This is a frontend module that provides billing functionality. diff --git a/packages/esm-billing-app/__mocks__/visit.mock.ts b/packages/esm-billing-app/__mocks__/visit.mock.ts new file mode 100644 index 00000000..1d1b0e81 --- /dev/null +++ b/packages/esm-billing-app/__mocks__/visit.mock.ts @@ -0,0 +1,151 @@ +export const mockCurrentVisit = { + uuid: 'ee527f74-7373-4494-98bc-002c979971d1', + encounters: [], + patient: { uuid: '0b25b92a-add3-4d52-8491-778bec556e02' }, + visitType: { uuid: '3371a4d4-f66f-4454-a86d-92c7b3da990c', name: 'Outpatient', display: 'Outpatient' }, + attributes: [ + { + uuid: 'e1522ecb-d027-4c30-aa25-38698ba18020', + display: 'Source form: 13', + attributeType: { + name: 'Source form', + datatypeClassname: 'org.openmrs.module.kenyaemr.datatype.FormDatatype', + uuid: '8bfab185-6947-4958-b7ab-dfafae1a3e3d', + }, + value: { + uuid: '23b4ebbd-29ad-455e-be0e-04aa6bc30798', + display: 'MOH 257 Visit Summary', + name: 'MOH 257 Visit Summary', + description: null, + encounterType: { + uuid: 'a0034eee-1940-4e35-847f-97537a35d05e', + display: 'HIV Consultation', + }, + version: '1', + build: null, + published: true, + formFields: [], + retired: false, + resourceVersion: '1.9', + }, + }, + { + uuid: '5dbb093d-b377-4684-9583-05074ae7187a', + display: 'Payment Method: 28989582-e8c3-46b0-96d0-c249cb06d5c6', + attributeType: { + name: 'Payment Method', + datatypeClassname: 'org.openmrs.customdatatype.datatype.FreeTextDatatype', + uuid: 'e6cb0c3b-04b0-4117-9bc6-ce24adbda802', + }, + value: '28989582-e8c3-46b0-96d0-c249cb06d5c6', + }, + { + uuid: '85abc096-8524-4c2e-863c-b44c99c144f7', + display: 'Patient Type: false', + attributeType: { + name: 'Patient Type', + datatypeClassname: 'org.openmrs.customdatatype.datatype.FreeTextDatatype', + uuid: '3b9dfac8-9e4d-11ee-8c90-0242ac120002', + }, + value: 'false', + }, + { + uuid: '7eb402bb-2bf8-43b7-91aa-ee3f5c753de1', + display: 'Visit queue number: CLI-090', + attributeType: { + name: 'Visit queue number', + datatypeClassname: 'org.openmrs.customdatatype.datatype.FreeTextDatatype', + uuid: 'c61ce16f-272a-41e7-9924-4c555d0932c5', + }, + value: 'CLI-090', + }, + ], + location: { + uuid: '233de33e-2778-4f9a-a398-fa09da9daa14', + name: 'Wamagana Health Centre', + display: 'Wamagana Health Centre', + }, + startDatetime: '2024-05-29T15:19:00.000+0300', + stopDatetime: '', +}; + +export const mockBills = [ + { + id: 1888, + uuid: '3b784fa7-c124-4710-9152-cad3dbaa19e8', + patientName: ' Test Unit patient', + identifier: 'MGTKYE ', + patientUuid: '0b25b92a-add3-4d52-8491-778bec556e02', + status: 'PENDING', + receiptNumber: '1916-6', + cashier: { + uuid: '693acc9b-734f-488d-b6d2-60368d02cec0', + display: '23797304 - Test Patient', + }, + cashPointUuid: '54065383-b4d4-42d2-af4d-d250a1fd2590', + cashPointName: 'OPD Cash Point', + cashPointLocation: 'Moi Teaching Refferal Hospital', + dateCreated: '29 — May — 2024', + lineItems: [ + { + uuid: '528c3411-b3b8-41dc-bf88-174b4adc1b5b', + display: 'BillLineItem', + voided: false, + voidReason: null, + item: '', + billableService: '3f5d0684-a280-477e-a67b-2a956a1f6dca:Registration Revist', + quantity: 1, + price: 50, + priceName: 'Default', + priceUuid: '', + lineItemOrder: 0, + paymentStatus: 'PENDING', + order: null, + resourceVersion: '1.8', + }, + ], + billingService: '3f5d0684-a280-477e-a67b-2a956a1f6dca:Registration Revist', + payments: [], + display: '1916-6', + totalAmount: 50, + }, + { + id: 1213, + uuid: '5b633220-9a99-4517-bcdf-c06a7d38dd23', + patientName: ' peter ndungu mairo', + identifier: 'MGTKYE ', + patientUuid: '0b25b92a-add3-4d52-8491-778bec556e02', + status: 'PENDING', + receiptNumber: '1228-6', + cashier: { + uuid: '48b55692-e061-4ffa-b1f2-fd4aaf506224', + display: 'admin - Super User', + }, + cashPointUuid: '54065383-b4d4-42d2-af4d-d250a1fd2590', + cashPointName: 'OPD Cash Point', + cashPointLocation: 'Moi Teaching Refferal Hospital', + dateCreated: '15 — May — 2024', + lineItems: [ + { + uuid: '16cc8b90-f2d4-4907-a3d5-0f57486e5dcf', + display: 'BillLineItem', + voided: false, + voidReason: null, + item: '', + billableService: '3f5d0684-a280-477e-a67b-2a956a1f6dca:Registration Revist', + quantity: 1, + price: 50, + priceName: 'Default', + priceUuid: '', + lineItemOrder: 0, + paymentStatus: 'PENDING', + order: null, + resourceVersion: '1.8', + }, + ], + billingService: '3f5d0684-a280-477e-a67b-2a956a1f6dca:Registration Revist', + payments: [], + display: '1228-6', + totalAmount: 50, + }, +]; diff --git a/packages/esm-billing-app/jest.config.js b/packages/esm-billing-app/jest.config.js new file mode 100644 index 00000000..e53fc903 --- /dev/null +++ b/packages/esm-billing-app/jest.config.js @@ -0,0 +1,8 @@ +const rootConfig = require('../../jest.config.js'); + +const packageConfig = { + ...rootConfig, + collectCoverage: false, +}; + +module.exports = packageConfig; diff --git a/packages/esm-billing-app/package.json b/packages/esm-billing-app/package.json new file mode 100644 index 00000000..10535518 --- /dev/null +++ b/packages/esm-billing-app/package.json @@ -0,0 +1,54 @@ +{ + "name": "@ampath/esm-billing-app", + "version": "5.1.1", + "description": "Billing app for AMRS", + "browser": "dist/ampath-esm-billing-app.js", + "main": "src/index.ts", + "source": true, + "license": "MPL-2.0", + "homepage": "https://github.com/AMPATH/ampath-esm-3.x#readme", + "scripts": { + "start": "openmrs develop", + "serve": "webpack serve --mode=development", + "debug": "npm run serve", + "build": "webpack --mode production", + "analyze": "webpack --mode=production --env.analyze=true", + "lint": "eslint src --ext ts,tsx", + "typescript": "tsc", + "extract-translations": "i18next 'src/**/*.component.tsx' 'src/index.ts' --config ../../tools/i18next-parser.config.js", + "test": "cross-env TZ=UTC jest --config jest.config.js --verbose false --passWithNoTests", + "test:watch": "cross-env TZ=UTC jest --watch --config jest.config.js", + "coverage": "yarn test --coverage" + }, + "browserslist": [ + "extends browserslist-config-openmrs" + ], + "keywords": [ + "openmrs" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/AMPATH/ampath-esm-3.x.git" + }, + "bugs": { + "url": "git+https://github.com/AMPATH/ampath-esm-3.x.git" + }, + "dependencies": { + "@carbon/react": "^1.42.1", + "lodash-es": "^4.17.15", + "react-to-print": "^2.14.13" + }, + "peerDependencies": { + "@openmrs/esm-framework": "5.x", + "react": "^18.1.0", + "react-i18next": "11.x", + "react-router-dom": "6.x", + "swr": "2.x" + }, + "devDependencies": { + "webpack": "^5.74.0" + } +} diff --git a/packages/esm-billing-app/src/bill-history/bill-history.component.tsx b/packages/esm-billing-app/src/bill-history/bill-history.component.tsx new file mode 100644 index 00000000..e3c19915 --- /dev/null +++ b/packages/esm-billing-app/src/bill-history/bill-history.component.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + DataTableSkeleton, + Layer, + DataTable, + TableContainer, + Table, + TableHead, + TableHeader, + TableRow, + TableBody, + TableCell, + Tile, + Pagination, + TableExpandHeader, + TableExpandRow, + TableExpandedRow, + Button, +} from '@carbon/react'; +import { Add } from '@carbon/react/icons'; +import { isDesktop, useLayoutType, usePagination, launchWorkspace } from '@openmrs/esm-framework'; +import { EmptyDataIllustration, ErrorState, usePaginationInfo, CardHeader } from '@openmrs/esm-patient-common-lib'; +import { useBills } from '../billing.resource'; +import InvoiceTable from '../invoice/invoice-table.component'; +import styles from './bill-history.scss'; + +interface BillHistoryProps { + patientUuid: string; +} + +const BillHistory: React.FC = ({ patientUuid }) => { + const { t } = useTranslation(); + const { bills, isLoading, error } = useBills(patientUuid); + const layout = useLayoutType(); + const [pageSize, setPageSize] = React.useState(10); + const responsiveSize = isDesktop(layout) ? 'sm' : 'lg'; + const { paginated, goTo, results, currentPage } = usePagination(bills, pageSize); + const { pageSizes } = usePaginationInfo(pageSize, bills?.length, currentPage, results?.length); + + const headerData = [ + { + header: t('visitTime', 'Visit time'), + key: 'visitTime', + }, + { + header: t('identifier', 'Identifier'), + key: 'identifier', + }, + { + header: t('billedItems', 'Billed Items'), + key: 'billedItems', + }, + { + header: t('billTotal', 'Bill total'), + key: 'billTotal', + }, + ]; + + const setBilledItems = (bill) => + bill.lineItems.reduce( + (acc, item) => acc + (acc ? ' & ' : '') + (item.billableService?.split(':')[1] || item.item?.split(':')[1] || ''), + '', + ); + + const rowData = results?.map((bill) => ({ + id: bill.uuid, + uuid: bill.uuid, + billTotal: bill.totalAmount, + visitTime: bill.dateCreated, + identifier: bill.identifier, + billedItems: setBilledItems(bill), + })); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ + + +
+ ); + } + + if (bills.length === 0) { + return ( + <> + + <> + + + +
+ +
+

There are no bills to display.

+ +
+
+ + ); + } + + return ( +
+ + + +
+ + {({ + rows, + headers, + getExpandHeaderProps, + getTableProps, + getTableContainerProps, + getHeaderProps, + getRowProps, + }) => ( + + + + + + {headers.map((header, i) => ( + + {header.header} + + ))} + + + + {rows.map((row, i) => { + const currentBill = bills?.find((bill) => bill.uuid === row.id); + + return ( + + + {row.cells.map((cell) => ( + {cell.value} + ))} + + {row.isExpanded ? ( + +
+ +
+
+ ) : ( + + )} +
+ ); + })} +
+
+
+ )} +
+ {paginated && ( + { + if (newPage !== currentPage) { + goTo(newPage); + } + setPageSize(pageSize); + }} + /> + )} +
+
+ ); +}; + +export default BillHistory; diff --git a/packages/esm-billing-app/src/bill-history/bill-history.scss b/packages/esm-billing-app/src/bill-history/bill-history.scss new file mode 100644 index 00000000..786f905e --- /dev/null +++ b/packages/esm-billing-app/src/bill-history/bill-history.scss @@ -0,0 +1,165 @@ +@use '@carbon/layout'; +@use '@carbon/type'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.container { + margin: 2rem 0; +} + +.billHistoryContainer { + background-color: $ui-02; + border: 1px solid $ui-03; + border-bottom: none; + width: 100%; + margin: 0 auto; + max-width: 95vw; + padding-bottom: 0; +} + +.headerContainer { + display: flex; + justify-content: space-between; + align-items: center; + padding: layout.$spacing-04 0 layout.$spacing-04 layout.$spacing-05; + background-color: $ui-02; +} + +.backgroundDataFetchingIndicator { + align-items: center; + display: flex; + flex: 1 1 0%; + justify-content: center; +} + +.tableContainer section { + position: relative; +} + +.tableContainer a { + text-decoration: none; +} + +.pagination { + overflow: hidden; + + &:global(.cds--pagination) { + border-top: none; + } +} + +.hiddenRow { + display: none; +} + +.emptyRow { + padding: 0 1rem; + display: flex; + align-items: center; +} + +.visitSummaryContainer { + width: 100%; + max-width: 768px; + margin: 1rem auto; +} + +.expandedActiveVisitRow > td > div { + max-height: max-content !important; +} + +.expandedActiveVisitRow td { + padding: 0 2rem; +} + +.expandedActiveVisitRow th[colspan] td[colspan] > div:first-child { + padding: 0 1rem; +} + +.action { + margin-bottom: layout.$spacing-03; +} + +.illo { + margin-top: layout.$spacing-05; +} + +.content { + @include type.type-style('heading-compact-01'); + color: $text-02; + margin-top: layout.$spacing-05; + margin-bottom: layout.$spacing-03; +} + +.desktopHeading, +.tabletHeading { + text-align: left; + text-transform: capitalize; + + h4 { + @include type.type-style('heading-compact-02'); + color: $text-02; + + &:after { + content: ''; + display: block; + width: 2rem; + padding-top: 3px; + border-bottom: 0.375rem solid; + @include brand-03(border-bottom-color); + } + } +} + +.tile { + text-align: center; + // border: 1px solid $ui-03; +} + +.filterEmptyState { + display: flex; + justify-content: center; + align-items: center; + padding: layout.$spacing-05; + margin: layout.$spacing-09; + text-align: center; +} + +.filterEmptyStateTile { + margin: auto; +} + +.filterEmptyStateContent { + @include type.type-style('heading-compact-02'); + color: $text-02; + margin-bottom: 0.5rem; +} + +.filterEmptyStateHelper { + @include type.type-style('body-compact-01'); + color: $text-02; +} + +.table { + tr[data-child-row] td { + padding-left: 2rem !important; + } +} + +.billingHeading { + position: relative; + max-width: 400px; + text-align: 'left'; + margin-bottom: 40px; +} + +.billingHeading::after { + position: absolute; + bottom: -10px; + width: 50px; + height: 5px; + background: green; + content: ''; + display: block; + left: 6%; + transform: translatex(-50%); +} diff --git a/packages/esm-billing-app/src/billable-services-admin-card-link.component.tsx b/packages/esm-billing-app/src/billable-services-admin-card-link.component.tsx new file mode 100644 index 00000000..b385dd3a --- /dev/null +++ b/packages/esm-billing-app/src/billable-services-admin-card-link.component.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Layer, ClickableTile } from '@carbon/react'; +import { ArrowRight } from '@carbon/react/icons'; + +const BillableServicesCardLink: React.FC = () => { + const { t } = useTranslation(); + const header = t('manageBillableServices', 'Manage billable services'); + return ( + + +
+
{header}
+
{t('billableServices', 'Billable Services')}
+
+
+ +
+
+
+ ); +}; + +export default BillableServicesCardLink; diff --git a/packages/esm-billing-app/src/billable-services/bill-waiver/bill-selection.component.tsx b/packages/esm-billing-app/src/billable-services/bill-waiver/bill-selection.component.tsx new file mode 100644 index 00000000..65f23016 --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/bill-waiver/bill-selection.component.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { + StructuredListHead, + StructuredListRow, + StructuredListCell, + StructuredListBody, + StructuredListWrapper, + Layer, + Checkbox, +} from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { convertToCurrency, extractString } from '../../helpers'; +import { type MappedBill, type LineItem } from '../../types'; +import styles from './bill-waiver.scss'; +import BillWaiverForm from './bill-waiver-form.component'; + +const PatientBillsSelections: React.FC<{ bills: MappedBill; setPatientUuid: (patientUuid) => void }> = ({ + bills, + setPatientUuid, +}) => { + const { t } = useTranslation(); + const [selectedBills, setSelectedBills] = React.useState>([]); + + const checkBoxLabel = (lineItem) => { + return `${lineItem.item === '' ? lineItem.billableService : lineItem.item} ${convertToCurrency(lineItem.price)}`; + }; + + const handleOnCheckBoxChange = (event, { checked, id }) => { + const selectedLineItem = bills.lineItems.find((lineItem) => lineItem.uuid === id); + if (checked) { + setSelectedBills([...selectedBills, selectedLineItem]); + } else { + setSelectedBills(selectedBills.filter((lineItem) => lineItem.uuid !== id)); + } + }; + return ( + + + + + {t('billItem', 'Bill item')} + {t('quantity', 'Quantity')} + {t('unitPrice', 'Unit Price')} + {t('total', 'Total')} + {t('actions', 'Actions')} + + + + {bills?.lineItems.map((lineItem) => ( + + + {lineItem.item === '' ? extractString(lineItem.billableService) : extractString(lineItem.item)} + + {lineItem.quantity} + {convertToCurrency(lineItem.price)} + {convertToCurrency(lineItem.price * lineItem.quantity)} + + handleOnCheckBoxChange(event, { checked, id })} + labelText={checkBoxLabel(lineItem)} + id={lineItem.uuid} + /> + + + ))} + + + + + ); +}; + +export default PatientBillsSelections; diff --git a/packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver-form.component.tsx b/packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver-form.component.tsx new file mode 100644 index 00000000..0ca92071 --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver-form.component.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import { Form, Stack, FormGroup, Layer, Button, NumberInput } from '@carbon/react'; +import { TaskAdd } from '@carbon/react/icons'; +import { useTranslation } from 'react-i18next'; +import styles from './bill-waiver-form.scss'; +import { type LineItem, type MappedBill } from '../../types'; +import { createBillWaiverPayload } from './utils'; +import { convertToCurrency, extractString } from '../../helpers'; +import { processBillPayment, usePaymentModes } from '../../billing.resource'; +import { showSnackbar } from '@openmrs/esm-framework'; +import { mutate } from 'swr'; + +type BillWaiverFormProps = { + bill: MappedBill; + lineItems: Array; + setPatientUuid: (patientUuid) => void; +}; + +const BillWaiverForm: React.FC = ({ bill, lineItems, setPatientUuid }) => { + const { t } = useTranslation(); + const [waiverAmount, setWaiverAmount] = useState(0); + + const totalAmount = lineItems.reduce((acc, curr) => acc + curr.price * curr.quantity, 0); + const { paymentModes } = usePaymentModes(false); + + if (lineItems?.length === 0) { + return null; + } + + const handleProcessPayment = () => { + const waiverEndPointPayload = createBillWaiverPayload(bill, waiverAmount, totalAmount, lineItems, paymentModes); + + processBillPayment(waiverEndPointPayload, bill.uuid).then( + (resp) => { + showSnackbar({ + title: t('billWaiver', 'Bill waiver'), + subtitle: t('billWaiverSuccess', 'Bill waiver successful'), + kind: 'success', + timeoutInMs: 3500, + isLowContrast: true, + }); + setPatientUuid(''); + mutate((key) => typeof key === 'string' && key.startsWith('/ws/rest/v1/cashier/bill?v=full'), undefined, { + revalidate: true, + }); + }, + (error) => { + showSnackbar({ + title: t('billWaiver', 'Bill waiver'), + subtitle: t('billWaiverError', 'Bill waiver failed {{error}}', { + error: error?.responseBody?.error?.message ?? error.message, + }), + kind: 'error', + timeoutInMs: 3500, + isLowContrast: true, + }); + }, + ); + }; + + return ( +
+
+ + +
+ +

+ {t('billName', ' {{billName}} ', { + billName: lineItems.map((item) => extractString(item.item || item.billableService)).join(', ') ?? '--', + })} +

+
+
+ +

{convertToCurrency(totalAmount)}

+
+ + + setWaiverAmount(event.target.value)} + /> + +
+
+ +
+
+
+ ); +}; + +export default BillWaiverForm; diff --git a/packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver-form.scss b/packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver-form.scss new file mode 100644 index 00000000..36742cbb --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver-form.scss @@ -0,0 +1,34 @@ +@use '@carbon/layout'; +@use '@carbon/type'; + +.billWaiverForm { + margin-top: layout.$spacing-05; + padding: 0 layout.$spacing-05; +} + +.buttonContainer { + display: flex; + justify-content: flex-end; + margin-top: -1rem; +} + +.formControlLayer { + padding: layout.$spacing-05 0; +} + +.billWaiverDescription { + display: grid; + grid-template-columns: 5rem 1fr; + column-gap: 1rem; + align-items: center; + margin-top: 0.125rem; + margin-bottom: 0.5rem; +} + +.label { + @include type.type-style('heading-compact-01'); +} + +.value { + @include type.type-style('body-01'); +} diff --git a/packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver.component.tsx b/packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver.component.tsx new file mode 100644 index 00000000..ec8b2d11 --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver.component.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { ExtensionSlot, UserHasAccess } from '@openmrs/esm-framework'; +import PatientBills from './patient-bills.component'; +import styles from './bill-waiver.scss'; +import BillWaiverForm from './bill-waiver-form.component'; +import { useBills } from '../../billing.resource'; + +type BillWaiverProps = {}; + +const BillWaiver: React.FC = () => { + const [patientUuid, setPatientUuid] = React.useState(''); + const { bills } = useBills(patientUuid); + const filterBills = bills.filter((bill) => bill.status !== 'PAID' && patientUuid === bill.patientUuid) ?? []; + return ( + +
+ setPatientUuid(patientUuid), + buttonProps: { + kind: 'primary', + }, + }} + /> + + +
+
+ ); +}; + +export default BillWaiver; diff --git a/packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver.scss b/packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver.scss new file mode 100644 index 00000000..f2e4a574 --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/bill-waiver/bill-waiver.scss @@ -0,0 +1,10 @@ +@use '@carbon/layout'; + +.billWaiverContainer { + margin: layout.$layout-01; + row-gap: layout.$layout-01; +} + +.billListContainer { + background-color: white; +} diff --git a/packages/esm-billing-app/src/billable-services/bill-waiver/patient-bills.component.tsx b/packages/esm-billing-app/src/billable-services/bill-waiver/patient-bills.component.tsx new file mode 100644 index 00000000..b459e1e1 --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/bill-waiver/patient-bills.component.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { useBills } from '../../billing.resource'; +import { + Layer, + DataTable, + TableContainer, + Table, + TableHead, + TableRow, + TableExpandHeader, + TableHeader, + TableBody, + TableExpandRow, + TableCell, + TableExpandedRow, + Tile, +} from '@carbon/react'; +import { convertToCurrency, extractString } from '../../helpers'; +import { useTranslation } from 'react-i18next'; +import { EmptyDataIllustration } from '@openmrs/esm-patient-common-lib'; +import PatientBillsSelections from './bill-selection.component'; +import { type MappedBill } from '../../types'; +import styles from '../../bills-table/bills-table.scss'; + +type PatientBillsProps = { + patientUuid: string; + bills: Array; + setPatientUuid: (patientUuid: string) => void; +}; + +const PatientBills: React.FC = ({ patientUuid, bills, setPatientUuid }) => { + const { t } = useTranslation(); + + if (!patientUuid) { + return; + } + + const tableHeaders = [ + { header: 'Date', key: 'date' }, + { header: 'Billable Service', key: 'billableService' }, + { header: 'Total Amount', key: 'totalAmount' }, + ]; + + const tableRows = bills.map((bill) => ({ + id: `${bill.uuid}`, + date: bill.dateCreated, + billableService: extractString(bill.billingService), + totalAmount: convertToCurrency(bill.totalAmount), + })); + + if (bills.length === 0 && patientUuid !== '') { + return ( + <> +
+ + +
+ +
+

{t('noBilltoDisplay', 'There are no bills to display for this patient')}

+
+
+
+ + ); + } + + return ( +
+ ( + + + + + + {headers.map((header, i) => ( + + {header.header} + + ))} + + + + {rows.map((row, index) => ( + + + {row.cells.map((cell) => ( + {cell.value} + ))} + + +
+ +
+
+
+ ))} +
+
+
+ )} + /> +
+ ); +}; + +export default PatientBills; diff --git a/packages/esm-billing-app/src/billable-services/bill-waiver/utils.ts b/packages/esm-billing-app/src/billable-services/bill-waiver/utils.ts new file mode 100644 index 00000000..f850b352 --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/bill-waiver/utils.ts @@ -0,0 +1,38 @@ +import { type OpenmrsResource } from '@openmrs/esm-framework'; +import { type LineItem, type MappedBill } from '../../types'; + +export const createBillWaiverPayload = ( + bill: MappedBill, + amountWaived: number, + totalAmount: number, + lineItems: Array, + paymentModes: Array, +) => { + const { cashier } = bill; + + const billPayment = { + amount: parseFloat(totalAmount.toFixed(2)), + amountTendered: parseFloat(Number(amountWaived).toFixed(2)), + attributes: [], + instanceType: paymentModes?.find((mode) => mode.name.toLowerCase().includes('waiver'))?.uuid, + }; + + const processedLineItems = lineItems.map((lineItem) => ({ + ...lineItem, + billableService: processBillItem(lineItem), + item: processBillItem(lineItem), + paymentStatus: 'PAID', + })); + + const processedPayment = { + cashPoint: bill.cashPointUuid, + cashier: cashier.uuid, + lineItems: processedLineItems, + payments: [...bill.payments, billPayment], + patient: bill.patientUuid, + }; + + return processedPayment; +}; + +const processBillItem = (item) => (item.item || item.billableService)?.split(':')[0]; diff --git a/packages/esm-billing-app/src/billable-services/billable-service.resource.tsx b/packages/esm-billing-app/src/billable-services/billable-service.resource.tsx new file mode 100644 index 00000000..d0296721 --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/billable-service.resource.tsx @@ -0,0 +1,51 @@ +import { type OpenmrsResource, openmrsFetch } from '@openmrs/esm-framework'; +import useSWR from 'swr'; +import { type ServiceConcept } from '../types'; + +type ResponseObject = { + results: Array; +}; + +export const useBillableServices = () => { + const url = `/ws/rest/v1/cashier/billableService?v=custom:(uuid,name,shortName,serviceStatus,serviceType:(display),servicePrices:(uuid,name,price))`; + const { data, isLoading, isValidating, error, mutate } = useSWR<{ data: ResponseObject }>(url, openmrsFetch, {}); + return { billableServices: data?.data.results ?? [], isLoading, isValidating, error, mutate }; +}; + +export function useServiceTypes() { + const url = `/ws/rest/v1/concept/d7bd4cc0-90b1-4f22-90f2-ab7fde936727?v=custom:(setMembers:(uuid,display))`; + const { data, error, isLoading } = useSWR<{ data: any }>(url, openmrsFetch, {}); + return { serviceTypes: data?.data.setMembers ?? [], error, isLoading }; +} + +export const usePaymentModes = () => { + const url = `/ws/rest/v1/cashier/paymentMode`; + const { data, error, isLoading } = useSWR<{ data: ResponseObject }>(url, openmrsFetch, {}); + return { paymentModes: data?.data.results ?? [], error, isLoading }; +}; + +export const createBillableService = (payload: any) => { + const url = `/ws/rest/v1/cashier/api/billable-service`; + return openmrsFetch(url, { + method: 'POST', + body: payload, + headers: { + 'Content-Type': 'application/json', + }, + }); +}; + +export function useConceptsSearch(conceptToLookup: string) { + const conditionsSearchUrl = `/ws/rest/v1/conceptsearch?q=${conceptToLookup}`; + + const { data, error, isLoading } = useSWR<{ data: { results: Array } }, Error>( + conceptToLookup ? conditionsSearchUrl : null, + openmrsFetch, + ); + + return { + searchResults: data?.data?.results ?? [], + error: error, + isSearching: isLoading, + }; +} diff --git a/packages/esm-billing-app/src/billable-services/billable-services-home.component.tsx b/packages/esm-billing-app/src/billable-services/billable-services-home.component.tsx new file mode 100644 index 00000000..af50fe2a --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/billable-services-home.component.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { BillableServicesDashboard } from './dashboard/dashboard.component'; +import AddBillableService from './create-edit/add-billable-service.component'; +import { SideNav, SideNavItems, SideNavLink } from '@carbon/react'; +import styles from './billable-services.scss'; +import BillingHeader from '../billing-header/billing-header.component'; +import { Wallet, Money } from '@carbon/react/icons'; +import { UserHasAccess, navigate } from '@openmrs/esm-framework'; +import BillWaiver from './bill-waiver/bill-waiver.component'; +const basePath = `${window.spaBase}/billable-services`; +const BillableServiceHome: React.FC = () => { + const { t } = useTranslation(); + + const handleNavigation = (path: string) => { + navigate({ to: `${basePath}/${path}` }); + }; + + return ( + +
+
+ + + handleNavigation('')} renderIcon={Wallet} isActive> + {t('billableServices', 'Billable Services')} + + + handleNavigation('waive-bill')} renderIcon={Money}> + {t('billWaiver', 'Bill waiver')} + + + + +
+
+ + + } /> + } /> + } /> + +
+
+
+ ); +}; + +export default BillableServiceHome; diff --git a/packages/esm-billing-app/src/billable-services/billable-services.component.tsx b/packages/esm-billing-app/src/billable-services/billable-services.component.tsx new file mode 100644 index 00000000..c0c3bb2d --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/billable-services.component.tsx @@ -0,0 +1,255 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { + DataTable, + InlineLoading, + Layer, + Pagination, + Search, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, + Tile, + Button, +} from '@carbon/react'; +import { useLayoutType, isDesktop, useConfig, usePagination, ErrorState, navigate } from '@openmrs/esm-framework'; +import { EmptyState } from '@openmrs/esm-patient-common-lib'; +import styles from './billable-services.scss'; +import { useTranslation } from 'react-i18next'; +import { useBillableServices } from './billable-service.resource'; +import { ArrowRight } from '@carbon/react/icons'; + +const BillableServices = () => { + const { t } = useTranslation(); + const { billableServices, isLoading, isValidating, error, mutate } = useBillableServices(); + const layout = useLayoutType(); + const config = useConfig(); + const [searchString, setSearchString] = useState(''); + const responsiveSize = isDesktop(layout) ? 'lg' : 'sm'; + const pageSizes = config?.billableServices?.pageSizes ?? [10, 20, 30, 40, 50]; + const [pageSize, setPageSize] = useState(config?.billableServices?.pageSize ?? 10); + + //creating service state + const [showOverlay, setShowOverlay] = useState(false); + const [overlayHeader, setOverlayTitle] = useState(''); + + const headerData = [ + { + header: t('serviceName', 'Service Name'), + key: 'serviceName', + }, + { + header: t('shortName', 'Short Name'), + key: 'shortName', + }, + { + header: t('serviceType', 'Service Type'), + key: 'serviceType', + }, + { + header: t('status', 'Service Status'), + key: 'status', + }, + { + header: t('prices', 'Prices'), + key: 'prices', + }, + { + header: t('actions', 'Actions'), + key: 'actions', + }, + ]; + + const launchBillableServiceForm = useCallback(() => { + navigate({ to: window.getOpenmrsSpaBase() + 'billable-services/add-service' }); + }, []); + + const searchResults = useMemo(() => { + if (billableServices !== undefined && billableServices.length > 0) { + if (searchString && searchString.trim() !== '') { + const search = searchString.toLowerCase(); + return billableServices?.filter((service) => + Object.entries(service).some(([header, value]) => { + return header === 'uuid' ? false : `${value}`.toLowerCase().includes(search); + }), + ); + } + } + return billableServices; + }, [searchString, billableServices]); + + const { paginated, goTo, results, currentPage } = usePagination(searchResults, pageSize); + + let rowData = []; + if (results) { + results.forEach((service, index) => { + const s = { + id: `${index}`, + uuid: service.uuid, + serviceName: service.name, + shortName: service.shortName, + serviceType: service?.serviceType?.display, + status: service.serviceStatus, + prices: '--', + actions: '--', + }; + let cost = ''; + service.servicePrices.forEach((price) => { + cost += `${price.name} (${price.price}) `; + }); + s.prices = cost; + rowData.push(s); + }); + } + + const handleSearch = useCallback( + (e) => { + goTo(1); + setSearchString(e.target.value); + }, + [goTo, setSearchString], + ); + + if (isLoading) { + ; + } + if (error) { + ; + } + if (billableServices.length === 0) { + ; + } + + return ( + <> + {billableServices?.length > 0 ? ( +
+ + 1 ? true : false}> + {({ rows, headers, getRowProps, getTableProps }) => ( + + + + + {headers.map((header) => ( + {header.header} + ))} + + + + {rows.map((row) => ( + + {row.cells.map((cell) => ( + {cell.value} + ))} + + ))} + +
+
+ )} +
+ {searchResults?.length === 0 && ( +
+ + +

+ {t('noMatchingServicesToDisplay', 'No matching services to display')} +

+

{t('checkFilters', 'Check the filters above')}

+
+
+
+ )} + {paginated && ( + { + if (newPageSize !== pageSize) { + setPageSize(newPageSize); + } + if (newPage !== currentPage) { + goTo(newPage); + } + }} + /> + )} +
+ ) : ( + + )} + + ); +}; + +function FilterableTableHeader({ layout, handleSearch, isValidating, responsiveSize, t }) { + return ( + <> +
+
+

{t('servicesList', 'Services list')}

+
+
+ {isValidating ? : null} +
+
+
+ + +
+ + ); +} +export default BillableServices; diff --git a/packages/esm-billing-app/src/billable-services/billable-services.scss b/packages/esm-billing-app/src/billable-services/billable-services.scss new file mode 100644 index 00000000..2f2691ff --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/billable-services.scss @@ -0,0 +1,219 @@ +@use '@carbon/layout'; +@use '@carbon/type'; +@use '@carbon/styles/scss/spacing'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.container { + margin: 2rem 0; +} + +.emptyStateContainer, +.loaderContainer { + @extend .container; +} + +.serviceContainer { + background-color: $ui-02; + border: 1px solid $ui-03; + width: 100%; + margin: 0 auto; + max-width: 95vw; + padding-bottom: 0; + + :has(.filterEmptyState) { + border-bottom: none; + } +} +.left-justified-items { + display: flex; + flex-direction: row; + align-items: center; + cursor: pointer; + align-items: center; +} + +.filterContainer { + flex: 1; + + :global(.cds--dropdown__wrapper--inline) { + gap: 0; + } + + :global(.cds--list-box__menu-icon) { + height: 1rem; + } + + :global(.cds--list-box__menu) { + min-width: max-content; + } + + :global(.cds--list-box) { + margin-left: layout.$spacing-03; + } +} + +.menu { + margin-left: layout.$spacing-03; +} + +.headerContainer { + display: flex; + justify-content: space-between; + align-items: center; + padding: layout.$spacing-04 layout.$spacing-05; + background-color: $ui-02; +} + +.backgroundDataFetchingIndicator { + align-items: center; + display: flex; + flex: 1; + justify-content: space-between; + + &:global(.cds--inline-loading) { + max-height: 1rem; + } +} + +.tableContainer section { + position: relative; +} + +.tableContainer a { + text-decoration: none; +} + +.pagination { + overflow: hidden; + + &:global(.cds--pagination) { + border-top: none; + } +} + +.hiddenRow { + display: none; +} + +.emptyRow { + padding: 0 1rem; + display: flex; + align-items: center; +} + +.visitSummaryContainer { + width: 100%; + max-width: 768px; + margin: 1rem auto; +} + +.expandedActiveVisitRow > td > div { + max-height: max-content !important; +} + +.expandedActiveVisitRow td { + padding: 0 2rem; +} + +.expandedActiveVisitRow th[colspan] td[colspan] > div:first-child { + padding: 0 1rem; +} + +.action { + margin-bottom: layout.$spacing-03; +} + +.illo { + margin-top: layout.$spacing-05; +} + +.content { + @include type.type-style('heading-compact-01'); + color: $text-02; + margin-top: layout.$spacing-05; + margin-bottom: layout.$spacing-03; +} + +.desktopHeading, +.tabletHeading { + text-align: left; + text-transform: capitalize; + flex: 1; + + h4 { + @include type.type-style('heading-compact-02'); + color: $text-02; + + &:after { + content: ''; + display: block; + width: 2rem; + padding-top: 3px; + border-bottom: 0.375rem solid; + @include brand-03(border-bottom-color); + } + } +} + +.tile { + text-align: center; + border: 1px solid $ui-03; +} + +.menuitem { + max-width: none; +} + +.filterEmptyState { + display: flex; + justify-content: center; + align-items: center; + padding: layout.$spacing-05; + margin: layout.$spacing-09; + text-align: center; +} + +.filterEmptyStateTile { + margin: auto; +} + +.filterEmptyStateContent { + @include type.type-style('heading-compact-02'); + color: $text-02; + margin-bottom: 0.5rem; +} + +.filterEmptyStateHelper { + @include type.type-style('body-compact-01'); + color: $text-02; +} + +.metricsContainer { + display: flex; + justify-content: space-between; + background-color: $ui-02; + height: spacing.$spacing-10; + align-items: center; + padding: 0 spacing.$spacing-05; +} + +.metricsTitle { + @include type.type-style('heading-03'); + color: $ui-05; +} + +.actionsContainer { + display: flex; + justify-content: space-between; + align-items: center; + background-color: $ui-02; +} +.actionBtn { + display: flex; + column-gap: 0.5rem; +} + +.mainSection { + display: grid; + grid-template-columns: 16rem 1fr; +} diff --git a/packages/esm-billing-app/src/billable-services/billiable-item/drug-order.component.tsx b/packages/esm-billing-app/src/billable-services/billiable-item/drug-order.component.tsx new file mode 100644 index 00000000..c4f2160a --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/billiable-item/drug-order.component.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { type Drug } from '@openmrs/esm-patient-common-lib'; +import { type DosingUnit, type MedicationFrequency, type MedicationRoute, type QuantityUnit } from '../../types'; +import { useBillableItem, useSockItemInventory } from './useBilliableItem'; +import { useTranslation } from 'react-i18next'; +import styles from './drug-order.scss'; +import { convertToCurrency } from '../../helpers'; + +type DrugOrderProps = { + order: { + drug: Drug; + unit: DosingUnit; + commonMedicationName: string; + dosage: number; + frequency: MedicationFrequency; + route: MedicationRoute; + quantityUnits: QuantityUnit; + patientInstructions: string; + asNeeded: boolean; + asNeededCondition: string; + }; +}; + +const DrugOrder: React.FC = ({ order }) => { + const { t } = useTranslation(); + const { stockItem, isLoading: isLoadingInventory } = useSockItemInventory(order?.drug?.uuid); + const { billableItem, isLoading } = useBillableItem(order?.drug.concept.uuid); + + if (isLoading || isLoadingInventory) { + return null; + } + + return ( +
+ {stockItem && ( +
+ + {t('inStock', '{{quantityUoM}}(s) In stock ', { quantityUoM: stockItem?.quantityUoM })} + + {Math.round(stockItem?.quantity)} +
+ )} +
+ {billableItem && + billableItem?.servicePrices.map((item) => ( +
+ {t('unitPrice', 'Unit price ')} + {convertToCurrency(item.price)} +
+ ))} +
+
+ ); +}; + +export default DrugOrder; diff --git a/packages/esm-billing-app/src/billable-services/billiable-item/drug-order.scss b/packages/esm-billing-app/src/billable-services/billiable-item/drug-order.scss new file mode 100644 index 00000000..f40a9fd7 --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/billiable-item/drug-order.scss @@ -0,0 +1,26 @@ +@use '@carbon/type'; +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/styles/scss/spacing'; + +.drugOrderContainer { + margin: spacing.$spacing-03 0; + + & > div { + margin: spacing.$spacing-03 0; + } +} + +.bold { + font-weight: bold; +} + +.itemInfo { + @include type.type-style('body-01'); +} + +.itemContainer { + display: grid; + grid-template-columns: 1fr 1fr; + padding-left: spacing.$spacing-03; +} diff --git a/packages/esm-billing-app/src/billable-services/billiable-item/lab-order.component.tsx b/packages/esm-billing-app/src/billable-services/billiable-item/lab-order.component.tsx new file mode 100644 index 00000000..58a93b5f --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/billiable-item/lab-order.component.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { convertToCurrency } from '../../helpers'; +import { useBillableItem } from './useBilliableItem'; + +type LabOrderProps = { + order: { + testType?: { + label: string; + conceptUuid: string; + }; + }; +}; + +const LabOrder: React.FC = ({ order }) => { + // TODO: Implement logic to display whether the lab order service is available to ensure clinicians can order the service + + const { billableItem, error, isLoading } = useBillableItem(order?.testType?.conceptUuid); + + const billItems = billableItem?.servicePrices + .map((servicePrice) => `${servicePrice?.paymentMode?.name} - ${convertToCurrency(servicePrice?.price)}`) + .join(' '); + + if (isLoading) { + return null; + } + + if (error) { + return null; + } + + return

{billItems}

; +}; + +export default LabOrder; diff --git a/packages/esm-billing-app/src/billable-services/billiable-item/useBilliableItem.tsx b/packages/esm-billing-app/src/billable-services/billiable-item/useBilliableItem.tsx new file mode 100644 index 00000000..50393f05 --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/billiable-item/useBilliableItem.tsx @@ -0,0 +1,50 @@ +import useSWRImmutable from 'swr/immutable'; +import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; +import useSWR from 'swr'; +import first from 'lodash-es/first'; + +type BillableItemResponse = { + uuid: string; + name: string; + concept: { + uuid: string; + display: string; + }; + servicePrices: Array<{ + uuid: string; + price: number; + paymentMode: { + uuid: string; + name: string; + }; + }>; +}; + +export const useBillableItem = (billableItemId: string) => { + const customRepresentation = `v=custom:(uuid,name,concept:(uuid,display),servicePrices:(uuid,price,paymentMode:(uuid,name)))`; + const { data, error, isLoading } = useSWRImmutable<{ data: { results: Array } }>( + `${restBaseUrl}/cashier/billableService?${customRepresentation}`, + openmrsFetch, + ); + const billableItem = data?.data?.results?.find((item) => item?.concept?.uuid === billableItemId); + + return { + billableItem: billableItem, + isLoading: isLoading, + error, + }; +}; + +export const useSockItemInventory = (stockItemId: string) => { + const url = `/ws/rest/v1/stockmanagement/stockiteminventory?v=default&limit=10&totalCount=true&drugUuid=${stockItemId}`; + const { data, error, isLoading } = useSWR<{ data: { results: Array<{ quantityUoM: string; quantity: number }> } }>( + url, + openmrsFetch, + ); + const stockItemsInfo = first(data?.data?.results ?? []); + return { + stockItem: stockItemsInfo, + isLoading: isLoading, + error, + }; +}; diff --git a/packages/esm-billing-app/src/billable-services/create-edit/add-billable-service.component.tsx b/packages/esm-billing-app/src/billable-services/create-edit/add-billable-service.component.tsx new file mode 100644 index 00000000..83ed659c --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/create-edit/add-billable-service.component.tsx @@ -0,0 +1,327 @@ +/* eslint-disable curly */ +import React, { useCallback, useRef, useState } from 'react'; +import styles from './add-billable-service.scss'; +import { + Form, + Button, + TextInput, + ComboBox, + Dropdown, + Layer, + InlineLoading, + Search, + Tile, + FormLabel, +} from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { + createBillableService, + useConceptsSearch, + usePaymentModes, + useServiceTypes, +} from '../billable-service.resource'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; +import { Add, TrashCan, WarningFilled } from '@carbon/react/icons'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { navigate, showSnackbar, useDebounce, useLayoutType, useSession } from '@openmrs/esm-framework'; +import { type ServiceConcept } from '../../types'; + +const servicePriceSchema = z.object({ + paymentMode: z.string().refine((value) => !!value, 'Payment method is required'), + price: z.union([ + z.number().refine((value) => !!value, 'Price is required'), + z.string().refine((value) => !!value, 'Price is required'), + ]), +}); +const paymentFormSchema = z.object({ payment: z.array(servicePriceSchema) }); + +type PaymentMode = { + paymentMode: string; + price: string | number; +}; +type PaymentModeFormValue = { + payment: Array; +}; +const DEFAULT_PAYMENT_OPTION = { paymentMode: '', price: 0 }; + +const AddBillableService: React.FC = () => { + const { t } = useTranslation(); + + const { paymentModes, isLoading: isLoadingPaymentModes } = usePaymentModes(); + const { serviceTypes, isLoading: isLoadingServicesTypes } = useServiceTypes(); + const [billableServicePayload, setBillableServicePayload] = useState({}); + + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + mode: 'all', + defaultValues: {}, + resolver: zodResolver(paymentFormSchema), + }); + const { fields, remove, append } = useFieldArray({ name: 'payment', control: control }); + + const handleAppendPaymentMode = useCallback(() => append(DEFAULT_PAYMENT_OPTION), [append]); + const handleRemovePaymentMode = useCallback((index) => remove(index), [remove]); + + const isTablet = useLayoutType() === 'tablet'; + const searchInputRef = useRef(null); + const handleSearchTermChange = (event: React.ChangeEvent) => setSearchTerm(event.target.value); + + const [selectedConcept, setSelectedConcept] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm); + const { searchResults, isSearching } = useConceptsSearch(debouncedSearchTerm); + const handleConceptChange = useCallback((selectedConcept: any) => { + setSelectedConcept(selectedConcept); + }, []); + + const handleNavigateToServiceDashboard = () => + navigate({ + to: window.getOpenmrsSpaBase() + 'billable-services', + }); + + if (isLoadingPaymentModes && isLoadingServicesTypes) { + return ( + + ); + } + + const onSubmit = (data) => { + const payload: any = {}; + + let servicePrices = []; + data.payment.forEach((element) => { + element.name = paymentModes.filter((p) => p.uuid === element.paymentMode)[0].name; + servicePrices.push(element); + }); + payload.name = billableServicePayload.serviceName; + payload.shortName = billableServicePayload.shortName; + payload.serviceType = billableServicePayload.serviceType.uuid; + payload.servicePrices = servicePrices; + payload.serviceStatus = 'ENABLED'; + payload.concept = selectedConcept?.concept?.uuid; + + createBillableService(payload).then( + (resp) => { + showSnackbar({ + title: t('billableService', 'Billable service'), + subtitle: 'Billable service created successfully', + kind: 'success', + timeoutInMs: 3000, + }); + handleNavigateToServiceDashboard(); + }, + (error) => { + showSnackbar({ title: 'Bill payment error', kind: 'error', subtitle: error }); + }, + ); + }; + + return ( +
+

{t('addBillableServices', 'Add Billable Services')}

+
+ + + setBillableServicePayload({ + ...billableServicePayload, + serviceName: e.target.value, + }) + } + placeholder="Enter service name" + /> + +
+
+ + + setBillableServicePayload({ + ...billableServicePayload, + shortName: e.target.value, + }) + } + placeholder="Enter service short name" + /> + +
+
+ Associated Concept + ( + + { + onChange(e); + handleSearchTermChange(e); + }} + renderIcon={errors?.search && } + onBlur={onBlur} + onClear={() => { + setSearchTerm(''); + setSelectedConcept(null); + }} + value={(() => { + if (selectedConcept) { + return selectedConcept.display; + } + if (debouncedSearchTerm) { + return value; + } + })()} + /> + + )} + /> + {(() => { + if (!debouncedSearchTerm || selectedConcept) return null; + if (isSearching) + return ; + if (searchResults && searchResults.length) { + return ( +
    + {/*TODO: use uuid instead of index as the key*/} + {searchResults?.map((searchResult, index) => ( +
  • handleConceptChange(searchResult)}> + {searchResult.display} +
  • + ))} +
+ ); + } + return ( + + + + {t('noResultsFor', 'No results for')} "{debouncedSearchTerm}" + + + + ); + })()} +
+
+ + item?.display} + onChange={({ selectedItem }) => { + setBillableServicePayload({ + ...billableServicePayload, + display: selectedItem?.display, + serviceType: selectedItem, + }); + }} + placeholder="Select service type" + required + /> + +
+ +
+
+ {fields.map((field, index) => ( +
+ ( + + field.onChange(selectedItem?.uuid)} + titleText={t('paymentMode', 'Payment Mode')} + label={t('selectPaymentMethod', 'Select payment method')} + items={paymentModes ?? []} + itemToString={(item) => (item ? item.name : '')} + invalid={!!errors?.payment?.[index]?.paymentMode} + invalidText={errors?.payment?.[index]?.paymentMode?.message} + /> + + )} + /> + ( + + + + )} + /> +
+ handleRemovePaymentMode(index)} + className={styles.removeButton} + size={20} + /> +
+
+ ))} + +
+
+ +
+ + +
+
+ ); +}; + +function ResponsiveWrapper({ children, isTablet }: { children: React.ReactNode; isTablet: boolean }) { + return isTablet ? {children} : <>{children}; +} + +export default AddBillableService; diff --git a/packages/esm-billing-app/src/billable-services/create-edit/add-billable-service.scss b/packages/esm-billing-app/src/billable-services/create-edit/add-billable-service.scss new file mode 100644 index 00000000..3b1f8c83 --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/create-edit/add-billable-service.scss @@ -0,0 +1,132 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; +@use '@carbon/colors'; +@use '@carbon/layout'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.form { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; +} + +.section { + margin: spacing.$spacing-03; +} + +.sectionTitle { + @include type.type-style('heading-compact-02'); + color: $text-02; + margin-bottom: spacing.$spacing-04; +} + +.modalBody { + padding-bottom: spacing.$spacing-05; +} + +.container { + margin: 1rem; +} + +.paymentContainer { + margin: layout.$layout-01; + padding: layout.$layout-01; + width: 70%; + border-right: 1px solid colors.$cool-gray-40; +} + +.paymentButtons { + margin: layout.$layout-01 0; +} + +.paymentMethodContainer { + display: grid; + grid-template-columns: repeat(4, minmax(auto, 1fr)); + align-items: flex-start; + column-gap: 1rem; + margin: 0.625rem 0; + width: 100%; +} + +.paymentTotals { + margin-top: layout.$spacing-01; +} + +.processPayments { + display: flex; + justify-content: flex-end; + margin: layout.$spacing-05; + column-gap: layout.$spacing-04; +} + +.errorPaymentContainer { + margin: layout.$spacing-04; + min-height: layout.$spacing-09; +} + +.removeButtonContainer { + display: flex; + align-self: center; + cursor: pointer; + margin-left: layout.$spacing-07; +} + +.removeButton { + color: colors.$red-60; +} + +.service { + padding: 1rem 0.75rem; +} + +.conceptsList { + background-color: $ui-02; + max-height: 14rem; + overflow-y: auto; + border: 1px solid $ui-03; + + li:hover { + background-color: $ui-03; + } +} + +.emptyResults { + @include type.type-style('body-compact-01'); + color: $text-02; + min-height: 1rem; + border: 1px solid $ui-03; +} + +.conceptLabel { + @include type.type-style('label-02'); + margin: 1rem; +} + +.errorContainer { + margin: 1rem; +} + +.serviceError { + :global(.cds--search-input):focus { + outline: 2.5px solid $danger; + } + + :global(.cds--search-magnifier) { + svg { + fill: $danger; + } + } +} + +.errorMessage { + @include type.type-style('label-02'); + color: $danger; + margin-top: 0.5rem; +} + +.spinner { + &:global(.cds--inline-loading) { + min-height: 1rem; + } +} diff --git a/packages/esm-billing-app/src/billable-services/dashboard/dashboard.component.tsx b/packages/esm-billing-app/src/billable-services/dashboard/dashboard.component.tsx new file mode 100644 index 00000000..15540d4a --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/dashboard/dashboard.component.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './dashboard.scss'; +import ServiceMetrics from './service-metrics.component'; +import BillableServices from '../billable-services.component'; + +export function BillableServicesDashboard() { + const { t } = useTranslation(); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/packages/esm-billing-app/src/billable-services/dashboard/dashboard.scss b/packages/esm-billing-app/src/billable-services/dashboard/dashboard.scss new file mode 100644 index 00000000..cd10b1b7 --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/dashboard/dashboard.scss @@ -0,0 +1,27 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.container { + height: calc(100vh - 3rem); +} + +.servicesTableContainer { + margin: 2rem 1rem; +} + +.illo { + margin-top: layout.$spacing-05; +} + +.content { + @include type.type-style('heading-compact-01'); + color: colors.$gray-70; + margin-top: layout.$spacing-05; +} + +.tile { + border: 1px solid colors.$gray-20; + padding: 1.5rem 0; + text-align: center; +} diff --git a/packages/esm-billing-app/src/billable-services/dashboard/service-metrics.component.tsx b/packages/esm-billing-app/src/billable-services/dashboard/service-metrics.component.tsx new file mode 100644 index 00000000..8d5dbbc9 --- /dev/null +++ b/packages/esm-billing-app/src/billable-services/dashboard/service-metrics.component.tsx @@ -0,0 +1,40 @@ +import React, { useMemo } from 'react'; +import Card from '../../metrics-cards/card.component'; +import styles from '../../metrics-cards/metrics-cards.scss'; +import { useTranslation } from 'react-i18next'; +import { ErrorState } from '@openmrs/esm-patient-common-lib'; +import { InlineLoading } from '@carbon/react'; +import { useBillableServices } from '../billable-service.resource'; + +export default function ServiceMetrics() { + const { t } = useTranslation(); + const { isLoading, error } = useBillableServices(); + + const cards = useMemo( + () => [ + { title: 'Cash Revenue', count: '--' }, + { title: 'Insurance Revenue', count: '--' }, + { title: 'Pending Claims', count: '--' }, + ], + [], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ; + } + return ( +
+ {cards.map((card) => ( + + ))} +
+ ); +} diff --git a/packages/esm-billing-app/src/billing-dashboard/billing-dashboard.component.tsx b/packages/esm-billing-app/src/billing-dashboard/billing-dashboard.component.tsx new file mode 100644 index 00000000..8235f6c8 --- /dev/null +++ b/packages/esm-billing-app/src/billing-dashboard/billing-dashboard.component.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import BillingHeader from '../billing-header/billing-header.component'; +import MetricsCards from '../metrics-cards/metrics-cards.component'; +import BillsTable from '../bills-table/bills-table.component'; +import styles from './billing-dashboard.scss'; + +export function BillingDashboard() { + const { t } = useTranslation(); + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/packages/esm-billing-app/src/billing-dashboard/billing-dashboard.scss b/packages/esm-billing-app/src/billing-dashboard/billing-dashboard.scss new file mode 100644 index 00000000..31558077 --- /dev/null +++ b/packages/esm-billing-app/src/billing-dashboard/billing-dashboard.scss @@ -0,0 +1,27 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.container { + height: calc(100vh - 3rem); +} + +.billsTableContainer { + margin: 2rem 1rem; +} + +.illo { + margin-top: layout.$spacing-05; +} + +.content { + @include type.type-style('heading-compact-01'); + color: colors.$gray-70; + margin-top: layout.$spacing-05; +} + +.tile { + border: 1px solid colors.$gray-20; + padding: 1.5rem 0; + text-align: center; +} diff --git a/packages/esm-billing-app/src/billing-form/billing-checkin-form.component.tsx b/packages/esm-billing-app/src/billing-form/billing-checkin-form.component.tsx new file mode 100644 index 00000000..c2994c3f --- /dev/null +++ b/packages/esm-billing-app/src/billing-form/billing-checkin-form.component.tsx @@ -0,0 +1,137 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { InlineLoading, InlineNotification, FilterableMultiSelect } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { showSnackbar, useConfig } from '@openmrs/esm-framework'; +import styles from './billing-checkin-form.scss'; +import VisitAttributesForm from './visit-attributes/visit-attributes-form.component'; +import { type BillingConfig } from '../config-schema'; +import { hasPatientBeenExempted } from './helper'; +import { EXEMPTED_PAYMENT_STATUS, PENDING_PAYMENT_STATUS } from '../constants'; +import { type BillingService } from '../types'; +import SHANumberValidity from './social-health-authority/sha-number-validity.component'; +import { createPatientBill, useBillableItems, useCashPoint } from '../billing.resource'; + +type BillingCheckInFormProps = { + patientUuid: string; + setExtraVisitInfo: (state) => void; +}; + +const BillingCheckInForm: React.FC = ({ patientUuid, setExtraVisitInfo }) => { + const { t } = useTranslation(); + const { + visitAttributeTypes: { isPatientExempted }, + } = useConfig(); + const { cashPoints, isLoading: isLoadingCashPoints, error: cashError } = useCashPoint(); + const { lineItems, isLoading: isLoadingLineItems, error: lineError } = useBillableItems(); + const [attributes, setAttributes] = useState([]); + const [paymentMethod, setPaymentMethod] = useState(); + let lineList = []; + + const handleCreateBill = useCallback((createBillPayload) => { + createPatientBill(createBillPayload).then( + () => { + showSnackbar({ title: 'Patient Bill', subtitle: 'Patient has been billed successfully', kind: 'success' }); + }, + (error) => { + const errorMessage = JSON.stringify(error?.responseBody?.error?.message?.replace(/\[/g, '').replace(/\]/g, '')); + showSnackbar({ + title: 'Patient Bill Error', + subtitle: `An error has occurred while creating patient bill, Contact system administrator quoting this error ${errorMessage}`, + kind: 'error', + isLowContrast: true, + }); + }, + ); + }, []); + + const handleBillingService = (selectedItems: Array) => { + const cashPointUuid = cashPoints?.[0]?.uuid ?? ''; + const billStatus = hasPatientBeenExempted(attributes, isPatientExempted) + ? EXEMPTED_PAYMENT_STATUS + : PENDING_PAYMENT_STATUS; + + const lineItems = selectedItems.map((item, index) => { + // // should default to first price if check returns empty. todo - update backend to return default price + const priceForPaymentMode = + item.servicePrices.find((p) => p.paymentMode?.uuid === paymentMethod) || item?.servicePrices[0]; + return { + billableService: item?.uuid ?? '', + quantity: 1, + price: priceForPaymentMode ? priceForPaymentMode.price : '0.000', + priceName: 'Default', + priceUuid: priceForPaymentMode ? priceForPaymentMode.uuid : '', + lineItemOrder: index, + paymentStatus: billStatus, + }; + }); + + const billPayload = { + lineItems: lineItems, + cashPoint: cashPointUuid, + patient: patientUuid, + status: billStatus, + payments: [], + }; + + setExtraVisitInfo({ + handleCreateExtraVisitInfo: () => handleCreateBill(billPayload), + attributes, + }); + }; + + useEffect(() => { + setExtraVisitInfo({ + handleCreateExtraVisitInfo: () => {}, + attributes, + }); + }, []); + + if (isLoadingLineItems || isLoadingCashPoints) { + return ( + + ); + } + + if (paymentMethod) { + lineList = []; + lineList = lineItems.filter((e) => + e.servicePrices.some((p) => p.paymentMode && p.paymentMode.uuid === paymentMethod?.uuid), + ); + } + + if (cashError || lineError) { + return ( + + ); + } + + return ( + <> + + +
+
{t('billing', 'Billing')}
+
+ (item ? item?.name : '')} + onChange={({ selectedItems }) => handleBillingService(selectedItems)} + /> +
+
+ + ); +}; + +export default React.memo(BillingCheckInForm); diff --git a/packages/esm-billing-app/src/billing-form/billing-checkin-form.scss b/packages/esm-billing-app/src/billing-form/billing-checkin-form.scss new file mode 100644 index 00000000..3cf04ff3 --- /dev/null +++ b/packages/esm-billing-app/src/billing-form/billing-checkin-form.scss @@ -0,0 +1,14 @@ +@use '@carbon/layout'; +@use '@carbon/type'; +@use '@carbon/colors'; + +.sectionTitle { + @include type.type-style('heading-compact-02'); + color: colors.$gray-70; + margin: 0 0 layout.$spacing-03 0; + flex-basis: 30%; +} + +.sectionField { + flex-basis: 70%; +} diff --git a/packages/esm-billing-app/src/billing-form/billing-form.component.tsx b/packages/esm-billing-app/src/billing-form/billing-form.component.tsx new file mode 100644 index 00000000..ee60e514 --- /dev/null +++ b/packages/esm-billing-app/src/billing-form/billing-form.component.tsx @@ -0,0 +1,297 @@ +import React, { useState } from 'react'; +import { + ButtonSet, + Button, + RadioButtonGroup, + RadioButton, + Search, + Table, + TableHead, + TableBody, + TableHeader, + TableRow, + TableCell, +} from '@carbon/react'; +import styles from './billing-form.scss'; +import { useTranslation } from 'react-i18next'; +import { useFetchSearchResults, processBillItems } from '../billing.resource'; +import { getPatientUuidFromUrl } from '@openmrs/esm-patient-common-lib'; +import { showSnackbar } from '@openmrs/esm-framework'; +import { mutate } from 'swr'; + +type BillingFormProps = { + patientUuid: string; + closeWorkspace: () => void; +}; + +const BillingForm: React.FC = ({ closeWorkspace }) => { + const { t } = useTranslation(); + const patientUuid = getPatientUuidFromUrl(); + + const [GrandTotal, setGrandTotal] = useState(0); + + const [searchOptions, setsearchOptions] = useState([]); + const [defaultSearchItems, setdefaultSearchItems] = useState([]); + + const [BillItems, setBillItems] = useState([]); + + const [searchVal, setsearchVal] = useState(''); + const [category, setCategory] = useState(''); + + const toggleSearch = (choiceSelected) => { + (document.getElementById('searchField') as HTMLInputElement).disabled = false; + + if (choiceSelected == 'Stock Item') { + setCategory('Stock Item'); + } else { + setCategory('Service'); + } + }; + + const calculateTotal = (event, itemName) => { + const Qnty = event.target.value; + const price = (document.getElementById(event.target.id + 'Price') as HTMLInputElement).innerHTML; + const total = parseInt(price) * Qnty; + (document.getElementById(event.target.id + 'Total') as HTMLInputElement).innerHTML = total.toString(); + + const updateItem = BillItems.filter((o) => o.Item.toLowerCase().includes(itemName.toLowerCase())); + + updateItem.map((o) => (o.Qnty = Qnty)); + updateItem.map((o) => (o.Total = total)); + + const totals = Array.from(document.querySelectorAll('[id$="Total"]')); + + let addUpTotals = 0; + totals.forEach((tot) => { + let getTot = (tot as HTMLInputElement).innerHTML; + addUpTotals += parseInt(getTot); + }); + setGrandTotal(addUpTotals); + }; + + const CalculateTotalAfteraddBillItem = () => { + let sum = 0; + BillItems.map((o) => (sum += o.Price)); + + setGrandTotal(sum); + }; + + const addItemToBill = (event, itemid, itemname, itemcategory, itemPrice) => { + BillItems.push({ + uuid: itemid, + Item: itemname, + Qnty: 1, + Price: itemPrice, + Total: itemPrice, + category: itemcategory, + }); + setBillItems(BillItems); + setsearchOptions([]); + CalculateTotalAfteraddBillItem(); + (document.getElementById('searchField') as HTMLInputElement).value = ''; + }; + + // filter items + const { data, error, isLoading, isValidating } = useFetchSearchResults(searchVal, category); + + const filterItems = (val) => { + setsearchVal(val); + + if (!isLoading) { + /* empty */ + } else { + if (typeof data !== 'undefined') { + //set to null then repopulate + while (searchOptions.length > 0) { + searchOptions.pop(); + } + + const res = data as { results: any[] }; + + res.results.map((o) => { + if (o.commonName && (o.commonName != '' || o.commonName != null)) { + searchOptions.push({ + uuid: o.uuid, + Item: o.commonName, + Qnty: 1, + Price: 10, + Total: 10, + category: 'StockItem', + }); + } else { + if (o.name.toLowerCase().includes(searchVal.toLowerCase())) { + searchOptions.push({ + uuid: o.uuid, + Item: o.name, + Qnty: 1, + Price: o.servicePrices[0].price, + Total: o.servicePrices[0].price, + category: 'Service', + }); + } + } + setsearchOptions(searchOptions); + }); + } + } + }; + + const postBillItems = () => { + const bill = { + cashPoint: '54065383-b4d4-42d2-af4d-d250a1fd2590', + cashier: 'f9badd80-ab76-11e2-9e96-0800200c9a66', + lineItems: [], + payments: [], + patient: patientUuid, + status: 'PENDING', + }; + + BillItems.map((o) => { + if (o.category == 'StockItem') { + bill.lineItems.push({ + item: o.uuid, + quantity: parseInt(o.Qnty), + price: o.Price, + priceName: 'Default', + priceUuid: '7b9171ac-d3c1-49b4-beff-c9902aee5245', + lineItemOrder: 0, + paymentStatus: 'PENDING', + }); + } else { + bill.lineItems.push({ + billableService: o.uuid, + quantity: parseInt(o.Qnty), + price: o.Price, + priceName: 'Default', + priceUuid: '7b9171ac-d3c1-49b4-beff-c9902aee5245', + lineItemOrder: 0, + paymentStatus: 'PENDING', + }); + } + }); + + const url = `/ws/rest/v1/cashier/bill`; + processBillItems(bill).then( + (resp) => { + closeWorkspace(); + mutate((key) => typeof key === 'string' && key.startsWith(url), undefined, { revalidate: true }); + showSnackbar({ + title: t('billItems', 'Save Bill'), + subtitle: 'Bill processing has been successful', + kind: 'success', + timeoutInMs: 3000, + }); + }, + (error) => { + showSnackbar({ title: 'Bill processing error', kind: 'error', subtitle: error }); + }, + ); + }; + + return ( +
+ + + + +
+ +
+ {}} + className={styles.billingItem} + onKeyUp={(e) => { + filterItems(e.target.value); + }} + /> + +
    + {searchOptions.map((row) => ( +
  • + +
  • + ))} +
+
+ + {/* alert((numberRef.current as HTMLInputElement).value)} + className="testingNumberInput" label="NumberInput label" helperText="Optional helper text." invalidText="Number is not valid" /> */} + + + + + Item + Quantity + Price + Total + + + + {BillItems && Array.isArray(BillItems) ? ( + BillItems.map((row) => ( + + {row.Item} + + { + calculateTotal(e, row.Item); + row.Qnty = e.target.value; + }} + /> + {/* alert((numberRef.current as HTMLInputElement).value)} /> */} + + {row.Price} + + {row.Total} + + + )) + ) : ( +

Loading...

+ )} + + + + Grand Total: + {GrandTotal} + +
+
+ + + + + +
+ ); +}; + +export default BillingForm; diff --git a/packages/esm-billing-app/src/billing-form/billing-form.scss b/packages/esm-billing-app/src/billing-form/billing-form.scss new file mode 100644 index 00000000..b8719c1e --- /dev/null +++ b/packages/esm-billing-app/src/billing-form/billing-form.scss @@ -0,0 +1,28 @@ +@use '@carbon/layout'; + +.billingFormContainer { + padding: layout.$spacing-05; +} + +.billingItem { + // padding: layout.$spacing-05; + margin-top: 30px; +} + +.searchContent { + // display: none; + position: absolute; + background-color: #fff; + min-width: 230px; + overflow: auto; + border: 1px solid #ddd; + z-index: 1; + width: 92%; +} + +.searchItem { + border-bottom: 0.1rem solid; + border-bottom-color: silver; + // padding: 10px; + cursor: pointer; +} diff --git a/packages/esm-billing-app/src/billing-form/helper.ts b/packages/esm-billing-app/src/billing-form/helper.ts new file mode 100644 index 00000000..55edb5a0 --- /dev/null +++ b/packages/esm-billing-app/src/billing-form/helper.ts @@ -0,0 +1,2 @@ +export const hasPatientBeenExempted = (attributes: Array, isPatientExempted: string): boolean => + attributes.find(({ attributeType }) => attributeType === isPatientExempted)?.value === true; diff --git a/packages/esm-billing-app/src/billing-form/social-health-authority/sha-number-validity.component.tsx b/packages/esm-billing-app/src/billing-form/social-health-authority/sha-number-validity.component.tsx new file mode 100644 index 00000000..8a238bf6 --- /dev/null +++ b/packages/esm-billing-app/src/billing-form/social-health-authority/sha-number-validity.component.tsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Form, TextInput, Button, InlineLoading, InlineNotification } from '@carbon/react'; + +type SHANumberValidityProps = { + paymentMethod: any; +}; + +const SHANumberValidity: React.FC = ({ paymentMethod }) => { + const { t } = useTranslation(); + const [shaNumber, setNumber] = useState(''); + const [message, setMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [validity, setValidity] = useState(false); // TODO: set validity based on api response + const isSHA = paymentMethod?.name === 'Social Health Insurance Fund (SHA)'; + + const handleValidateSHANumber = () => { + setIsLoading(true); + const randomNumber = Math.floor(Math.random() * 10) + 1; + // TODO call api to validate sha number + setTimeout(() => { + // TODO: set validity based on api response and update the expiry date based on the response as visit attribute + randomNumber % 2 === 0 ? setValidity(false) : setValidity(true); + setMessage( + randomNumber % 2 === 0 + ? t('invalidSHANumber', 'SHA number is invalid, advice patient to update payment or contact SHA') + : t('validSHANumber', 'SHA number is valid, proceed with care'), + ); + setIsLoading(false); + }, 1500); + }; + + if (!isSHA) { + return null; + } + + return ( +
+ setNumber(e.target.value)} + labelText={t('shaNumber', 'SHA Number')} + placeholder={t('enterSHANumber', 'Enter SHA Number')} + /> + {isLoading ? ( + + ) : ( + + )} + {message !== '' && ( +

+ +

+ )} + + ); +}; + +export default SHANumberValidity; diff --git a/packages/esm-billing-app/src/billing-form/visit-attributes/visit-attributes-form.component.tsx b/packages/esm-billing-app/src/billing-form/visit-attributes/visit-attributes-form.component.tsx new file mode 100644 index 00000000..96a59ec6 --- /dev/null +++ b/packages/esm-billing-app/src/billing-form/visit-attributes/visit-attributes-form.component.tsx @@ -0,0 +1,198 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './visit-attributes-form.scss'; +import { TextInput, InlineLoading, ComboBox, RadioButtonGroup, RadioButton } from '@carbon/react'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useConfig } from '@openmrs/esm-framework'; +import { type BillingConfig } from '../../config-schema'; +import { usePaymentModes } from '../../billing.resource'; + +type VisitAttributesFormProps = { + setAttributes: (state) => void; + setPaymentMethod?: (value: any) => void; +}; + +type VisitAttributesFormValue = { + isPatientExempted: string; + paymentMethods: { uuid: string; name: string }; + insuranceScheme: string; + policyNumber: string; + exemptionCategory: string; +}; + +const visitAttributesFormSchema = z.object({ + isPatientExempted: z.string(), + paymentMethods: z.object({ uuid: z.string(), name: z.string() }), + insuranceSchema: z.string(), + policyNumber: z.string(), + exemptionCategory: z.string(), +}); + +const VisitAttributesForm: React.FC = ({ setAttributes, setPaymentMethod }) => { + const { t } = useTranslation(); + const { visitAttributeTypes, patientExemptionCategories } = useConfig(); + const { control, getValues, watch, setValue } = useForm({ + mode: 'all', + defaultValues: {}, + resolver: zodResolver(visitAttributesFormSchema), + }); + const [isPatientExempted, paymentMethods, insuranceSchema, policyNumber, exemptionCategory] = watch([ + 'isPatientExempted', + 'paymentMethods', + 'insuranceScheme', + 'policyNumber', + 'exemptionCategory', + ]); + + const { paymentModes, isLoading: isLoadingPaymentModes } = usePaymentModes(); + + const resetFormFieldsForNonExemptedPatients = useCallback(() => { + if ((isPatientExempted && paymentMethods !== null) || paymentMethods !== undefined) { + setValue('insuranceScheme', ''); + setValue('policyNumber', ''); + } + }, [isPatientExempted, paymentMethods, setValue]); + + const createVisitAttributesPayload = useCallback(() => { + const { exemptionCategory, paymentMethods, policyNumber, isPatientExempted } = getValues(); + setPaymentMethod(paymentMethods); + resetFormFieldsForNonExemptedPatients(); + const formPayload = [ + { uuid: visitAttributeTypes.isPatientExempted, value: isPatientExempted }, + { uuid: visitAttributeTypes.paymentMethods, value: paymentMethods?.uuid }, + { uuid: visitAttributeTypes.policyNumber, value: policyNumber }, + { uuid: visitAttributeTypes.insuranceScheme, value: insuranceSchema }, + { uuid: visitAttributeTypes.exemptionCategory, value: exemptionCategory }, + ]; + const visitAttributesPayload = formPayload.filter( + (item) => item.value !== undefined && item.value !== null && item.value !== '', + ); + return Object.entries(visitAttributesPayload).map(([key, value]) => ({ + attributeType: value.uuid, + value: value.value, + })); + }, [ + visitAttributeTypes.insuranceScheme, + visitAttributeTypes.isPatientExempted, + visitAttributeTypes.exemptionCategory, + visitAttributeTypes.paymentMethods, + visitAttributeTypes.policyNumber, + getValues, + insuranceSchema, + resetFormFieldsForNonExemptedPatients, + setPaymentMethod, + ]); + + React.useEffect(() => { + setAttributes(createVisitAttributesPayload()); + }, [paymentMethods, insuranceSchema, policyNumber, exemptionCategory, setAttributes, createVisitAttributesPayload]); + + if (isLoadingPaymentModes) { + return ( + + ); + } + + return ( +
+
{t('billing', 'Billing')}
+
+
+ ( + field.onChange(selected)} + orientation="horizontal" + legendText={t('isPatientExemptedLegend', 'Is patient exempted from payment?')} + name="patientExemption"> + + + + )} + /> +
+ {isPatientExempted && ( +
+ ( + field.onChange(selectedItem?.uuid)} + id="exemptionCategory" + items={patientExemptionCategories} + itemToString={(item) => (item ? item.label : '')} + titleText={t('exemptionCategory', 'Exemption category')} + placeholder={t('selectExemptionCategory', 'Select exemption category')} + /> + )} + /> +
+ )} +
+ ( + field.onChange(selectedItem)} + id="paymentMethods" + items={paymentModes} + itemToString={(item) => (item ? item.name : '')} + titleText={t('paymentMethodsTitle', 'Payment methods')} + placeholder={t('selectPaymentMethodPlaceholder', 'Select payment method')} + /> + )} + /> +
+ + {paymentMethods?.name?.toLocaleLowerCase() === 'insurance' && ( + <> +
+ ( + field.onChange(e.target.value)} + id="insurance-scheme" + type="text" + labelText={t('insuranceScheme', 'Insurance scheme')} + /> + )} + /> +
+
+ ( + field.onChange(e.target.value)} + id="policy-number" + type="text" + labelText={t('policyNumber', 'Policy number')} + /> + )} + /> +
+ + )} +
+
+ ); +}; + +export default VisitAttributesForm; diff --git a/packages/esm-billing-app/src/billing-form/visit-attributes/visit-attributes-form.scss b/packages/esm-billing-app/src/billing-form/visit-attributes/visit-attributes-form.scss new file mode 100644 index 00000000..47c7f7e9 --- /dev/null +++ b/packages/esm-billing-app/src/billing-form/visit-attributes/visit-attributes-form.scss @@ -0,0 +1,18 @@ +@use '@carbon/layout'; +@use '@carbon/type'; +@use '@carbon/colors'; + +.sectionTitle { + @include type.type-style('heading-compact-02'); + color: colors.$gray-70; + margin: 0 0 layout.$spacing-03 0; + flex-basis: 30%; +} + +.sectionField { + flex-basis: 70%; +} + +.sectionFieldLayer { + margin-bottom: 0.5rem; +} diff --git a/packages/esm-billing-app/src/billing-header/billing-header.component.tsx b/packages/esm-billing-app/src/billing-header/billing-header.component.tsx new file mode 100644 index 00000000..99fb9f55 --- /dev/null +++ b/packages/esm-billing-app/src/billing-header/billing-header.component.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Calendar, Location, UserFollow } from '@carbon/react/icons'; +import { formatDate, useSession } from '@openmrs/esm-framework'; +import BillingIllustration from './billing-illustration.component'; +import styles from './billing-header.scss'; + +interface BillingHeaderProps { + title: string; +} + +const BillingHeader: React.FC = ({ title }) => { + const { t } = useTranslation(); + const session = useSession(); + const location = session?.sessionLocation?.display; + + return ( +
+
+ +
+

{t('billing', 'Billing')}

+

{title}

+
+
+
+
+

{session?.user?.person?.display}

+ +
+
+ + {location} + · + + {formatDate(new Date(), { mode: 'standard' })} +
+
+
+ ); +}; + +export default BillingHeader; diff --git a/packages/esm-billing-app/src/billing-header/billing-header.scss b/packages/esm-billing-app/src/billing-header/billing-header.scss new file mode 100644 index 00000000..78916f37 --- /dev/null +++ b/packages/esm-billing-app/src/billing-header/billing-header.scss @@ -0,0 +1,83 @@ +@use '@carbon/layout'; +@use '@carbon/type'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.header { + @include type.type-style('body-compact-02'); + color: $text-02; + height: layout.$spacing-12; + background-color: $ui-02; + border-bottom: 1px solid $ui-03; + display: flex; + justify-content: space-between; + padding: layout.$spacing-05; +} + +.left-justified-items { + display: flex; + flex-direction: row; + align-items: center; + cursor: pointer; + align-items: center; +} + +.right-justified-items { + @include type.type-style('body-compact-02'); + color: $text-02; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.page-name { + @include type.type-style('heading-04'); +} + +.page-labels { + margin: layout.$spacing-05; + + p:first-of-type { + margin-bottom: layout.$spacing-02; + } +} + +.date-and-location { + display: flex; + justify-content: flex-end; + align-items: center; +} + +.userContainer { + display: flex; + justify-content: flex-end; + gap: layout.$spacing-05; +} + +.value { + margin-left: layout.$spacing-02; +} + +.middot { + margin: 0 layout.$spacing-03; +} + +.view { + @include type.type-style('label-01'); +} + +// Overriding styles for RTL support +html[dir='rtl'] { + .date-and-location { + & > svg { + order: -1; + } + & > span:nth-child(2) { + order: -2; + } + } +} + +.userIcon { + fill: $ui-05; + margin: layout.$spacing-01; +} diff --git a/packages/esm-billing-app/src/billing-header/billing-illustration.component.tsx b/packages/esm-billing-app/src/billing-header/billing-illustration.component.tsx new file mode 100644 index 00000000..8c67cb60 --- /dev/null +++ b/packages/esm-billing-app/src/billing-header/billing-illustration.component.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +const BillingIllustration: React.FC = () => { + return ( + + Billing module illustration + + + + + + + + ); +}; + +export default BillingIllustration; diff --git a/packages/esm-billing-app/src/billing-prompt/billing-prompt.resource.tsx b/packages/esm-billing-app/src/billing-prompt/billing-prompt.resource.tsx new file mode 100644 index 00000000..1d216bfe --- /dev/null +++ b/packages/esm-billing-app/src/billing-prompt/billing-prompt.resource.tsx @@ -0,0 +1,45 @@ +import { type Visit, useConfig, useVisit } from '@openmrs/esm-framework'; +import { useBills } from '../billing.resource'; +import { type BillingConfig } from '../config-schema'; + +const INPATIENT_VISIT_TYPE = 'a73e2ac6-263b-47fc-99fc-e0f2c09fc914'; +const INSURANCE_PAYMENT_METHOD = 'beac329b-f1dc-4a33-9e7c-d95821a137a6'; + +// helper functions +const isCurrentVisitInPatient = (currentVisit: Visit) => currentVisit?.visitType?.uuid === INPATIENT_VISIT_TYPE; +const isPaymentMethodInsurance = (currentVisit: Visit, insurancePaymentMethod: string) => + currentVisit?.attributes.find((attr) => attr.attributeType.uuid === insurancePaymentMethod)?.value === + INSURANCE_PAYMENT_METHOD; + +export const useBillingPrompt = (patientUuid: string) => { + const { + visitAttributeTypes: { paymentMethods }, + } = useConfig(); + const { currentVisit, isLoading: isLoadingVisit } = useVisit(patientUuid); + const { bills, isLoading, error } = useBills(patientUuid); + + const flattenBills = bills + .flatMap((bill) => bill.lineItems) + .filter((lineItem) => lineItem.paymentStatus !== 'EXEMPTED'); + const flattenPayments = bills.flatMap((bill) => bill.payments); + + const totalBill = flattenBills.reduce((acc, curr) => acc + curr.price * curr.quantity, 0); + const totalPayments = flattenPayments.reduce((acc, curr) => acc + curr.amountTendered, 0); + const patientBillBalance = totalBill - totalPayments; + + // Should show billing prompt if the following conditions are met: + // 1. The current visit is not an inpatient visit + // 2. The patient has a bill balance + // 3. The payment method is not insurances + + return { + shouldShowBillingPrompt: + !isCurrentVisitInPatient(currentVisit) && + patientBillBalance > 0 && + !isPaymentMethodInsurance(currentVisit, paymentMethods), + isLoading: isLoading || isLoadingVisit, + error, + currentVisit, + bills, + }; +}; diff --git a/packages/esm-billing-app/src/billing.resource.ts b/packages/esm-billing-app/src/billing.resource.ts new file mode 100644 index 00000000..4a33e487 --- /dev/null +++ b/packages/esm-billing-app/src/billing.resource.ts @@ -0,0 +1,224 @@ +import useSWR from 'swr'; +import { + formatDate, + parseDate, + openmrsFetch, + useSession, + useVisit, + useConfig, + type OpenmrsResource, +} from '@openmrs/esm-framework'; +import { type FacilityDetail, type MappedBill, type PatientInvoice, type PaymentMethod } from './types'; +import isEmpty from 'lodash-es/isEmpty'; +import sortBy from 'lodash-es/sortBy'; +import dayjs from 'dayjs'; +import { type BillingConfig } from './config-schema'; +import { useState } from 'react'; + +export const useBills = (patientUuid: string = '', billStatus: string = '') => { + // TODO: Should be provided from the UI + const defaultCreatedOnOrAfterDateTime = dayjs().startOf('day').toISOString(); + const url = `/ws/rest/v1/cashier/bill?status=${billStatus}&v=custom:(uuid,display,voided,voidReason,adjustedBy,cashPoint:(uuid,name),cashier:(uuid,display),dateCreated,lineItems,patient:(uuid,display))&createdOnOrAfter=${defaultCreatedOnOrAfterDateTime}`; + + const { data, error, isLoading, isValidating, mutate } = useSWR<{ data: { results: Array } }>( + patientUuid ? `${url}&patientUuid=${patientUuid}` : url, + openmrsFetch, + { + errorRetryCount: 2, + }, + ); + + const mapBillProperties = (bill: PatientInvoice): MappedBill => { + // create base object + const mappedBill: MappedBill = { + id: bill?.id, + uuid: bill?.uuid, + patientName: bill?.patient?.display.split('-')?.[1], + identifier: bill?.patient?.display.split('-')?.[0], + patientUuid: bill?.patient?.uuid, + status: bill.lineItems.some((item) => item.paymentStatus === 'PENDING') ? 'PENDING' : 'PAID', + receiptNumber: bill?.receiptNumber, + cashier: bill?.cashier, + cashPointUuid: bill?.cashPoint?.uuid, + cashPointName: bill?.cashPoint?.name, + cashPointLocation: bill?.cashPoint?.location?.display, + dateCreated: bill?.dateCreated ? formatDate(parseDate(bill.dateCreated), { mode: 'wide' }) : '--', + lineItems: bill.lineItems, + billingService: bill.lineItems.map((bill) => bill.item || bill.billableService || '--').join(' '), + payments: bill.payments, + display: bill.display, + totalAmount: bill?.lineItems?.map((item) => item.price * item.quantity).reduce((prev, curr) => prev + curr, 0), + }; + + return mappedBill; + }; + + const sortBills = sortBy(data?.data?.results ?? [], ['dateCreated']).reverse(); + const filteredBills = billStatus === '' ? sortBills : sortBills?.filter((bill) => bill.status === billStatus); + const mappedResults = filteredBills?.map((bill) => mapBillProperties(bill)); + const filteredResults = mappedResults?.filter((res) => res.patientUuid === patientUuid); + const formattedBills = isEmpty(patientUuid) ? mappedResults : filteredResults || []; + + return { + bills: formattedBills, + error, + isLoading, + isValidating, + mutate, + }; +}; + +export const useBill = (billUuid: string) => { + const url = `/ws/rest/v1/cashier/bill/${billUuid}`; + const { data, error, isLoading, isValidating, mutate } = useSWR<{ data: PatientInvoice }>( + billUuid ? url : null, + openmrsFetch, + { + errorRetryCount: 2, + }, + ); + + const mapBillProperties = (bill: PatientInvoice): MappedBill => { + // create base object + const mappedBill: MappedBill = { + id: bill?.id, + uuid: bill?.uuid, + patientName: bill?.patient?.display.split('-')?.[1], + identifier: bill?.patient?.display.split('-')?.[0], + patientUuid: bill?.patient?.uuid, + status: + bill.lineItems.length > 1 + ? bill.lineItems.some((item) => item.paymentStatus === 'PENDING') + ? 'PENDING' + : 'PAID' + : bill.status, + receiptNumber: bill?.receiptNumber, + cashier: bill?.cashier, + cashPointUuid: bill?.cashPoint?.uuid, + cashPointName: bill?.cashPoint?.name, + cashPointLocation: bill?.cashPoint?.location?.display, + dateCreated: bill?.dateCreated ? formatDate(parseDate(bill.dateCreated), { mode: 'wide' }) : '--', + lineItems: bill.lineItems, + billingService: bill.lineItems.map((bill) => bill.item).join(' '), + payments: bill.payments, + totalAmount: bill?.lineItems?.map((item) => item.price * item.quantity).reduce((prev, curr) => prev + curr, 0), + tenderedAmount: bill?.payments?.map((item) => item.amountTendered).reduce((prev, curr) => prev + curr, 0), + }; + + return mappedBill; + }; + + const formattedBill = data?.data ? mapBillProperties(data?.data) : ({} as MappedBill); + + return { + bill: formattedBill, + error, + isLoading, + isValidating, + mutate, + }; +}; + +export const processBillPayment = (payload, billUuid: string) => { + const url = `/ws/rest/v1/cashier/bill/${billUuid}`; + return openmrsFetch(url, { + method: 'POST', + body: payload, + headers: { + 'Content-Type': 'application/json', + }, + }); +}; + +export function useDefaultFacility() { + const { authenticated } = useSession(); + const url = '/ws/rest/v1/kenyaemr/default-facility'; + const { data, isLoading } = useSWR<{ data: FacilityDetail }>(authenticated ? url : null, openmrsFetch, {}); + return { data: data?.data, isLoading: isLoading }; +} + +export function useFetchSearchResults(searchVal, category) { + let url = ``; + if (category == 'Stock Item') { + url = `/ws/rest/v1/stockmanagement/stockitem?v=default&limit=10&q=${searchVal}`; + } else { + url = `/ws/rest/v1/cashier/billableService?v=custom:(uuid,name,shortName,serviceStatus,serviceType:(display),servicePrices:(uuid,name,price,paymentMode))`; + } + const { data, error, isLoading, isValidating } = useSWR(searchVal ? url : null, openmrsFetch, {}); + + return { data: data?.data, error, isLoading: isLoading, isValidating }; +} + +export const usePatientPaymentInfo = (patientUuid: string) => { + const { currentVisit } = useVisit(patientUuid); + const attributes = currentVisit?.attributes ?? []; + const paymentInformation = attributes + .map((attribute) => ({ + name: attribute.attributeType.name, + value: attribute.value, + })) + .filter(({ name }) => name === 'Insurance scheme' || name === 'Policy Number'); + + return paymentInformation; +}; + +export const processBillItems = (payload) => { + const url = `/ws/rest/v1/cashier/bill`; + return openmrsFetch(url, { + method: 'POST', + body: payload, + headers: { + 'Content-Type': 'application/json', + }, + }); +}; + +export const usePaymentModes = (excludeWaiver: boolean = true) => { + const { excludedPaymentMode } = useConfig(); + const url = `/ws/rest/v1/cashier/paymentMode`; + const { data, isLoading, error, mutate } = useSWR<{ data: { results: Array } }>(url, openmrsFetch, { + errorRetryCount: 2, + }); + const allowedPaymentModes = + excludedPaymentMode?.length > 0 + ? data?.data?.results.filter((mode) => !excludedPaymentMode.some((excluded) => excluded.uuid === mode.uuid)) ?? [] + : data?.data?.results ?? []; + return { + paymentModes: excludeWaiver ? allowedPaymentModes : data?.data?.results, + isLoading, + mutate, + error, + }; +}; + +export const useBillableItems = () => { + const url = `/ws/rest/v1/cashier/billableService?v=custom:(uuid,name,shortName,serviceStatus,serviceType:(display),servicePrices:(uuid,name,price,paymentMode))`; + const { data, isLoading, error } = useSWR<{ data: { results: Array } }>(url, openmrsFetch); + const [searchTerm, setSearchTerm] = useState(''); + const filteredItems = + data?.data?.results?.filter((item) => item.name.toLowerCase().includes(searchTerm.toLowerCase())) ?? []; + return { + lineItems: filteredItems, + isLoading, + error, + searchTerm, + setSearchTerm, + }; +}; +export const useCashPoint = () => { + const url = `/ws/rest/v1/cashier/cashPoint`; + const { data, isLoading, error } = useSWR<{ data: { results: Array } }>(url, openmrsFetch); + + return { isLoading, error, cashPoints: data?.data?.results ?? [] }; +}; + +export const createPatientBill = (payload) => { + const postUrl = `/ws/rest/v1/cashier/bill`; + return openmrsFetch(postUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload }); +}; + +export const useConceptAnswers = (conceptUuid: string) => { + const url = `/ws/rest/v1/concept/${conceptUuid}`; + const { data, isLoading, error } = useSWR<{ data: { answers: Array } }>(url, openmrsFetch); + return { conceptAnswers: data?.data?.answers, isLoading, error }; +}; diff --git a/packages/esm-billing-app/src/bills-table/bills-table.component.tsx b/packages/esm-billing-app/src/bills-table/bills-table.component.tsx new file mode 100644 index 00000000..7ea7559c --- /dev/null +++ b/packages/esm-billing-app/src/bills-table/bills-table.component.tsx @@ -0,0 +1,288 @@ +import React, { useCallback, useId, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { + DataTable, + DataTableSkeleton, + Dropdown, + InlineLoading, + Layer, + Pagination, + Search, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, + Tile, +} from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { + useLayoutType, + isDesktop, + useConfig, + usePagination, + ErrorState, + ConfigurableLink, +} from '@openmrs/esm-framework'; +import { EmptyDataIllustration } from '@openmrs/esm-patient-common-lib'; +import { useBills } from '../billing.resource'; +import styles from './bills-table.scss'; + +const filterItems = [ + { id: '', text: 'All bills' }, + { id: 'PENDING', text: 'Pending bills' }, + { id: 'PAID', text: 'Paid bills' }, + { id: 'POSTED', text: 'Posted bills' }, +]; + +type BillTableProps = { + defaultBillPaymentStatus?: string; +}; + +const BillsTable: React.FC = ({ defaultBillPaymentStatus = '' }) => { + const { t } = useTranslation(); + const id = useId(); + const config = useConfig(); + const layout = useLayoutType(); + const responsiveSize = isDesktop(layout) ? 'sm' : 'lg'; + const [billPaymentStatus, setBillPaymentStatus] = useState(defaultBillPaymentStatus); + const pageSizes = config?.bills?.pageSizes ?? [10, 20, 30, 40, 50]; + const [pageSize, setPageSize] = useState(config?.bills?.pageSize ?? 10); + const { bills, isLoading, isValidating, error } = useBills('', billPaymentStatus); + const [searchString, setSearchString] = useState(''); + + const headerData = [ + { + header: t('visitTime', 'Visit time'), + key: 'visitTime', + }, + { + header: t('identifier', 'Identifier'), + key: 'identifier', + }, + { + header: t('name', 'Name'), + key: 'patientName', + }, + { + header: t('billedItems', 'Billed Items'), + key: 'billedItems', + }, + ]; + + const searchResults = useMemo(() => { + if (bills !== undefined && bills.length > 0) { + if (searchString && searchString.trim() !== '') { + const search = searchString.toLowerCase(); + return bills?.filter((activeBillRow) => + Object.entries(activeBillRow).some(([header, value]) => { + if (header === 'patientUuid') { + return false; + } + return `${value}`.toLowerCase().includes(search); + }), + ); + } + } + + return bills; + }, [searchString, bills]); + + const { paginated, goTo, results, currentPage } = usePagination(searchResults, pageSize); + + const setBilledItems = (bill) => + bill?.lineItems?.reduce( + (acc, item) => acc + (acc ? ' & ' : '') + (item.billableService.split(':')[1] || item.item.split(':')[1] || ''), + '', + ); + + const billingUrl = '${openmrsSpaBase}/home/billing/patient/${patientUuid}/${uuid}'; + + const rowData = results?.map((bill, index) => ({ + id: `${index}`, + uuid: bill.uuid, + patientName: ( + + {bill.patientName} + + ), + visitTime: bill.dateCreated, + identifier: bill.identifier, + department: '--', + billedItems: setBilledItems(bill), + billingPrice: '--', + })); + + const handleSearch = useCallback( + (e) => { + goTo(1); + setSearchString(e.target.value); + }, + [goTo, setSearchString], + ); + + const handleFilterChange = ({ selectedItem }) => setBillPaymentStatus(selectedItem.id); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ + + +
+ ); + } + + return ( + <> +
+ item.id === billPaymentStatus)} + items={filterItems} + itemToString={(item) => (item ? item.text : '')} + label="" + onChange={handleFilterChange} + size={responsiveSize} + titleText={t('filterBy', 'Filter by') + ':'} + type="inline" + /> +
+ + {bills?.length > 0 ? ( +
+ + 1 ? true : false}> + {({ rows, headers, getRowProps, getTableProps }) => ( + + + + + {headers.map((header) => ( + {header.header} + ))} + + + + {rows.map((row) => ( + + {row.cells.map((cell) => ( + {cell.value} + ))} + + ))} + +
+
+ )} +
+ {searchResults?.length === 0 && ( +
+ + +

+ {t('noMatchingBillsToDisplay', 'No matching bills to display')} +

+

{t('checkFilters', 'Check the filters above')}

+
+
+
+ )} + {paginated && ( + { + if (newPageSize !== pageSize) { + setPageSize(newPageSize); + } + if (newPage !== currentPage) { + goTo(newPage); + } + }} + /> + )} +
+ ) : ( + + +
+ +
+

There are no bills to display.

+
+
+ )} + + ); +}; + +function FilterableTableHeader({ layout, handleSearch, isValidating, responsiveSize, t }) { + return ( + <> +
+
+

{t('billList', 'Bill list')}

+
+
+ {isValidating ? : null} +
+
+ + + ); +} + +export default BillsTable; diff --git a/packages/esm-billing-app/src/bills-table/bills-table.scss b/packages/esm-billing-app/src/bills-table/bills-table.scss new file mode 100644 index 00000000..bbb9af05 --- /dev/null +++ b/packages/esm-billing-app/src/bills-table/bills-table.scss @@ -0,0 +1,181 @@ +@use '@carbon/layout'; +@use '@carbon/type'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.container { + margin: 2rem 0; +} + +.emptyStateContainer, +.loaderContainer { + @extend .container; +} + +.billListContainer { + background-color: $ui-02; + border: 1px solid $ui-03; + width: 100%; + margin: 0 auto; + max-width: 95vw; + padding-bottom: 0; + + :has(.filterEmptyState) { + border-bottom: none; + } +} + +.filterContainer { + flex: 1; + + :global(.cds--dropdown__wrapper--inline) { + gap: 0; + } + + :global(.cds--list-box__menu-icon) { + height: 1rem; + } + + :global(.cds--list-box__menu) { + min-width: max-content; + } + + :global(.cds--list-box) { + margin-left: layout.$spacing-03; + } +} + +.menu { + margin-left: layout.$spacing-03; +} + +.headerContainer { + display: flex; + justify-content: space-between; + align-items: center; + padding: layout.$spacing-04 layout.$spacing-05; + background-color: $ui-02; +} + +.backgroundDataFetchingIndicator { + align-items: center; + display: flex; + flex: 1; + justify-content: space-between; + + &:global(.cds--inline-loading) { + max-height: 1rem; + } +} + +.tableContainer section { + position: relative; +} + +.tableContainer a { + text-decoration: none; +} + +.pagination { + overflow: hidden; + + &:global(.cds--pagination) { + border-top: none; + } +} + +.hiddenRow { + display: none; +} + +.emptyRow { + padding: 0 1rem; + display: flex; + align-items: center; +} + +.visitSummaryContainer { + width: 100%; + max-width: 768px; + margin: 1rem auto; +} + +.expandedActiveVisitRow > td > div { + max-height: max-content !important; +} + +.expandedActiveVisitRow td { + padding: 0 2rem; +} + +.expandedActiveVisitRow th[colspan] td[colspan] > div:first-child { + padding: 0 1rem; +} + +.action { + margin-bottom: layout.$spacing-03; +} + +.illo { + margin-top: layout.$spacing-05; +} + +.content { + @include type.type-style('heading-compact-01'); + color: $text-02; + margin-top: layout.$spacing-05; + margin-bottom: layout.$spacing-03; +} + +.desktopHeading, +.tabletHeading { + text-align: left; + text-transform: capitalize; + flex: 1; + + h4 { + @include type.type-style('heading-compact-02'); + color: $text-02; + + &:after { + content: ''; + display: block; + width: 2rem; + padding-top: 3px; + border-bottom: 0.375rem solid; + @include brand-03(border-bottom-color); + } + } +} + +.tile { + text-align: center; + border: 1px solid $ui-03; +} + +.menuitem { + max-width: none; +} + +.filterEmptyState { + display: flex; + justify-content: center; + align-items: center; + padding: layout.$spacing-05; + margin: layout.$spacing-09; + text-align: center; +} + +.filterEmptyStateTile { + margin: auto; +} + +.filterEmptyStateContent { + @include type.type-style('heading-compact-02'); + color: $text-02; + margin-bottom: 0.5rem; +} + +.filterEmptyStateHelper { + @include type.type-style('body-compact-01'); + color: $text-02; +} diff --git a/packages/esm-billing-app/src/config-schema.ts b/packages/esm-billing-app/src/config-schema.ts new file mode 100644 index 00000000..f7b86295 --- /dev/null +++ b/packages/esm-billing-app/src/config-schema.ts @@ -0,0 +1,115 @@ +import { Type } from '@openmrs/esm-framework'; + +export interface BillingConfig { + visitAttributeTypes: { + isPatientExempted: string; + paymentMethods: string; + insuranceScheme: string; + policyNumber: string; + exemptionCategory: string; + billPaymentStatus: string; + }; + patientExemptionCategories: Array<{ value: string; label: string }>; + excludedPaymentMode: Array<{ uuid: string; label: string }>; + enforceBillPayment: boolean; + mpesaCallbackUrl: string; + shortCode: string; + passKey: string; + authorizationUrl: string; + initiateUrl: string; +} + +export const configSchema = { + visitAttributeTypes: { + isPatientExempted: { + _type: Type.String, + _default: '3b9dfac8-9e4d-11ee-8c90-0242ac120002', + _description: 'Whether the patient should be exempted from paying for service i.e Prisoners', + }, + paymentMethods: { + _type: Type.String, + _description: 'The payment methods visit attribute uuid', + _default: 'e6cb0c3b-04b0-4117-9bc6-ce24adbda802', + }, + insuranceScheme: { + _type: Type.String, + _description: 'The insurance scheme visit attribute uuid', + _default: '2d0fa959-6780-41f1-85b1-402045935068', + }, + policyNumber: { + _type: Type.String, + _description: 'The policy number visit attribute uuid', + _default: '0f4f3306-f01b-43c6-af5b-fdb60015cb02', + }, + exemptionCategory: { + _type: Type.String, + _description: 'The exemption category visit attribute uuid', + _default: 'df0362f9-782e-4d92-8bb2-3112e9e9eb3c', + }, + billPaymentStatus: { + _type: Type.String, + _description: 'The bill payment status visit attribute uuid', + _default: '919b51c9-8e2e-468f-8354-181bf3e55786', + }, + }, + patientExemptionCategories: { + _type: Type.Array, + _elements: { + value: { + _type: Type.UUID, + _description: 'The value of the exemption category', + }, + label: { + _type: Type.String, + _default: null, + _description: 'The label of the exemption category', + }, + }, + _default: [{ value: 'IN_PRISON', label: 'In Prison' }], + }, + excludedPaymentMode: { + _type: Type.Array, + _elements: { + uuid: { + _type: Type.UUID, + _description: 'The value of the payment mode to be excluded', + }, + label: { + _type: Type.String, + _default: null, + _description: 'The label of the payment mode to be excluded', + }, + }, + _default: [], + }, + enforceBillPayment: { + _type: Type.Boolean, + _default: true, + _description: 'Whether to enforce bill payment or not for patient to receive service', + }, + mpesaCallbackUrl: { + _type: Type.String, + _default: '', + _description: 'MPESA callback Url to receive confirmation payload from MPESA Daraja API', + }, + shortCode: { + _type: Type.String, + _default: '', + _description: 'shortcode used to identify an organization and receive the transaction', + }, + passKey: { + _type: Type.String, + _default: '', + _description: 'Passkey used for generating password for generation of access token to auth APIs', + }, + authorizationUrl: { + _type: Type.String, + _default: '', + _description: 'MPESA Authenciation url gives you a time bound access token to call allowed APIs.', + }, + initiateUrl: { + _type: Type.String, + _default: '', + _description: 'MPESA Initiator url which Initiates online payment on behalf of a customer.', + }, +}; diff --git a/packages/esm-billing-app/src/constants.ts b/packages/esm-billing-app/src/constants.ts new file mode 100644 index 00000000..ff0b98e7 --- /dev/null +++ b/packages/esm-billing-app/src/constants.ts @@ -0,0 +1,3 @@ +export const PENDING_PAYMENT_STATUS = 'PENDING'; +export const EXEMPTED_PAYMENT_STATUS = 'EXEMPTED'; +export const spaBasePath = `${window.spaBase}/home`; diff --git a/packages/esm-billing-app/src/dashboard.meta.ts b/packages/esm-billing-app/src/dashboard.meta.ts new file mode 100644 index 00000000..aff6b561 --- /dev/null +++ b/packages/esm-billing-app/src/dashboard.meta.ts @@ -0,0 +1,6 @@ +export const dashboardMeta = { + slot: 'patient-chart-billing-dashboard-slot', + columns: 1, + title: 'Billing', + path: 'Billing', +}; diff --git a/packages/esm-billing-app/src/declarations.d.ts b/packages/esm-billing-app/src/declarations.d.ts new file mode 100644 index 00000000..dbad8404 --- /dev/null +++ b/packages/esm-billing-app/src/declarations.d.ts @@ -0,0 +1,4 @@ +declare module '*.css'; +declare module '*.scss'; +declare module '@carbon/react'; +declare type SideNavProps = object; diff --git a/packages/esm-billing-app/src/helpers/functions.ts b/packages/esm-billing-app/src/helpers/functions.ts new file mode 100644 index 00000000..6228b792 --- /dev/null +++ b/packages/esm-billing-app/src/helpers/functions.ts @@ -0,0 +1,83 @@ +import { type Payment, type LineItem } from '../types'; + +// amount already paid +export function calculateTotalAmountTendered(payments: Array) { + return Array.isArray(payments) + ? payments.reduce((totalAmount, item) => { + // Ensure that "amount" property is present and numeric + if (typeof item.amount === 'number' && item.voided !== true) { + return totalAmount + item.amount; + } + return totalAmount; + }, 0) + : 0; +} + +// balance +export function calculateTotalBalance(lineItems: Array, payments: Array) { + return Math.min(this.calculateTotalAmount(lineItems) - this.calculateTotalAmountTendered(payments)); +} + +// total bill +export function calculateTotalAmount(lineItems: Array) { + return Array.isArray(lineItems) + ? lineItems.reduce((totalAmount, item) => { + // Ensure that "price" and "quantity" properties are present and numeric + if (typeof item.price === 'number' && typeof item.quantity === 'number' && item.voided !== true) { + return totalAmount + item.price * item.quantity; + } + return totalAmount; + }, 0) + : 0; +} + +export const convertToCurrency = (amountToConvert: number) => { + const formatter = new Intl.NumberFormat('en-KE', { + style: 'currency', + currency: 'KES', + minimumFractionDigits: 2, + }); + + let formattedAmount = formatter.format(Math.abs(amountToConvert)); + + if (amountToConvert < 0) { + formattedAmount = `(${formattedAmount})`; + } + + return formattedAmount; +}; + +export const getGender = (gender: string, t) => { + 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; + } +}; + +/** + * Extracts and returns the substring after the first colon (:) in the input string. + * The input string is expected to be in the format "uuid:string". + * + * @param {string} input - The input string from which the substring is to be extracted. + * @returns {string} The substring found after the first colon in the input string. + */ +export function extractString(input: string): string { + const parts = input.split(':'); + return removeUUID(parts.length < 2 ? input : parts[1]); +} + +function removeUUID(str) { + // Regular expression to match a UUID + const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i; + + // Replace the UUID with an empty string + return str.replace(uuidPattern, ''); +} diff --git a/packages/esm-billing-app/src/helpers/index.ts b/packages/esm-billing-app/src/helpers/index.ts new file mode 100644 index 00000000..484b7159 --- /dev/null +++ b/packages/esm-billing-app/src/helpers/index.ts @@ -0,0 +1 @@ +export * from './functions'; diff --git a/packages/esm-billing-app/src/hooks/getMflCode.tsx b/packages/esm-billing-app/src/hooks/getMflCode.tsx new file mode 100644 index 00000000..01dfb8b7 --- /dev/null +++ b/packages/esm-billing-app/src/hooks/getMflCode.tsx @@ -0,0 +1,19 @@ +import { type OpenmrsResource, openmrsFetch } from '@openmrs/esm-framework'; +import useSWRImmutable from 'swr/immutable'; + +export function useSystemSetting(key: string) { + const { data, isLoading } = useSWRImmutable<{ data: { results: Array } }>( + `/ws/rest/v1/systemsetting?q=${key}&v=full`, + openmrsFetch, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); + + const mflCodeResource = data?.data?.results?.find((resource) => resource.property === 'facility.mflcode'); + + const mflCodeValue = mflCodeResource?.value; + + return { mflCodeValue, isLoading }; +} diff --git a/packages/esm-billing-app/src/index.ts b/packages/esm-billing-app/src/index.ts new file mode 100644 index 00000000..2a2b54a9 --- /dev/null +++ b/packages/esm-billing-app/src/index.ts @@ -0,0 +1,54 @@ +import { defineConfigSchema, getSyncLifecycle } from '@openmrs/esm-framework'; +import { createDashboardLink } from '@openmrs/esm-patient-common-lib'; +import { createLeftPanelLink } from './left-panel-link.component'; +import { configSchema } from './config-schema'; +import { dashboardMeta } from './dashboard.meta'; +import rootComponent from './root.component'; +import BillHistory from './bill-history/bill-history.component'; +import BillingCheckInForm from './billing-form/billing-checkin-form.component'; +import BillableServicesCardLink from './billable-services-admin-card-link.component'; +import BillableServiceHome from './billable-services/billable-services-home.component'; +import BillingForm from './billing-form/billing-form.component'; +import RequirePaymentModal from './modal/require-payment-modal.component'; +import VisitAttributeTags from './invoice/payments/visit-tags/visit-attribute.component'; +import InitiatePaymentDialog from './invoice/payments/initiate-payment/initiate-payment.component'; +import DrugOrder from './billable-services/billiable-item/drug-order.component'; +import LabOrder from './billable-services/billiable-item/lab-order.component'; + +const moduleName = '@ampath/esm-billing-app'; + +const options = { + featureName: 'billing', + moduleName, +}; + +export const importTranslation = require.context('../translations', false, /.json$/, 'lazy'); + +export function startupApp() { + defineConfigSchema(moduleName, configSchema); +} + +export const billingSummaryDashboardLink = getSyncLifecycle( + createDashboardLink({ ...dashboardMeta, moduleName }), + options, +); +// t('billing', 'Billing') +export const billingDashboardLink = getSyncLifecycle( + createLeftPanelLink({ + name: 'billing', + title: 'Billing', + }), + options, +); + +export const root = getSyncLifecycle(rootComponent, options); +export const billingPatientSummary = getSyncLifecycle(BillHistory, options); +export const billingCheckInForm = getSyncLifecycle(BillingCheckInForm, options); +export const billableServicesHome = getSyncLifecycle(BillableServiceHome, options); +export const billableServicesCardLink = getSyncLifecycle(BillableServicesCardLink, options); +export const billingForm = getSyncLifecycle(BillingForm, options); +export const requirePaymentModal = getSyncLifecycle(RequirePaymentModal, options); +export const visitAttributeTags = getSyncLifecycle(VisitAttributeTags, options); +export const initiatePaymentDialog = getSyncLifecycle(InitiatePaymentDialog, options); +export const labOrder = getSyncLifecycle(LabOrder, options); +export const drugOrder = getSyncLifecycle(DrugOrder, options); diff --git a/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-breakdown/claims-breakdown.component.tsx b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-breakdown/claims-breakdown.component.tsx new file mode 100644 index 00000000..bd432617 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-breakdown/claims-breakdown.component.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import styles from './claims-breakdown.scss'; + +type ClaimsBreakDownProps = { + label: string; + value: string; + hasBalance?: Boolean; +}; + +export const ClaimsBreakDown: React.FC = ({ label, value, hasBalance }) => { + return ( +
+
+ {label}: + {value} +
+
+ ); +}; diff --git a/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-breakdown/claims-breakdown.scss b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-breakdown/claims-breakdown.scss new file mode 100644 index 00000000..086d74c4 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-breakdown/claims-breakdown.scss @@ -0,0 +1,35 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.claimsBreakdownContainer { + display: flex; + justify-content: flex-end; + padding: layout.$spacing-09 !important; +} + +.claimsBreakdown { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: flex-end; + margin-right: auto; +} + +.label { + @include type.type-style('heading-03'); + color: colors.$gray-100; + text-align: end; +} + +.value { + @extend .label; + font-weight: bold; + margin-left: layout.$spacing-03; + text-align: start; +} + +.extendedLabel { + @extend .label; + font-weight: bold; + color: crimson; +} diff --git a/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-dashboard.component.tsx b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-dashboard.component.tsx new file mode 100644 index 00000000..995a9cc6 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-dashboard.component.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import ClaimsHeader from './claims-header/claims-header.component'; + +const ClaimScreen: React.FC = () => { + const { t } = useTranslation(); + + return ( + <> + + + ); +}; + +export default ClaimScreen; diff --git a/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-form/claims-form.component.tsx b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-form/claims-form.component.tsx new file mode 100644 index 00000000..8cbf21e6 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-form/claims-form.component.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Column, + Form, + Layer, + Stack, + TextInput, + Row, + DatePicker, + ButtonSet, + Button, + DatePickerInput, +} from '@carbon/react'; +import styles from './claims-form.scss'; + +const ClaimsForm: React.FC = () => { + const { t } = useTranslation(); + + return ( +
+ {t('formTitle', 'Fill in the form details')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +export default ClaimsForm; diff --git a/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-form/claims-form.scss b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-form/claims-form.scss new file mode 100644 index 00000000..6c920f90 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-form/claims-form.scss @@ -0,0 +1,118 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; +@use '@carbon/layout'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.heading { + @include type.type-style('heading-compact-01'); + margin: spacing.$spacing-05 0 spacing.$spacing-05; +} + +.warningContainer { + background-color: $carbon--red-50; + padding: spacing.$spacing-04; + margin: spacing.$spacing-03 0 spacing.$spacing-03; + display: flex; + justify-content: space-between; + .warning { + @include type.type-style('heading-compact-01'); + color: $ui-05; + } +} + +.form { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 50%; +} + +.grid { + margin: 0 spacing.$spacing-05; + padding: 0rem; +} + +.input { + margin-top: spacing.$spacing-05; +} + +.inputRow { + margin-top: spacing.$spacing-05; + width: 50%; // Adjust width as per your design requirements +} + +.datePickersRow { + display: flex; + flex-wrap: nowrap; + gap: spacing.$spacing-05; // Adjust gap between columns + align-items: center; +} + +.datePickerInput { + width: 100%; +} + +.button { + height: spacing.$spacing-10; + display: flex; + align-content: flex-start; + align-items: baseline; + min-width: 20%; +} + +.buttonSet { + padding: 0rem; + margin-top: spacing.$spacing-05; + display: flex; + justify-content: flex-end; + gap: spacing.$spacing-05; + margin-bottom: spacing.$spacing-05; +} + +.claimFormTitle { + @include type.type-style('heading-02'); + display: flex; + align-items: center; + justify-content: space-between; + margin: spacing.$spacing-05; + row-gap: 1.5rem; + position: relative; + + &::after { + content: ''; + display: block; + width: 2rem; + border-bottom: 0.375rem solid var(--brand-03); + position: absolute; + bottom: -0.75rem; + left: 0; + } + + & > span { + @include type.type-style('body-01'); + } +} + +:global(.omrs-breakpoint-lt-desktop) { + .form { + height: var(--tablet-workspace-window-height); + } + + .buttonSet { + padding: spacing.$spacing-06 spacing.$spacing-05; + background-color: $ui-02; + justify-content: flex-end; + gap: spacing.$spacing-05; + } +} + +/* New Styles for Facility and Visit Type */ +.facilityVisitRow { + display: flex; + flex-wrap: nowrap; + gap: spacing.$spacing-05; /* Adjust gap between columns */ +} + +.facilityColumn { + flex: 1 1 0%; /* Makes columns take up equal space */ +} diff --git a/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-header/claims-header.component.tsx b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-header/claims-header.component.tsx new file mode 100644 index 00000000..5cc3bb6a --- /dev/null +++ b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-header/claims-header.component.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react'; +import { InlineLoading } from '@carbon/react'; +import { useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ExtensionSlot, usePatient, showModal } from '@openmrs/esm-framework'; +import { ErrorState } from '@openmrs/esm-patient-common-lib'; +import styles from './claims-header.scss'; +import { useBill } from '../../../../billing.resource'; +import { type LineItem } from '../../../../types'; +import ClaimsTable from '../claims-table.component.tsx/claims-table.component'; +import { ClaimsBreakDown } from '../claims-breakdown/claims-breakdown.component'; +import ClaimsForm from '../claims-form/claims-form.component'; + +const ClaimsHeader: React.FC = () => { + const { t } = useTranslation(); + const { billUuid, patientUuid } = useParams(); + const { patient, isLoading: isLoadingPatient } = usePatient(patientUuid); + const { bill, isLoading: isLoadingBill, error } = useBill(billUuid); + const [selectedLineIems, setSelectedLineItems] = useState([]); + const handleSelectItem = (lineItems: Array) => { + setSelectedLineItems(lineItems); + }; + + useEffect(() => { + const paidLineItems = bill?.lineItems?.filter((item) => item.paymentStatus === 'PAID') ?? []; + setSelectedLineItems(paidLineItems); + }, [bill.lineItems]); + + if (isLoadingPatient && isLoadingBill) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ {patient && patientUuid && } +
+ Create Claim Form +
+ + + +
+ ); +}; + +export default ClaimsHeader; diff --git a/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-header/claims-header.scss b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-header/claims-header.scss new file mode 100644 index 00000000..09aa2aed --- /dev/null +++ b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-header/claims-header.scss @@ -0,0 +1,51 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.claimContainer { + background-color: colors.$gray-10; + height: calc(100vh - 3rem); +} + +.errorContainer { + margin: layout.$spacing-05; +} + +.loader { + display: flex; + min-height: layout.$spacing-09; + justify-content: center; +} +.claimTitle { + @include type.type-style('heading-02'); + display: flex; + flex: 3; + flex-flow: row wrap; + align-items: center; + justify-content: space-between; + margin: layout.$spacing-05; + row-gap: 1.5rem; +} +.details { + display: flex; + flex: 3; + flex-flow: row wrap; + align-items: center; + justify-content: space-between; + margin: layout.$spacing-05; + row-gap: 1.5rem; +} + +@media screen { + .printContainer { + background-color: colors.$white; + display: none; + } +} + +@media print { + html, + body { + background-color: colors.$white !important; + } +} diff --git a/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-table.component.tsx/claims-table.component.tsx b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-table.component.tsx/claims-table.component.tsx new file mode 100644 index 00000000..91740187 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-table.component.tsx/claims-table.component.tsx @@ -0,0 +1,188 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import fuzzy from 'fuzzy'; +import { + DataTable, + DataTableSkeleton, + Layer, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, + TableToolbar, + TableToolbarContent, + TableToolbarSearch, + TableSelectRow, + Tile, + type DataTableHeader, + type DataTableRow, +} from '@carbon/react'; +import { isDesktop, useDebounce, useLayoutType } from '@openmrs/esm-framework'; +import styles from './claims-table.scss'; +import { type LineItem, type MappedBill } from '../../../../types'; +import ClaimsForm from '../claims-form/claims-form.component'; + +type ClaimsTableProps = { + bill: MappedBill; + isSelectable?: boolean; + isLoadingBill?: boolean; + onSelectItem?: (selectedLineItems: LineItem[]) => void; +}; + +const ClaimsTable: React.FC = ({ bill, isSelectable = true, isLoadingBill, onSelectItem }) => { + const { t } = useTranslation(); + const { lineItems } = bill; + const paidLineItems = lineItems?.filter((item) => item.paymentStatus === 'PAID') ?? []; + const layout = useLayoutType(); + const responsiveSize = isDesktop(layout) ? 'sm' : 'lg'; + const [selectedLineItems, setSelectedLineItems] = useState(paidLineItems ?? []); + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm); + const filteredLineItems = useMemo(() => { + if (!debouncedSearchTerm) { + return lineItems; + } + + return debouncedSearchTerm + ? fuzzy + .filter(debouncedSearchTerm, lineItems, { + extract: (lineItem: LineItem) => `${lineItem.item}`, + }) + .sort((r1, r2) => r1.score - r2.score) + .map((result) => result.original) + : lineItems; + }, [debouncedSearchTerm, lineItems]); + + const tableHeaders: Array = [ + { header: 'No', key: 'no' }, + { header: 'Serial No.', key: 'serialno' }, + { header: 'Inventory Name', key: 'inventoryname' }, + { header: 'Status', key: 'status' }, + { header: 'Total', key: 'total' }, + ]; + const processBillItem = (item) => (item.item || item.billableService)?.split(':')[1]; + + const tableRows: Array = useMemo( + () => + filteredLineItems?.map((item, index) => { + return { + no: `${index + 1}`, + id: `${item.uuid}`, + inventoryname: processBillItem(item), + serialno: bill.receiptNumber, + status: item.paymentStatus, + total: item.price * item.quantity, + }; + }) ?? [], + [bill.receiptNumber, filteredLineItems], + ); + + if (isLoadingBill) { + return ( +
+ +
+ ); + } + + const handleRowSelection = (row: typeof DataTableRow, checked: boolean) => { + const matchingRow = filteredLineItems.find((item) => item.uuid === row.id); + let newSelectedLineItems; + + if (checked) { + newSelectedLineItems = [...selectedLineItems, matchingRow]; + } else { + newSelectedLineItems = selectedLineItems.filter((item) => item.uuid !== row.id); + } + setSelectedLineItems(newSelectedLineItems); + onSelectItem(newSelectedLineItems); + }; + + return ( +
+ + {({ rows, headers, getRowProps, getSelectionProps, getTableProps, getToolbarProps }) => ( + + {t('selectitemstobeclaimed', 'Select items that are to be included in the claims')} + + } + title={t('lineItems', 'Line items')}> +
+ + + ) => setSearchTerm(e.target.value)} + placeholder={t('searchThisTable', 'Search this table')} + size={responsiveSize} + /> + + +
+ + + + {rows.length > 1 && isSelectable ? : null} + {headers.map((header) => ( + {header.header} + ))} + + + + {rows.map((row, index) => { + return ( + + {rows.length > 1 && isSelectable && ( + handleRowSelection(row, checked)} + checked={Boolean(selectedLineItems?.find((item) => item?.uuid === row?.id))} + /> + )} + {row.cells.map((cell) => ( + {cell.value} + ))} + + ); + })} + +
+
+ )} +
+ {filteredLineItems?.length === 0 && ( +
+ + +

+ {t('noMatchingItemsToDisplay', 'No matching items to display')} +

+

{t('checkFilters', 'Check the filters above')}

+
+
+
+ )} + +
+ ); +}; + +export default ClaimsTable; diff --git a/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-table.component.tsx/claims-table.scss b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-table.component.tsx/claims-table.scss new file mode 100644 index 00000000..bb7a5d56 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/claims/claims-dashboard/claims-table.component.tsx/claims-table.scss @@ -0,0 +1,103 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.filterEmptyState { + align-items: center; + background-color: white; + display: flex; + justify-content: center; + padding: layout.$spacing-09 !important; + text-align: center; +} + +.filterEmptyStateTile { + margin: auto; +} + +.filterEmptyStateContent { + @include type.type-style('heading-compact-02'); + color: $text-02; + margin-bottom: 0.5rem; +} + +.filterEmptyStateHelper { + @include type.type-style('body-compact-01'); + color: $text-02; +} + +.headerContainer { + background-color: colors.$gray-10; +} + +.claimContainer { + margin: layout.$spacing-09 layout.$spacing-05 0; + border: 1px solid $ui-03; + display: flex; + justify-content: flex-start; +} + +.searchbox { + input:focus { + outline: 2px solid colors.$orange-40 !important; + } +} + +.table { + td { + border-bottom: none !important; + } +} + +.tableDescription { + display: flex; + align-items: flex-start; + margin-top: layout.$spacing-02; + column-gap: layout.$spacing-01; + + ::after { + content: ''; + display: block; + width: 2rem; + padding-top: 0.188rem; + border-bottom: 0.375rem solid var(--brand-03); + } + + & > span { + @include type.type-style('body-01'); + } +} + +.tableToolbar { + width: 20%; + min-width: 12.5rem; +} + +.toolbarWrapper { + position: relative; + display: flex; + justify-content: flex-end; +} + +:global(.omrs-breakpoint-lt-desktop) { + .toolbarWrapper { + height: layout.$spacing-09; + } +} + +:global(.omrs-breakpoint-gt-tablet) { + .toolbarWrapper { + height: layout.$spacing-07; + } +} +.tableContainer { + width: 100%; +} + +@media (min-width: 1200px) { + .tableContainer { + width: 50%; + margin-right: auto; + } +} diff --git a/packages/esm-billing-app/src/invoice/claims/make-claims.component.tsx b/packages/esm-billing-app/src/invoice/claims/make-claims.component.tsx new file mode 100644 index 00000000..86672f19 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/claims/make-claims.component.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './make-claims.scss'; +import { Button } from '@carbon/react'; +import { Report } from '@carbon/react/icons'; +import { navigate } from '@openmrs/esm-framework'; +import { spaBasePath } from '../../constants'; + +const MakeClaims: React.FC = () => { + const { t } = useTranslation(); + const navigateToCreateClaimScreen = () => { + navigate({ to: `${spaBasePath}/billing/claims` }); + }; + + return ( + + ); +}; + +export default MakeClaims; diff --git a/packages/esm-billing-app/src/invoice/claims/make-claims.scss b/packages/esm-billing-app/src/invoice/claims/make-claims.scss new file mode 100644 index 00000000..7f385f29 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/claims/make-claims.scss @@ -0,0 +1,7 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.button { + margin-left: layout.$spacing-04; +} diff --git a/packages/esm-billing-app/src/invoice/invoice-table.component.tsx b/packages/esm-billing-app/src/invoice/invoice-table.component.tsx new file mode 100644 index 00000000..3ebf7e58 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/invoice-table.component.tsx @@ -0,0 +1,193 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import fuzzy from 'fuzzy'; +import { + DataTable, + DataTableSkeleton, + Layer, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, + TableToolbar, + TableToolbarContent, + TableToolbarSearch, + TableSelectRow, + Tile, + type DataTableHeader, + type DataTableRow, +} from '@carbon/react'; +import { isDesktop, useDebounce, useLayoutType } from '@openmrs/esm-framework'; +import { type LineItem, type MappedBill } from '../types'; +import styles from './invoice-table.scss'; + +type InvoiceTableProps = { + bill: MappedBill; + isSelectable?: boolean; + isLoadingBill?: boolean; + onSelectItem?: (selectedLineItems: LineItem[]) => void; +}; + +const InvoiceTable: React.FC = ({ bill, isSelectable = true, isLoadingBill, onSelectItem }) => { + const { t } = useTranslation(); + const { lineItems } = bill; + const paidLineItems = lineItems?.filter((item) => item.paymentStatus === 'PAID') ?? []; + const layout = useLayoutType(); + const responsiveSize = isDesktop(layout) ? 'sm' : 'lg'; + const [selectedLineItems, setSelectedLineItems] = useState(paidLineItems ?? []); + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm); + const filteredLineItems = useMemo(() => { + if (!debouncedSearchTerm) { + return lineItems; + } + + return debouncedSearchTerm + ? fuzzy + .filter(debouncedSearchTerm, lineItems, { + extract: (lineItem: LineItem) => `${lineItem.item}`, + }) + .sort((r1, r2) => r1.score - r2.score) + .map((result) => result.original) + : lineItems; + }, [debouncedSearchTerm, lineItems]); + + const tableHeaders: Array = [ + { header: 'No', key: 'no' }, + { header: 'Bill item', key: 'billItem' }, + { header: 'Bill code', key: 'billCode' }, + { header: 'Status', key: 'status' }, + { header: 'Quantity', key: 'quantity' }, + { header: 'Price', key: 'price' }, + { header: 'Total', key: 'total' }, + ]; + const processBillItem = (item) => (item.item || item.billableService)?.split(':')[1]; + + const tableRows: Array = useMemo( + () => + filteredLineItems?.map((item, index) => { + return { + no: `${index + 1}`, + id: `${item.uuid}`, + billItem: processBillItem(item), + billCode: bill.receiptNumber, + status: item.paymentStatus, + quantity: item.quantity, + price: item.price, + total: item.price * item.quantity, + }; + }) ?? [], + [bill.receiptNumber, filteredLineItems], + ); + + if (isLoadingBill) { + return ( +
+ +
+ ); + } + + const handleRowSelection = (row: typeof DataTableRow, checked: boolean) => { + const matchingRow = filteredLineItems.find((item) => item.uuid === row.id); + let newSelectedLineItems; + + if (checked) { + newSelectedLineItems = [...selectedLineItems, matchingRow]; + } else { + newSelectedLineItems = selectedLineItems.filter((item) => item.uuid !== row.id); + } + setSelectedLineItems(newSelectedLineItems); + onSelectItem(newSelectedLineItems); + }; + + return ( +
+ + {({ rows, headers, getRowProps, getSelectionProps, getTableProps, getToolbarProps }) => ( + + {t('itemsToBeBilled', 'Items to be billed')} + + } + title={t('lineItems', 'Line items')}> +
+ + + ) => setSearchTerm(e.target.value)} + placeholder={t('searchThisTable', 'Search this table')} + size={responsiveSize} + /> + + +
+ + + + {rows.length > 1 && isSelectable ? : null} + {headers.map((header) => ( + {header.header} + ))} + + + + {rows.map((row, index) => { + return ( + + {rows.length > 1 && isSelectable && ( + handleRowSelection(row, checked)} + checked={ + tableRows[index].status === 'PAID' || + Boolean(selectedLineItems?.find((item) => item?.uuid === row?.id)) + } + /> + )} + {row.cells.map((cell) => ( + {cell.value} + ))} + + ); + })} + +
+
+ )} +
+ {filteredLineItems?.length === 0 && ( +
+ + +

+ {t('noMatchingItemsToDisplay', 'No matching items to display')} +

+

{t('checkFilters', 'Check the filters above')}

+
+
+
+ )} +
+ ); +}; + +export default InvoiceTable; diff --git a/packages/esm-billing-app/src/invoice/invoice-table.scss b/packages/esm-billing-app/src/invoice/invoice-table.scss new file mode 100644 index 00000000..1f43be14 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/invoice-table.scss @@ -0,0 +1,91 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.filterEmptyState { + align-items: center; + background-color: white; + display: flex; + justify-content: center; + padding: layout.$spacing-09 !important; + text-align: center; +} + +.filterEmptyStateTile { + margin: auto; +} + +.filterEmptyStateContent { + @include type.type-style('heading-compact-02'); + color: $text-02; + margin-bottom: 0.5rem; +} + +.filterEmptyStateHelper { + @include type.type-style('body-compact-01'); + color: $text-02; +} + +.headerContainer { + background-color: colors.$gray-10; +} + +.invoiceContainer { + margin: layout.$spacing-09 layout.$spacing-05 0; + border: 1px solid $ui-03; +} + +.searchbox { + input:focus { + outline: 2px solid colors.$orange-40 !important; + } +} + +.table { + td { + border-bottom: none !important; + } +} + +.tableDescription { + display: flex; + align-items: flex-start; + margin-top: layout.$spacing-02; + column-gap: layout.$spacing-01; + + ::after { + content: ''; + display: block; + width: 2rem; + padding-top: 0.188rem; + border-bottom: 0.375rem solid var(--brand-03); + } + + & > span { + @include type.type-style('body-01'); + } +} + +.tableToolbar { + width: 20%; + min-width: 12.5rem; +} + +.toolbarWrapper { + position: relative; + display: flex; + justify-content: flex-end; +} + +:global(.omrs-breakpoint-lt-desktop) { + .toolbarWrapper { + height: layout.$spacing-09; + } +} + +:global(.omrs-breakpoint-gt-tablet) { + .toolbarWrapper { + height: layout.$spacing-07; + } +} diff --git a/packages/esm-billing-app/src/invoice/invoice.component.tsx b/packages/esm-billing-app/src/invoice/invoice.component.tsx new file mode 100644 index 00000000..c685c532 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/invoice.component.tsx @@ -0,0 +1,157 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Button, InlineLoading } from '@carbon/react'; +import { Printer } from '@carbon/react/icons'; +import { useParams } from 'react-router-dom'; +import { useReactToPrint } from 'react-to-print'; +import { useTranslation } from 'react-i18next'; +import { ExtensionSlot, usePatient, showModal } from '@openmrs/esm-framework'; +import { ErrorState } from '@openmrs/esm-patient-common-lib'; +import { convertToCurrency } from '../helpers'; +import { type LineItem } from '../types'; +import { useBill } from '../billing.resource'; +import InvoiceTable from './invoice-table.component'; +import Payments from './payments/payments.component'; +import PrintReceipt from './printable-invoice/print-receipt.component'; +import PrintableInvoice from './printable-invoice/printable-invoice.component'; +import styles from './invoice.scss'; +import MakeClaims from './claims/make-claims.component'; + +interface InvoiceDetailsProps { + label: string; + value: string | number; +} + +const Invoice: React.FC = () => { + const { t } = useTranslation(); + const { billUuid, patientUuid } = useParams(); + const { patient, isLoading: isLoadingPatient } = usePatient(patientUuid); + const { bill, isLoading: isLoadingBill, error } = useBill(billUuid); + const [isPrinting, setIsPrinting] = useState(false); + const [selectedLineItems, setSelectedLineItems] = useState([]); + const componentRef = useRef(null); + const onBeforeGetContentResolve = useRef<(() => void) | null>(null); + const handleSelectItem = (lineItems: Array) => { + const paidLineItems = bill?.lineItems?.filter((item) => item.paymentStatus === 'PAID') ?? []; + setSelectedLineItems([...lineItems, ...paidLineItems]); + }; + + const handleAfterPrint = useCallback(() => { + onBeforeGetContentResolve.current = null; + setIsPrinting(false); + }, []); + + const reactToPrintContent = useCallback(() => componentRef.current, []); + + const handleOnBeforeGetContent = useCallback(() => { + return new Promise((resolve) => { + if (patient && bill) { + setIsPrinting(true); + onBeforeGetContentResolve.current = resolve; + } + }); + }, [bill, patient]); + + const handlePrint = useReactToPrint({ + content: reactToPrintContent, + documentTitle: `Invoice ${bill?.receiptNumber} - ${patient?.name?.[0]?.given?.join(' ')} ${ + patient?.name?.[0].family + }`, + onBeforeGetContent: handleOnBeforeGetContent, + onAfterPrint: handleAfterPrint, + removeAfterPrint: true, + }); + + const handleBillPayment = () => { + const dispose = showModal('initiate-payment-modal', { + closeModal: () => dispose(), + bill: bill, + }); + }; + + useEffect(() => { + const paidLineItems = bill?.lineItems?.filter((item) => item.paymentStatus === 'PAID') ?? []; + setSelectedLineItems(paidLineItems); + }, [bill.lineItems]); + + useEffect(() => { + if (isPrinting && onBeforeGetContentResolve.current) { + onBeforeGetContentResolve.current(); + } + }, [isPrinting]); + + const invoiceDetails = { + 'Total Amount': convertToCurrency(bill?.totalAmount), + 'Amount Tendered': convertToCurrency(bill?.tenderedAmount), + 'Invoice Number': bill.receiptNumber, + 'Date And Time': bill?.dateCreated, + 'Invoice Status': bill?.status, + }; + + if (isLoadingPatient && isLoadingBill) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ {patient && patientUuid && } +
+
+ {Object.entries(invoiceDetails).map(([key, val]) => ( + + ))} +
+
+ + + {/* {bill.status === 'PAID' ? : null} */} + +
+
+ + + + +
+ {isPrinting && } +
+
+ ); +}; + +function InvoiceDetails({ label, value }: InvoiceDetailsProps) { + return ( +
+

{label}

+ {value} +
+ ); +} + +export default Invoice; diff --git a/packages/esm-billing-app/src/invoice/invoice.scss b/packages/esm-billing-app/src/invoice/invoice.scss new file mode 100644 index 00000000..f067f6b6 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/invoice.scss @@ -0,0 +1,97 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.invoiceContainer { + background-color: colors.$gray-10; + height: calc(100vh - 3rem); +} + +.errorContainer { + margin: layout.$spacing-05; +} + +.loader { + display: flex; + min-height: layout.$spacing-09; + justify-content: center; +} + +.detailsContainer { + display: flex; +} + +.details { + display: flex; + flex: 3; + flex-flow: row wrap; + align-items: center; + justify-content: space-between; + margin: layout.$spacing-05; + row-gap: 1.5rem; +} + +.label { + @include type.type-style('body-compact-02'); + color: colors.$gray-70; + margin: layout.$spacing-01; +} + +.value { + @include type.type-style('heading-03'); + display: inline-block; + margin-top: layout.$spacing-04; +} + +.backButton { + margin: layout.$spacing-04; + + button { + display: flex; + padding-left: 0 !important; + + svg { + order: 1; + margin-right: layout.$spacing-03; + margin-left: 0 !important; + } + + span { + order: 2; + } + } +} + +.button { + margin-left: layout.$spacing-04; +} + +.invoicePaymentsContainer { + display: flex; + flex-direction: column; + margin: layout.$spacing-05; +} + +.paymentSection { + display: flex; + flex-direction: row; +} + +.billDetail { + font-weight: bold; + color: colors.$cool-gray-90; +} + +@media screen { + .printContainer { + background-color: colors.$white; + display: none; + } +} + +@media print { + html, + body { + background-color: colors.$white !important; + } +} diff --git a/packages/esm-billing-app/src/invoice/payments/initiate-payment/initiate-payment.component.tsx b/packages/esm-billing-app/src/invoice/payments/initiate-payment/initiate-payment.component.tsx new file mode 100644 index 00000000..fb9bea0b --- /dev/null +++ b/packages/esm-billing-app/src/invoice/payments/initiate-payment/initiate-payment.component.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Form, ModalBody, ModalHeader, TextInput, Layer } from '@carbon/react'; +import styles from './initiate-payment.scss'; +import { Controller, useForm } from 'react-hook-form'; +import { type MappedBill } from '../../../types'; +import { showSnackbar, useConfig } from '@openmrs/esm-framework'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { formatPhoneNumber } from '../utils'; +import { Buffer } from 'buffer'; +import { useSystemSetting } from '../../../hooks/getMflCode'; +import { initiateStkPush } from '../../../m-pesa/mpesa-resource'; + +const InitiatePaymentSchema = z.object({ + phoneNumber: z + .string() + .nonempty({ message: 'Phone number is required' }) + .regex(/^\d{10}$/, { message: 'Phone number must be numeric and 10 digits' }), + billAmount: z.string().nonempty({ message: 'Amount is required' }), +}); + +export interface InitiatePaymentDialogProps { + closeModal: () => void; + bill: MappedBill; +} + +const InitiatePaymentDialog: React.FC = ({ closeModal, bill }) => { + const { t } = useTranslation(); + const { mpesaCallbackUrl, passKey, shortCode, authorizationUrl, initiateUrl } = useConfig(); + const { mflCodeValue } = useSystemSetting('facility.mflcode'); + const { + control, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + mode: 'all', + defaultValues: { + billAmount: String(bill.totalAmount), + }, + resolver: zodResolver(InitiatePaymentSchema), + }); + + const onSubmit = async (data) => { + try { + const timeStamp = new Date() + .toISOString() + .replace(/[^0-9]/g, '') + .slice(0, -3); + const phoneNumber = formatPhoneNumber(data.phoneNumber); + const amountBilled = data.billAmount; + const password = shortCode + passKey + timeStamp; + const callBackUrl = mpesaCallbackUrl; + const Password = Buffer.from(password).toString('base64'); + const accountReference = `${mflCodeValue}#${bill.receiptNumber}`; + + const payload = { + BusinessShortCode: shortCode, + Password: Password, + Timestamp: timeStamp, + TransactionType: 'CustomerPayBillOnline', + PartyA: phoneNumber, + PartyB: shortCode, + PhoneNumber: phoneNumber, + CallBackURL: callBackUrl, + AccountReference: accountReference, + TransactionDesc: 'KenyaEMRPay', + Amount: amountBilled, + }; + + await initiateStkPush(payload, initiateUrl, authorizationUrl); + showSnackbar({ + title: t('stkPush', 'STK Push'), + subtitle: t('stkPushSucess', 'STK Push send successfully'), + kind: 'success', + timeoutInMs: 3500, + isLowContrast: true, + }); + closeModal(); + } catch (err) { + const errorMessage = + err.response?.data?.errorMessage || err.message || t('stkPushError', 'STK Push request failed'); + showSnackbar({ + title: t('stkPush', 'STK Push'), + subtitle: errorMessage, + kind: 'error', + timeoutInMs: 3500, + isLowContrast: true, + }); + } + }; + + return ( +
+ + +
+

{t('paymentPayment', 'Bill Payment')}

+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ + +
+
+
+
+ ); +}; + +export default InitiatePaymentDialog; diff --git a/packages/esm-billing-app/src/invoice/payments/initiate-payment/initiate-payment.scss b/packages/esm-billing-app/src/invoice/payments/initiate-payment/initiate-payment.scss new file mode 100644 index 00000000..f5b482f0 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/payments/initiate-payment/initiate-payment.scss @@ -0,0 +1,33 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; +@use '@carbon/colors'; +@use '@carbon/layout'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.form { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; +} + +.section { + margin: spacing.$spacing-03; +} + +.sectionTitle { + @include type.type-style('heading-compact-02'); + color: $text-02; + margin-bottom: spacing.$spacing-04; +} + +.buttonLayout { + margin-top: layout.$spacing-05; + margin-bottom: layout.$spacing-01; +} + +.button { + margin-left: layout.$spacing-04; + margin-top: layout.$spacing-05; + margin-bottom: layout.$spacing-01; +} diff --git a/packages/esm-billing-app/src/invoice/payments/invoice-breakdown/invoice-breakdown.component.tsx b/packages/esm-billing-app/src/invoice/payments/invoice-breakdown/invoice-breakdown.component.tsx new file mode 100644 index 00000000..2bd598fb --- /dev/null +++ b/packages/esm-billing-app/src/invoice/payments/invoice-breakdown/invoice-breakdown.component.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import styles from './invoice-breakdown.scss'; + +type InvoiceBreakDownProps = { + label: string; + value: string; + hasBalance?: Boolean; +}; + +export const InvoiceBreakDown: React.FC = ({ label, value, hasBalance }) => { + return ( +
+ {label}: + {value} +
+ ); +}; diff --git a/packages/esm-billing-app/src/invoice/payments/invoice-breakdown/invoice-breakdown.scss b/packages/esm-billing-app/src/invoice/payments/invoice-breakdown/invoice-breakdown.scss new file mode 100644 index 00000000..e69d68d5 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/payments/invoice-breakdown/invoice-breakdown.scss @@ -0,0 +1,29 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.invoiceBreakdown { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: flex-end; + margin: layout.$spacing-02 0; +} + +.label { + @include type.type-style('heading-03'); + color: colors.$gray-100; + text-align: end; +} + +.value { + @extend .label; + font-weight: bold; + margin-left: layout.$spacing-03; + text-align: start; +} + +.extendedLabel { + @extend .label; + font-weight: bold; + color: crimson; +} diff --git a/packages/esm-billing-app/src/invoice/payments/payment-form/payment-form.component.tsx b/packages/esm-billing-app/src/invoice/payments/payment-form/payment-form.component.tsx new file mode 100644 index 00000000..668047be --- /dev/null +++ b/packages/esm-billing-app/src/invoice/payments/payment-form/payment-form.component.tsx @@ -0,0 +1,105 @@ +import React, { useCallback } from 'react'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { TrashCan, Add } from '@carbon/react/icons'; +import { Button, Dropdown, NumberInputSkeleton, TextInput, NumberInput } from '@carbon/react'; +import { ErrorState } from '@openmrs/esm-patient-common-lib'; +import { type PaymentFormValue } from '../payments.component'; +import styles from './payment-form.scss'; +import { usePaymentModes } from '../../../billing.resource'; + +type PaymentFormProps = { disablePayment: boolean; amountDue: number }; + +const DEFAULT_PAYMENT = { method: '', amount: 0, referenceCode: '' }; + +const PaymentForm: React.FC = ({ disablePayment, amountDue }) => { + const { t } = useTranslation(); + const { + control, + formState: { errors }, + } = useFormContext(); + const { paymentModes, isLoading, error } = usePaymentModes(); + const { fields, remove, append } = useFieldArray({ name: 'payment', control: control }); + + const handleAppendPaymentMode = useCallback(() => append(DEFAULT_PAYMENT), [append]); + const handleRemovePaymentMode = useCallback((index) => remove(index), [remove]); + + if (isLoading) { + return ; + } + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ {fields.map((field, index) => ( +
+ ( + field.onChange(selectedItem?.uuid)} + titleText={t('paymentMethod', 'Payment method')} + label={t('selectPaymentMethod', 'Select payment method')} + items={paymentModes} + itemToString={(item) => (item ? item.name : '')} + invalid={!!errors?.payment?.[index]?.method} + invalidText={errors?.payment?.[index]?.method?.message} + /> + )} + /> + ( + field.onChange(Number(e.target.value))} + invalid={!!errors?.payment?.[index]?.amount} + invalidText={errors?.payment?.[index]?.amount?.message} + label={t('amount', 'Amount')} + placeholder={t('enterAmount', 'Enter amount')} + /> + )} + /> + ( + + )} + /> +
+ handleRemovePaymentMode(index)} className={styles.removeButton} size={20} /> +
+
+ ))} + +
+ ); +}; + +export default PaymentForm; diff --git a/packages/esm-billing-app/src/invoice/payments/payment-form/payment-form.scss b/packages/esm-billing-app/src/invoice/payments/payment-form/payment-form.scss new file mode 100644 index 00000000..6edab6da --- /dev/null +++ b/packages/esm-billing-app/src/invoice/payments/payment-form/payment-form.scss @@ -0,0 +1,54 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.container { + margin: 1rem; +} + +.paymentContainer { + margin: layout.$layout-01; + padding: layout.$layout-01; + width: 70%; + border-right: 1px solid colors.$cool-gray-40; +} + +.paymentButtons { + margin: layout.$layout-01 0; +} + +.paymentMethodContainer { + display: grid; + grid-template-columns: repeat(4, minmax(auto, 1fr)); + align-items: flex-start; + column-gap: 1rem; + margin: 0.625rem 0; + width: 100%; +} + +.paymentTotals { + margin-top: layout.$spacing-01; +} + +.processPayments { + display: flex; + justify-content: flex-end; + margin: layout.$spacing-05; + column-gap: layout.$spacing-04; +} + +.errorPaymentContainer { + margin: layout.$spacing-04; + min-height: layout.$spacing-09; +} + +.removeButtonContainer { + display: flex; + align-self: center; + cursor: pointer; + margin-left: layout.$spacing-07; +} + +.removeButton { + color: colors.$red-60; +} diff --git a/packages/esm-billing-app/src/invoice/payments/payment-history/payment-history.component.tsx b/packages/esm-billing-app/src/invoice/payments/payment-history/payment-history.component.tsx new file mode 100644 index 00000000..ebeb51b9 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/payments/payment-history/payment-history.component.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { DataTable, Table, TableHead, TableRow, TableHeader, TableBody, TableCell } from '@carbon/react'; +import { type MappedBill } from '../../../types'; +import { formatDate } from '@openmrs/esm-framework'; +import { convertToCurrency } from '../../../helpers'; + +type PaymentHistoryProps = { + bill: MappedBill; +}; + +const PaymentHistory: React.FC = ({ bill }) => { + const headers = [ + { + key: 'dateCreated', + header: 'Date of payment', + }, + { + key: 'amount', + header: 'Bill amount', + }, + { + key: 'amountTendered', + header: 'Amount tendered', + }, + { + key: 'paymentMethod', + header: 'Payment method', + }, + ]; + const rows = bill?.payments?.map((payment) => ({ + id: `${payment.uuid}`, + dateCreated: formatDate(new Date(payment.dateCreated)), + amountTendered: convertToCurrency(payment.amountTendered), + amount: convertToCurrency(payment.amount), + paymentMethod: payment.instanceType.name, + })); + + if (Object.values(bill?.payments ?? {}).length === 0) { + return; + } + + return ( + + {({ rows, headers, getTableProps, getHeaderProps, getRowProps }) => ( + + + + {headers.map((header) => ( + {header.header} + ))} + + + + {rows.map((row) => ( + + {row.cells.map((cell) => ( + {cell.value} + ))} + + ))} + +
+ )} +
+ ); +}; + +export default PaymentHistory; diff --git a/packages/esm-billing-app/src/invoice/payments/payments.component.tsx b/packages/esm-billing-app/src/invoice/payments/payments.component.tsx new file mode 100644 index 00000000..a1442042 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/payments/payments.component.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { navigate, showSnackbar, useVisit } from '@openmrs/esm-framework'; +import { Button } from '@carbon/react'; +import { CardHeader } from '@openmrs/esm-patient-common-lib'; +import { type LineItem, type MappedBill } from '../../types'; +import { convertToCurrency } from '../../helpers'; +import { createPaymentPayload } from './utils'; +import { processBillPayment } from '../../billing.resource'; +import { InvoiceBreakDown } from './invoice-breakdown/invoice-breakdown.component'; +import PaymentHistory from './payment-history/payment-history.component'; +import PaymentForm from './payment-form/payment-form.component'; +import styles from './payments.scss'; + +type PaymentProps = { + bill: MappedBill; + selectedLineItems: Array; +}; + +export type Payment = { method: string; amount: string | number; referenceCode?: number | string }; + +export type PaymentFormValue = { + payment: Array; +}; + +const Payments: React.FC = ({ bill, selectedLineItems }) => { + const { t } = useTranslation(); + const paymentSchema = z.object({ + method: z.string().refine((value) => !!value, 'Payment method is required'), + amount: z + .number() + .lte(bill.totalAmount - bill.tenderedAmount, { message: 'Amount paid should not be greater than amount due' }), + referenceCode: z.union([z.number(), z.string()]).optional(), + }); + + const paymentFormSchema = z.object({ payment: z.array(paymentSchema) }); + const { currentVisit } = useVisit(bill?.patientUuid); + const methods = useForm({ + mode: 'all', + defaultValues: {}, + resolver: zodResolver(paymentFormSchema), + }); + + const formValues = useWatch({ + name: 'payment', + control: methods.control, + }); + + const hasMoreThanOneLineItem = bill?.lineItems?.length > 1; + + const computedTotal = hasMoreThanOneLineItem ? computeTotalPrice(selectedLineItems) : bill.totalAmount ?? 0; + + const totalAmountTendered = formValues?.reduce((curr: number, prev) => curr + Number(prev.amount) ?? 0, 0) ?? 0; + const amountDue = Number(computedTotal) - (Number(bill.tenderedAmount) + Number(totalAmountTendered)); + + const handleNavigateToBillingDashboard = () => + navigate({ + to: window.getOpenmrsSpaBase() + 'home/billing', + }); + + const handleProcessPayment = () => { + const paymentPayload = createPaymentPayload(bill, bill.patientUuid, formValues, amountDue, selectedLineItems); + processBillPayment(paymentPayload, bill.uuid).then( + () => { + showSnackbar({ + title: t('billPayment', 'Bill payment'), + subtitle: 'Bill payment processing has been successful', + kind: 'success', + timeoutInMs: 3000, + }); + handleNavigateToBillingDashboard(); + }, + (error) => { + showSnackbar({ title: 'Bill payment error', kind: 'error', subtitle: error }); + }, + ); + }; + + const amountDueDisplay = (amount: number) => (amount < 0 ? 'Client balance' : 'Amount Due'); + + return ( + +
+
+ + + +
+ {bill && } + +
+
+
+
+ + + + +
+ + +
+
+
+ + ); +}; + +const computeTotalPrice = (items) => { + if (items && !items.length) { + return 0; + } + + let totalPrice = 0; + + items?.forEach((item) => { + const { price, quantity } = item; + totalPrice += price * quantity; + }); + + return totalPrice; +}; + +export default Payments; diff --git a/packages/esm-billing-app/src/invoice/payments/payments.scss b/packages/esm-billing-app/src/invoice/payments/payments.scss new file mode 100644 index 00000000..a80ae5a7 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/payments/payments.scss @@ -0,0 +1,46 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.wrapper { + display: flex; +} + +.divider { + background: colors.$gray-20; + height: 12rem; + margin: 2rem; + width: 1px; +} + +.paymentContainer { + margin: layout.$layout-01 0; + padding: layout.$layout-01; + width: 70%; +} + +.paymentButtons { + margin: layout.$layout-01 0; +} + +.paymentMethodContainer { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + align-items: flex-end; + column-gap: 1rem; + margin: 0.625rem 0; +} + +.paymentTotals { + margin: layout.$spacing-05 0; + padding: layout.$spacing-07 layout.$spacing-05; +} + +.processPayments { + display: flex; + justify-content: flex-end; + margin: layout.$spacing-05; + padding-top: layout.$spacing-05; + column-gap: layout.$spacing-04; + border-top: 1px solid colors.$cool-gray-40; +} diff --git a/packages/esm-billing-app/src/invoice/payments/utils.ts b/packages/esm-billing-app/src/invoice/payments/utils.ts new file mode 100644 index 00000000..f98fa29d --- /dev/null +++ b/packages/esm-billing-app/src/invoice/payments/utils.ts @@ -0,0 +1,84 @@ +import { type LineItem, type MappedBill } from '../../types'; +import { type Payment } from './payments.component'; + +const hasLineItem = (lineItems: Array, item: LineItem) => { + if (lineItems?.length === 0) { + return false; + } + const foundItem = lineItems.find((lineItem) => lineItem.uuid === item.uuid); + return Boolean(foundItem); +}; + +export const createPaymentPayload = ( + bill: MappedBill, + patientUuid: string, + formValues: Array, + amountDue: number, + selectedLineItems: Array, +) => { + const { cashier } = bill; + const totalAmount = bill?.totalAmount; + const paymentStatus = amountDue <= 0 ? 'PAID' : 'PENDING'; + const previousPayments = bill.payments.map((payment) => ({ + amount: payment.amount, + amountTendered: payment.amountTendered, + attributes: [], + instanceType: payment.instanceType.uuid, + })); + + const newPayments = formValues.map((formValue) => ({ + amount: parseFloat(totalAmount.toFixed(2)), + amountTendered: parseFloat(Number(formValue.amount).toFixed(2)), + attributes: [], + instanceType: formValue.method, + })); + + const updatedPayments = newPayments.concat(previousPayments); + const totalAmountRendered = updatedPayments.reduce((acc, payment) => acc + payment.amountTendered, 0); + + const updatedLineItems = bill.lineItems.map((lineItem) => ({ + ...lineItem, + billableService: processBillItem(lineItem), + item: processBillItem(lineItem), + paymentStatus: + bill?.lineItems.length > 1 + ? hasLineItem(selectedLineItems ?? [], lineItem) && totalAmountRendered >= lineItem.price * lineItem.quantity + ? 'PAID' + : 'PENDING' + : paymentStatus, + })); + + const allItemsBillPaymentStatus = + updatedLineItems.filter((item) => item.paymentStatus === 'PENDING').length === 0 ? 'PAID' : 'PENDING'; + + const processedPayment = { + cashPoint: bill.cashPointUuid, + cashier: cashier.uuid, + lineItems: updatedLineItems, + payments: [...updatedPayments], + patient: patientUuid, + status: selectedLineItems?.length > 0 ? allItemsBillPaymentStatus : paymentStatus, + }; + + return processedPayment; +}; + +const processBillItem = (item) => (item.item || item.billableService)?.split(':')[0]; + +export function formatPhoneNumber(phone) { + let phone_ = phone.toString().replace(/\D/g, ''); // Convert to string first + const length = phone_.length; + + let _phone = ''; + if (length === 12 && phone_.substring(0, 3) === '254') { + _phone = phone_; + } else if (length === 9 && phone_.substring(0, 1) === '7') { + _phone = '254' + phone_; + } else if (length === 10 && phone_.substring(0, 1) === '0') { + _phone = '254' + phone_.substring(1, 10); + } else { + _phone = 'Invalid Phone Number ' + phone; + } + + return _phone; +} diff --git a/packages/esm-billing-app/src/invoice/payments/visit-tags/visit-attribute.component.tsx b/packages/esm-billing-app/src/invoice/payments/visit-tags/visit-attribute.component.tsx new file mode 100644 index 00000000..7678de6c --- /dev/null +++ b/packages/esm-billing-app/src/invoice/payments/visit-tags/visit-attribute.component.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Tag } from '@carbon/react'; +import { usePatientPaymentInfo } from '../../../billing.resource'; + +type VisitAttributeTagsProps = { patientUuid: string }; + +const VisitAttributeTags: React.FC = ({ patientUuid }) => { + const patientBillingInfo = usePatientPaymentInfo(patientUuid); + return ( +
+ {patientBillingInfo?.map((tag) => ( + + {tag.name} + {tag.value} + + ))} +
+ ); +}; + +export default VisitAttributeTags; diff --git a/packages/esm-billing-app/src/invoice/printable-invoice/print-receipt.component.tsx b/packages/esm-billing-app/src/invoice/printable-invoice/print-receipt.component.tsx new file mode 100644 index 00000000..2311d1dd --- /dev/null +++ b/packages/esm-billing-app/src/invoice/printable-invoice/print-receipt.component.tsx @@ -0,0 +1,28 @@ +import { ConfigurableLink } from '@openmrs/esm-framework'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './print-receipt.scss'; +import { Button } from '@carbon/react'; +import { Printer } from '@carbon/react/icons'; + +interface PrintReceiptProps { + billId: number; +} +const PrintReceipt: React.FC = ({ billId }) => { + const { t } = useTranslation(); + return ( + + ); +}; + +export default PrintReceipt; diff --git a/packages/esm-billing-app/src/invoice/printable-invoice/print-receipt.scss b/packages/esm-billing-app/src/invoice/printable-invoice/print-receipt.scss new file mode 100644 index 00000000..f71809c2 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/printable-invoice/print-receipt.scss @@ -0,0 +1,14 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.configurableLink { + color: colors.$white; + margin: 0; + padding: 0; + text-decoration: none; +} + +.button { + margin-left: layout.$spacing-04; +} diff --git a/packages/esm-billing-app/src/invoice/printable-invoice/printable-footer.component.tsx b/packages/esm-billing-app/src/invoice/printable-invoice/printable-footer.component.tsx new file mode 100644 index 00000000..39a9d6ef --- /dev/null +++ b/packages/esm-billing-app/src/invoice/printable-invoice/printable-footer.component.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { useDefaultFacility } from '../../billing.resource'; +import styles from './printable-footer.scss'; + +const PrintableFooter = () => { + const { data, isLoading } = useDefaultFacility(); + + if (isLoading) { + return
--
; + } + return ( +
+

{data?.display}

+
+ ); +}; + +export default PrintableFooter; diff --git a/packages/esm-billing-app/src/invoice/printable-invoice/printable-footer.scss b/packages/esm-billing-app/src/invoice/printable-invoice/printable-footer.scss new file mode 100644 index 00000000..9e744f54 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/printable-invoice/printable-footer.scss @@ -0,0 +1,17 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.container { + display: flex; + flex-direction: column; + position: fixed; + width: 100%; + bottom: 0; +} + +.itemFooter { + padding: 1rem; + @include type.type-style('body-compact-02'); + color: colors.$cool-gray-90; +} diff --git a/packages/esm-billing-app/src/invoice/printable-invoice/printable-footer.test.tsx b/packages/esm-billing-app/src/invoice/printable-invoice/printable-footer.test.tsx new file mode 100644 index 00000000..096206ed --- /dev/null +++ b/packages/esm-billing-app/src/invoice/printable-invoice/printable-footer.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { screen, render } from '@testing-library/react'; +import PrintableFooter from './printable-footer.component'; +import { useDefaultFacility } from '../../billing.resource'; + +const mockUseDefaultFacility = useDefaultFacility as jest.MockedFunction; + +jest.mock('../../billing.resource', () => ({ + useDefaultFacility: jest.fn(), +})); + +describe('PrintableFooter', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should render PrintableFooter component', () => { + mockUseDefaultFacility.mockReturnValue({ data: { display: 'MTRH', uuid: 'mtrh-uuid' }, isLoading: false }); + render(); + const footer = screen.getByText('MTRH'); + expect(footer).toBeInTheDocument(); + }); + + test('should show placeholder text when facility isLoading', () => { + mockUseDefaultFacility.mockReturnValue({ data: { display: 'MTRH', uuid: 'mtrh-uuid' }, isLoading: true }); + render(); + const footer = screen.getByText('--'); + expect(footer).toBeInTheDocument(); + }); +}); diff --git a/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice-header.component.tsx b/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice-header.component.tsx new file mode 100644 index 00000000..5569c47b --- /dev/null +++ b/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice-header.component.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { type PatientDetails } from '../../types'; +import styles from './printable-invoice-header.scss'; +import { useConfig } from '@openmrs/esm-framework'; +import { useTranslation } from 'react-i18next'; +import { useDefaultFacility } from '../../billing.resource'; + +interface PrintableInvoiceHeaderProps { + patientDetails: PatientDetails; +} + +const PrintableInvoiceHeader: React.FC = ({ patientDetails }) => { + const { t } = useTranslation(); + const { logo } = useConfig(); + const { data } = useDefaultFacility(); + + return ( +
+
+

{t('invoice', 'Invoice')}

+ {logo?.src ? ( + {logo.alt} + ) : logo?.name ? ( + logo.name + ) : ( + // OpenMRS Logo + + + + )} +
+ +
+
+

{t('billedTo', 'Billed to')}

+

{patientDetails?.name}

+

{patientDetails?.county}

+

+ {patientDetails?.subCounty} + {patientDetails?.city ? `, ${patientDetails?.city}` : null} +

+
+ +
+

{data?.display}

+

Kenya

+
+
+
+ ); +}; + +export default PrintableInvoiceHeader; diff --git a/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice-header.scss b/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice-header.scss new file mode 100644 index 00000000..c72d4e07 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice-header.scss @@ -0,0 +1,61 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.container { + padding: 0 1rem 2rem; + border-bottom: 1px solid #ebedf2; + margin-bottom: 2rem; +} + +.printableBody { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.printableHeader { + display: flex; + flex-direction: row; +} + +.img { + display: flex; + margin-left: auto; +} + +.billDetails { + display: flex; + width: 50%; + flex-direction: column; +} + +.facilityDetails { + display: flex; + flex-flow: column wrap; + text-align: right; +} + +.heading { + font-size: 40px; + text-transform: uppercase; + margin-bottom: layout.$spacing-05; +} + +.facilityName { + @include type.type-style('heading-compact-02'); + font-weight: bold; + color: colors.$green-70; +} + +.itemHeading { + @include type.type-style('body-compact-02'); + margin-bottom: 0.25rem; + font-weight: bold; + color: colors.$cool-gray-90; +} + +.itemLabel { + @include type.type-style('body-compact-02'); + color: colors.$cool-gray-90; +} diff --git a/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice-header.test.tsx b/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice-header.test.tsx new file mode 100644 index 00000000..49f5aa4f --- /dev/null +++ b/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice-header.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { screen, render } from '@testing-library/react'; +import PrintableInvoiceHeader from './printable-invoice-header.component'; +import { useDefaultFacility } from '../../billing.resource'; +import { useConfig } from '@openmrs/esm-framework'; + +const mockUseDefaultFacility = useDefaultFacility as jest.MockedFunction; +const mockUseConfig = useConfig as jest.MockedFunction; + +jest.mock('../../billing.resource', () => ({ + useDefaultFacility: jest.fn(), +})); + +jest.mock('@openmrs/esm-framework', () => ({ + useConfig: jest.fn(), +})); +const testProps = { + patientDetails: { + name: 'John Doe', + county: 'Nairobi', + subCounty: 'Westlands', + city: 'Nairobi', + age: '45', + gender: 'Male', + }, +}; + +describe('PrintableInvoiceHeader', () => { + test('should render PrintableInvoiceHeader component', () => { + mockUseConfig.mockReturnValue({ logo: { src: 'logo.png', alt: 'logo' } }); + mockUseDefaultFacility.mockReturnValue({ data: { display: 'MTRH', uuid: 'mtrh-uuid' }, isLoading: false }); + render(); + const header = screen.getByText('Invoice'); + expect(header).toBeInTheDocument(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Nairobi')).toBeInTheDocument(); + expect(screen.getByText('Westlands, Nairobi')).toBeInTheDocument(); + expect(screen.getByText('MTRH')).toBeInTheDocument(); + expect(screen.getByText('Kenya')).toBeInTheDocument(); + }); + + test('should display the logo when logo is provided', () => { + mockUseConfig.mockReturnValue({ logo: { src: 'logo.png', alt: 'logo' } }); + mockUseDefaultFacility.mockReturnValue({ data: { display: 'MTRH', uuid: 'mtrh-uuid' }, isLoading: false }); + render(); + const logo = screen.getByAltText('logo'); + expect(logo).toBeInTheDocument(); + }); + + test('should display the default logo when logo is not provided', () => { + mockUseConfig.mockReturnValue({ logo: {} }); + mockUseDefaultFacility.mockReturnValue({ data: { display: 'MTRH', uuid: 'mtrh-uuid' }, isLoading: false }); + render(); + const logo = screen.getByRole('img'); + expect(logo).toBeInTheDocument(); + }); +}); diff --git a/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice.component.tsx b/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice.component.tsx new file mode 100644 index 00000000..64db0bb6 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice.component.tsx @@ -0,0 +1,146 @@ +import React, { useMemo } from 'react'; +import { + DataTable, + Table, + TableContainer, + TableHead, + TableRow, + TableBody, + TableHeader, + TableCell, + DataTableSkeleton, +} from '@carbon/react'; +import { age, isDesktop, useLayoutType } from '@openmrs/esm-framework'; +import { getGender } from '../../helpers'; +import { type MappedBill } from '../../types'; +import { useTranslation } from 'react-i18next'; +import PrintableFooter from './printable-footer.component'; +import PrintableInvoiceHeader from './printable-invoice-header.component'; +import styles from './printable-invoice.scss'; + +type PrintableInvoiceProps = { + bill: MappedBill; + patient: fhir.Patient; + isLoading: boolean; +}; + +const PrintableInvoice: React.FC = ({ bill, patient, isLoading }) => { + const { t } = useTranslation(); + const layout = useLayoutType(); + const responsiveSize = isDesktop(layout) ? 'sm' : 'lg'; + const headerData = [ + { header: 'Inventory item', key: 'billItem' }, + { header: 'Quantity', key: 'quantity' }, + { header: 'Unit price', key: 'price' }, + { header: 'Total', key: 'total' }, + ]; + + const rowData = + bill?.lineItems?.map((item) => { + return { + id: `${item.uuid}`, + billItem: item.item, + quantity: item.quantity, + price: item.price, + total: item.price * item.quantity, + }; + }) ?? []; + + const invoiceTotal = { + 'Total Amount': bill?.totalAmount, + 'Amount Tendered': bill?.tenderedAmount, + 'Discount Amount': 0, + 'Amount due': bill?.totalAmount - bill?.tenderedAmount, + }; + + const patientDetails = useMemo(() => { + return { + name: `${patient?.name?.[0]?.given?.join(' ')} ${patient?.name?.[0].family}`, + age: age(patient?.birthDate), + gender: getGender(patient?.gender, t), + city: patient?.address?.[0].city, + county: patient?.address?.[0].district, + subCounty: patient?.address?.[0].state, + }; + }, [patient, t]); + + const invoiceDetails = { + 'Invoice #': bill.receiptNumber, + 'Invoice date': bill.dateCreated, + Status: bill.status, + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+
+ {Object.entries(invoiceDetails).map(([key, val]) => ( +
+

{key}

+ {val} +
+ ))} +
+ +
+
+ + {({ rows, headers, getRowProps, getTableProps }) => ( + + + + + {headers.map((header) => ( + {header.header} + ))} + + + + {rows.map((row) => ( + + {row.cells.map((cell) => ( + {cell.value} + ))} + + ))} + +
+
+ )} +
+
+ +
+ {Object.entries(invoiceTotal).map(([key, val]) => ( +

+ {key}: {val} +

+ ))} +
+
+
+ +
+ ); +}; + +export default PrintableInvoice; diff --git a/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice.scss b/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice.scss new file mode 100644 index 00000000..596aeb95 --- /dev/null +++ b/packages/esm-billing-app/src/invoice/printable-invoice/printable-invoice.scss @@ -0,0 +1,50 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.printableInvoiceContainer { + display: flex; + flex-direction: row; +} + +.itemsContainer { + display: flex; + flex-direction: column; + width: 80%; + padding: layout.$spacing-05; + margin: layout.$spacing-05; +} + +.detailsContainer { + margin: layout.$spacing-05 0; + width: 20%; +} + +.tableContainer { + min-height: 50vh; +} + +.totalContainer { + display: flex; + flex-direction: column; +} + +.item { + margin: layout.$spacing-04; +} + +.itemHeading { + @include type.type-style('body-compact-02'); + font-weight: bold; + color: colors.$cool-gray-90; + margin-bottom: layout.$spacing-02; +} + +.itemTotal { + border-top: solid 0.1pt colors.$cool-gray-30; + padding: layout.$spacing-02; +} + +.itemLabel { + float: right; +} diff --git a/packages/esm-billing-app/src/left-panel-link.component.tsx b/packages/esm-billing-app/src/left-panel-link.component.tsx new file mode 100644 index 00000000..608d0e60 --- /dev/null +++ b/packages/esm-billing-app/src/left-panel-link.component.tsx @@ -0,0 +1,40 @@ +import React, { useMemo } from 'react'; +import last from 'lodash-es/last'; +import { BrowserRouter, useLocation } from 'react-router-dom'; +import { ConfigurableLink } from '@openmrs/esm-framework'; + +export interface LinkConfig { + name: string; + title: string; +} + +export function LinkExtension({ config }: { config: LinkConfig }) { + const { name, title } = config; + const location = useLocation(); + const spaBasePath = window.getOpenmrsSpaBase() + 'home'; + + let urlSegment = useMemo(() => decodeURIComponent(last(location.pathname.split('/'))), [location.pathname]); + + const isUUID = (value) => { + const regex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/; + return regex.test(value); + }; + + if (isUUID(urlSegment)) { + urlSegment = 'billing'; + } + + return ( + + {title} + + ); +} + +export const createLeftPanelLink = (config: LinkConfig) => () => ( + + + +); diff --git a/packages/esm-billing-app/src/m-pesa/mpesa-resource.tsx b/packages/esm-billing-app/src/m-pesa/mpesa-resource.tsx new file mode 100644 index 00000000..70e1ee38 --- /dev/null +++ b/packages/esm-billing-app/src/m-pesa/mpesa-resource.tsx @@ -0,0 +1,74 @@ +import useSWR from 'swr'; +import { openmrsFetch, useConfig } from '@openmrs/esm-framework'; +import { Buffer } from 'buffer'; +import { type BillingConfig } from '../config-schema'; + +type PaymentMethod = { + uuid: string; + description: string; + name: string; + retired: boolean; +}; + +const swrOption = { + errorRetryCount: 2, +}; + +export const usePaymentModes = (excludeWaiver: boolean = true) => { + const { excludedPaymentMode } = useConfig(); + const url = `/ws/rest/v1/cashier/paymentMode`; + const { data, isLoading, error, mutate } = useSWR<{ data: { results: Array } }>( + url, + openmrsFetch, + swrOption, + ); + const allowedPaymentModes = + excludedPaymentMode?.length > 0 + ? data?.data?.results.filter((mode) => !excludedPaymentMode.some((excluded) => excluded.uuid === mode.uuid)) ?? [] + : data?.data?.results ?? []; + return { + paymentModes: excludeWaiver ? allowedPaymentModes : data?.data?.results, + isLoading, + mutate, + error, + }; +}; + +export const generateStkAccessToken = async (authorizationUrl: string) => { + // eslint-disable-next-line no-useless-catch + try { + const consumerKey = ''; + const consumerSecret = ''; + const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64'); + const headers = { + 'Content-Type': 'application/json', + Authorization: `Basic ${auth}`, + }; + const response = await fetch(authorizationUrl, { method: 'GET', headers: headers }); + const { access_token } = await response.json(); + return access_token; + } catch (error) { + //throw error; + console.error('An error occurred:', error); + } +}; + +export const initiateStkPush = async (payload, initiateUrl: string, authorizationUrl: string) => { + // eslint-disable-next-line no-useless-catch + try { + const access_token = await generateStkAccessToken(authorizationUrl); + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${access_token}`, + }; + const response = await fetch(initiateUrl, { + method: 'POST', + headers: headers, + body: JSON.stringify(payload), + }); + return await response.json(); + } catch (err) { + console.error('An error occurred :', err); + //throw err; + } +}; diff --git a/packages/esm-billing-app/src/metrics-cards/card.component.tsx b/packages/esm-billing-app/src/metrics-cards/card.component.tsx new file mode 100644 index 00000000..02d882b9 --- /dev/null +++ b/packages/esm-billing-app/src/metrics-cards/card.component.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import styles from './card.scss'; + +export default function Card({ count, title }) { + return ( +
+

{title}

+ {count} +
+ ); +} diff --git a/packages/esm-billing-app/src/metrics-cards/card.scss b/packages/esm-billing-app/src/metrics-cards/card.scss new file mode 100644 index 00000000..3de4880a --- /dev/null +++ b/packages/esm-billing-app/src/metrics-cards/card.scss @@ -0,0 +1,20 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.container { + border: 1px solid colors.$gray-20; + padding: layout.$spacing-05; + flex: 1; +} + +.title { + @include type.type-style('heading-compact-02'); +} + +.count { + @include type.type-style('heading-05'); + display: inline-block; + font-weight: 300; + margin-top: layout.$spacing-05; +} diff --git a/packages/esm-billing-app/src/metrics-cards/metrics-cards.component.tsx b/packages/esm-billing-app/src/metrics-cards/metrics-cards.component.tsx new file mode 100644 index 00000000..d38b855d --- /dev/null +++ b/packages/esm-billing-app/src/metrics-cards/metrics-cards.component.tsx @@ -0,0 +1,42 @@ +import React, { useMemo } from 'react'; +import Card from './card.component'; +import styles from './metrics-cards.scss'; +import { useBills } from '../billing.resource'; +import { useBillMetrics } from './metrics.resource'; +import { useTranslation } from 'react-i18next'; +import { ErrorState } from '@openmrs/esm-patient-common-lib'; +import { InlineLoading } from '@carbon/react'; + +export default function MetricsCards() { + const { t } = useTranslation(); + const { bills, isLoading, error } = useBills(''); + const { cumulativeBills, pendingBills, paidBills } = useBillMetrics(bills); + + const cards = useMemo( + () => [ + { title: 'Cumulative Bills', count: cumulativeBills }, + { title: 'Pending Bills', count: pendingBills }, + { title: 'Paid Bills', count: paidBills }, + ], + [cumulativeBills, pendingBills, paidBills], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ; + } + return ( +
+ {cards.map((card) => ( + + ))} +
+ ); +} diff --git a/packages/esm-billing-app/src/metrics-cards/metrics-cards.scss b/packages/esm-billing-app/src/metrics-cards/metrics-cards.scss new file mode 100644 index 00000000..99546e07 --- /dev/null +++ b/packages/esm-billing-app/src/metrics-cards/metrics-cards.scss @@ -0,0 +1,12 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.container { + display: flex; + margin: layout.$spacing-05; + justify-content: space-between; + column-gap: layout.$spacing-09; + row-gap: layout.$spacing-05; + flex-flow: row wrap; +} diff --git a/packages/esm-billing-app/src/metrics-cards/metrics.resource.ts b/packages/esm-billing-app/src/metrics-cards/metrics.resource.ts new file mode 100644 index 00000000..7658308d --- /dev/null +++ b/packages/esm-billing-app/src/metrics-cards/metrics.resource.ts @@ -0,0 +1,45 @@ +import { calculateTotalAmount, convertToCurrency } from '../helpers'; +import { type MappedBill } from '../types'; + +/** + * A custom hook for calculating bill metrics. + * + * This hook takes in an array of bills and calculates the total amount for different + * bill statuses (cumulative, pending, paid) using provided helper functions. + * + * @param {Array} bills - An array of bill objects. Each bill object should have a `status` and `lineItems` properties. + * + * @returns {{ + * cumulativeBills: string, + * pendingBills: string, + * paidBills: string + * }} + */ + +export const useBillMetrics = (bills: Array) => { + const { paidTotal, pendingTotal, cumulativeTotal } = calculateBillTotals(bills); + return { + cumulativeBills: convertToCurrency(cumulativeTotal), + pendingBills: convertToCurrency(pendingTotal), + paidBills: convertToCurrency(paidTotal), + }; +}; + +const calculateBillTotals = (bills: Array) => { + let paidTotal = 0; + let pendingTotal = 0; + let cumulativeTotal = 0; + + bills.forEach((bill) => { + if (bill.status === 'PAID') { + paidTotal += bill.totalAmount; + } else if (bill.status === 'PENDING') { + pendingTotal += bill.totalAmount; + } + cumulativeTotal += bill.totalAmount; // Add to cumulative total regardless of status + }); + + return { paidTotal, pendingTotal, cumulativeTotal }; +}; + +export default calculateBillTotals; diff --git a/packages/esm-billing-app/src/modal/require-payment-modal.component.tsx b/packages/esm-billing-app/src/modal/require-payment-modal.component.tsx new file mode 100644 index 00000000..7e9ecd1e --- /dev/null +++ b/packages/esm-billing-app/src/modal/require-payment-modal.component.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ModalBody, + ModalFooter, + Button, + InlineLoading, + StructuredListWrapper, + StructuredListHead, + StructuredListRow, + StructuredListCell, + StructuredListBody, + ComposedModal, + Heading, +} from '@carbon/react'; +import styles from './require-payment.scss'; +import { convertToCurrency, extractString } from '../helpers'; +import { navigate, useConfig } from '@openmrs/esm-framework'; +import { type BillingConfig } from '../config-schema'; +import { getPatientUuidFromUrl } from '@openmrs/esm-patient-common-lib'; +import { useBillingPrompt } from '../billing-prompt/billing-prompt.resource'; + +type RequirePaymentModalProps = {}; + +const RequirePaymentModal: React.FC = () => { + const { t } = useTranslation(); + const patientUuid = getPatientUuidFromUrl(); + const { shouldShowBillingPrompt, isLoading, bills } = useBillingPrompt(patientUuid); + const [showModal, setShowModal] = useState({ loadingModal: true, billingModal: true }); + const { enforceBillPayment } = useConfig(); + + const closeButtonText = enforceBillPayment + ? t('navigateBack', 'Navigate back') + : t('proceedToCare', 'Proceed to care'); + + const handleCloseModal = () => { + enforceBillPayment + ? navigate({ to: `\${openmrsSpaBase}/home` }) + : setShowModal((prevState) => ({ ...prevState, billingModal: false })); + }; + + const lineItems = bills + .filter((bill) => bill.status !== 'PAID') + .flatMap((bill) => bill.lineItems) + .filter((lineItem) => lineItem.paymentStatus !== 'EXEMPTED'); + + if (!shouldShowBillingPrompt) { + return null; + } + + return ( + + {isLoading ? ( + + {t('billingStatus', 'Billing status')} + + + ) : ( + + {t('patientBillingAlert', 'Patient Billing Alert')} +

+ {t('billPaymentRequiredMessage', 'The current patient has pending bill. Advice patient to settle bill.')} +

+ + + + {t('item', 'Item')} + {t('quantity', 'Quantity')} + {t('unitPrice', 'Unit price')} + {t('total', 'Total')} + + + + {lineItems.map((lineItem) => { + return ( + + {extractString(lineItem.billableService || lineItem.item)} + {lineItem.quantity} + {convertToCurrency(lineItem.price)} + {convertToCurrency(lineItem.quantity * lineItem.price)} + + ); + })} + + + {!enforceBillPayment && ( +

+ {t( + 'providerMessage', + 'By clicking Proceed to care, you acknowledge that you have advised the patient to settle the bill.', + )} +

+ )} +
+ )} + + + + +
+ ); +}; + +export default RequirePaymentModal; diff --git a/packages/esm-billing-app/src/modal/require-payment.scss b/packages/esm-billing-app/src/modal/require-payment.scss new file mode 100644 index 00000000..10aa8f97 --- /dev/null +++ b/packages/esm-billing-app/src/modal/require-payment.scss @@ -0,0 +1,18 @@ +@use '@carbon/type'; +@use '@carbon/colors'; + +.bodyShort02 { + margin-bottom: 0.5rem; + @include type.type-style('body-compact-02'); +} + +.providerMessage { + @include type.type-style('heading-01'); + color: colors.$red-50; + margin-top: 1.5rem; +} + +.modalTitle { + @include type.type-style('productive-heading-03'); + padding: 0.5rem 0; +} diff --git a/packages/esm-billing-app/src/root.component.tsx b/packages/esm-billing-app/src/root.component.tsx new file mode 100644 index 00000000..d8c9eff0 --- /dev/null +++ b/packages/esm-billing-app/src/root.component.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { BillingDashboard } from './billing-dashboard/billing-dashboard.component'; +import Invoice from './invoice/invoice.component'; +import ClaimScreen from './invoice/claims/claims-dashboard/claims-dashboard.component'; + +const RootComponent: React.FC = () => { + const baseName = window.getOpenmrsSpaBase() + 'home/billing'; + + return ( + + + } /> + } /> + } /> + + + ); +}; + +export default RootComponent; diff --git a/packages/esm-billing-app/src/root.scss b/packages/esm-billing-app/src/root.scss new file mode 100644 index 00000000..0a39cd42 --- /dev/null +++ b/packages/esm-billing-app/src/root.scss @@ -0,0 +1,30 @@ +@use '@carbon/styles/scss/type'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.productiveHeading01 { + @include type.type-style('heading-compact-01'); +} + +.productiveHeading02 { + @include type.type-style('heading-compact-02'); +} + +.bodyLong01 { + @include type.type-style('body-01'); +} + +.caption01 { + @include type.type-style('legal-01'); +} + +.bodyShort02 { + @include type.type-style('body-compact-02'); +} + +.text02 { + color: $text-02; +} + +.text01 { + color: $ui-05; +} diff --git a/packages/esm-billing-app/src/routes.json b/packages/esm-billing-app/src/routes.json new file mode 100644 index 00000000..e6d62c71 --- /dev/null +++ b/packages/esm-billing-app/src/routes.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://json.openmrs.org/routes.schema.json", + "backendDependencies": { + "amrscore": "^1.0.0" + }, + "pages": [ + { + "component": "billableServicesHome", + "route":"billable-services" + }, + { + "component": "requirePaymentModal", + "routeRegex": "^patient\/.+\/chart", + "online": true, + "offline": false + } + ], + "extensions": [ + { + "component": "billingDashboardLink", + "name": "billing-dashboard-link", + "slot": "homepage-dashboard-slot", + "meta": { + "name": "billing", + "title": "billing", + "slot": "billing-dashboard-slot" + } + }, + { + "component": "root", + "name": "billing-dashboard-root", + "slot": "billing-dashboard-slot" + }, + { + "name": "billing-patient-summary", + "component": "billingPatientSummary", + "slot": "patient-chart-billing-dashboard-slot", + "order": 10, + "meta": { + "columnSpan": 4 + } + }, + { + "name": "billing-summary-dashboard-link", + "component": "billingSummaryDashboardLink", + "slot": "patient-chart-dashboard-slot", + "order": 11, + "meta": { + "columns": 1, + "columnSpan": 1, + "slot": "patient-chart-billing-dashboard-slot", + "path": "Billing" + } + }, + { + "name": "billing-check-in-form", + "slot": "extra-visit-attribute-slot", + "component": "billingCheckInForm" + }, + { + "slot": "system-admin-page-card-link-slot", + "component": "billableServicesCardLink", + "name": "billable-services-admin-card-link" + }, + { + "name": "billing-form", + "component": "billingForm" + }, + { + "name": "require-billing-modal", + "component": "requirePaymentModal" + }, + { + "name": "patient-banner-billing-tags", + "component": "visitAttributeTags", + "slot": "patient-banner-tags-slot", + "order": 2 + }, + { + "name": "initiate-payment-modal", + "component": "initiatePaymentDialog" + }, + { + "name": "lab-order-billable-item", + "component": "labOrder", + "slot": "top-of-lab-order-form-slot" + }, + { + "name": "drug-order-billable-item", + "component": "drugOrder", + "slot": "medication-info-slot" + } + ] +} diff --git a/packages/esm-billing-app/src/types/index.ts b/packages/esm-billing-app/src/types/index.ts new file mode 100644 index 00000000..14248555 --- /dev/null +++ b/packages/esm-billing-app/src/types/index.ts @@ -0,0 +1,259 @@ +import { type Drug, type OrderBasketItem } from '@openmrs/esm-patient-common-lib'; +export interface MappedBill { + uuid: string; + id: number; + patientUuid: string; + patientName: string; + cashPointUuid: string; + cashPointName: string; + cashPointLocation: string; + cashier: Provider; + receiptNumber: string; + status: string; + identifier: string; + dateCreated: string; + lineItems: Array; + billingService: string; + payments: Array; + totalAmount?: number; + tenderedAmount?: number; + display?: string; +} + +interface LocationLink { + rel: string; + uri: string; + resourceAlias: string; +} + +interface Location { + uuid: string; + display: string; + links: LocationLink[]; +} + +interface CashPoint { + uuid: string; + name: string; + description: string; + retired: boolean; + location: Location; +} + +interface ProviderLink { + rel: string; + uri: string; + resourceAlias: string; +} + +interface Provider { + uuid: string; + display: string; + links: ProviderLink[]; +} + +export interface LineItem { + uuid: string; + display: string; + voided: boolean; + voidReason: string | null; + item: string; + billableService: string; + quantity: number; + price: number; + priceName: string; + priceUuid: string; + lineItemOrder: number; + resourceVersion: string; + paymentStatus: string; +} + +interface PatientLink { + rel: string; + uri: string; + resourceAlias: string; +} + +interface Patient { + uuid: string; + display: string; + links: PatientLink[]; +} + +interface AttributeType { + uuid: string; + name: string; + description: string; + retired: boolean; + attributeOrder: number; + format: string; + foreignKey: string | null; + regExp: string | null; + required: boolean; +} + +interface Attribute { + uuid: string; + display: string; + voided: boolean; + voidReason: string | null; + value: string; + attributeType: AttributeType; + order: number; + valueName: string; + resourceVersion: string; +} + +interface PaymentInstanceType { + uuid: string; + name: string; + description: string; + retired: boolean; +} + +export interface Payment { + uuid: string; + instanceType: PaymentInstanceType; + attributes: Attribute[]; + amount: number; + amountTendered: number; + dateCreated: number; + voided: boolean; + resourceVersion: string; +} + +export interface PatientInvoice { + uuid: string; + display: string; + voided: boolean; + voidReason: string | null; + adjustedBy: any[]; + billAdjusted: any; + cashPoint: CashPoint; + cashier: Provider; + dateCreated: string; + lineItems: LineItem[]; + patient: Patient; + payments: Payment[]; + receiptNumber: string; + status: string; + adjustmentReason: any; + id: number; + resourceVersion: string; +} + +export interface PatientDetails { + name: string; + age: string; + gender: string; + city: string; + county: string; + subCounty: string; +} + +export interface FacilityDetail { + uuid: string; + display: string; +} + +export type ServiceConcept = { + concept: { + uuid: string; + display: string; + }; + conceptName: { + uuid: string; + display: string; + }; + display: string; +}; + +export type BillingService = { + name: string; + servicePrices: Array<{ name: string; paymentMode: { uuid: string; name: string }; price: number; uuid: string }>; + serviceStatus: string; + serviceType: { display: string }; + shortName: string; + uuid: string; +}; + +export interface DrugOrderBasketItem extends OrderBasketItem { + drug: Drug; + unit: DosingUnit; + commonMedicationName: string; + dosage: number; + frequency: MedicationFrequency; + route: MedicationRoute; + quantityUnits: QuantityUnit; + patientInstructions: string; + asNeeded: boolean; + asNeededCondition: string; + // TODO: This is unused + startDate: Date | string; + durationUnit: DurationUnit; + duration: number | null; + pillsDispensed: number; + numRefills: number; + indication: string; + isFreeTextDosage: boolean; + freeTextDosage: string; + previousOrder?: string; + template?: OrderTemplate; +} + +export interface DrugOrderTemplate { + uuid: string; + name: string; + drug: Drug; + template: OrderTemplate; +} + +export interface OrderTemplate { + type: string; + dosingType: string; + dosingInstructions: DosingInstructions; +} + +export interface DosingInstructions { + dose: Array; + units: Array; + route: Array; + frequency: Array; + instructions?: Array; + durationUnits?: Array; + quantityUnits?: Array; + asNeeded?: boolean; + asNeededCondition?: string; +} + +export interface MedicationDosage extends Omit { + value: number; +} + +export type MedicationFrequency = CommonMedicationValueCoded; + +export type MedicationRoute = CommonMedicationValueCoded; + +export type MedicationInstructions = CommonMedicationProps; + +export type DosingUnit = CommonMedicationValueCoded; + +export type QuantityUnit = CommonMedicationValueCoded; + +export type DurationUnit = CommonMedicationValueCoded; + +interface CommonMedicationProps { + value: string; + default?: boolean; +} + +export interface CommonMedicationValueCoded extends CommonMedicationProps { + valueCoded: string; +} + +export type PaymentMethod = { + uuid: string; + description: string; + name: string; + retired: boolean; +}; diff --git a/packages/esm-billing-app/translations/en.json b/packages/esm-billing-app/translations/en.json new file mode 100644 index 00000000..1c0fe84a --- /dev/null +++ b/packages/esm-billing-app/translations/en.json @@ -0,0 +1,144 @@ +{ + "actions": "Actions", + "addBill": "Add bill item(s)", + "addBillableServices": "Add Billable Services", + "addNewBillableService": "Add new billable service", + "addNewService": "Add new service", + "addPaymentOptions": "Add payment option", + "amount": "Amount", + "amountClaimed": "Amount Claimed", + "amountToWaiveAriaLabel": "Enter amount to waive", + "amountToWaiveHelper": "Specify the amount to be deducted from the bill", + "amountToWaiveLabel": "Amount to Waive", + "billableService": "Billable service", + "billableServices": "Billable Services", + "billAmount": "Bill Amount", + "billedItems": "Billed Items", + "billedTo": "Billed to", + "billErrorService": "Bill service error", + "billing": "Billing", + "billingStatus": "Billing status", + "billItem": "Bill item", + "billItems": "Save Bill", + "billList": "Bill list", + "billMetrics": "Bill metrics", + "billName": " {{billName}} ", + "billPayment": "Bill payment", + "billPaymentRequiredMessage": "The current patient has pending bill. Advice patient to settle bill before receiving services", + "billServicesManagement": "Bill services management", + "billsList": "Bill list", + "billTotal": "Bill total", + "billWaiver": "Bill waiver", + "billWaiverError": "Bill waiver failed {{error}}", + "billWaiverSuccess": "Bill waiver successful", + "cancel": "Cancel", + "checkFilters": "Check the filters above", + "checkValidity": "Check Validity", + "claimcode": " Claim Code", + "claimExplanation": "Claim Explanation", + "diagnosis": "Diagnosis", + "discard": "Discard", + "discardClaim": "Discard Claim", + "discount": "Discount", + "enterAmount": "Enter amount", + "enterConcept": "Associated concept", + "enterReferenceNumber": "Enter ref. number", + "enterSHANumber": "Enter SHA Number", + "errorLoadingBillServices": "Error loading bill services", + "errorLoadingPaymentModes": "Payment modes error", + "exemptionCategory": "Exemption category", + "facility": "Facility", + "filterBy": "Filter by", + "filterTable": "Filter table", + "formTitle": "Fill in the form details", + "guarantee": "Guarantee ID", + "home": "Home", + "identifier": "Identifier", + "initiatePay": "Initiate Payment", + "initiatePayment": "Initiate Payment", + "inStock": "{{quantityUoM}}(s) In stock ", + "insuranceScheme": "Insurance scheme", + "invalidSHANumber": "SHA number is invalid, advice patient to update payment or contact SHA", + "invalidWaiverAmountMessage": "Amount to waive cannot be greater than total amount", + "invoice": "Invoice", + "invoiceError": "Invoice error", + "isPatientExemptedLegend": "Is patient exempted from payment?", + "item": "Item", + "itemsToBeBilled": "Items to be billed", + "launchBillForm": "Launch bill form", + "lineItems": "Line items", + "loading": "Loading", + "loadingBillingServices": "Loading billing services...", + "loadingDescription": "Loading", + "makeclaims": "Make Claims", + "manageBillableServices": "Manage billable services", + "name": "Name", + "navigateBack": "Navigate back", + "nextPage": "Next page", + "noBilltoDisplay": "There are no bills to display for this patient", + "noMatchingBillsToDisplay": "No matching bills to display", + "noMatchingItemsToDisplay": "No matching items to display", + "noMatchingServicesToDisplay": "No matching services to display", + "noResultsFor": "No results for", + "noServicesToDisplay": "There are no services to display", + "patientBilling": "Patient Billing", + "patientBillingAlert": "Patient Billing Alert", + "patientBills": "Patient bill", + "patientBillsDescription": "List of patient bills", + "paymentMethod": "Payment method", + "paymentMethodsTitle": "Payment methods", + "paymentMode": "Payment Mode", + "paymentPayment": "Bill Payment", + "payments": "Payments", + "Phone Number": "Phone Number", + "policyNumber": "Policy number", + "postWaiver": "Post waiver", + "previousPage": "Previous page", + "prices": "Prices", + "printBill": "Print bill", + "printReceipt": "Print receipt", + "proceedToCare": "Proceed to care", + "processClaim": "Process Claim", + "processPayment": "Process Payment", + "providerMessage": "By clicking Proceed to care, you acknowledge that you have advised the patient to settle the bill.", + "quantity": "Quantity", + "referenceNumber": "Ref number", + "save": "Save", + "searchConcepts": "Search associated concept", + "searching": "Searching", + "searchServices": "Search services", + "searchThisTable": "Search this table", + "selectCategory": "Select category", + "selectExemptionCategory": "Select exemption category", + "selectitemstobeclaimed": "Select items that are to be included in the claims", + "selectPaymentMethod": "Select payment method", + "selectPaymentMethodPlaceholder": "Select payment method", + "sellingAmount": "Enter selling price", + "sellingPrice": "Selling Price", + "service": "Service", + "serviceMetrics": "Service Metrics", + "serviceName": "Service Name", + "serviceShortName": "Short Name", + "servicesList": "Services list", + "serviceType": "Service Type", + "shaNotValid": "Invalid SHA Number", + "shaNumber": "SHA Number", + "shortName": "Short Name", + "status": "Service Status", + "stkPush": "STK Push", + "stkPushError": "STK Push request failed", + "stkPushSucess": "STK Push send successfully", + "stockItem": "Stock Item", + "total": "Total", + "totalAmount": "Total Amount", + "totalTendered": "Total Tendered", + "treatmentend": "Treatment End", + "treatmentstart": "Treatment Start", + "unitPrice": "Unit price", + "valid": "Valid SHA Number", + "validatingSHANumber": "Validating SHA Number", + "validSHANumber": "SHA number is valid, proceed with care", + "visitTime": "Visit time", + "visitType": " Visit Type", + "waiverForm": "Waiver form" +} diff --git a/packages/esm-billing-app/tsconfig.json b/packages/esm-billing-app/tsconfig.json new file mode 100644 index 00000000..54ce28cf --- /dev/null +++ b/packages/esm-billing-app/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/*.test.tsx"] +} diff --git a/packages/esm-billing-app/webpack.config.js b/packages/esm-billing-app/webpack.config.js new file mode 100644 index 00000000..2c74029c --- /dev/null +++ b/packages/esm-billing-app/webpack.config.js @@ -0,0 +1 @@ +module.exports = require('openmrs/default-webpack-config'); diff --git a/packages/esm-patient-flags-app/src/amrs-link/amrs-chart-link.component.tsx b/packages/esm-patient-flags-app/src/amrs-link/amrs-chart-link.component.tsx index ad2986d8..5d87d195 100644 --- a/packages/esm-patient-flags-app/src/amrs-link/amrs-chart-link.component.tsx +++ b/packages/esm-patient-flags-app/src/amrs-link/amrs-chart-link.component.tsx @@ -12,8 +12,6 @@ const AMRSChartLink = () => { return (