diff --git a/.github/workflows/cypress.yaml b/.github/workflows/cypress.yaml index 3abf95cd4b0..6b731c8840b 100644 --- a/.github/workflows/cypress.yaml +++ b/.github/workflows/cypress.yaml @@ -68,7 +68,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22.11.0" - name: Install dependencies 📦 run: npm run install-all diff --git a/cypress/e2e/assets_spec/AssetsCreation.cy.ts b/cypress/e2e/assets_spec/AssetsCreation.cy.ts index 76f225b03d7..820ac4231a9 100644 --- a/cypress/e2e/assets_spec/AssetsCreation.cy.ts +++ b/cypress/e2e/assets_spec/AssetsCreation.cy.ts @@ -43,21 +43,21 @@ describe("Asset", () => { const qr_id_1 = uuidv4(); - assetPage.enterAssetDetails( - "New Test Asset 1", - "Test Description", - "Working", - qr_id_1, - "Manufacturer's Name", - "2025-12-25", - "Customer Support's Name", - phone_number, - "email@support.com", - "Vendor's Name", - serialNumber, - "25122021", - "Test note for asset creation!", - ); + assetPage.enterAssetDetails({ + name: "New Test Asset 1", + description: "Test Description", + workingStatus: "Working", + qrId: qr_id_1, + manufacturer: "Manufacturer's Name", + warranty: "2025-12-25", + supportName: "Customer Support's Name", + supportPhone: phone_number, + supportEmail: "email@support.com", + vendorName: "Vendor's Name", + serialNumber: serialNumber, + lastServicedOn: "25122021", + notes: "Test note for asset creation!", + }); assetPage.clickCreateAddMore(); assetPage.verifySuccessNotification("Asset created successfully"); @@ -66,21 +66,21 @@ describe("Asset", () => { assetPage.selectLocation("Camera Loc"); assetPage.selectAssetClass("ONVIF Camera"); - assetPage.enterAssetDetails( - "New Test Asset 2", - "Test Description", - "Working", - qr_id_2, - "Manufacturer's Name", - "2025-12-25", - "Customer Support's Name", - phone_number, - "email@support.com", - "Vendor's Name", - serialNumber, - "25122021", - "Test note for asset creation!", - ); + assetPage.enterAssetDetails({ + name: "New Test Asset 2", + description: "Test Description", + workingStatus: "Working", + qrId: qr_id_2, + manufacturer: "Manufacturer's Name", + warranty: "2025-12-25", + supportName: "Customer Support's Name", + supportPhone: phone_number, + supportEmail: "email@support.com", + vendorName: "Vendor's Name", + serialNumber: serialNumber, + lastServicedOn: "25122021", + notes: "Test note for asset creation!", + }); assetPage.interceptAssetCreation(); assetPage.clickCreateAsset(); @@ -139,21 +139,22 @@ describe("Asset", () => { const qr_id_1 = uuidv4(); - assetPage.enterAssetDetails( - "New Test Asset Vital", - "Test Description", - "Working", - qr_id_1, - "Manufacturer's Name", - "2025-12-25", - "Customer Support's Name", - phone_number, - "email@support.com", - "Vendor's Name", - serialNumber, - "25122021", - "Test note for asset creation!", - ); + assetPage.enterAssetDetails({ + name: "New Test Asset Vital", + description: "Test Description", + workingStatus: "Working", + qrId: qr_id_1, + manufacturer: "Manufacturer's Name", + warranty: "2025-12-25", + supportName: "Customer Support's Name", + supportPhone: phone_number, + supportEmail: "email@support.com", + vendorName: "Vendor's Name", + serialNumber: serialNumber, + lastServicedOn: "25122021", + notes: "Test note for asset creation!", + }); + assetPage.interceptAssetCreation(); assetPage.clickCreateAsset(); assetPage.verifyAssetCreation(); diff --git a/cypress/e2e/facility_spec/FacilityCreation.cy.ts b/cypress/e2e/facility_spec/FacilityCreation.cy.ts index d8d73417e7e..78ce17d8b9e 100644 --- a/cypress/e2e/facility_spec/FacilityCreation.cy.ts +++ b/cypress/e2e/facility_spec/FacilityCreation.cy.ts @@ -4,7 +4,6 @@ import FacilityPage from "../../pageobject/Facility/FacilityCreation"; import FacilityHome from "../../pageobject/Facility/FacilityHome"; import LoginPage from "../../pageobject/Login/LoginPage"; import ManageUserPage from "../../pageobject/Users/ManageUserPage"; -import { UserCreationPage } from "../../pageobject/Users/UserCreation"; import { nonAdminRoles } from "../../pageobject/utils/userConfig"; describe("Facility Creation", () => { @@ -13,7 +12,6 @@ describe("Facility Creation", () => { const loginPage = new LoginPage(); const facilityHome = new FacilityHome(); const manageUserPage = new ManageUserPage(); - const userCreationPage = new UserCreationPage(); const facilityFeature = [ "CT Scan", "X-Ray", @@ -87,7 +85,7 @@ describe("Facility Creation", () => { facilityPage.scrollToFacilityTriage(); facilityPage.clickAddFacilityTriage(); manageUserPage.clickSubmit(); - userCreationPage.verifyErrorMessages(triageErrorMessage); + cy.verifyErrorMessages(triageErrorMessage); // create a entry and verify reflection facilityPage.fillEntryDate(triageDate); facilityPage.fillTriageEntryFields( @@ -123,7 +121,7 @@ describe("Facility Creation", () => { // create facility with multiple capacity and verify form error message for facility form facilityPage.visitCreateFacilityPage(); facilityPage.submitForm(); - userCreationPage.verifyErrorMessages(facilityErrorMessage); + cy.verifyErrorMessages(facilityErrorMessage); facilityPage.fillFacilityName(facilityName); facilityPage.selectFacilityType(facilityType); facilityPage.clickfacilityfeatureoption(); @@ -270,12 +268,12 @@ describe("Facility Creation", () => { // add no bed capacity and verify form error message facilityPage.isVisibleselectBedType(); facilityPage.saveAndExitBedCapacityForm(); - userCreationPage.verifyErrorMessages(bedErrorMessage); + cy.verifyErrorMessages(bedErrorMessage); facilityPage.clickcancelbutton(); // add no doctor capacity and verify form error message facilityPage.isVisibleAreaOfSpecialization(); facilityPage.clickdoctorcapacityaddmore(); - userCreationPage.verifyErrorMessages(doctorErrorMessage); + cy.verifyErrorMessages(doctorErrorMessage); facilityPage.clickcancelbutton(); cy.url().then((newUrl) => { facilityUrl1 = newUrl; diff --git a/cypress/e2e/facility_spec/FacilityHomepage.cy.ts b/cypress/e2e/facility_spec/FacilityHomepage.cy.ts index 7d03f7a16f0..6f01baac714 100644 --- a/cypress/e2e/facility_spec/FacilityHomepage.cy.ts +++ b/cypress/e2e/facility_spec/FacilityHomepage.cy.ts @@ -181,7 +181,7 @@ describe("Facility Homepage Function", () => { cy.clickSubmitButton("Notify"); facilityNotify.verifyErrorMessage(notificationErrorMsg); // close pop-up and verify - facilityHome.verifyAndCloseNotifyModal(); + cy.clickCancelButton("Cancel"); // signout as district admin and login as a Nurse loginPage.ensureLoggedIn(); loginPage.clickSignOutBtn(); diff --git a/cypress/e2e/facility_spec/FacilityLocation.cy.ts b/cypress/e2e/facility_spec/FacilityLocation.cy.ts index b4d153b16ed..1ae61e5ecb0 100644 --- a/cypress/e2e/facility_spec/FacilityLocation.cy.ts +++ b/cypress/e2e/facility_spec/FacilityLocation.cy.ts @@ -1,19 +1,18 @@ +import { AssetHome } from "pageobject/Asset/AssetHome"; import LoginPage from "pageobject/Login/LoginPage"; +import { generatePhoneNumber } from "pageobject/utils/constants"; import { pageNavigation } from "pageobject/utils/paginationHelpers"; import { v4 as uuidv4 } from "uuid"; import { AssetPage } from "../../pageobject/Asset/AssetCreation"; -import FacilityPage from "../../pageobject/Facility/FacilityCreation"; import FacilityHome from "../../pageobject/Facility/FacilityHome"; import FacilityLocation from "../../pageobject/Facility/FacilityLocation"; -import { UserCreationPage } from "../../pageobject/Users/UserCreation"; describe("Location Management Section", () => { const assetPage = new AssetPage(); - const userCreationPage = new UserCreationPage(); - const facilityPage = new FacilityPage(); const facilityLocation = new FacilityLocation(); const facilityHome = new FacilityHome(); + const assetHome = new AssetHome(); const loginPage = new LoginPage(); const EXPECTED_LOCATION_ERROR_MESSAGES = [ @@ -24,28 +23,18 @@ describe("Location Management Section", () => { "Please enter a name", "Please select a bed type", ]; - const locationName = "Test-location"; - const locationNameTwo = "Test-location-2"; const locationDescription = "Test Description"; const locationType = "WARD"; const locationMiddleware = "dev_middleware.coronasafe.live"; - const locationModifiedName = "Test Modified location"; const locationModifiedDescription = "Test Modified Description"; const locationModifiedType = "ICU"; const locationModifiedMiddleware = "dev-middleware.coronasafe.live"; - const bedName = "Test-Bed"; const bedDescrption = "test description"; const bedType = "ICU"; const bedStatus = "Vacant"; - const bedModifiedName = "test modified bed"; - const duplicateBedName = "ICCU"; const bedModifiedDescrption = "test modified description"; const bedModifiedType = "Isolation"; - const numberOfBeds = 10; - const numberOfModifiedBeds = 25; - const qr_id_1 = uuidv4(); - const phone_number = "9999999999"; - const serialNumber = Math.floor(Math.random() * 10 ** 10).toString(); + const phone_number = generatePhoneNumber(); before(() => { loginPage.loginByRole("districtAdmin"); @@ -57,229 +46,226 @@ describe("Location Management Section", () => { cy.restoreLocalStorage(); cy.clearLocalStorage(/filters--.+/); cy.awaitUrl("/"); - facilityLocation.loadLocationManagementPage("Dummy Shifting Center"); }); - it("Add a Bed to facility location along with duplication and deleting a bed", () => { - // mandatory field verification in bed creation - cy.get("body").then(($body) => { - if ($body.find("#manage-bed-button:visible").length) { - // If the '#manage-bed-button' is visible - facilityLocation.clickManageBedButton(); - } else { - // If the '#manage-bed-button' is not visible - facilityLocation.clickAddNewLocationButton(); - facilityPage.fillFacilityName(locationName); - facilityLocation.selectLocationType(locationType); - assetPage.clickassetupdatebutton(); - facilityLocation.clickNotification(); - facilityLocation.clickManageBedButton(); - } + it("Delete location with linked assets", () => { + const locationName = `ICU-${uuidv4().substring(0, 2)}`; + const facilityName = "Dummy Facility 13"; + const assetName = "Test Asset linked to location"; + // Select a new facility + facilityLocation.navigateToFacilityLocationManagement(facilityName); + // Create a new location + facilityLocation.clickAddNewLocationButton(); + facilityLocation.fillLocationDetails( + locationName, + undefined, + locationType, + undefined, + ); + facilityLocation.clickAddLocationButton(); + facilityLocation.verifyAddLocationSuccessfulMesssage(); + // create asset and link it to location + assetHome.navigateToAssetsPage(); + assetPage.createAsset(); + assetPage.selectFacility(facilityName); + assetPage.selectLocation(locationName); + assetPage.enterAssetDetails({ + name: assetName, + workingStatus: "Working", + supportPhone: phone_number, }); - facilityLocation.clickAddBedButton(); - assetPage.clickassetupdatebutton(); - userCreationPage.verifyErrorMessages(EXPECTED_BED_ERROR_MESSAGES); - // create a new single bed and verify - facilityLocation.enterBedName(bedName); - facilityLocation.enterBedDescription(bedDescrption); - facilityLocation.selectBedType(bedType); - assetPage.clickassetupdatebutton(); - // Verify the bed creation - facilityLocation.verifyBedNameBadge(bedName); - facilityLocation.verifyBedBadge(bedType); - facilityLocation.verifyBedBadge(bedStatus); - // Try to create duplication bed and verify the error - facilityLocation.clickAddBedButton(); - facilityLocation.enterBedName(bedName); - facilityLocation.selectBedType(bedType); assetPage.clickassetupdatebutton(); - facilityLocation.verifyNotification( - "Name - Bed with same name already exists in location", + cy.verifyNotification("Asset created successfully"); + cy.closeNotification(); + // Select a new facility + facilityHome.navigateToFacilityHomepage(); + facilityLocation.navigateToFacilityLocationManagement(facilityName); + facilityLocation.clickDeleteLocation(locationName); + cy.clickSubmitButton("Confirm"); + cy.verifyNotification("Cannot delete a Location with associated Assets"); + cy.closeNotification(); + + // delete asset + facilityLocation.clickManageAssets(); + assetPage.openCreatedAsset(); + assetPage.deleteAsset(); + cy.verifyNotification("Asset deleted successfully"); + cy.closeNotification(); + + // delete location + facilityHome.navigateToFacilityHomepage(); + facilityLocation.navigateToFacilityLocationManagement(facilityName); + facilityLocation.clickDeleteLocation(locationName); + cy.clickSubmitButton("Confirm"); + cy.verifyNotification(`Location ${locationName} deleted successfully`); + cy.closeNotification(); + }); + + it("Delete location with linked beds", () => { + const locationName = `ICU-${uuidv4().substring(0, 2)}`; + const facilityName = "Dummy Facility 12"; + const bedName = `Bed-${uuidv4().substring(0, 2)}`; + // Select a new facility + facilityLocation.navigateToFacilityLocationManagement(facilityName); + // Create a new location with a bed + facilityLocation.clickAddNewLocationButton(); + facilityLocation.fillLocationDetails( + locationName, + undefined, + locationType, + undefined, ); - facilityHome.verifyAndCloseNotifyModal(); - // edit the created bed - facilityLocation.clickEditBedButton(); - facilityLocation.enterBedName(duplicateBedName); - facilityLocation.enterBedDescription(bedModifiedDescrption); - facilityLocation.selectBedType(bedModifiedType); - assetPage.clickassetupdatebutton(); - // verify the modification - facilityLocation.verifyBedNameBadge(duplicateBedName); - facilityLocation.verifyBedBadge(bedModifiedType); - facilityLocation.verifyBedBadge(bedStatus); - facilityLocation.closeNotification(); + facilityLocation.clickAddLocationButton(); + facilityLocation.verifyAddLocationSuccessfulMesssage(); + // Create a new bed to the location + facilityLocation.clickManageBedButton(locationName); + facilityLocation.clickAddBedButton(); + facilityLocation.fillBedForm(bedName, undefined, bedType, undefined); + facilityLocation.clickSubmitBedsButton(); + facilityLocation.verifyAddSingleBedSuccessfulMesssage(); + // Now try to delete the location with bed in it + facilityLocation.fetchAndNavigateToLocationPage(); + facilityLocation.clickDeleteLocation(locationName); + cy.clickSubmitButton("Confirm"); + cy.verifyNotification("Cannot delete a Location with associated Beds"); + cy.closeNotification(); + + // delete bed + facilityLocation.clickManageBedPopup(); + facilityLocation.deleteBedWithName(bedName); + cy.clickSubmitButton("Delete"); + cy.verifyNotification("Bed deleted successfully"); + cy.closeNotification(); + + // delete location + facilityLocation.fetchAndNavigateToLocationPage(); + facilityLocation.clickDeleteLocation(locationName); + cy.clickSubmitButton("Confirm"); + cy.verifyNotification(`Location ${locationName} deleted successfully`); + cy.closeNotification(); }); it("Adds Location to a facility and modify it", () => { + const locationName = `ICU-${uuidv4().substring(0, 2)}`; + const locationModifiedName = `ICU-${uuidv4().substring(0, 2)}}`; + const facilityName = "Dummy Facility 11"; + // Select a new facility + facilityLocation.navigateToFacilityLocationManagement(facilityName); // add a new location form mandatory error facilityLocation.clickAddNewLocationButton(); - assetPage.clickassetupdatebutton(); - userCreationPage.verifyErrorMessages(EXPECTED_LOCATION_ERROR_MESSAGES); + facilityLocation.clickAddLocationButton(); + cy.verifyErrorMessages(EXPECTED_LOCATION_ERROR_MESSAGES); // create a new location - facilityPage.fillFacilityName(locationNameTwo); - facilityLocation.fillDescription(locationDescription); - facilityLocation.selectLocationType(locationType); - facilityLocation.fillMiddlewareAddress(locationMiddleware); - assetPage.clickassetupdatebutton(); - facilityLocation.clickNotification(); + facilityLocation.fillLocationDetails( + locationName, + locationDescription, + locationType, + locationMiddleware, + ); + facilityLocation.clickAddLocationButton(); + facilityLocation.verifyAddLocationSuccessfulMesssage(); // verify the reflection - facilityLocation.verifyLocationName(locationNameTwo); + facilityLocation.verifyLocationName(locationName); facilityLocation.verifyLocationType(locationType); facilityLocation.verifyLocationDescription(locationDescription); facilityLocation.verifyLocationMiddleware(locationMiddleware); - // modify the existing data + // verify the duplicate location error message + facilityLocation.clickAddNewLocationButton(); + facilityLocation.fillLocationDetails( + locationName, + undefined, + locationType, + undefined, + ); + facilityLocation.clickAddLocationButton(); + cy.verifyNotification( + "Name - Asset location with this name and facility already exists.", + ); + cy.closeNotification(); + facilityLocation.closeAddLocationForm(); facilityLocation.clickEditLocationButton(); - facilityPage.fillFacilityName(locationModifiedName); - facilityLocation.fillDescription(locationModifiedDescription); - facilityLocation.selectLocationType(locationModifiedType); - facilityLocation.fillMiddlewareAddress(locationModifiedMiddleware); - assetPage.clickassetupdatebutton(); + facilityLocation.fillLocationDetails( + locationModifiedName, + locationModifiedDescription, + locationModifiedType, + locationModifiedMiddleware, + ); + facilityLocation.clickUpdateLocationButton(); + facilityLocation.verifyEditLocationSuccessfulMessage(); // verify the reflection facilityLocation.verifyLocationName(locationModifiedName); facilityLocation.verifyLocationType(locationModifiedType); facilityLocation.verifyLocationDescription(locationModifiedDescription); facilityLocation.verifyLocationMiddleware(locationModifiedMiddleware); - facilityLocation.closeNotification(); }); - it("Multiple Bed to a facility location and delete a bed", () => { - // create multiple bed and verify - facilityLocation.clickManageBedButton(); + it("Add single & Multiple Bed to facility location along with duplication and deleting a bed", () => { + const locationName = `ICU-${uuidv4().substring(0, 2)}`; + const facilityName = "Dummy Request Fulfilment Center"; + const bedName = `Bed-${uuidv4().substring(0, 2)}`; + const duplicateBedName = `Duplicate-${uuidv4().substring(0, 2)}`; + const multipleBedName = `Multi-${uuidv4().substring(0, 2)}}`; + const numberOfBeds = 25; + // Select a new facility + facilityLocation.navigateToFacilityLocationManagement(facilityName); + // Create a new location and Bed into it + facilityLocation.clickAddNewLocationButton(); + facilityLocation.fillLocationDetails( + locationName, + undefined, + locationType, + undefined, + ); + facilityLocation.clickAddLocationButton(); + facilityLocation.verifyAddLocationSuccessfulMesssage(); + // Verify the error message of beds creation form + facilityLocation.clickManageBedButton(locationName); facilityLocation.clickAddBedButton(); - facilityLocation.enterBedName(bedName); - facilityLocation.enterBedDescription(bedDescrption); - facilityLocation.selectBedType(bedType); - facilityLocation.setMultipleBeds(numberOfBeds); - assetPage.clickassetupdatebutton(); - facilityLocation.clickNotification(); - // verify the bed creation + facilityLocation.clickSubmitBedsButton(); + cy.verifyErrorMessages(EXPECTED_BED_ERROR_MESSAGES); + // Add a new bed to the location + facilityLocation.fillBedForm(bedName, bedDescrption, bedType, undefined); + facilityLocation.clickSubmitBedsButton(); + facilityLocation.verifyAddSingleBedSuccessfulMesssage(); + // Verify the bed creation + facilityLocation.verifyBedNameBadge(bedName); facilityLocation.verifyBedBadge(bedType); facilityLocation.verifyBedBadge(bedStatus); - facilityLocation.verifyIndividualBedName(bedName, numberOfBeds); - // delete a bed and verify it - facilityLocation.deleteFirstBed(); - facilityLocation.deleteBedRequest(); - assetPage.clickassetupdatebutton(); - facilityLocation.deleteBedRequest(); - facilityLocation.closeNotification(); - }); - - it("Add Multiple Bed to a facility location and verify pagination", () => { - // bed creation - facilityLocation.clickManageBedButton(); + // Try to create duplication bed and verify the error facilityLocation.clickAddBedButton(); - facilityLocation.enterBedName(bedModifiedName); - facilityLocation.enterBedDescription(bedModifiedDescrption); - facilityLocation.selectBedType(bedModifiedType); - facilityLocation.setMultipleBeds(numberOfModifiedBeds); - assetPage.clickassetupdatebutton(); - // pagination - pageNavigation.navigateToNextPage(); - pageNavigation.navigateToPreviousPage(); - facilityLocation.closeNotification(); - }); - - it("Delete location", () => { - facilityLocation.clickAddNewLocationButton(); - facilityLocation.enterLocationName("Test Location"); - facilityLocation.selectLocationType("OTHER"); - assetPage.clickassetupdatebutton(); - facilityLocation.deleteLocation("Test Location"); - assetPage.clickassetupdatebutton(); - facilityLocation.verifyNotification( - "Location Test Location deleted successfully", + facilityLocation.fillBedForm(bedName, undefined, bedType, undefined); + facilityLocation.clickSubmitBedsButton(); + cy.verifyNotification( + "Name - Bed with same name already exists in location", ); - facilityLocation.closeNotification(); - }); - - it("Delete location with linked beds", () => { - facilityLocation.clickAddNewLocationButton(); - facilityLocation.enterLocationName("Test Location with Beds"); - facilityLocation.selectLocationType("OTHER"); - cy.clickSubmitButton("Add Location"); - cy.verifyNotification("Location created successfully"); - cy.closeNotification(); - facilityLocation.clickManageBedButton(); - facilityLocation.clickAddBedButton(); - facilityLocation.enterBedName("Bed 1"); - facilityLocation.selectBedType("Regular"); - cy.clickSubmitButton("Add Bed(s)"); - cy.verifyNotification("1 Bed created successfully"); cy.closeNotification(); - facilityLocation.loadLocationManagementPage("Dummy Shifting Center"); - facilityLocation.deleteLocation("Test Location with Beds"); - assetPage.clickassetupdatebutton(); - facilityLocation.verifyNotification( - "Cannot delete a Location with associated Beds", + facilityLocation.closeAddLocationForm(); + // edit the newly created existing bed + facilityLocation.clickEditBedButton(bedName); + facilityLocation.fillBedForm( + duplicateBedName, + bedModifiedDescrption, + bedModifiedType, + undefined, ); - facilityLocation.closeNotification(); - - // delete bed - facilityLocation.clickManageBeds(); - facilityLocation.deleteFirstBed(); - assetPage.clickassetupdatebutton(); - facilityLocation.closeNotification(); - - // delete location - facilityLocation.loadLocationManagementPage("Dummy Shifting Center"); - facilityLocation.deleteLocation("Test Location with Beds"); - assetPage.clickassetupdatebutton(); - facilityLocation.verifyNotification( - "Location Test Location with Beds deleted successfully", - ); - facilityLocation.closeNotification(); - }); - - it("Delete location with linked assets", () => { - facilityLocation.clickAddNewLocationButton(); - facilityLocation.enterLocationName("Test Location with linked Assets"); - facilityLocation.selectLocationType("OTHER"); - assetPage.clickassetupdatebutton(); - facilityLocation.verifyNotification("Location created successfully"); - facilityLocation.closeNotification(); - // create asset and link it to location - cy.awaitUrl("/assets"); - assetPage.createAsset(); - assetPage.selectFacility("Dummy Shifting Center"); - assetPage.selectLocation("Test Location with linked Assets"); - assetPage.enterAssetDetails( - "Test Asset linked to Facility", - "Test Description", - "Working", - qr_id_1, - "Manufacturer's Name", - "2025-12-25", - "Customer Support's Name", - phone_number, - "email@support.com", - "Vendor's Name", - serialNumber, - "25122021", - "Test note for asset creation!", - ); - assetPage.clickassetupdatebutton(); - facilityLocation.loadLocationManagementPage("Dummy Shifting Center"); - facilityLocation.deleteLocation("Test Location with linked Assets"); - assetPage.clickassetupdatebutton(); - facilityLocation.verifyNotification( - "Cannot delete a Location with associated Assets", - ); - facilityLocation.closeNotification(); - - // delete asset - facilityLocation.clickManageAssets(); - assetPage.openCreatedAsset(); - assetPage.deleteAsset(); - facilityLocation.closeNotification(); - - // delete location - facilityLocation.loadLocationManagementPage("Dummy Shifting Center"); - facilityLocation.deleteLocation("Test Location with linked Assets"); - assetPage.clickassetupdatebutton(); - facilityLocation.verifyNotification( - "Location Test Location with linked Assets deleted successfully", + facilityLocation.clickUpdateBedButton(); + facilityLocation.verifyEditBedSuccessfulMessage(); + // verify the modification + facilityLocation.verifyBedNameBadge(duplicateBedName); + facilityLocation.verifyBedBadge(bedModifiedType); + facilityLocation.verifyBedBadge(bedStatus); + // Create Multiple Bed + facilityLocation.clickAddBedButton(); + facilityLocation.fillBedForm( + multipleBedName, + bedDescrption, + bedType, + numberOfBeds, ); - facilityLocation.closeNotification(); + facilityLocation.clickSubmitBedsButton(); + // Verify Pagination in the page + pageNavigation.navigateToNextPage(); + pageNavigation.navigateToPreviousPage(); }); afterEach(() => { diff --git a/cypress/e2e/patient_spec/PatientHomepage.cy.ts b/cypress/e2e/patient_spec/PatientHomepage.cy.ts index 2d8d20572bc..a120e282a2d 100644 --- a/cypress/e2e/patient_spec/PatientHomepage.cy.ts +++ b/cypress/e2e/patient_spec/PatientHomepage.cy.ts @@ -157,7 +157,7 @@ describe("Patient Homepage present functionalities", () => { patientHome.verifyPatientExportRequest(); }); - it("Verify the functionality of the patient tab pagination", () => { + it("Test Pagination on Patient List Page", () => { let firstPatientPageOne: string; cy.get('[data-cy="patient"]') .first() @@ -165,6 +165,7 @@ describe("Patient Homepage present functionalities", () => { .then((patientOne: string) => { firstPatientPageOne = patientOne.trim(); pageNavigation.navigateToNextPage(); + cy.wait(2000); pageNavigation.verifyCurrentPageNumber(2); cy.get('[data-cy="patient"]') .first() diff --git a/cypress/e2e/users_spec/UsersCreation.cy.ts b/cypress/e2e/users_spec/UsersCreation.cy.ts index 1c720e7df12..d93707617ff 100644 --- a/cypress/e2e/users_spec/UsersCreation.cy.ts +++ b/cypress/e2e/users_spec/UsersCreation.cy.ts @@ -43,7 +43,7 @@ describe("User Creation", () => { "Please enter valid phone number", "Please enter the username", "Please enter date in DD/MM/YYYY format", - "Please enter the password", + "Password is required", "Confirm password is required", "First Name is required", "Last Name is required", @@ -136,7 +136,7 @@ describe("User Creation", () => { userProfilePage.clearAltPhoneNumber(); userProfilePage.clearWorkingHours(); userProfilePage.clickUpdateButton(); - userCreationPage.verifyErrorMessages(EXPECTED_PROFILE_ERROR_MESSAGES); + cy.verifyErrorMessages(EXPECTED_PROFILE_ERROR_MESSAGES); }); it("create new user and verify reflection", () => { @@ -164,20 +164,17 @@ describe("User Creation", () => { cy.verifyNotification("User added successfully"); userPage.typeInSearchInput(username); userPage.checkUsernameText(username); - cy.verifyContentPresence("#name", [newUserFirstName]); + cy.verifyContentPresence(`#name-${username}`, [newUserFirstName]); cy.verifyContentPresence("#role", [role]); cy.verifyContentPresence("#district", [district]); - cy.verifyContentPresence("#home_facility", [homeFacility]); - cy.verifyContentPresence("#qualification", [qualification]); - cy.verifyContentPresence("#doctor-experience", [experience]); - cy.verifyContentPresence("#medical-council-registration", [regNo]); + cy.verifyContentPresence("#home-facility", [homeFacility]); }); it("create new user form throwing mandatory field error", () => { userCreationPage.clickAddUserButton(); userCreationPage.clickSaveUserButton(); cy.get(".error-text", { timeout: 10000 }).should("be.visible"); - userCreationPage.verifyErrorMessages(EXPECTED_ERROR_MESSAGES); + cy.verifyErrorMessages(EXPECTED_ERROR_MESSAGES); }); it("view user redirection from facility page", () => { diff --git a/cypress/e2e/users_spec/UsersHomepage.cy.ts b/cypress/e2e/users_spec/UsersHomepage.cy.ts index 8d86482645b..a82b718047d 100644 --- a/cypress/e2e/users_spec/UsersHomepage.cy.ts +++ b/cypress/e2e/users_spec/UsersHomepage.cy.ts @@ -8,15 +8,15 @@ describe("User Homepage", () => { const userPage = new UserPage(); const loginPage = new LoginPage(); const currentuser = "devdistrictadmin"; - const firstName = "Dummy"; - const lastName = "Nurse"; + const firstName = "Dev"; + const lastName = "Staff"; const role = "Nurse"; const state = "Kerala"; const district = "Ernakulam"; - const phoneNumber = "8878825662"; - const altPhoneNumber = "8878825662"; + const phoneNumber = "9876543219"; + const altPhoneNumber = "9876543219"; const homeFacility = "Dummy Facility 40"; - const nurseUserName = "dummynurse1"; + const nurseUserName = "devstaff2"; const doctorUserName = "dev-doctor2"; before(() => { @@ -89,6 +89,18 @@ describe("User Homepage", () => { pageNavigation.verifyCurrentPageNumber(1); }); + it("Switch to list view, search by username and verify the results", () => { + userPage.switchToListView(); + userPage.verifyListView(); + userPage.checkSearchInputVisibility(); + userPage.typeInSearchInput(doctorUserName); + userPage.checkUrlForUsername(doctorUserName); + userPage.checkUsernameText(doctorUserName); + userPage.checkUsernameBadgeVisibility(true); + userPage.clearSearchInput(); + userPage.verifyListView(); + }); + afterEach(() => { cy.saveLocalStorage(); }); diff --git a/cypress/e2e/users_spec/UsersManage.cy.ts b/cypress/e2e/users_spec/UsersManage.cy.ts index 41557e87756..c7d237efb43 100644 --- a/cypress/e2e/users_spec/UsersManage.cy.ts +++ b/cypress/e2e/users_spec/UsersManage.cy.ts @@ -1,3 +1,4 @@ +import * as dayjs from "dayjs"; import FacilityHome from "pageobject/Facility/FacilityHome"; import { advanceFilters } from "pageobject/utils/advanceFilterHelpers"; @@ -17,11 +18,14 @@ describe("Manage User", () => { const firstNameUserSkill = "Dev"; const lastNameUserSkill = "Doctor"; const usernameforworkinghour = "devdistrictadmin"; + const nurseUsername = "dummynurse1"; + const doctorUsername = "devdoctor"; + const doctorToDelete = "dummydoctor12"; const usernamerealname = "Dummy Doctor"; const facilitytolinkusername = "Dummy Shifting Center"; const facilitytolinkskill = "Dummy Facility 40"; const workinghour = "23"; - const linkedskill = "General Medicine"; + const linkedskill = "Immunologist"; before(() => { loginPage.loginByRole("districtAdmin"); @@ -30,28 +34,226 @@ describe("Manage User", () => { beforeEach(() => { cy.restoreLocalStorage(); + cy.viewport(1280, 720); cy.clearLocalStorage(/filters--.+/); cy.awaitUrl("/users"); }); + // To Do: Add avatar upload + /* it("District Admin can change their own avatar", () => { + userPage.typeInSearchInput(nurseUsername); + userPage.checkUsernameText(nurseUsername); + manageUserPage.clickMoreDetailsButton(nurseUsername); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.verifyChangeAvatarButtonVisible(); + manageUserPage.clickChangeAvatarButton(); + }); */ + + it("edit a nurse user's basic information and verify its reflection", () => { + userPage.typeInSearchInput(nurseUsername); + userPage.checkUsernameText(nurseUsername); + manageUserPage.clickMoreDetailsButton(nurseUsername); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickBasicInfoViewButton(); + manageUserPage.clickBasicInfoEditButton(); + manageUserPage.clearUserBasicInfo(); + manageUserPage.clickSubmit(); + manageUserPage.verifyErrorText("First Name is required"); + manageUserPage.verifyErrorText("Last Name is required"); + manageUserPage.editUserBasicInfo("Devo", "Districto", "11081999", "Female"); + manageUserPage.clickSubmit(); + manageUserPage.clickBasicInfoViewButton(); + manageUserPage.verifyEditUserDetails( + "Devo", + "Districto", + "8/11/1999", + "Female", + ); + }); + + it("edit a nurse user's contact information and verify its reflection", () => { + userPage.typeInSearchInput(nurseUsername); + userPage.checkUsernameText(nurseUsername); + manageUserPage.clickMoreDetailsButton(nurseUsername); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickContactInfoViewButton(); + manageUserPage.clickContactInfoEditButton(); + manageUserPage.clearUserContactInfo(); + manageUserPage.clickSubmit(); + manageUserPage.verifyErrorText("Please enter a valid email address"); + manageUserPage.verifyErrorText("Please enter valid phone number"); + manageUserPage.editUserContactInfo("dev@gmail.com", "6234343435"); + manageUserPage.clickSubmit(); + manageUserPage.clickContactInfoViewButton(); + manageUserPage.verifyEditUserContactInfo("dev@gmail.com", "6234343435"); + }); + + it("edit a nurse user's professional information and verify its reflection", () => { + userPage.typeInSearchInput(nurseUsername); + userPage.checkUsernameText(nurseUsername); + manageUserPage.clickMoreDetailsButton(nurseUsername); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickProfessionalInfoViewButton(); + // Should have qualification field + // Should not have years of experience and medical council registration fields + manageUserPage.verifyQualificationExist(); + manageUserPage.verifyYoeAndCouncilRegistrationDoesntExist(); + manageUserPage.clickProfessionalInfoEditButton(); + manageUserPage.clearDoctorOrNurseProfessionalInfo(false); + manageUserPage.clickSubmit(); + manageUserPage.verifyErrorText("Qualification is required"); + manageUserPage.editUserProfessionalInfo("Msc"); + manageUserPage.clickSubmit(); + manageUserPage.clickProfessionalInfoViewButton(); + manageUserPage.verifyEditUserProfessionalInfo("Msc"); + }); + + it("edit a doctor user's professional information and verify its reflection", () => { + // Should have qualification, years of experience and medical council registration + userPage.typeInSearchInput(usernameToLinkFacilitydoc1); + userPage.checkUsernameText(usernameToLinkFacilitydoc1); + manageUserPage.clickMoreDetailsButton(usernameToLinkFacilitydoc1); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickProfessionalInfoViewButton(); + manageUserPage.verifyQualificationExist(); + manageUserPage.verifyYoeAndCouncilRegistrationExist(); + manageUserPage.clickProfessionalInfoEditButton(); + manageUserPage.clearDoctorOrNurseProfessionalInfo(true); + manageUserPage.clickSubmit(); + manageUserPage.verifyErrorText("Qualification is required"); + manageUserPage.verifyErrorText("Years of experience is required"); + manageUserPage.verifyErrorText("Medical Council Registration is required"); + manageUserPage.editUserProfessionalInfo("Msc", "120", "1234567890"); + manageUserPage.clickSubmit(); + manageUserPage.verifyErrorText( + "Please enter a valid number between 0 and 100.", + ); + manageUserPage.clearDoctorOrNurseProfessionalInfo(true); + manageUserPage.editUserProfessionalInfo("Msc", "10", "1234567890"); + manageUserPage.clickSubmit(); + manageUserPage.clickProfessionalInfoViewButton(); + const experienceCommencedOn = dayjs().subtract(10, "year"); + const formattedDate = dayjs(experienceCommencedOn).format("YYYY-MM-DD"); + manageUserPage.verifyEditUserProfessionalInfo( + "Msc", + formattedDate, + "1234567890", + ); + }); + + it("Nurse user doesn't have edit options or password change option (for other users)", () => { + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsNurse(); + loginPage.ensureLoggedIn(); + cy.visit("/users"); + userPage.typeInSearchInput(doctorUsername); + userPage.checkUsernameText(doctorUsername); + manageUserPage.clickMoreDetailsButton(doctorUsername); + manageUserPage.verifyMoreDetailsPage(false); + manageUserPage.verifyUsername(doctorUsername); + manageUserPage.verifyBasicInfoEditButtonNotExist(); + manageUserPage.verifyContactInfoEditButtonNotExist(); + manageUserPage.verifyProfessionalInfoEditButtonNotExist(); + manageUserPage.verifyPasswordEditButtonNotExist(); + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsDistrictAdmin(); + loginPage.ensureLoggedIn(); + }); + + it("Nurse user doesn't have delete option for other users", () => { + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsNurse(); + loginPage.ensureLoggedIn(); + cy.visit("/users"); + userPage.typeInSearchInput(doctorUsername); + userPage.checkUsernameText(doctorUsername); + manageUserPage.clickMoreDetailsButton(doctorUsername); + manageUserPage.verifyMoreDetailsPage(false); + manageUserPage.verifyDeleteButtonNotExist(); + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsDistrictAdmin(); + loginPage.ensureLoggedIn(); + }); + + it("Nurse user can change their own password", () => { + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsNurse(); + loginPage.ensureLoggedIn(); + cy.visit("/users"); + userPage.typeInSearchInput(nurseUsername); + userPage.checkUsernameText(nurseUsername); + manageUserPage.clickMoreDetailsButton(nurseUsername); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickPasswordEditButton(); + manageUserPage.changePassword("Coronasafe@123", "Coronasafe@1233"); + manageUserPage.clickSubmit(); + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsNurse("Coronasafe@1233"); + loginPage.ensureLoggedIn(); + cy.visit("/users"); + userPage.typeInSearchInput(nurseUsername); + userPage.checkUsernameText(nurseUsername); + manageUserPage.clickMoreDetailsButton(nurseUsername); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickPasswordEditButton(); + manageUserPage.changePassword("Coronasafe@1233", "Coronasafe@123"); + manageUserPage.clickSubmit(); + loginPage.ensureLoggedIn(); + loginPage.clickSignOutBtn(); + loginPage.loginManuallyAsDistrictAdmin(); + loginPage.ensureLoggedIn(); + }); + + it("District Admin can delete a user", () => { + userPage.typeInSearchInput(doctorToDelete); + userPage.checkUsernameText(doctorToDelete); + manageUserPage.clickMoreDetailsButton(doctorToDelete); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.verifyDeleteButtonVisible(); + manageUserPage.clickDeleteButton(); + manageUserPage.clickSubmit(); + cy.verifyNotification("User Deleted Successfully"); + cy.closeNotification(); + userPage.typeInSearchInput(doctorToDelete); + userPage.checkUsernameTextDoesNotExist(doctorToDelete); + }); + it("linking skills for users and verify its reflection in profile", () => { // select the district user and select one skill link and verify its profile reflection userPage.typeInSearchInput(usernameforworkinghour); userPage.checkUsernameText(usernameforworkinghour); - manageUserPage.clicklinkedskillbutton(); + manageUserPage.clickMoreDetailsButton(usernameforworkinghour); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickLinkedSkillTab(); + cy.wait(500); + manageUserPage.verifyLinkedSkillsTabPage(); manageUserPage.selectSkillFromDropdown(linkedskill); - manageUserPage.clickAddSkillButton(); - manageUserPage.clickCloseSlideOver(); - cy.wait(5000); - manageUserPage.clicklinkedskillbutton(); + manageUserPage.clickAddSkillButton(usernameforworkinghour); + cy.wait(500); manageUserPage.assertSkillInAddedUserSkills(linkedskill); - manageUserPage.clickCloseSlideOver(); - cy.wait(5000); + cy.wait(500); manageUserPage.navigateToProfile(); cy.verifyContentPresence("#username-profile-details", [ usernameforworkinghour, ]); manageUserPage.assertSkillInAlreadyLinkedSkills(linkedskill); + // unlink the skill + manageUserPage.navigateToManageUser(); + userPage.typeInSearchInput(usernameforworkinghour); + userPage.checkUsernameText(usernameforworkinghour); + manageUserPage.clickMoreDetailsButton(usernameforworkinghour); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickLinkedSkillTab(); + manageUserPage.assertSkillInAddedUserSkills(linkedskill); + manageUserPage.clickUnlinkSkill(); + manageUserPage.verifyUnlinkSkillModal(); + manageUserPage.clickConfirmUnlinkSkill(); }); it("linking skills for a doctor users and verify its reflection in doctor connect", () => { @@ -62,13 +264,15 @@ describe("Manage User", () => { userPage.selectHomeFacility(facilitytolinkskill); advanceFilters.applySelectedFilter(); userPage.checkUsernameText(usernameToLinkSkill); - manageUserPage.clicklinkedskillbutton(); + manageUserPage.clickMoreDetailsButton(usernameToLinkSkill); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickLinkedSkillTab(); + manageUserPage.verifyLinkedSkillsTabPage(); manageUserPage.selectSkillFromDropdown(linkedskill); - manageUserPage.clickAddSkillButton(); + manageUserPage.clickAddSkillButton(usernameToLinkSkill); cy.verifyNotification("Skill added successfully"); cy.closeNotification(); manageUserPage.assertSkillInAddedUserSkills(linkedskill); - manageUserPage.clickCloseSlideOver(); // verifying the doctor connect facilityHome.navigateToFacilityHomepage(); facilityHome.typeFacilitySearch(facilitytolinkskill); @@ -79,16 +283,27 @@ describe("Manage User", () => { }); it("add working hour for a user and verify its reflection in card and user profile", () => { - // verify mandatory field error and select working hour for a user + // verify qualification and yoe and council registration fields are not present + // verify field error and add working hour userPage.typeInSearchInput(usernameforworkinghour); userPage.checkUsernameText(usernameforworkinghour); - manageUserPage.clicksetaveragehourbutton(); - manageUserPage.clearweeklyhourfield(); + manageUserPage.clickMoreDetailsButton(usernameforworkinghour); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.verifyProfileTabPage(); + manageUserPage.clickProfessionalInfoViewButton(); + manageUserPage.verifyQualificationDoesntExist(); + manageUserPage.verifyYoeAndCouncilRegistrationDoesntExist(); + manageUserPage.clickProfessionalInfoEditButton(); + manageUserPage.clearProfessionalInfo(); + manageUserPage.typeInWeeklyWorkingHours("200"); manageUserPage.clickSubmit(); - manageUserPage.verifyErrorText("Value should be between 0 and 168"); - // verify the data is reflected in user card and profile page + manageUserPage.verifyErrorText( + "Average weekly working hours must be a number between 0 and 168", + ); + manageUserPage.clearProfessionalInfo(); manageUserPage.typeInWeeklyWorkingHours(workinghour); manageUserPage.clickSubmit(); + // verify the data is reflected in the page manageUserPage.verifyWorkingHours(workinghour); manageUserPage.navigateToProfile(); manageUserPage.verifyProfileWorkingHours(workinghour); @@ -98,42 +313,51 @@ describe("Manage User", () => { // verify the user doesn't have any home facility userPage.typeInSearchInput(usernameToLinkFacilitydoc1); userPage.checkUsernameText(usernameToLinkFacilitydoc1); - manageUserPage.assertHomeFacility("No Home Facility"); + manageUserPage.assertHomeFacility("No home facility"); + manageUserPage.clickMoreDetailsButton(usernameToLinkFacilitydoc1); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickLinkedFacilitiesTab(); + manageUserPage.verifyLinkedFacilitiesTabPage(); // Link a new facility and ensure it is under linked facility - doctor username (1) - manageUserPage.clickFacilitiesTab(); manageUserPage.selectFacilityFromDropdown(facilitytolinkusername); manageUserPage.clickLinkFacility(); manageUserPage.assertLinkedFacility(facilitytolinkusername); // Verify in the already linked facility are not present in droplist manageUserPage.assertFacilityNotInDropdown(facilitytolinkusername); - manageUserPage.clickCloseSlideOver(); + // Go back to manage user page + manageUserPage.navigateToManageUser(); // Link a new facility and ensure it is under home facility - doctor username (2) - userPage.clearSearchInput(); userPage.typeInSearchInput(usernameToLinkFacilitydoc2); userPage.checkUsernameText(usernameToLinkFacilitydoc2); - manageUserPage.clickFacilitiesTab(); + manageUserPage.clickMoreDetailsButton(usernameToLinkFacilitydoc2); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickLinkedFacilitiesTab(); + manageUserPage.verifyLinkedFacilitiesTabPage(); manageUserPage.selectFacilityFromDropdown(facilitytolinkusername); manageUserPage.clickLinkFacility(); - manageUserPage.clickHomeFacilityIcon(); + manageUserPage.clickLinkedFacilitySettings(); + manageUserPage.clickSetHomeFacility(); manageUserPage.assertnotLinkedFacility(facilitytolinkusername); manageUserPage.assertHomeFacilitylink(facilitytolinkusername); - manageUserPage.clickCloseSlideOver(); // verify the home facility doctor id have reflection in user card - userPage.clearSearchInput(); + manageUserPage.navigateToManageUser(); userPage.typeInSearchInput(usernameToLinkFacilitydoc2); userPage.checkUsernameText(usernameToLinkFacilitydoc2); manageUserPage.assertHomeFacility(facilitytolinkusername); // Link a new facility and unlink the facility from the doctor username (3) - userPage.clearSearchInput(); + manageUserPage.navigateToManageUser(); userPage.typeInSearchInput(usernameToLinkFacilitydoc3); userPage.checkUsernameText(usernameToLinkFacilitydoc3); - manageUserPage.clickFacilitiesTab(); + manageUserPage.clickMoreDetailsButton(usernameToLinkFacilitydoc3); + manageUserPage.verifyMoreDetailsPage(); + manageUserPage.clickLinkedFacilitiesTab(); + manageUserPage.verifyLinkedFacilitiesTabPage(); manageUserPage.selectFacilityFromDropdown(facilitytolinkusername); manageUserPage.clickLinkFacility(); + manageUserPage.clickLinkedFacilitySettings(); manageUserPage.clickUnlinkFacilityButton(); manageUserPage.clickSubmit(); manageUserPage.linkedfacilitylistnotvisible(); - manageUserPage.clickCloseSlideOver(); // Go to particular facility doctor connect and all user-id are reflected based on there access // Path will be facility page to patient page then doctor connect button facilityHome.navigateToFacilityHomepage(); diff --git a/cypress/pageobject/Asset/AssetCreation.ts b/cypress/pageobject/Asset/AssetCreation.ts index 5e590a0f7fd..7a37f65dff7 100644 --- a/cypress/pageobject/Asset/AssetCreation.ts +++ b/cypress/pageobject/Asset/AssetCreation.ts @@ -31,39 +31,68 @@ export class AssetPage { }); } - enterAssetDetails( - name: string, - description: string, - workingStatus: string, - qrId: string, - manufacturer: string, - warranty: string, - supportName: string, - supportPhone: string, - supportEmail: string, - vendorName: string, - serialNumber: string, - lastServicedOn: string, - notes: string, - ) { - cy.get("[data-testid=asset-name-input] input").type(name); - cy.get("[data-testid=asset-description-input] textarea").type(description); - cy.get("[data-testid=asset-working-status-input] li") - .contains(workingStatus) - .click(); - cy.get("[data-testid=asset-qr-id-input] input").type(qrId); - cy.get("[data-testid=asset-manufacturer-input] input").type(manufacturer); - cy.get("[data-testid=asset-warranty-input] input").type(warranty); - cy.get("[data-testid=asset-support-name-input] input").type(supportName); - cy.get("#customer-support-phone-div").type(supportPhone); - cy.get("[data-testid=asset-support-email-input] input").type(supportEmail); - cy.get("[data-testid=asset-vendor-name-input] input").type(vendorName); - cy.get("[data-testid=asset-serial-number-input] input").type(serialNumber); - cy.clickAndTypeDate( - "[data-testid=asset-last-serviced-on-input]", - lastServicedOn, - ); - cy.get("[data-testid=asset-notes-input] textarea").type(notes); + enterAssetDetails({ + name, + description, + workingStatus, + qrId, + manufacturer, + warranty, + supportName, + supportPhone, + supportEmail, + vendorName, + serialNumber, + lastServicedOn, + notes, + }: { + name?: string; + description?: string; + workingStatus?: string; + qrId?: string; + manufacturer?: string; + warranty?: string; + supportName?: string; + supportPhone?: string; + supportEmail?: string; + vendorName?: string; + serialNumber?: string; + lastServicedOn?: string; + notes?: string; + }) { + if (name) cy.get("[data-testid=asset-name-input] input").type(name); + if (description) + cy.get("[data-testid=asset-description-input] textarea").type( + description, + ); + if (workingStatus) + cy.get("[data-testid=asset-working-status-input] li") + .contains(workingStatus) + .click(); + if (qrId) cy.get("[data-testid=asset-qr-id-input] input").type(qrId); + if (manufacturer) + cy.get("[data-testid=asset-manufacturer-input] input").type(manufacturer); + if (warranty) + cy.get("[data-testid=asset-warranty-input] input").type(warranty); + if (supportName) + cy.get("[data-testid=asset-support-name-input] input").type(supportName); + if (supportPhone) cy.get("#customer-support-phone-div").type(supportPhone); + if (supportEmail) + cy.get("[data-testid=asset-support-email-input] input").type( + supportEmail, + ); + if (vendorName) + cy.get("[data-testid=asset-vendor-name-input] input").type(vendorName); + if (serialNumber) + cy.get("[data-testid=asset-serial-number-input] input").type( + serialNumber, + ); + if (lastServicedOn) + cy.clickAndTypeDate( + "[data-testid=asset-last-serviced-on-input]", + lastServicedOn, + ); + if (notes) cy.get("[data-testid=asset-notes-input] textarea").type(notes); } interceptAssetCreation() { diff --git a/cypress/pageobject/Facility/FacilityHome.ts b/cypress/pageobject/Facility/FacilityHome.ts index da26cc29fc7..fe8585b48be 100644 --- a/cypress/pageobject/Facility/FacilityHome.ts +++ b/cypress/pageobject/Facility/FacilityHome.ts @@ -26,8 +26,7 @@ class FacilityHome { } typeFacilitySearch(facilityName: string) { - cy.get("#search-by-facility").click().clear(); - cy.get("#search-by-facility").click().type(facilityName); + cy.get("#facility-search").click().clear().type(facilityName); } clickMenuItem(itemName: string) { @@ -92,11 +91,6 @@ class FacilityHome { cy.get('[data-test-id="occupancy-badge"]').should("be.visible"); } - verifyAndCloseNotifyModal() { - cy.get("#cancel").should("be.visible"); - cy.get("#cancel").click(); - } - navigateBack() { cy.go(-1); } diff --git a/cypress/pageobject/Facility/FacilityLocation.ts b/cypress/pageobject/Facility/FacilityLocation.ts index 31bc30290f7..9cb23f9fba0 100644 --- a/cypress/pageobject/Facility/FacilityLocation.ts +++ b/cypress/pageobject/Facility/FacilityLocation.ts @@ -1,37 +1,81 @@ -class FacilityLocation { - loadLocationManagementPage(name: string) { - cy.awaitUrl("/"); - cy.intercept("GET", "**/api/v1/facility/**").as("getFacilities"); - cy.get("[id='facility-name-card']").contains(name).click(); - cy.wait("@getFacilities").its("response.statusCode").should("eq", 200); - cy.get("h1.text-3xl.font-bold", { timeout: 10000 }).should("be.visible"); - cy.get("#manage-facility-dropdown button").should("be.visible"); - cy.get("[id='manage-facility-dropdown']").scrollIntoView().click(); - cy.get("[id=location-management]").click(); - } +import FacilityPage from "./FacilityCreation"; +import FacilityHome from "./FacilityHome"; - closeNotification() { - cy.get(".pnotify") - .should("exist") - .each(($div) => { - cy.wrap($div).click(); - }); +const facilityHome = new FacilityHome(); +const facilityPage = new FacilityPage(); + +class FacilityLocation { + navigateToFacilityLocationManagement(facilityName: string) { + facilityHome.typeFacilitySearch(facilityName); + facilityHome.assertFacilityInCard(facilityName); + facilityHome.clickViewFacilityDetails(); + facilityPage.clickManageFacilityDropdown(); + this.clickFacilityLocationManagement(); + } + + fillLocationDetails( + name?: string, + description?: string, + type?: string, + middleware?: string, + ) { + if (name) this.typeLocationName(name); + if (description) this.fillDescription(description); + if (type) this.selectLocationType(type); + if (middleware) this.fillMiddlewareAddress(middleware); + } + + fetchAndNavigateToLocationPage() { + cy.url().then((currentUrl) => { + const baseUrl = currentUrl.split("/location/")[0]; + const locationUrl = `${baseUrl}/location/`; + cy.visit(locationUrl); + }); + } + + fillBedForm( + bedName?: string, + bedDescription?: string, + bedType?: string, + numberOfBeds?: number, + ) { + if (bedName) this.enterBedName(bedName); + if (bedDescription) { + this.enterBedDescription(bedDescription); + } + if (bedType) this.selectBedType(bedType); + if (numberOfBeds && numberOfBeds > 1) { + this.setMultipleBeds(numberOfBeds); + } } clickAddNewLocationButton() { cy.get("#add-new-location").click(); } + typeLocationName(locationName: string) { + cy.get("#location-name").type(locationName); + } + clickFacilityLocationManagement() { cy.get("[id=location-management]").click(); } + clickAddLocationButton() { + cy.clickSubmitButton("Add Location"); + } + clickEditLocationButton() { cy.get("#edit-location-button").click(); } - clickEditBedButton() { - cy.get("#edit-bed-button").click(); + clickEditBedButton(cardText: string) { + cy.get("#bed-cards") + .contains(cardText) + .parents("#bed-cards") + .within(() => { + cy.get("#edit-bed-button").click(); + }); } fillDescription(description: string) { @@ -42,13 +86,8 @@ class FacilityLocation { cy.get("div").contains(name).click(); } - enterLocationName(name: string) { - cy.get("input[id=name]").type(name); - } - selectLocationType(type: string) { - cy.get("#location-type").click(); - cy.get("li[role=option]").contains(type).click(); + cy.clickAndSelectOption("#location-type", type); } fillMiddlewareAddress(address: string) { @@ -63,10 +102,6 @@ class FacilityLocation { cy.get("#location-type").contains(type); } - verifyNotification(message: string) { - cy.get(".pnotify-container").should("contain", message).and("be.visible"); - } - verifyLocationDescription(description: string) { cy.get("#view-location-description").contains(description); } @@ -75,16 +110,49 @@ class FacilityLocation { cy.get("#view-location-middleware").contains(middleware); } - clickManageBedButton() { - cy.get("#manage-bed-button").first().click(); + clickManageBedButton(cardText: string) { + cy.get("#location-cards") + .contains(cardText) + .parents("#location-cards") + .within(() => { + cy.get("#manage-bed-button").click(); + }); + } + + clickManageBedPopup() { + cy.get("#manage-beds").click(); } clickAddBedButton() { - cy.get("#add-new-bed").click(); + cy.verifyAndClickElement("#add-new-bed", "Add New Bed(s)"); + } + + clickSubmitBedsButton() { + cy.clickSubmitButton("Add Bed(s)"); + } + + closeAddLocationForm() { + cy.clickCancelButton("Cancel"); + } + + verifyAddLocationSuccessfulMesssage() { + cy.verifyNotification("Location created successfully"); + cy.closeNotification(); } - clickNotification() { - cy.get(".pnotify-container").click(); + verifyEditBedSuccessfulMessage() { + cy.verifyNotification("Bed updated successfully"); + cy.closeNotification(); + } + + verifyEditLocationSuccessfulMessage() { + cy.verifyNotification("Location updated successfully"); + cy.closeNotification(); + } + + verifyAddSingleBedSuccessfulMesssage() { + cy.verifyNotification("1 Bed created successfully"); + cy.closeNotification(); } enterBedName(name: string) { @@ -95,6 +163,14 @@ class FacilityLocation { cy.get("#bed-description").clear().click().type(description); } + clickUpdateBedButton() { + cy.clickSubmitButton("Update Bed"); + } + + clickUpdateLocationButton() { + cy.clickSubmitButton("Update Location"); + } + selectBedType(type: string) { cy.get("#bed-type").click(); cy.get("li[role=option]").contains(type).click(); @@ -136,16 +212,22 @@ class FacilityLocation { cy.get("#manage-assets").click(); } - deleteLocation(name: string) { - cy.contains("div", name) - .should("exist") - .then(($div) => { - $div.parents("div").eq(2).find("button#delete-location-button").click(); + clickDeleteLocation(cardText: string) { + cy.get("#location-cards") + .contains(cardText) + .parents("#location-cards") + .within(() => { + cy.verifyAndClickElement("#delete-location-button", "Delete"); }); } - deleteFirstBed() { - cy.get("#delete-bed-button").first().click(); + deleteBedWithName(text: string) { + cy.get("#bed-cards") + .contains(text) + .parents("#bed-cards") + .within(() => { + cy.get("#delete-bed-button").click(); + }); } deleteBedRequest() { diff --git a/cypress/pageobject/Login/LoginPage.ts b/cypress/pageobject/Login/LoginPage.ts index 481c6ec3045..64e9cd5f25e 100644 --- a/cypress/pageobject/Login/LoginPage.ts +++ b/cypress/pageobject/Login/LoginPage.ts @@ -19,9 +19,11 @@ class LoginPage { cy.clickSubmitButton("Login"); } - loginManuallyAsNurse(): void { + loginManuallyAsNurse(password?: string): void { cy.get("input[id='username']").click().type("dummynurse1"); - cy.get("input[id='password']").click().type("Coronasafe@123"); + cy.get("input[id='password']") + .click() + .type(password || "Coronasafe@123"); cy.clickSubmitButton("Login"); } diff --git a/cypress/pageobject/Patient/PatientCreation.ts b/cypress/pageobject/Patient/PatientCreation.ts index 1f915f5474e..febf31b33b5 100644 --- a/cypress/pageobject/Patient/PatientCreation.ts +++ b/cypress/pageobject/Patient/PatientCreation.ts @@ -11,7 +11,8 @@ export class PatientPage { } visitPatient(patientName: string) { - cy.get("#name").click().type(patientName); + cy.get('[data-test-id="patient-search__name"]').click(); + cy.get("#patient-search").click().type(patientName); // Type the patient name cy.intercept("GET", "**/api/v1/consultation/**").as("getPatient"); cy.get("#patient-name-list").contains(patientName).click(); cy.wait(2000); @@ -66,7 +67,8 @@ export class PatientPage { } typePatientNameList(patientName: string) { - cy.get("#name").click().type(patientName); + cy.get('[data-test-id="patient-search__name"]').click(); + cy.get("#patient-search").click().type(patientName); } typePatientAddress(address: string) { diff --git a/cypress/pageobject/Patient/PatientLogupdate.ts b/cypress/pageobject/Patient/PatientLogupdate.ts index 68287bfae41..45c1924e1a3 100644 --- a/cypress/pageobject/Patient/PatientLogupdate.ts +++ b/cypress/pageobject/Patient/PatientLogupdate.ts @@ -89,7 +89,7 @@ class PatientLogupdate { } clickUpdateDetail() { - cy.verifyAndClickElement("#consultation-preview", "Update Details"); + cy.verifyAndClickElement("#consultation-preview", "Update Log"); cy.wait(3000); } diff --git a/cypress/pageobject/Users/ManageUserPage.ts b/cypress/pageobject/Users/ManageUserPage.ts index 01871acd97d..24c056a70eb 100644 --- a/cypress/pageobject/Users/ManageUserPage.ts +++ b/cypress/pageobject/Users/ManageUserPage.ts @@ -1,10 +1,6 @@ export class ManageUserPage { assertHomeFacility(expectedText: string) { - cy.get("#home_facility").should("contain.text", expectedText); - } - - clickFacilitiesTab() { - cy.get("#facilities").click(); + cy.get("#home-facility").should("contain.text", expectedText); } selectFacilityFromDropdown(facilityName: string) { @@ -15,10 +11,6 @@ export class ManageUserPage { cy.typeAndSelectOption("input[name='skill']", skill); } - clickLinkFacility() { - cy.get("#link-facility").click(); - } - assertLinkedFacility(facilityName: string) { cy.get("#linked-facility-list").should("contain.text", facilityName); } @@ -40,36 +32,157 @@ export class ManageUserPage { cy.get("[role='option']").should("not.exist"); } - clickCloseSlideOver() { - cy.get("#close-slide-over").click({ force: true }); + clickLinkedFacilitySettings() { + cy.get("#linked-facility-settings").click(); } - clickHomeFacilityIcon() { - cy.get("#home-facility-icon").click(); + clickSetHomeFacility() { + cy.get("#set-home-facility").click(); } clickUnlinkFacilityButton() { - cy.get("#unlink-facility-button").click(); + cy.get("#unlink-facility").click(); + } + + clickConfirmUnlinkSkill() { + cy.get("button[name='confirm-unlink-skill']").click(); + } + + clickLinkFacility() { + cy.get("#link-facility").click(); } clickSubmit() { cy.get("#submit").click(); } - clicksetaveragehourbutton() { - cy.get("#avg-workinghour").click(); + verifyErrorText(expectedError: string) { + cy.get(".error-text").first().scrollIntoView(); + cy.get(".error-text") + .should("be.visible") + .then(($elements) => { + const errorTextArray = Array.from($elements).map( + (el) => el.textContent, + ); + expect(errorTextArray).to.include(expectedError); + }); } - clearweeklyhourfield() { - cy.get("#weekly_working_hours").click().clear(); + clearUserBasicInfo() { + cy.get("input[name='first_name']").click().clear(); + cy.get("input[name='last_name']").click().clear(); } - verifyErrorText(expectedError: string) { - cy.get(".error-text").should("contain", expectedError).and("be.visible"); + editUserBasicInfo( + fName: string, + lName: string, + dateOfBirth: string, + gender: string, + ) { + cy.get("input[name='first_name']").click().type(fName); + cy.get("input[name='last_name']").click().type(lName); + cy.clickAndTypeDate("#date_of_birth", dateOfBirth); + cy.get("#gender").click(); + cy.get("[role='option']").contains(gender).click(); + } + + verifyEditUserDetails( + fName: string, + lName: string, + dateOfBirth: string, + gender: string, + ) { + cy.get("#view-first_name").should("contain.text", fName); + cy.get("#view-last_name").should("contain.text", lName); + cy.get("#view-date_of_birth").should("contain.text", dateOfBirth); + cy.get("#view-gender").should("contain.text", gender); + } + + clearUserContactInfo() { + cy.get("input[name='email']").click().clear(); + cy.get("input[name='phone_number']").click().clear(); + cy.get("input[name='phone_number_is_whatsapp']").should("be.checked"); + } + + editUserContactInfo(email: string, phoneNumber: string) { + cy.get("input[name='email']").click().type(email); + cy.get("input[name='phone_number']").click().type(phoneNumber); + cy.get("input[name='phone_number_is_whatsapp']").should("be.checked"); + } + + verifyEditUserContactInfo(email: string, phoneNumber: string) { + cy.get("#view-email").should("contain.text", email); + cy.get("#view-phone_number").should("contain.text", phoneNumber); + cy.get("#view-whatsapp_number").should("contain.text", phoneNumber); + } + + clearDoctorOrNurseProfessionalInfo(yoeAndCouncilRegistration: boolean) { + cy.get("input[name='qualification']").click().clear(); + if (yoeAndCouncilRegistration) { + cy.get("input[name='doctor_experience_commenced_on']").click().clear(); + cy.get("input[name='doctor_medical_council_registration']") + .click() + .clear(); + } + } + + clearProfessionalInfo() { + cy.get("input[name='weekly_working_hours']").scrollIntoView(); + cy.get("input[name='weekly_working_hours']").click().clear(); + cy.get("input[name='video_connect_link']").click().clear(); + } + + editUserProfessionalInfo( + qualification: string, + yearsOfExperience?: string, + medicalCouncilRegistration?: string, + ) { + cy.get("input[name='qualification']").click().type(qualification); + if (yearsOfExperience) { + cy.get("input[name='doctor_experience_commenced_on']") + .click() + .type(yearsOfExperience); + } + if (medicalCouncilRegistration) { + cy.get("input[name='doctor_medical_council_registration']") + .click() + .type(medicalCouncilRegistration); + } + } + + verifyEditUserProfessionalInfo( + qualification: string, + yearsOfExperience?: string, + medicalCouncilRegistration?: string, + ) { + cy.get("#view-qualification").should("contain.text", qualification); + if (yearsOfExperience) { + cy.get("#view-years_of_experience").should( + "contain.text", + yearsOfExperience, + ); + } + if (medicalCouncilRegistration) { + cy.get("#view-doctor_medical_council_registration").should( + "contain.text", + medicalCouncilRegistration, + ); + } + } + + verifyPasswordEditButtonNotExist() { + cy.get("#change-edit-password-button").should("not.exist"); + } + + changePassword(oldPassword: string, newPassword: string) { + cy.get("input[name='old_password']").click().type(oldPassword); + cy.get("input[name='new_password_1']").click().type(newPassword); + cy.get("input[name='new_password_2']").click().type(newPassword); } typeInWeeklyWorkingHours(hours: string) { - cy.get("#weekly_working_hours").click().type(hours); + cy.get("input[name='weekly_working_hours']").scrollIntoView(); + cy.get("input[name='weekly_working_hours']").click().type(hours); } navigateToProfile() { @@ -80,14 +193,19 @@ export class ManageUserPage { } verifyWorkingHours(expectedHours: string) { - cy.get("#working-hours").should("contain", `${expectedHours} hours`); + cy.verifyContentPresence("#view-average_weekly_working_hours", [ + expectedHours, + ] as string[]); } verifyProfileWorkingHours(expectedHours: string) { - cy.get("#averageworkinghour-profile-details").should( - "contain", + cy.verifyContentPresence("#averageworkinghour-profile-details", [ expectedHours, - ); + ] as string[]); + } + + navigateToManageUser() { + cy.visit("/users"); } clickFacilityPatients() { @@ -95,12 +213,153 @@ export class ManageUserPage { cy.get("#facility-patients").click(); } - clicklinkedskillbutton() { + clickLinkedSkillTab() { cy.get("#skills").click(); } - clickAddSkillButton() { - cy.intercept("GET", "**/api/v1/skill/**").as("getSkills"); + clickLinkedFacilitiesTab() { + cy.get("#facilities").click(); + } + + clickMoreDetailsButton(username: string) { + cy.intercept("GET", "**/api/v1/users/**").as("getUserDetails"); + cy.get(`#more-details-${username}`).click(); + cy.wait("@getUserDetails"); + } + + verifyMoreDetailsPage(hasPermissions = true) { + cy.get("#username").should("be.visible"); + cy.get("#role").should("be.visible"); + cy.get("#usermanagement_tab_nav").should("be.visible"); + cy.get("#profile").should("be.visible"); + if (hasPermissions) { + cy.get("#facilities").should("be.visible"); + cy.get("#skills").should("be.visible"); + } + cy.get("#view-username").scrollIntoView(); + cy.get("#view-username").should("be.visible"); + } + + verifyChangeAvatarButtonVisible() { + cy.get("#change-avatar").should("be.visible"); + } + + clickChangeAvatarButton() { + cy.get("#change-avatar").click(); + } + + clickBasicInfoViewButton() { + cy.get("#basic-info-view-button").scrollIntoView(); + cy.get("#basic-info-view-button").should("be.visible"); + cy.get("#basic-info-view-button").click(); + } + + clickBasicInfoEditButton() { + cy.get("#basic-info-edit-button").scrollIntoView(); + cy.get("#basic-info-edit-button").should("be.visible"); + cy.get("#basic-info-edit-button").click(); + } + + clickContactInfoViewButton() { + cy.get("#contact-info-view-button").scrollIntoView(); + cy.get("#contact-info-view-button").should("be.visible"); + cy.get("#contact-info-view-button").click(); + } + + clickContactInfoEditButton() { + cy.get("#contact-info-edit-button").scrollIntoView(); + cy.get("#contact-info-edit-button").should("be.visible"); + cy.get("#contact-info-edit-button").click(); + } + + clickProfessionalInfoViewButton() { + cy.get("#professional-info-view-button").scrollIntoView(); + cy.get("#professional-info-view-button").should("be.visible"); + cy.get("#professional-info-view-button").click(); + } + + clickProfessionalInfoEditButton() { + cy.get("#professional-info-edit-button").scrollIntoView(); + cy.get("#professional-info-edit-button").should("be.visible"); + cy.get("#professional-info-edit-button").click(); + } + + clickPasswordEditButton() { + cy.get("#change-edit-password-button").scrollIntoView(); + cy.get("#change-edit-password-button").should("be.visible"); + cy.get("#change-edit-password-button").click(); + } + + verifyQualificationDoesntExist() { + cy.get("input[name='qualification']").should("not.exist"); + } + + verifyQualificationExist() { + cy.get("#view-qualification").should("be.visible"); + } + + verifyYoeAndCouncilRegistrationDoesntExist() { + cy.get("#view-years_of_experience").should("not.exist"); + cy.get("#view-doctor_medical_council_registration").should("not.exist"); + } + + verifyYoeAndCouncilRegistrationExist() { + cy.get("#view-years_of_experience").should("be.visible"); + cy.get("#view-doctor_medical_council_registration").should("be.visible"); + } + + verifyUsername(username: string) { + cy.get("#view-username").should("contain", username); + } + + verifyBasicInfoEditButtonNotExist() { + cy.get("#basic-info-edit-button").should("not.exist"); + } + + verifyContactInfoEditButtonNotExist() { + cy.get("#contact-info-edit-button").should("not.exist"); + } + + verifyProfessionalInfoEditButtonNotExist() { + cy.get("#professional-info-edit-button").should("not.exist"); + } + + verifyProfileTabPage() { + cy.get("#user-edit-form").should("be.visible"); + } + + verifyDoctorQualification() { + cy.get("#view-qualification").should("be.visible"); + } + + verifyDoctorQualificationDoesNotExist() { + cy.get("#view-qualification").should("not.exist"); + } + + verifyLinkedSkillsTabPage() { + cy.get("#select-skill").scrollIntoView(); + cy.get("#select-skill").should("be.visible"); + } + + verifyLinkedFacilitiesTabPage() { + cy.get("#select-facility").should("be.visible"); + } + + verifyDeleteButtonNotExist() { + cy.get("[data-testid='user-delete-button']").should("not.exist"); + } + + verifyDeleteButtonVisible() { + cy.get("[data-testid='user-delete-button']").scrollIntoView(); + cy.get("[data-testid='user-delete-button']").should("be.visible"); + } + + clickDeleteButton() { + cy.get("[data-testid='user-delete-button']").click(); + } + + clickAddSkillButton(username: string) { + cy.intercept("GET", `**/api/v1/users/${username}/skill/**`).as("getSkills"); cy.get("#add-skill-button").click(); cy.wait("@getSkills").its("response.statusCode").should("eq", 200); } @@ -125,10 +384,19 @@ export class ManageUserPage { cy.get("#unlink-skill").click(); } + verifyUnlinkSkillModal() { + cy.get("#unlink-skill-modal-description").should("be.visible"); + cy.get("button[name='confirm-unlink-skill']").should("be.visible"); + } + assertSkillInAddedUserSkills(skillName: string) { cy.get("#added-user-skills").should("contain", skillName); } + assertSkillNotInAddedUserSkills(skillName: string) { + cy.get("#added-user-skills").should("not.contain", skillName); + } + assertDoctorConnectVisibility(realName: string) { cy.get('*[id="doctor-connect-home-doctor"]').should( "contain.text", diff --git a/cypress/pageobject/Users/UserCreation.ts b/cypress/pageobject/Users/UserCreation.ts index 72157861f2c..26eaa088e23 100644 --- a/cypress/pageobject/Users/UserCreation.ts +++ b/cypress/pageobject/Users/UserCreation.ts @@ -46,17 +46,6 @@ export class UserCreationPage { } clickSaveUserButton() { - cy.clickSubmitButton("Save User"); - } - - verifyErrorMessages(errorMessages: string[]) { - cy.get(".error-text").then(($errors) => { - const displayedErrorMessages = $errors - .map((_, el) => Cypress.$(el).text()) - .get(); - errorMessages.forEach((errorMessage) => { - expect(displayedErrorMessages).to.include(errorMessage); - }); - }); + cy.clickSubmitButton("Submit"); } } diff --git a/cypress/pageobject/Users/UserSearch.ts b/cypress/pageobject/Users/UserSearch.ts index 9d996abd83f..882a4dc8ea6 100644 --- a/cypress/pageobject/Users/UserSearch.ts +++ b/cypress/pageobject/Users/UserSearch.ts @@ -23,7 +23,11 @@ export class UserPage { } checkUsernameText(username: string) { - cy.get(this.usernameText).should("contain.text", username); + cy.get(`${this.usernameText}-${username}`).should("contain.text", username); + } + + checkUsernameTextDoesNotExist(username: string) { + cy.get(`${this.usernameText}-${username}`).should("not.exist"); } checkUsernameBadgeVisibility(shouldBeVisible: boolean) { @@ -69,24 +73,16 @@ export class UserPage { } verifyMultipleBadgesWithSameId(alreadylinkedusersviews: string[]) { - cy.get("#user-view-name").then(($elements) => { - const userViews = $elements - .map((_, el) => Cypress.$(el).text().trim()) - .get(); - let foundMatches = 0; - - alreadylinkedusersviews.forEach((expectedContent) => { - const index = userViews.findIndex((actualContent) => - actualContent.includes(expectedContent), - ); - if (index !== -1) { - userViews.splice(index, 1); // Remove the matched element - foundMatches++; - } - if (foundMatches === alreadylinkedusersviews.length) { - return false; // Break the loop if all matches are found - } - }); + cy.wrap(alreadylinkedusersviews).each((username) => { + cy.get(`#name-${username}`).scrollIntoView().should("be.visible"); }); } + + switchToListView() { + cy.get("#user-list-view").click(); + } + + verifyListView() { + cy.get("#user-list-view").should("have.class", "text-white"); + } } diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index d97bb52732f..0268beebc42 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -229,3 +229,15 @@ Cypress.Commands.add("verifyContentPresence", (selector, texts) => { }); }); }); + +Cypress.Commands.add("verifyErrorMessages", (errorMessages: string[]) => { + const selector = ".error-text"; // Static selector + cy.get(selector).then(($errors) => { + const displayedErrorMessages = $errors + .map((_, el) => Cypress.$(el).text()) + .get(); + errorMessages.forEach((errorMessage) => { + expect(displayedErrorMessages).to.include(errorMessage); + }); + }); +}); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 6429ef5710f..831ee71a5bc 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -45,6 +45,7 @@ declare global { selector: string, texts: string[], ): Chainable; + verifyErrorMessages(errorMessages: string[]): Chainable; } } } diff --git a/package-lock.json b/package-lock.json index 26832f0dbd8..08ceb2deb4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,12 @@ "@hello-pangea/dnd": "^17.0.0", "@pnotify/core": "^5.2.0", "@pnotify/mobile": "^5.2.0", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.4", @@ -30,10 +34,11 @@ "browser-image-compression": "^2.0.2", "browserslist": "^4.24.2", "browserslist-useragent-regexp": "^4.1.3", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.0.4", "cross-env": "^7.0.3", - "cypress": "^13.16.0", + "cypress": "^13.16.1", "dayjs": "^1.11.13", "echarts": "^5.5.1", "echarts-for-react": "^3.0.2", @@ -41,8 +46,7 @@ "hi-profiles": "^1.1.0", "i18next": "^23.16.4", "i18next-browser-languagedetector": "^8.0.0", - "i18next-http-backend": "^2.6.2", - "lodash-es": "^4.17.21", + "i18next-http-backend": "^3.0.1", "postcss-loader": "^8.1.1", "qrcode.react": "^4.1.0", "raviger": "^4.1.2", @@ -50,7 +54,7 @@ "react-copy-to-clipboard": "^5.1.0", "react-dom": "18.3.1", "react-google-recaptcha": "^3.1.0", - "react-i18next": "^15.1.1", + "react-i18next": "^15.1.3", "react-infinite-scroll-component": "^6.1.0", "react-pdf": "^9.1.1", "react-webcam": "^7.2.0", @@ -69,7 +73,6 @@ "@types/events": "^3.0.3", "@types/google.maps": "^3.58.1", "@types/jsdom": "^21.1.7", - "@types/lodash-es": "^4.17.12", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-copy-to-clipboard": "^5.0.7", @@ -3607,6 +3610,11 @@ "validator": "^13.9.0" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", @@ -3707,6 +3715,41 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz", + "integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -3845,6 +3888,28 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz", + "integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menu": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.2.tgz", @@ -3885,6 +3950,42 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", + "integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", @@ -4049,6 +4150,36 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.0.tgz", + "integrity": "sha512-q2jMBdsJ9zB7QG6ngQNzNwlvxLQqONyL58QbEGwuyRZZb/ARQwk3uQVbCF7GvQVOtV6EU/pDxAw3zRzJZI3rpQ==", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", @@ -5482,23 +5613,6 @@ "optional": true, "peer": true }, - "node_modules/@types/lodash": { - "version": "4.17.11", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.11.tgz", - "integrity": "sha512-jzqWo/uQP/iqeGGTjhgFp2yaCrCYTauASQcpdzESNCkHjSprBJVcZP9KG9aQ0q+xcsXiKd/iuw/4dLjS3Odc7Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@types/mdast": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", @@ -7234,24 +7348,15 @@ } }, "node_modules/class-variance-authority": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", - "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", "license": "Apache-2.0", "dependencies": { - "clsx": "2.0.0" + "clsx": "^2.1.1" }, "funding": { - "url": "https://joebell.co.uk" - } - }, - "node_modules/class-variance-authority/node_modules/clsx": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", - "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", - "license": "MIT", - "engines": { - "node": ">=6" + "url": "https://polar.sh/cva" } }, "node_modules/clean-stack": { @@ -7338,6 +7443,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.4.tgz", + "integrity": "sha512-AnsjfHyHpQ/EFeAnG216WY7A5LiYCoZzCSygiLvfXC3H3LFGCprErteUcszaVluGOhuOTbJS3jWHrSDYPBBygg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.0", + "use-sync-external-store": "^1.2.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", @@ -7566,9 +7687,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -7631,9 +7752,9 @@ "license": "MIT" }, "node_modules/cypress": { - "version": "13.16.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.16.0.tgz", - "integrity": "sha512-g6XcwqnvzXrqiBQR/5gN+QsyRmKRhls1y5E42fyOvsmU7JuY+wM6uHJWj4ZPttjabzbnRvxcik2WemR8+xT6FA==", + "version": "13.16.1", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.16.1.tgz", + "integrity": "sha512-17FtCaz0cx7ssWYKXzGB0Vub8xHwpVPr+iPt2fHhLMDhVAPVrplD+rTQsZUsfb19LVBn5iwkEUFjQ1yVVJXsLA==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -10443,9 +10564,9 @@ } }, "node_modules/i18next-http-backend": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.6.2.tgz", - "integrity": "sha512-Hp/kd8/VuoxIHmxsknJXjkTYYHzivAyAF15pzliKzk2TiXC25rZCEerb1pUFoxz4IVrG3fCvQSY51/Lu4ECV4A==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.1.tgz", + "integrity": "sha512-XT2lYSkbAtDE55c6m7CtKxxrsfuRQO3rUfHzj8ZyRtY9CkIX3aRGwXGTkUhpGWce+J8n7sfu3J0f2wTzo7Lw0A==", "license": "MIT", "dependencies": { "cross-fetch": "4.0.0" @@ -12264,12 +12385,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/lodash.castarray": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", @@ -12281,8 +12396,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/lodash.isplainobject": { "version": "4.0.6", @@ -14263,9 +14377,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -15609,9 +15723,9 @@ } }, "node_modules/react-i18next": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.1.1.tgz", - "integrity": "sha512-R/Vg9wIli2P3FfeI8o1eNJUJue5LWpFsQePCHdQDmX0Co3zkr6kdT8gAseb/yGeWbNz1Txc4bKDQuZYsC0kQfw==", + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.1.3.tgz", + "integrity": "sha512-J11oA30FbM3NZegUZjn8ySK903z6PLBz/ZuBYyT1JMR0QPrW6PFXvl1WoUhortdGi9dM0m48/zJQlPskVZXgVw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.0", diff --git a/package.json b/package.json index 968c4a3e95b..8fd129463e4 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,11 @@ "@hello-pangea/dnd": "^17.0.0", "@pnotify/core": "^5.2.0", "@pnotify/mobile": "^5.2.0", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", @@ -69,10 +73,11 @@ "browser-image-compression": "^2.0.2", "browserslist": "^4.24.2", "browserslist-useragent-regexp": "^4.1.3", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.0.4", "cross-env": "^7.0.3", - "cypress": "^13.16.0", + "cypress": "^13.16.1", "dayjs": "^1.11.13", "echarts": "^5.5.1", "echarts-for-react": "^3.0.2", @@ -80,8 +85,7 @@ "hi-profiles": "^1.1.0", "i18next": "^23.16.4", "i18next-browser-languagedetector": "^8.0.0", - "i18next-http-backend": "^2.6.2", - "lodash-es": "^4.17.21", + "i18next-http-backend": "^3.0.1", "postcss-loader": "^8.1.1", "qrcode.react": "^4.1.0", "raviger": "^4.1.2", @@ -89,7 +93,7 @@ "react-copy-to-clipboard": "^5.1.0", "react-dom": "18.3.1", "react-google-recaptcha": "^3.1.0", - "react-i18next": "^15.1.1", + "react-i18next": "^15.1.3", "react-infinite-scroll-component": "^6.1.0", "react-pdf": "^9.1.1", "react-webcam": "^7.2.0", @@ -108,7 +112,6 @@ "@types/events": "^3.0.3", "@types/google.maps": "^3.58.1", "@types/jsdom": "^21.1.7", - "@types/lodash-es": "^4.17.12", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-copy-to-clipboard": "^5.0.7", diff --git a/public/locale/en.json b/public/locale/en.json index b5f46dd7308..9f0bc5ecf12 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -181,6 +181,14 @@ "ROUNDS_TYPE__NORMAL": "Brief Update", "ROUNDS_TYPE__TELEMEDICINE": "Tele-medicine Log", "ROUNDS_TYPE__VENTILATOR": "Detailed Update", + "SAMPLE_TEST_HISTORY__APPROVED": "Approved", + "SAMPLE_TEST_HISTORY__COMPLETED": "Completed", + "SAMPLE_TEST_HISTORY__RECEIVED_AT_LAB": "Received At Lab", + "SAMPLE_TEST_HISTORY__REQUEST_SUBMITTED": "Request Submitted", + "SAMPLE_TEST_RESULT__AWAITING": "Awaiting", + "SAMPLE_TEST_RESULT__INVALID": "Invalid", + "SAMPLE_TEST_RESULT__NEGATIVE": "Negative", + "SAMPLE_TEST_RESULT__POSITIVE": "Positive", "SLEEP__EXCESSIVE": "Excessive", "SLEEP__NO_SLEEP": "No sleep", "SLEEP__SATISFACTORY": "Satisfactory", @@ -215,6 +223,9 @@ "URINATION_FREQUENCY__DECREASED": "Decreased", "URINATION_FREQUENCY__INCREASED": "Increased", "URINATION_FREQUENCY__NORMAL": "Normal", + "USERMANAGEMENT_TAB__FACILITIES": "Linked Facilities", + "USERMANAGEMENT_TAB__PROFILE": "User Information", + "USERMANAGEMENT_TAB__SKILLS": "Linked Skills", "VENTILATOR": "Detailed Update", "VENTILATOR_MODE__CMV": "Control Mechanical Ventilation (CMV)", "VENTILATOR_MODE__CMV_short": "CMV", @@ -285,6 +296,7 @@ "add_consultation": "Add consultation", "add_consultation_update": "Add Consultation Update", "add_details_of_patient": "Add Details of Patient", + "add_facility": "Add Facility", "add_insurance_details": "Add Insurance Details", "add_location": "Add Location", "add_new_beds": "Add New Bed(s)", @@ -296,6 +308,7 @@ "add_preset": "Add preset", "add_prn_prescription": "Add PRN Prescription", "add_remarks": "Add remarks", + "add_skill": "Add Skill", "add_spoke": "Add Spoke Facility", "address": "Address", "administer": "Administer", @@ -369,6 +382,7 @@ "autofilled_fields": "Autofilled Fields", "available_features": "Available Features", "available_in": "Available in", + "avatar_updated_success": "Avatar updated successfully", "average_weekly_working_hours": "Average weekly working hours", "awaiting_destination_approval": "AWAITING DESTINATION APPROVAL", "back": "Back", @@ -376,6 +390,7 @@ "back_to_consultation": "Go back to Consultation", "back_to_login": "Back to login", "base_dosage": "Dosage", + "basic_info": "Basic Information", "bed_capacity": "Bed Capacity", "bed_created_notification_one": "{{count}} Bed created successfully", "bed_created_notification_other": "{{count}} Beds created successfully", @@ -410,6 +425,8 @@ "category": "Category", "caution": "Caution", "central_nursing_station": "Central Nursing Station", + "change_avatar": "Change Avatar", + "change_avatar_note": "JPG, GIF or PNG. 1MB max.", "change_file": "Change File", "change_password": "Change Password", "chat_on_whatsapp": "Chat on Whatsapp", @@ -423,8 +440,11 @@ "checking_for_update": "Checking for update", "checking_policy_eligibility": "Checking Policy Eligibility", "choose_date_time": "Choose Date and Time", + "choose_district": "Choose District", "choose_file": "Upload From Device", + "choose_localbody": "Choose Local Body", "choose_location": "Choose Location", + "choose_state": "Choose State", "claim__add_item": "Add Item", "claim__create_claim": "Create Claim", "claim__create_preauthorization": "Create Pre Authorization", @@ -460,7 +480,10 @@ "clear": "Clear", "clear_all_filters": "Clear All Filters", "clear_home_facility": "Clear Home Facility", + "clear_home_facility_confirm": "Are you sure you want to clear the home facility", + "clear_home_facility_error": "Error while clearing home facility. Try again later.", "clear_selection": "Clear selection", + "clear_skill": "Clear Skill", "close": "Close", "close_scanner": "Close Scanner", "collapse_sidebar": "Collapse Sidebar", @@ -475,6 +498,7 @@ "confirm_delete": "Confirm Delete", "confirm_discontinue": "Confirm Discontinue", "confirm_password": "Confirm Password", + "confirm_password_required": "Confirm password is required", "confirm_transfer_complete": "Confirm Transfer Complete!", "confirmed": "Confirmed", "consent__hi_range": "Health Information Range", @@ -515,7 +539,10 @@ "consultation_not_filed_description": "Please file a consultation for this patient to continue.", "consultation_notes": "General Instructions (Advice)", "consultation_updates": "Consultation updates", - "contact_info": "Contact Info", + "contact_info": "Contact Information", + "contact_info_note": "View or update user's contact information", + "contact_info_note_self": "View or update your contact information", + "contact_info_note_view": "View user's contact information", "contact_number": "Contact Number", "contact_person": "Name of Contact Person at Facility", "contact_person_at_the_facility": "Contact person at the current facility", @@ -523,6 +550,7 @@ "contact_phone": "Contact Person Number", "contact_with_confirmed_carrier": "Contact with confirmed carrier", "contact_with_suspected_carrier": "Contact with suspected carrier", + "contact_your_admin_to_add_facilities": "Contact your admin to add facilities", "contact_your_admin_to_add_skills": "Contact your admin to add skills", "continue": "Continue", "continue_watching": "Continue watching", @@ -570,6 +598,9 @@ "days": "Days", "death_report": "Death Report", "delete": "Delete", + "delete_account": "Delete account", + "delete_account_btn": "Yes, delete this account", + "delete_account_note": "Deleting this account will remove all associated data and cannot be undone.", "delete_facility": "Delete Facility", "delete_item": "Delete {{name}}", "delete_record": "Delete Record", @@ -613,6 +644,9 @@ "disease_status": "Disease status", "district": "District", "district_program_management_supporting_unit": "District Program Management Supporting Unit", + "dob_format": "Please enter date in DD/MM/YYYY format", + "doctor_experience_error": "Please enter a valid number between 0 and 100.", + "doctor_experience_required": "Years of experience is required", "doctor_s_medical_council_registration": "Doctor's Medical Council Registration", "doctors_name": "Doctor's Name", "domestic_healthcare_support": "Domestic healthcare support", @@ -632,6 +666,9 @@ "duplicate_patient_record_rejection": "I confirm that the suspect / patient I want to create is not on the list.", "edit": "Edit", "edit_avatar": "Edit Avatar", + "edit_avatar_note": "Change the avatar of the user", + "edit_avatar_note_self": "Change your avatar", + "edit_avatar_permission_error": "You do not have permissions to edit the avatar of this user", "edit_caution_note": "A new prescription will be added to the consultation with the edited details and the current prescription will be discontinued.", "edit_cover_photo": "Edit Cover Photo", "edit_history": "Edit History", @@ -684,6 +721,8 @@ "enter_mobile_otp": "Enter OTP sent to the given mobile number", "enter_otp": "Enter OTP sent to the registered mobile with the respective ID", "enter_valid_age": "Please Enter Valid Age", + "enter_valid_dob": "Enter a valid date of birth", + "enter_valid_dob_age": "Please enter an age greater than 15 years", "entered-in-error": "Entered in error", "error_404": "Error 404", "error_deleting_shifting": "Error while deleting Shifting record", @@ -700,6 +739,8 @@ "facilities": "Facilities", "facility": "Facility", "facility_consent_requests_page_title": "Patient Consent List", + "facility_district_name": "Facility/District Name", + "facility_linked_success": "Facility linked successfully", "facility_name": "Facility Name", "facility_preference": "Facility preference", "facility_search_placeholder": "Search by Facility / District Name", @@ -733,10 +774,12 @@ "filter_by_category": "Filter by category", "filters": "Filters", "first_name": "First Name", + "first_name_required": "First Name is required", "footer_body": "Open Healthcare Network is an open-source public utility designed by a multi-disciplinary team of innovators and volunteers. Open Healthcare Network CARE is a Digital Public Good recognised by the United Nations.", "forget_password": "Forgot password?", "forget_password_instruction": "Enter your username, and if it exists, we will send you a link to reset your password.", "frequency": "Frequency", + "from_user": "from User", "full_name": "Full Name", "full_screen": "Full Screen", "gender": "Gender", @@ -785,6 +828,9 @@ "hide": "Hide", "high": "High", "home_facility": "Home Facility", + "home_facility_cleared_success": "Home Facility cleared successfully", + "home_facility_updated_error": "Error while updating Home Facility", + "home_facility_updated_success": "Home Facility updated successfully", "hubs": "Hub Facilities", "i_declare": "I hereby declare that:", "icd11_as_recommended": "As per ICD-11 recommended by WHO", @@ -803,7 +849,7 @@ "insurer_name_required": "Insurer Name is required", "international_mobile": "International Mobile", "invalid_asset_id_msg": "Oops! The asset ID you entered does not appear to be valid.", - "invalid_email": "Please Enter a Valid Email Address", + "invalid_email": "Please enter a valid email address", "invalid_ip_address": "Invalid IP Address", "invalid_link_msg": "It appears that the password reset link you have used is either invalid or expired. Please request a new password reset link.", "invalid_password": "Password doesn't meet the requirements", @@ -813,7 +859,11 @@ "invalid_phone_number": "Invalid Phone Number", "invalid_pincode": "Invalid Pincode", "invalid_reset": "Invalid Reset", + "invalid_url": "Please enter a valid url", + "invalid_url_http_https": "URL should start with http:// or https://", + "invalid_url_javascript": "URL should not include javascript, please enter a valid URL.", "invalid_username": "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + "invalid_username_format": "Please enter a 4-16 characters long username with lowercase letters, digits and . _ - only and it should not start or end with . _ -", "inventory_management": "Inventory Management", "investigation_report": "Investigation Report", "investigation_report_for_{{name}}": "Investigation Report for {{name}}", @@ -836,6 +886,7 @@ "is_emergency": "Is emergency", "is_emergency_case": "Is emergency case", "is_it_upshift": "is it upshift", + "is_phone_a_whatsapp_number": "Is the phone number a WhatsApp number?", "is_pregnant": "Is pregnant", "is_this_an_emergency": "Is this an emergency?", "is_this_an_upshift": "Is this an upshift?", @@ -852,6 +903,7 @@ "last_edited": "Last Edited", "last_modified": "Last Modified", "last_name": "Last Name", + "last_name_required": "Last Name is required", "last_online": "Last Online", "last_serviced_on": "Last Serviced On", "last_updated_by": "Last updated by", @@ -863,8 +915,11 @@ "link_abha_profile": "Link ABHA Profile", "link_camera_and_bed": "Link bed to Camera", "link_existing_abha_profile": "Already have an ABHA number", + "link_facility_error": "Error while linking facility. Try again later.", "linked_facilities": "Linked Facilities", + "linked_facilities_note": "Add or remove facilities and set or change the Home Facility", "linked_skills": "Linked Skills", + "linked_skills_note": "Search and select skills to add to the skill set", "liquid_oxygen_capacity": "Liquid Oxygen Capacity", "list_view": "List View", "litres": "Litres", @@ -892,6 +947,7 @@ "manage_bed_presets": "Manage Presets of Bed", "manage_prescriptions": "Manage Prescriptions", "manage_preset": "Manage preset {{ name }}", + "manage_user": "Manage User", "manufacturer": "Manufacturer", "map_acronym": "M.A.P.", "mark_all_as_read": "Mark all as Read", @@ -906,6 +962,7 @@ "measured_before": "Measured before", "medical": "Medical", "medical_council_registration": "Medical Council Registration", + "medical_council_registration_required": "Medical Council Registration is required", "medical_worker": "Medical Worker", "medicine": "Medicine", "medicine_administration_history": "Medicine Administration History", @@ -932,6 +989,7 @@ "modified_date": "Modified Date", "modified_on": "Modified On", "monitor": "Monitor", + "more_details": "More details", "more_info": "More Info", "move_to_onvif_preset": "Move to an ONVIF Preset", "moving_camera": "Moving Camera", @@ -942,6 +1000,8 @@ "never": "never", "new_password": "New Password", "new_password_confirmation": "Confirm New Password", + "new_password_same_as_old": "New password is same as old password, please enter a different new password.", + "new_password_validation": "New password is not valid.", "next_sessions": "Next Sessions", "no": "No", "no_attachments_found": "This communication has no attachments.", @@ -957,7 +1017,7 @@ "no_duplicate_facility": "You should not create duplicate facilities", "no_facilities": "No Facilities found", "no_files_found": "No {{type}} files found", - "no_home_facility": "No home facility assigned", + "no_home_facility": "No home facility", "no_image_found": "No image found", "no_investigation": "No investigation Reports found", "no_investigation_suggestions": "No Investigation Suggestions", @@ -968,6 +1028,7 @@ "no_notices_for_you": "No notices for you.", "no_patients_found": "No Patients Found", "no_patients_to_show": "No patients to show.", + "no_permission_to_view_page": "You do not have permissions to view this page", "no_policy_added": "No Insurance Policy Added", "no_policy_found": "No Insurance Policy Found for this Patient", "no_presets": "No Presets", @@ -1023,10 +1084,17 @@ "pain_chart_description": "Mark region and intensity of pain", "passport_number": "Passport Number", "password": "Password", - "password_mismatch": "Password and confirm password must be same.", + "password_length_validation": "Password must be at least 8 characters long", + "password_lowercase_validation": "Password must contain at least one lowercase letter (a-z)", + "password_mismatch": "New password and confirm password must be the same.", + "password_number_validation": "Password must contain at least one number (0-9)", + "password_required": "Password is required", "password_reset_failure": "Password Reset Failed", "password_reset_success": "Password Reset successfully", "password_sent": "Password Reset Email Sent", + "password_update_error": "Error while updating password. Try again later.", + "password_uppercase_validation": "Password must contain at least one uppercase letter (A-Z)", + "password_validation": "Password must contain at least: 8 characters, 1 uppercase letter (A-Z), 1 lowercase letter (a-z), and 1 number (0-9)", "patient": "Patient", "patient-notes": "Notes", "patient__general-info": "General Info", @@ -1073,19 +1141,31 @@ "permanent_address": "Permanent Address", "permission_denied": "You do not have permission to perform this action", "personal_information": "Personal Information", + "personal_information_note": "View or update user's personal information", + "personal_information_note_self": "View or update your personal information", + "personal_information_note_view": "View user's personal information", "phone": "Phone", "phone_no": "Phone no.", "phone_number": "Phone Number", "phone_number_at_current_facility": "Phone Number of Contact person at current Facility", "pincode": "Pincode", "please_assign_bed_to_patient": "Please assign a bed to this patient", + "please_confirm_password": "Please confirm your new password.", "please_enter_a_reason_for_the_shift": "Please enter a reason for the shift.", + "please_enter_current_password": "Please enter your current password.", + "please_enter_new_password": "Please enter your new password.", + "please_enter_username": "Please enter the username", "please_select_a_facility": "Please select a facility", "please_select_breathlessness_level": "Please select Breathlessness Level", + "please_select_district": "Please select the district", "please_select_facility_type": "Please select Facility Type", + "please_select_gender": "Please select the Gender", + "please_select_localbody": "Please select the local body", "please_select_patient_category": "Please select Patient Category", "please_select_preferred_vehicle_type": "Please select Preferred Vehicle Type", + "please_select_state": "Please select the state", "please_select_status": "Please select Status", + "please_select_user_type": "Please select the User Type", "please_upload_a_csv_file": "Please Upload A CSV file", "policy": "Policy", "policy__insurer": "Insurer", @@ -1129,9 +1209,14 @@ "procedure_suggestions": "Procedure Suggestions", "procedures_select_placeholder": "Select procedures to add details", "process_transcript": "Process Again", + "professional_info": "Professional Information", + "professional_info_note": "View or update user's professional information", + "professional_info_note_self": "View or update your professional information", + "professional_info_note_view": "View user's professional information", "profile": "Profile", "provisional": "Provisional", "qualification": "Qualification", + "qualification_required": "Qualification is required", "raise_consent_request": "Raise a consent request to fetch patient records over ABDM", "ration_card__APL": "APL", "ration_card__BPL": "BPL", @@ -1160,6 +1245,9 @@ "reload": "Reload", "remove": "Remove", "rename": "Rename", + "replace_home_facility": "Replace Home Facility", + "replace_home_facility_confirm": "Are you sure you want to replace", + "replace_home_facility_confirm_as": "as home facility for user", "reply": "Reply", "report": "Report", "req_atleast_one_digit": "Require at least one digit", @@ -1178,6 +1266,7 @@ "resend_otp": "Resend OTP", "reset": "Reset", "reset_password": "Reset Password", + "reset_password_note_self": "Enter your current password, then create and confirm your new password", "resource": "Resource", "resource_approving_facility": "Resource approving facility", "resource_origin_facility": "Origin Facility", @@ -1198,6 +1287,7 @@ "review_missed": "Review Missed", "revoked_on": "Revoked On", "right": "Right", + "role": "Role", "route": "Route", "routine": "Routine", "sample_collection_date": "Sample Collection Date", @@ -1214,6 +1304,10 @@ "scan_asset_qr": "Scan Asset QR!", "scribe__reviewing_field": "Reviewing field {{currentField}} / {{totalFields}}", "scribe_error": "Could not autofill fields", + "search_by_emergency_contact_phone_number": "Search by Emergency Contact Phone Number", + "search_by_patient_name": "Search by Patient Name", + "search_by_patient_no": "Search by Patient Number", + "search_by_phone_number": "Search by Phone Number", "search_by_username": "Search by username", "search_for_facility": "Search for Facility", "search_icd11_placeholder": "Search for ICD-11 Diagnoses", @@ -1250,6 +1344,7 @@ "session_expired": "Session Expired", "session_expired_msg": "It appears that your session has expired. This could be due to inactivity. Please login again to continue.", "set_average_weekly_working_hours_for": "Set Average weekly working hours for", + "set_home_facility": "Set as home facility", "set_your_local_language": "Set your local language", "settings_and_filters": "Settings and Filters", "severity_of_breathlessness": "Severity of Breathlessness", @@ -1271,6 +1366,8 @@ "show_patient_presets": "Show Patient Presets", "show_unread_notifications": "Show Unread", "sign_out": "Sign Out", + "skill_add_error": "Error while adding skill", + "skill_added_successfully": "Skill added successfully", "skills": "Skills", "social_profile": "Social Profile", "socioeconomic_status": "Socioeconomic status", @@ -1318,6 +1415,7 @@ "to_be_conducted": "To be conducted", "total_amount": "Total Amount", "total_beds": "Total Beds", + "total_patients": "Total Patients", "total_staff": "Total Staff", "total_users": "Total Users", "transcript_edit_info": "You can update this if we made an error", @@ -1349,6 +1447,19 @@ "unlink_asset_bed_and_presets": "Delete linked presets and unlink bed", "unlink_asset_bed_caution": "This action will also delete all presets that are associated to this camera and bed.", "unlink_camera_and_bed": "Unlink this bed from this camera", + "unlink_facility": "Unlink Facility", + "unlink_facility_access": "The user will lose access to the facility", + "unlink_facility_confirm": "Are you sure you want to unlink the facility", + "unlink_facility_error": "Error while unlinking facility. Try again later.", + "unlink_facility_success": "Facility unlinked successfully", + "unlink_home_facility_error": "Error while unlinking home facility. Try again later.", + "unlink_home_facility_success": "Home Facility cleared successfully", + "unlink_skill": "Unlink Skill", + "unlink_skill_access": "The user will not have the skill associated anymore.", + "unlink_skill_confirm": "Are you sure you want to unlink the skill", + "unlink_skill_error": "Error while unlinking skill. Try again later.", + "unlink_skill_success": "Skill unlinked successfully", + "unlink_this_facility": "Unlink Facility", "unsubscribe": "Unsubscribe", "unsubscribe_failed": "Unsubscribe failed.", "unsubscribed_successfully": "Unsubscribed Successfully.", @@ -1383,9 +1494,22 @@ "upload_report": "Upload Report", "uploading": "Uploading", "use_existing_abha_address": "Use Existing ABHA Address", - "user_deleted_successfuly": "User Deleted Successfuly", + "user_add_error": "Error while adding User", + "user_added_successfully": "User added successfully", + "user_delete_error": "Error while deleting User", + "user_deleted_successfully": "User Deleted Successfully", + "user_details": "User Details", + "user_details_update_error": "Error while updating user details", + "user_details_update_success": "User details updated successfully", "user_management": "User Management", + "user_qualifications": "Qualifications", + "user_qualifications_note": "Enter appropriate qualifications for this user", + "user_type": "User Type", "username": "Username", + "username_already_exists": "This username already exists", + "username_available": "Username is available", + "username_not_available": "Username is not available", + "username_userdetails_not_found": "Unable to fetch details as username or user details not found", "users": "Users", "vacant": "Vacant", "vaccinated": "Vaccinated", @@ -1419,6 +1543,8 @@ "view_patients": "View Patients", "view_update_patient_files": "View/Update patient files", "view_updates": "View Updates", + "view_user": "View User", + "view_user_profile": "View Profile", "view_users": "View Users", "village": "Village", "virtual_nursing_assistant": "Virtual Nursing Assistant", @@ -1431,10 +1557,12 @@ "volunteer_unassigned": "Volunteer unassigned successfuly", "ward": "Ward", "warranty_amc_expiry": "Warranty / AMC Expiry", + "weekly_working_hours_error": "Average weekly working hours must be a number between 0 and 168", "what_facility_assign_the_patient_to": "What facility would you like to assign the patient to", "whatsapp_number": "Whatsapp Number", "why_the_asset_is_not_working": "Why the asset is not working?", "width": "Width ({{unit}})", + "with": "with", "working_status": "Working Status", "year_of_birth": "Year of Birth", "years": "years", diff --git a/src/CAREUI/icons/CareIcon.tsx b/src/CAREUI/icons/CareIcon.tsx index 80b57085615..a148f5b3312 100644 --- a/src/CAREUI/icons/CareIcon.tsx +++ b/src/CAREUI/icons/CareIcon.tsx @@ -1,7 +1,8 @@ +import duoToneIconData from "@/CAREUI/icons/DuoTonePaths.json"; import iconData from "@/CAREUI/icons/UniconPaths.json"; import "@/CAREUI/icons/icon.css"; -export type IconName = keyof typeof iconData; +export type IconName = keyof typeof iconData | keyof typeof duoToneIconData; export interface CareIconProps { icon: IconName; @@ -26,11 +27,16 @@ export default function CareIcon({ }: CareIconProps) { // TODO: fill & strokeWidth are defined for only one icon // Rethink Implementation - const [viewBox, path, fill, strokeWidth] = iconData[icon] as [ + + const [viewBox, path, fill, strokeWidth, secondaryPath] = ( + (icon.startsWith("d-") ? duoToneIconData : iconData) as typeof iconData & + typeof duoToneIconData + )[icon] as [ number, string, boolean | undefined, number | undefined, + string | undefined, ]; const svgClassName = `care-svg-icon__baseline ${className || ""}`.trim(); @@ -50,6 +56,15 @@ export default function CareIcon({ stroke={fill === false ? "currentColor" : undefined} strokeWidth={fill === false ? strokeWidth : undefined} /> + {secondaryPath && ( + + )} ); } diff --git a/src/CAREUI/icons/DuoTonePaths.json b/src/CAREUI/icons/DuoTonePaths.json new file mode 100644 index 00000000000..663f71a7dba --- /dev/null +++ b/src/CAREUI/icons/DuoTonePaths.json @@ -0,0 +1,72 @@ +{ + "d-people": [ + 24, + "M14.6,10.9c.7.7,1.66,1.1,2.65,1.1s1.95-.4,2.65-1.1c.7-.7,1.1-1.66,1.1-2.65s-.4-1.95-1.1-2.65c-.7-.7-1.66-1.1-2.65-1.1s-1.95.4-2.65,1.1c-.7.7-1.1,1.66-1.1,2.65s.4,1.95,1.1,2.65ZM16.5,19.5c0,.52-.1,1.03-.32,1.5h6.32s1.5,0,1.5-1.5-1.5-6-7.5-6c-.98-.01-1.96.13-2.9.42,1.88,1.45,2.9,3.55,2.9,5.58Z", + true, + null, + "M13.5,21s1.5,0,1.5-1.5-1.5-6-7.5-6S0,18,0,19.5s1.5,1.5,1.5,1.5h12ZM7.5,12c-1.19,0-2.34-.47-3.18-1.32-.84-.84-1.32-1.99-1.32-3.18s.47-2.34,1.32-3.18c.84-.84,1.99-1.32,3.18-1.32s2.34.47,3.18,1.32c.84.84,1.32,1.99,1.32,3.18s-.47,2.34-1.32,3.18c-.84.84-1.99,1.32-3.18,1.32Z" + ], + "d-bell": [ + 24, + "M13.62,22.62c-.56.56-1.33.88-2.12.88s-1.56-.32-2.12-.88c-.56-.56-.88-1.33-.88-2.12h6c0,.8-.32,1.56-.88,2.12Z", + true, + null, + "M12.99,1.65c.02-.21,0-.42-.07-.62-.07-.2-.17-.38-.31-.54-.14-.16-.31-.28-.5-.36-.19-.09-.4-.13-.61-.13s-.42.04-.61.13c-.19.09-.36.21-.5.36-.14.16-.25.34-.31.54-.07.2-.09.41-.07.62-1.7.34-3.22,1.26-4.32,2.6-1.09,1.34-1.69,3.02-1.69,4.75,0,1.65-.75,9-3,10.5h21c-2.25-1.5-3-8.85-3-10.5,0-3.63-2.58-6.66-6.01-7.35Z" + ], + "d-book-open": [ + 24, + "M11.75,3.02c1.57-.95,2.95-1.46,4.45-1.73,1.55-.27,3.12-.29,4.7-.29.56,0,.84,0,1.06.11.19.1.34.25.44.44.11.21.11.5.11,1.06v8.96c0,1.59,0,2.39-.29,3-.27.57-.63.97-1.18,1.29-.58.35-1.5.43-3.33.6-.42.04-.83.09-1.24.16-1.73.3-3.25,1.09-4.71,2.03V3.02ZM2.75,18.51c-.41,0-.75.34-.75.75s.34.75.75.75c1.97,0,3.4.12,4.68.47,1.27.35,2.45.93,3.91,1.9.25.17.58.17.83,0,1.46-.97,2.63-1.55,3.91-1.9,1.28-.35,2.7-.47,4.68-.47.41,0,.75-.34.75-.75s-.34-.75-.75-.75c-2.03,0-3.6.13-5.07.53-1.34.37-2.56.95-3.93,1.83-1.37-.88-2.59-1.46-3.93-1.83-1.47-.4-3.05-.53-5.07-.53Z", + true, + null, + "M11.75,3.02c-1.57-.95-2.95-1.46-4.45-1.73-1.55-.27-3.12-.29-4.7-.29-.56,0-.84,0-1.06.11-.19.1-.34.25-.44.44-.11.21-.11.5-.11,1.06v8.96c0,1.59,0,2.39.29,3,.27.57.63.97,1.18,1.29.58.35,1.5.43,3.33.6.42.04.83.09,1.24.16,1.73.3,3.25,1.09,4.71,2.03V3.02Z" + ], + "d-patient": [ + 24, + "M12.5,9.66c2.67,0,4.83-2.16,4.83-4.83S15.17,0,12.5,0s-4.83,2.16-4.83,4.83,2.16,4.83,4.83,4.83Z", + true, + null, + "M19.48,12.64c-.26-.26-.55-.5-.86-.69-.26-.17-.53-.31-.82-.43l-1.61,11.62h-4.15l4.07-12.01c-.1,0-.2,0-.31,0h-6.61c-1.39,0-2.69.54-3.67,1.52-.98.98-1.52,2.29-1.52,3.67v4.51c0,1.27,1.04,2.31,2.31,2.31h4.82l-.1.29c-.05.13-.02.28.06.39.08.11.21.18.35.18h5.13c.22,0,.4-.16.43-.37l.07-.49v-.06c1.04-.23,1.81-1.15,1.81-2.25,0-.86-.47-1.63-1.21-2.03l.12-.9c1.18.49,1.95,1.64,1.95,2.93,0,.91-.38,1.73-1,2.31h2.25v-6.82c0-1.39-.54-2.69-1.52-3.67h0ZM7.67,18.52c-.24,0-.43-.19-.43-.43v-1.77c0-.24.19-.43.43-.43s.43.19.43.43v1.34h4.89l-.29.87h-5.02Z" + ], + "d-calendar": [ + 24, + "M7,2c.55,0,1,.45,1,1v1h7v-1c0-.55.45-1,1-1s1,.45,1,1v1h2.5c1.38,0,2.5,1.12,2.5,2.5v2.5H1v-2.5c0-1.38,1.12-2.5,2.5-2.5h2.5v-1c0-.55.45-1,1-1ZM5,13c-.55,0-1,.45-1,1s.45,1,1,1h.01c.55,0,1-.45,1-1s-.45-1-1-1h-.01ZM5,17c-.55,0-1,.45-1,1s.45,1,1,1h.01c.55,0,1-.45,1-1s-.45-1-1-1h-.01ZM9,14c0-.55.45-1,1-1h.01c.55,0,1,.45,1,1s-.45,1-1,1h-.01c-.55,0-1-.45-1-1ZM10,17c-.55,0-1,.45-1,1s.45,1,1,1h.01c.55,0,1-.45,1-1s-.45-1-1-1h-.01ZM14,14c0-.55.45-1,1-1h.01c.55,0,1,.45,1,1s-.45,1-1,1h-.01c-.55,0-1-.45-1-1Z", + true, + null, + "M1,20v-11h21v11c0,1.66-1.34,3-3,3H4c-1.66,0-3-1.34-3-3Z" + ], + "d-folder": [ + 24, + "M2,10v-5c0-1.66,1.34-3,3-3h4.31c.62,0,1.2.29,1.58.78l1.81,2.34c.19.24.48.39.79.39h6.51c1.1,0,2,.9,2,2v2.5c0,1.1-.9,2-2,2H4c-1.1,0-2-.9-2-2Z", + true, + null, + "M2.24,19.04l-.2-14c-.02-1.67,1.33-3.04,3-3.04h4.23c.61,0,1.19.28,1.57.76l1.88,2.38c.18.22.44.36.73.38l6.46.37c1.05.06,1.87.92,1.89,1.97l.16,11.1c.02,1.67-1.33,3.04-3,3.04H5.24c-1.64,0-2.98-1.32-3-2.96Z" + ], + "d-ambulance": [ + 24, + "M15.44,7.06c0-.25.1-.49.28-.66.18-.18.42-.28.66-.28s.49.1.66.28c.18.18.28.42.28.66v.95h-1.89v-.95ZM19.41,9.05l1.29,2.53,1.91,1.02c.32.17.59.44.79.75.18.31.3.68.3,1.05v.23h0c.08,0,.16.03.23.08.06.06.08.14.08.23v1.47c0,.08-.03.16-.1.23-.06.06-.14.08-.23.08h0v.04c0,.07-.01.14-.04.21s-.07.13-.13.18-.11.1-.18.13-.14.04-.21.04h-2.22c-.01-.66-.3-1.3-.78-1.77s-1.12-.72-1.78-.72-1.31.25-1.78.72c-.48.47-.76,1.1-.78,1.77h-1.26v-8.96h3.73c.24,0,.47.07.66.18.2.13.37.3.47.51h.03ZM15.75,13.09h1.24s.11-.03.14-.06c.04-.04.06-.08.06-.14s-.03-.11-.06-.14c-.04-.04-.08-.06-.14-.06h-1.24s-.11.03-.14.06c-.04.04-.06.08-.06.14s.03.11.06.14c.04.04.08.06.14.06ZM15.54,9.25v2.83h4.31l-1.31-2.47c-.06-.11-.14-.2-.24-.25s-.23-.1-.34-.1h-2.42ZM5.31,15.38c-1.12,0-2.03.91-2.03,2.03s.91,2.03,2.03,2.03,2.03-.91,2.03-2.03-.91-2.03-2.03-2.03ZM5.31,18.23c-.45,0-.82-.37-.82-.82s.37-.82.82-.82.82.37.82.82-.37.82-.82.82ZM18.39,15.38c-1.12,0-2.03.91-2.03,2.03s.91,2.03,2.03,2.03,2.03-.91,2.03-2.03-.91-2.03-2.03-2.03ZM18.39,18.23c-.45,0-.82-.37-.82-.82s.37-.82.82-.82.82.37.82.82-.37.82-.82.82Z", + true, + null, + "M.34,14.62V5.91c0-.1.01-.2.06-.28s.1-.17.17-.24.16-.13.24-.17.18-.06.28-.06h2.09c0-.16.07-.3.17-.41.11-.11.25-.17.41-.17h6.36c.16,0,.3.06.41.17s.17.25.17.41h2.5c.2,0,.4.08.54.23s.23.34.23.54v11.4h-6.09c-.01-.66-.3-1.3-.78-1.77s-1.12-.72-1.78-.72-1.31.25-1.78.72c-.48.47-.76,1.1-.78,1.77h-1.67c-.17,0-.34-.06-.48-.17s-.23-.25-.27-.42h-.03c-.08,0-.16-.03-.23-.08-.06-.06-.08-.14-.08-.23v-1.47c0-.08.03-.16.08-.23.06-.06.23-.08.23-.08l.03-.03ZM5.27,11.4h1.33v1.33h1.45v-1.33h1.33v-1.45h-1.33v-1.33h-1.45v1.33h-1.33v1.45Z" + ], + "d-microscope": [ + 24, + "M2.9,5.43c-.21-.21-.21-.55,0-.76l3.29-3.29c.21-.21.55-.21.76,0l6.07,6.07c.21.21.21.55,0,.76l-3.29,3.29c-.21.21-.55.21-.76,0L2.9,5.43ZM4.97,22.33c0-.3.24-.54.54-.54h10.55c.3,0,.54.24.54.54v1.13c0,.3-.24.54-.54.54H5.5c-.3,0-.54-.24-.54-.54v-1.13ZM15.29,15.68c-.3,0-.54.24-.54.54v.76c0,.3.24.54.54.54h6.88c.3,0,.54-.24.54-.54v-.76c0-.3-.24-.54-.54-.54h-6.88Z", + true, + null, + "M1.67,2.43c-.21.21-.21.55,0,.76l1.35,1.35,3.03-3.03L4.7.16c-.21-.21-.55-.21-.76,0L1.67,2.43ZM19.55,17.52c-.71,1.07-1.67,1.99-2.82,2.71-1.15.71-2.47,1.2-3.86,1.43-1.39.23-2.82.19-4.19-.12s-2.65-.86-3.75-1.64c-1.1-.78-1.99-1.75-2.62-2.86-.63-1.11-.97-2.32-1.01-3.56-.04-1.24.22-2.46.78-3.6.55-1.14,1.38-2.16,2.43-2.99l1.55,1.5c-.8.63-1.43,1.41-1.85,2.28-.42.87-.63,1.81-.59,2.75.03.94.3,1.87.77,2.72.48.85,1.16,1.59,2,2.18.84.59,1.82,1.02,2.86,1.25,1.05.23,2.14.26,3.2.09,1.06-.17,2.07-.54,2.95-1.09.88-.54,1.62-1.25,2.16-2.07l2,1.01ZM9.86,11.37l3.03-3.03,1.35,1.35c.21.21.21.55,0,.76l-2.27,2.27c-.21.21-.55.21-.76,0l-1.35-1.35Z" + ], + "d-notice-board": [ + 24, + "M.97,16.66c-.19,0-.38.08-.52.22-.14.14-.22.32-.22.52s.08.38.22.52c.14.14.32.22.52.22h4.21l-1.24,4.97c-.04.19,0,.38.09.55.1.16.26.28.45.33.19.05.38.02.55-.08.17-.1.29-.25.34-.44l.6-2.38h12.09l.6,2.38c.05.19.17.34.34.44.17.1.36.13.55.08.19-.05.35-.16.45-.33.1-.16.13-.36.09-.55l-1.24-4.97h4.21c.19,0,.38-.08.52-.22s.22-.32.22-.52-.08-.38-.22-.52-.32-.22-.52-.22H.97ZM6.32,19.61l.37-1.47h10.62l.37,1.47H6.32Z", + true, + null, + "M12.66.41c-.06-.12-.15-.22-.27-.3-.12-.07-.25-.11-.39-.11s-.27.04-.39.11c-.12.07-.21.17-.27.3l-.53,1.06H3.91c-.58,0-1.15.23-1.56.65s-.65.97-.65,1.56v11.03h20.59V3.68c0-.58-.23-1.15-.65-1.56-.41-.41-.97-.65-1.56-.65h-6.9l-.53-1.06Z" + ], + "d-hospital": [ + 24, + "M12.7,10.42h1.15v1.41h-1.15v1.15h-1.41v-1.15h-1.15v-1.41h1.15v-1.15h1.41v1.15ZM6.24,10.74v10.61c0,.29-.24.53-.53.53H1.03c-.29,0-.53-.24-.53-.53v-10.61c0-.29.24-.53.53-.53h4.68c.29,0,.53.24.53.53ZM4.96,14.98c0-.29-.24-.53-.53-.53h-2.12c-.29,0-.53.24-.53.53v2.12c0,.29.24.53.53.53h2.12c.29,0,.53-.24.53-.53v-2.12ZM23.5,10.74v10.61c0,.29-.24.53-.53.53h-4.68c-.29,0-.53-.24-.53-.53v-10.61c0-.29.24-.53.53-.53h4.68c.29,0,.53.24.53.53ZM22.22,14.98c0-.29-.24-.53-.53-.53h-2.12c-.29,0-.53.24-.53.53v2.12c0,.29.24.53.53.53h2.12c.29,0,.53-.24.53-.53v-2.12Z", + true, + null, + "M7.3,6.9v14.45c0,.29.24.53.53.53h1.5c.29,0,.53-.24.53-.53v-4.88h4.28v4.88c0,.29.24.53.53.53h1.5c.29,0,.53-.24.53-.53v-11.5s0-2.94,0-2.94c0-.29-.24-.53-.53-.53H7.83c-.29,0-.53.24-.53.53ZM12.02,13.94c-1.54,0-2.82-1.24-2.82-2.82s1.24-2.82,2.82-2.82,2.82,1.24,2.82,2.82-1.28,2.82-2.82,2.82ZM17.3,5.31H6.74c-.31,0-.56-.25-.56-.56v-2.07c0-.31.25-.56.56-.56h10.56c.31,0,.56.25.56.56v2.07c0,.31-.25.56-.56.56Z" + ] +} \ No newline at end of file diff --git a/src/Routers/routes/UserRoutes.tsx b/src/Routers/routes/UserRoutes.tsx index ff7212a02e2..cc668e2fee6 100644 --- a/src/Routers/routes/UserRoutes.tsx +++ b/src/Routers/routes/UserRoutes.tsx @@ -1,5 +1,6 @@ import ManageUsers from "@/components/Users/ManageUsers"; -import { UserAdd } from "@/components/Users/UserAdd"; +import UserAdd from "@/components/Users/UserAdd"; +import UserHome from "@/components/Users/UserHome"; import UserProfile from "@/components/Users/UserProfile"; import { AppRoutes } from "@/Routers/AppRouter"; @@ -7,6 +8,12 @@ import { AppRoutes } from "@/Routers/AppRouter"; const UserRoutes: AppRoutes = { "/users": () => , "/users/add": () => , + "/users/:username": ({ username }) => ( + + ), + "/users/:username/:tab": ({ username, tab }) => ( + + ), "/user/profile": () => , }; diff --git a/src/Utils/Notifications.js b/src/Utils/Notifications.js index 5b3ecdf143c..08bc90443b5 100644 --- a/src/Utils/Notifications.js +++ b/src/Utils/Notifications.js @@ -1,6 +1,5 @@ import { Stack, alert, defaultModules } from "@pnotify/core"; import * as PNotifyMobile from "@pnotify/mobile"; -import { camelCase, startCase } from "lodash-es"; defaultModules.set(PNotifyMobile, {}); @@ -35,6 +34,25 @@ const notify = (text, type) => { }); }; +/** + * Formats input string to a more human readable format + * @param {string} key - The key to format + * @returns {string} The formatted key + * @example + * formatKey("patient_name") => "Patient Name" + */ +const formatKey = (key) => { + return key + .replace(/[^a-zA-Z0-9]+/g, " ") // Replace non-alphanumeric characters with a space + .trim() + .split(" ") + .map( + (word) => + word.charAt(0).toLocaleUpperCase() + word.slice(1).toLocaleLowerCase(), + ) // Capitalize the first letter of each word and lowercase the rest + .join(" "); +}; + const notifyError = (error) => { let errorMsg = ""; if (typeof error === "string" || !error) { @@ -44,7 +62,7 @@ const notifyError = (error) => { errorMsg = error.detail; } else { for (let [key, value] of Object.entries(error)) { - let keyName = startCase(camelCase(key)); + let keyName = formatKey(key); if (Array.isArray(value)) { const uniques = [...new Set(value)]; errorMsg += `${keyName} - ${uniques.splice(0, 5).join(", ")}`; diff --git a/src/Utils/permissions.ts b/src/Utils/permissions.ts index 35f12715eff..41587d2dcfd 100644 --- a/src/Utils/permissions.ts +++ b/src/Utils/permissions.ts @@ -2,29 +2,58 @@ import { UserModel } from "@/components/Users/models"; import { USER_TYPES, UserRole } from "@/common/constants"; +const checkIfStateOrDistrictAdminInSameLocation = ( + authUser: UserModel, + targetUser: UserModel, +) => { + const hasLocation = Boolean( + targetUser.state_object || targetUser.district_object, + ); + + const isStateAdminOfSameState = + authUser.user_type === "StateAdmin" && + targetUser.state_object?.id === authUser.state; + + const isDistrictAdminOfSameDistrict = + authUser.user_type === "DistrictAdmin" && + targetUser.district_object?.id === authUser.district; + + return ( + hasLocation && (isStateAdminOfSameState || isDistrictAdminOfSameDistrict) + ); +}; + export const showUserDelete = (authUser: UserModel, targetUser: UserModel) => { // Auth user should be higher in hierarchy than target user + // User can't delete their own account if ( USER_TYPES.indexOf(authUser.user_type) <= - USER_TYPES.indexOf(targetUser.user_type) + USER_TYPES.indexOf(targetUser.user_type) || + authUser.username === targetUser.username ) return false; - if ( - authUser.user_type === "StateAdmin" && - targetUser.state_object?.id === authUser.state - ) - return true; - - if ( - authUser.user_type === "DistrictAdmin" && - targetUser.district_object?.id === authUser.district - ) - return true; + return checkIfStateOrDistrictAdminInSameLocation(authUser, targetUser); +}; - return false; +export const showUserPasswordReset = ( + authUser: UserModel, + targetUser: UserModel, +) => { + return authUser.username === targetUser.username; }; +export const showAvatarEdit = (authUser: UserModel, targetUser: UserModel) => { + return authUser.username === targetUser.username || authUser.is_superuser; +}; +export const editUserPermissions = ( + authUser: UserModel, + targetUser: UserModel, +) => { + if (authUser.username === targetUser.username || authUser.is_superuser) + return true; + return checkIfStateOrDistrictAdminInSameLocation(authUser, targetUser); +}; export const CameraFeedPermittedUserTypes: UserRole[] = [ "DistrictAdmin", "StateAdmin", diff --git a/src/Utils/utils.ts b/src/Utils/utils.ts index 3888406ad29..5588e048bd2 100644 --- a/src/Utils/utils.ts +++ b/src/Utils/utils.ts @@ -217,7 +217,9 @@ export const formatCurrency = (price: number) => }); export const isUserOnline = (user: { last_login: DateLike }) => { - return dayjs().subtract(5, "minutes").isBefore(user.last_login); + return user.last_login + ? dayjs().subtract(5, "minutes").isBefore(user.last_login) + : false; }; export interface CountryData { @@ -544,3 +546,18 @@ export const fahrenheitToCelsius = (fahrenheit: number) => { export const keysOf = (obj: T) => { return Object.keys(obj) as (keyof T)[]; }; + +// Utility to check if a value is "empty" +export const isEmpty = (value: unknown) => { + return value === "" || value == undefined; +}; + +// equivalent to lodash omitBy +export function omitBy>( + obj: T, + predicate: (value: unknown) => boolean, +): Partial { + return Object.fromEntries( + Object.entries(obj).filter(([_, value]) => !predicate(value)), + ) as Partial; +} diff --git a/src/common/validation.tsx b/src/common/validation.tsx index 99b4342fbf6..f091303a452 100644 --- a/src/common/validation.tsx +++ b/src/common/validation.tsx @@ -57,6 +57,11 @@ export const validatePincode = (pincode: string) => { return pattern.test(pincode); }; +export const validateNumber = (number: string) => { + const pattern = /^[0-9]+$/; + return pattern.test(number); +}; + export const checkIfValidIP = (str: string) => { // Regular expression to check if string is a IP address const regexExp = diff --git a/src/components/Assets/AssetsList.tsx b/src/components/Assets/AssetsList.tsx index f48723f2784..685d7dfa460 100644 --- a/src/components/Assets/AssetsList.tsx +++ b/src/components/Assets/AssetsList.tsx @@ -52,9 +52,7 @@ const AssetsList = () => { const [importAssetModalOpen, setImportAssetModalOpen] = useState(false); const assetsExist = assets.length > 0 && Object.keys(assets[0]).length > 0; const [showFacilityDialog, setShowFacilityDialog] = useState(false); - const [selectedFacility, setSelectedFacility] = useState({ - name: "", - }); + const [selectedFacility, setSelectedFacility] = useState(); const params = { limit: resultsPerPage, page: qParams.page, @@ -372,7 +370,7 @@ const AssetsList = () => { text="Total Assets" count={totalCount} loading={loading} - icon="l-monitor-heart-rate" + icon="d-folder" className="flex-1" />
@@ -460,15 +458,11 @@ const AssetsList = () => {
)} - {typeof facility === "undefined" && ( + {facility == null && ( setFacility(e)} - selectedFacility={ - facility ?? { - name: "", - } - } + selectedFacility={selectedFacility} handleOk={() => { return undefined; }} @@ -497,10 +491,16 @@ const AssetsList = () => { show={showFacilityDialog} setSelected={(e) => setSelectedFacility(e)} selectedFacility={selectedFacility} - handleOk={() => navigate(`facility/${selectedFacility.id}/assets/new`)} + handleOk={() => { + if (selectedFacility) { + navigate(`facility/${selectedFacility.id}/assets/new`); + } else { + Notification.Warn({ msg: "No facility selected" }); + } + }} handleCancel={() => { setShowFacilityDialog(false); - setSelectedFacility({ name: "" }); + setSelectedFacility(undefined); }} /> diff --git a/src/components/Auth/Login.tsx b/src/components/Auth/Login.tsx index 4255161194a..c6a3ae4652b 100644 --- a/src/components/Auth/Login.tsx +++ b/src/components/Auth/Login.tsx @@ -213,7 +213,7 @@ const Login = (props: { forgot?: boolean }) => {
diff --git a/src/components/Auth/ResetPassword.tsx b/src/components/Auth/ResetPassword.tsx index 11b541e276c..f993b621062 100644 --- a/src/components/Auth/ResetPassword.tsx +++ b/src/components/Auth/ResetPassword.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Cancel, Submit } from "@/components/Common/ButtonV2"; import TextFormField from "@/components/Form/FormFields/TextFormField"; -import { validateRule } from "@/components/Users/UserAdd"; +import { validateRule } from "@/components/Users/UserAddEditForm"; import { LocalStorageKeys } from "@/common/constants"; import { validatePassword } from "@/common/validation"; @@ -134,22 +134,22 @@ const ResetPassword = (props: ResetPasswordProps) => {
{validateRule( form.password?.length >= 8, - "Password should be atleast 8 characters long", + t("password_length_validation"), !form.password, )} {validateRule( form.password !== form.password.toUpperCase(), - "Password should contain at least 1 lowercase letter", + t("password_lowercase_validation"), !form.password, )} {validateRule( form.password !== form.password.toLowerCase(), - "Password should contain at least 1 uppercase letter", + t("password_uppercase_validation"), !form.password, )} {validateRule( /\d/.test(form.password), - "Password should contain at least 1 number", + t("password_number_validation"), !form.password, )}
@@ -168,7 +168,7 @@ const ResetPassword = (props: ResetPasswordProps) => { form.password.length > 0 && validateRule( form.confirm === form.password, - "Confirm password should match the entered password", + t("password_mismatch"), !form.password && form.password.length > 0, )}
diff --git a/src/components/Common/ConfirmDialog.tsx b/src/components/Common/ConfirmDialog.tsx index 0183fc1d651..5fff5d9f8fb 100644 --- a/src/components/Common/ConfirmDialog.tsx +++ b/src/components/Common/ConfirmDialog.tsx @@ -13,6 +13,7 @@ type ConfirmDialogProps = { onConfirm: () => void; children?: React.ReactNode; cancelLabel?: string; + name?: string; }; const ConfirmDialog = ({ @@ -22,6 +23,7 @@ const ConfirmDialog = ({ onConfirm, cancelLabel, children, + name, ...props }: ConfirmDialogProps) => { return ( @@ -29,7 +31,12 @@ const ConfirmDialog = ({ {children}
- + {action}
diff --git a/src/components/Common/ExcelFIleDragAndDrop.tsx b/src/components/Common/ExcelFIleDragAndDrop.tsx index 67c64f0f433..d868e03f2a4 100644 --- a/src/components/Common/ExcelFIleDragAndDrop.tsx +++ b/src/components/Common/ExcelFIleDragAndDrop.tsx @@ -1,4 +1,3 @@ -import { forIn } from "lodash-es"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import * as XLSX from "xlsx"; @@ -68,9 +67,9 @@ export default function ExcelFileDragAndDrop({ const data = XLSX.utils.sheet_to_json(worksheet, { defval: "" }); //converts the date to string data.forEach((row: any) => { - forIn(row, (value: any, key: string) => { - if (value instanceof Date) { - row[key] = value.toISOString().split("T")[0]; + Object.keys(row).forEach((key) => { + if (row[key] instanceof Date) { + row[key] = row[key].toISOString().split("T")[0]; } }); }); diff --git a/src/components/Common/FacilitySelect.tsx b/src/components/Common/FacilitySelect.tsx index e4397cbcb9c..4c0a6629bec 100644 --- a/src/components/Common/FacilitySelect.tsx +++ b/src/components/Common/FacilitySelect.tsx @@ -27,6 +27,7 @@ interface FacilitySelectProps { allowNone?: boolean; placeholder?: string; filter?: (facilities: FacilityModel) => boolean; + id?: string; } export const FacilitySelect = (props: FacilitySelectProps) => { @@ -50,6 +51,7 @@ export const FacilitySelect = (props: FacilitySelectProps) => { errors = "", placeholder, filter, + id, } = props; const facilitySearch = useCallback( @@ -88,6 +90,7 @@ export const FacilitySelect = (props: FacilitySelectProps) => { return ( { ); return ( -
+
-
- -
); }; diff --git a/src/components/Common/Page.tsx b/src/components/Common/Page.tsx index 699f3d47204..874bfce4ad6 100644 --- a/src/components/Common/Page.tsx +++ b/src/components/Common/Page.tsx @@ -1,10 +1,10 @@ import { RefObject, useContext, useEffect } from "react"; +import { cn } from "@/lib/utils"; + import PageTitle, { PageTitleProps } from "@/components/Common/PageTitle"; import { SidebarShrinkContext } from "@/components/Common/Sidebar/Sidebar"; -import { classNames } from "@/Utils/utils"; - interface PageProps extends PageTitleProps { children: React.ReactNode | React.ReactNode[]; options?: React.ReactNode | React.ReactNode[]; @@ -17,6 +17,7 @@ interface PageProps extends PageTitleProps { * @default false **/ collapseSidebar?: boolean; + hideTitleOnPage?: boolean; } export default function Page(props: PageProps) { @@ -33,12 +34,12 @@ export default function Page(props: PageProps) { let padding = ""; if (!props.noImplicitPadding) { - if (!props.hideBack || props.componentRight) padding = "py-3 md:p-6"; + if (!props.hideBack || props.componentRight) padding = "py-3 md:px-6"; else padding = "px-6 py-5"; } return ( -
+
{props.options}
diff --git a/src/components/Common/PageTitle.tsx b/src/components/Common/PageTitle.tsx index ecdadec2d62..b7af8f0eaa8 100644 --- a/src/components/Common/PageTitle.tsx +++ b/src/components/Common/PageTitle.tsx @@ -19,6 +19,7 @@ export interface PageTitleProps { // New props for Breadcrumbs hideBack?: boolean; backUrl?: string; + hideTitleOnPage?: boolean; onBackClick?: () => boolean | void; } @@ -35,6 +36,7 @@ export default function PageTitle({ hideBack = false, backUrl, onBackClick, + hideTitleOnPage, }: PageTitleProps) { const divRef = useRef(); @@ -70,7 +72,9 @@ export default function PageTitle({ )} >
-

{title}

+ {!hideTitleOnPage && ( +

{title}

+ )}
{componentRight}
diff --git a/src/components/Common/SearchByMultipleFields.tsx b/src/components/Common/SearchByMultipleFields.tsx new file mode 100644 index 00000000000..12b2bbd61c9 --- /dev/null +++ b/src/components/Common/SearchByMultipleFields.tsx @@ -0,0 +1,289 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +import { FieldError } from "@/components/Form/FieldValidators"; +import PhoneNumberFormField from "@/components/Form/FormFields/PhoneNumberFormField"; + +interface SearchOption { + key: string; + label: string; + type: "text" | "phone"; + placeholder: string; + value: string; + shortcutKey: string; + component?: React.ComponentType; +} + +interface SearchByMultipleFieldsProps { + id: string; + options: SearchOption[]; + onSearch: (key: string, value: string) => void; + initialOptionIndex?: number; + className?: string; + inputClassName?: string; + buttonClassName?: string; + clearSearch?: { value: boolean; params?: string[] }; +} + +type EventType = { + value: string; + target?: { value: string }; +}; + +const SearchByMultipleFields: React.FC = ({ + id, + options, + onSearch, + initialOptionIndex, + className, + inputClassName, + buttonClassName, + clearSearch, +}) => { + const { t } = useTranslation(); + const [selectedOptionIndex, setSelectedOptionIndex] = useState( + initialOptionIndex || 0, + ); + const selectedOption = options[selectedOptionIndex]; + const [searchValue, setSearchValue] = useState( + options[selectedOptionIndex].value || "", + ); + const [open, setOpen] = useState(false); + const inputRef = useRef(null); + const [focusedIndex, setFocusedIndex] = useState(0); + const [error, setError] = useState(); + + useEffect(() => { + if (clearSearch?.value) { + const clearinput = options + .map((op) => op.key) + .some((element) => clearSearch.params?.includes(element)); + clearinput ? setSearchValue("") : null; + inputRef.current?.focus(); + } + }, [clearSearch]); + + const handleOptionChange = useCallback( + (index: number) => { + setSelectedOptionIndex(index); + const option = options[index]; + setSearchValue(option.value || ""); + setFocusedIndex(options.findIndex((op) => op.key === option.key)); + setOpen(false); + inputRef.current?.focus(); + setError(false); + onSearch(option.key, option.value); + }, + [onSearch], + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.key === "/" && + !(document.activeElement instanceof HTMLInputElement) + ) { + e.preventDefault(); + setOpen(true); + } + if (open) { + if (e.key === "ArrowDown") { + setFocusedIndex((prevIndex) => + prevIndex === options.length - 1 ? 0 : prevIndex + 1, + ); + } else if (e.key === "ArrowUp") { + setFocusedIndex((prevIndex) => + prevIndex === 0 ? options.length - 1 : prevIndex - 1, + ); + } else if (e.key === "Enter") { + handleOptionChange(focusedIndex); + } + + if (e.key === "Escape") { + inputRef.current?.focus(); + setOpen(false); + } + + options.forEach((option, i) => { + if ( + e.key.toLocaleLowerCase() === + option.shortcutKey.toLocaleLowerCase() && + open + ) { + e.preventDefault(); + handleOptionChange(i); + } + }); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [focusedIndex, open, handleOptionChange, options]); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [selectedOptionIndex]); + + useEffect(() => { + const timeout = setTimeout( + () => + selectedOption.value !== searchValue && + onSearch(selectedOption.key, searchValue), + 1000, + ); + return () => clearTimeout(timeout); + }, [searchValue]); + + const handleSearchChange = useCallback( + (value: string) => { + setSearchValue(value); + }, + [selectedOption, onSearch], + ); + + const renderSearchInput = useMemo(() => { + const commonProps = { + ref: inputRef, + value: searchValue, + onChange: (e: EventType) => + handleSearchChange(e.target ? e.target.value : e.value), + className: cn( + "flex-grow border-none shadow-none focus-visible:ring-0 h-10", + inputClassName, + ), + }; + + switch (selectedOption.type) { + case "phone": + return ( + setError(error)} + /> + ); + default: + return ( + + ); + } + }, [selectedOption, searchValue, handleSearchChange, t, inputClassName]); + + return ( +
+
+ + + + + + + + + {options.map((option, index) => ( + handleOptionChange(index)} + className={cn({ + "bg-gray-100": focusedIndex === index, + "hover:bg-secondary-100": true, + })} + > + + {t(option.key)} + + {option.label.charAt(0).toUpperCase()} + + + ))} + + + + + + {renderSearchInput} +
+ {error && ( +
+ {t("invalid_phone_number")} +
+ )} +
+ {options.map((option, i) => ( + + ))} +
+
+ ); +}; + +export default SearchByMultipleFields; diff --git a/src/components/Common/Sidebar/Sidebar.tsx b/src/components/Common/Sidebar/Sidebar.tsx index 6612555a908..ed0a1a4eee5 100644 --- a/src/components/Common/Sidebar/Sidebar.tsx +++ b/src/components/Common/Sidebar/Sidebar.tsx @@ -56,14 +56,14 @@ const StatelessSidebar = ({ }: StatelessSidebarProps) => { const { t } = useTranslation(); const BaseNavItems: INavItem[] = [ - { text: t("facilities"), to: "/facility", icon: "l-hospital" }, - { text: t("patients"), to: "/patients", icon: "l-user-injured" }, - { text: t("assets"), to: "/assets", icon: "l-shopping-cart-alt" }, - { text: t("sample_test"), to: "/sample", icon: "l-medkit" }, - { text: t("shifting"), to: "/shifting", icon: "l-ambulance" }, - { text: t("resource"), to: "/resource", icon: "l-heart-medical" }, - { text: t("users"), to: "/users", icon: "l-users-alt" }, - { text: t("notice_board"), to: "/notice_board", icon: "l-meeting-board" }, + { text: t("facilities"), to: "/facility", icon: "d-hospital" }, + { text: t("patients"), to: "/patients", icon: "d-patient" }, + { text: t("assets"), to: "/assets", icon: "d-folder" }, + { text: t("sample_test"), to: "/sample", icon: "d-microscope" }, + { text: t("shifting"), to: "/shifting", icon: "d-ambulance" }, + { text: t("resource"), to: "/resource", icon: "d-book-open" }, + { text: t("users"), to: "/users", icon: "d-people" }, + { text: t("notice_board"), to: "/notice_board", icon: "d-notice-board" }, ]; const PluginNavItems = useCareAppNavItems(); diff --git a/src/components/Common/SkillSelect.tsx b/src/components/Common/SkillSelect.tsx index 74ddbba5d83..eff16d1380e 100644 --- a/src/components/Common/SkillSelect.tsx +++ b/src/components/Common/SkillSelect.tsx @@ -1,7 +1,7 @@ import { useCallback } from "react"; import AutoCompleteAsync from "@/components/Form/AutoCompleteAsync"; -import { SkillModel, SkillObjectModel } from "@/components/Users/models"; +import { SkillModel } from "@/components/Users/models"; import routes from "@/Utils/request/api"; import request from "@/Utils/request/request"; @@ -15,8 +15,8 @@ interface SkillSelectProps { multiple?: boolean; showNOptions?: number; disabled?: boolean; - selected: SkillObjectModel | SkillObjectModel[] | null; - setSelected: (selected: SkillObjectModel) => void; + selected: SkillModel | null; + setSelected: (selected: SkillModel | null) => void; userSkills?: SkillModel[]; } diff --git a/src/components/Common/Tabs.tsx b/src/components/Common/Tabs.tsx index 9a549cfabcf..9c375db618e 100644 --- a/src/components/Common/Tabs.tsx +++ b/src/components/Common/Tabs.tsx @@ -8,7 +8,7 @@ export default function Tabs(props: { className?: string; currentTab: string | number; onTabChange: (value: string | number) => void; - tabs: { text: ReactNode; value: string | number }[]; + tabs: { text: ReactNode; value: string | number; id?: string }[]; }) { const { className, currentTab, onTabChange, tabs } = props; const ref = useRef(null); @@ -60,6 +60,7 @@ export default function Tabs(props: { {tabs.map((tab, i) => (
+
diff --git a/src/components/Notifications/NotificationsList.tsx b/src/components/Notifications/NotificationsList.tsx index f9b65b35201..dd25eb799bf 100644 --- a/src/components/Notifications/NotificationsList.tsx +++ b/src/components/Notifications/NotificationsList.tsx @@ -490,7 +490,7 @@ export default function NotificationsList({ text={t("Notifications")} id="notification-slide-btn" do={() => setOpen(!open)} - icon={} + icon={} badgeCount={unreadCount} handleOverflow={handleOverflow} /> diff --git a/src/components/Patient/DailyRoundListDetails.tsx b/src/components/Patient/DailyRoundListDetails.tsx index ec568ba56d2..2a2b76d26b2 100644 --- a/src/components/Patient/DailyRoundListDetails.tsx +++ b/src/components/Patient/DailyRoundListDetails.tsx @@ -1,7 +1,11 @@ +import { Link } from "raviger"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import ButtonV2 from "@/components/Common/ButtonV2"; +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import { Button } from "@/components/ui/button"; + import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; import { DailyRoundsModel } from "@/components/Patient/models"; @@ -50,11 +54,14 @@ export const DailyRoundListDetails = (props: any) => {
- - Update Details - +
diff --git a/src/components/Patient/DailyRounds.tsx b/src/components/Patient/DailyRounds.tsx index f0c78522e40..cf43a5adab9 100644 --- a/src/components/Patient/DailyRounds.tsx +++ b/src/components/Patient/DailyRounds.tsx @@ -411,7 +411,7 @@ export const DailyRounds = (props: any) => { ); } else { navigate( - `/facility/${facilityId}/patient/${patientId}/consultation/${consultationId}/log_updates/${obj.id}/update`, + `/facility/${facilityId}/patient/${patientId}/consultation/${consultationId}/log_updates/${obj.id}/critical_care/update`, ); } } diff --git a/src/components/Patient/DiagnosesFilter.tsx b/src/components/Patient/DiagnosesFilter.tsx index c8fc2bc1a44..912a10cfde2 100644 --- a/src/components/Patient/DiagnosesFilter.tsx +++ b/src/components/Patient/DiagnosesFilter.tsx @@ -1,4 +1,3 @@ -import { debounce } from "lodash-es"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -6,6 +5,8 @@ import { ICD11DiagnosisModel } from "@/components/Diagnosis/types"; import { getDiagnosesByIds } from "@/components/Diagnosis/utils"; import AutocompleteMultiSelectFormField from "@/components/Form/FormFields/AutocompleteMultiselect"; +import useDebounce from "@/hooks/useDebounce"; + import { Error } from "@/Utils/Notifications"; import routes from "@/Utils/request/api"; import useQuery from "@/Utils/request/useQuery"; @@ -34,6 +35,7 @@ interface Props { value?: string; onChange: (event: { name: DiagnosesFilterKey; value: string }) => void; } + export default function DiagnosesFilter(props: Props) { const { t } = useTranslation(); const [diagnoses, setDiagnoses] = useState([]); @@ -42,6 +44,11 @@ export default function DiagnosesFilter(props: Props) { prefetch: false, }); + const handleQuery = useDebounce( + (query: string) => refetch({ query: { query } }), + 300, + ); + useEffect(() => { if (res?.status === 500) { Error({ msg: "ICD-11 Diagnosis functionality is facing issues." }); @@ -88,7 +95,7 @@ export default function DiagnosesFilter(props: Props) { options={mergeQueryOptions(diagnoses, data ?? [], (obj) => obj.id)} optionLabel={(option) => option.label} optionValue={(option) => option} - onQuery={debounce((query: string) => refetch({ query: { query } }), 300)} + onQuery={handleQuery} isLoading={loading} /> ); diff --git a/src/components/Patient/ManagePatients.tsx b/src/components/Patient/ManagePatients.tsx index 9507ce3d8d7..25a1f9166ed 100644 --- a/src/components/Patient/ManagePatients.tsx +++ b/src/components/Patient/ManagePatients.tsx @@ -1,38 +1,16 @@ import dayjs from "dayjs"; import { Link, navigate } from "raviger"; -import { ReactNode, useEffect, useState } from "react"; +import { ReactNode, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import Chip from "@/CAREUI/display/Chip"; -import CountBlock from "@/CAREUI/display/Count"; -import FilterBadge from "@/CAREUI/display/FilterBadge"; -import RecordMeta from "@/CAREUI/display/RecordMeta"; -import CareIcon from "@/CAREUI/icons/CareIcon"; -import { AdvancedFilterButton } from "@/CAREUI/interactive/FiltersSlideover"; - import { Avatar } from "@/components/Common/Avatar"; import ButtonV2 from "@/components/Common/ButtonV2"; import { ExportMenu } from "@/components/Common/Export"; import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; +import SearchByMultipleFields from "@/components/Common/SearchByMultipleFields"; import SortDropdownMenu from "@/components/Common/SortDropdown"; import Tabs from "@/components/Common/Tabs"; -import { ICD11DiagnosisModel } from "@/components/Diagnosis/types"; -import { getDiagnosesByIds } from "@/components/Diagnosis/utils"; -import FacilitiesSelectDialogue from "@/components/ExternalResult/FacilitiesSelectDialogue"; -import DoctorVideoSlideover from "@/components/Facility/DoctorVideoSlideover"; -import { FacilityModel, PatientCategory } from "@/components/Facility/models"; -import { PhoneNumberValidator } from "@/components/Form/FieldValidators"; -import PhoneNumberFormField from "@/components/Form/FormFields/PhoneNumberFormField"; -import { FieldChangeEvent } from "@/components/Form/FormFields/Utils"; -import SearchInput from "@/components/Form/SearchInput"; -import { - DIAGNOSES_FILTER_LABELS, - DiagnosesFilterKey, - FILTER_BY_DIAGNOSES_KEYS, -} from "@/components/Patient/DiagnosesFilter"; -import PatientFilter from "@/components/Patient/PatientFilter"; -import { isPatientMandatoryDataFilled } from "@/components/Patient/Utils"; import useAuthUser from "@/hooks/useAuthUser"; import useFilters from "@/hooks/useFilters"; @@ -49,17 +27,36 @@ import { } from "@/common/constants"; import { parseOptionId } from "@/common/utils"; -import { triggerGoal } from "@/Integrations/Plausible"; -import * as Notification from "@/Utils/Notifications"; import routes from "@/Utils/request/api"; -import request from "@/Utils/request/request"; -import useQuery from "@/Utils/request/useQuery"; + +import Chip from "../../CAREUI/display/Chip"; +import CountBlock from "../../CAREUI/display/Count"; +import FilterBadge from "../../CAREUI/display/FilterBadge"; +import RecordMeta from "../../CAREUI/display/RecordMeta"; +import CareIcon from "../../CAREUI/icons/CareIcon"; +import { AdvancedFilterButton } from "../../CAREUI/interactive/FiltersSlideover"; +import { triggerGoal } from "../../Integrations/Plausible"; +import * as Notification from "../../Utils/Notifications"; +import request from "../../Utils/request/request"; +import useQuery from "../../Utils/request/useQuery"; import { formatPatientAge, humanizeStrings, isAntenatal, parsePhoneNumber, -} from "@/Utils/utils"; +} from "../../Utils/utils"; +import { ICD11DiagnosisModel } from "../Diagnosis/types"; +import { getDiagnosesByIds } from "../Diagnosis/utils"; +import FacilitiesSelectDialogue from "../ExternalResult/FacilitiesSelectDialogue"; +import DoctorVideoSlideover from "../Facility/DoctorVideoSlideover"; +import { FacilityModel, PatientCategory } from "../Facility/models"; +import { + DIAGNOSES_FILTER_LABELS, + DiagnosesFilterKey, + FILTER_BY_DIAGNOSES_KEYS, +} from "./DiagnosesFilter"; +import PatientFilter from "./PatientFilter"; +import { isPatientMandatoryDataFilled } from "./Utils"; interface TabPanelProps { children?: ReactNode; @@ -93,6 +90,7 @@ export const PatientManager = () => { Pagination, FilterBadges, resultsPerPage, + clearSearch, } = useFilters({ limit: 12, cacheBlacklist: [ @@ -102,37 +100,11 @@ export const PatientManager = () => { "emergency_phone_number", ], }); - const [selectedFacility, setSelectedFacility] = useState({ - name: "", - }); + const [selectedFacility, setSelectedFacility] = useState(); const authUser = useAuthUser(); const [diagnoses, setDiagnoses] = useState([]); const [showDialog, setShowDialog] = useState<"create" | "list-discharged">(); const [showDoctors, setShowDoctors] = useState(false); - const [phoneNumber, _setPhoneNumber] = useState(""); - const [emergencyPhoneNumber, _setEmergencyPhoneNumber] = useState(""); - - const setPhoneNumber = (value: string) => { - _setPhoneNumber(value); - const error = PhoneNumberValidator()(value); - if (!error) { - updateQuery({ phone_number: value }); - } - if ((value === "+91" || value === "") && qParams.phone_number) { - updateQuery({ phone_number: null }); - } - }; - - const setEmergencyPhoneNumber = (value: string) => { - _setEmergencyPhoneNumber(value); - const error = PhoneNumberValidator()(value); - if (!error) { - updateQuery({ emergency_phone_number: value }); - } - if ((value === "+91" || value === "") && qParams.emergency_phone_number) { - updateQuery({ emergency_phone_number: null }); - } - }; const tabValue = qParams.last_consultation__new_discharge_reason || @@ -319,14 +291,6 @@ export const PatientManager = () => { const { loading: isLoading, data } = useQuery(routes.patientList, { query: params, - onResponse: () => { - if (!params.phone_number) { - _setPhoneNumber("+91"); - } - if (!params.emergency_phone_number) { - _setEmergencyPhoneNumber("+91"); - } - }, }); const getTheCategoryFromId = () => { @@ -779,22 +743,74 @@ export const PatientManager = () => { ); } - const queryField = (name: string, defaultValue?: T) => { - return { - name, - value: qParams[name] || defaultValue, - onChange: (e: FieldChangeEvent) => updateQuery({ [e.name]: e.value }), - }; - }; - const onlyAccessibleFacility = permittedFacilities?.count === 1 ? permittedFacilities.results[0] : null; + const searchOptions = [ + { + key: "name", + label: "Name", + type: "text" as const, + placeholder: "search_by_patient_name", + value: qParams.name || "", + shortcutKey: "n", + }, + { + key: "patient_no", + label: "IP/OP No", + type: "text" as const, + placeholder: "search_by_patient_no", + value: qParams.patient_no || "", + shortcutKey: "u", + }, + { + key: "phone_number", + label: "Phone Number", + type: "phone" as const, + placeholder: "Search_by_phone_number", + value: qParams.phone_number || "", + shortcutKey: "p", + }, + { + key: "emergency_contact_number", + label: "Emergency Contact Phone Number", + type: "phone" as const, + placeholder: "search_by_emergency_phone_number", + value: qParams.emergency_phone_number || "", + shortcutKey: "e", + }, + ]; + + const handleSearch = useCallback( + (key: string, value: string) => { + const updatedQuery = { + phone_number: + key === "phone_number" + ? value.length >= 13 || value === "" + ? value + : undefined + : undefined, + name: key === "name" ? value : undefined, + patient_no: key === "patient_no" ? value : undefined, + emergency_phone_number: + key === "emergency_contact_number" + ? value.length >= 13 || value === "" + ? value + : undefined + : undefined, + }; + + updateQuery(updatedQuery); + }, + [updateQuery], + ); + return (
@@ -958,74 +974,42 @@ export const PatientManager = () => { setSelected={(e) => setSelectedFacility(e)} selectedFacility={selectedFacility} handleOk={() => { - switch (showDialog) { - case "create": - navigate(`facility/${selectedFacility.id}/patient`); - break; - case "list-discharged": - navigate(`facility/${selectedFacility.id}/discharged-patients`); - break; + if (selectedFacility) { + switch (showDialog) { + case "create": + navigate(`facility/${selectedFacility.id}/patient`); + break; + case "list-discharged": + navigate(`facility/${selectedFacility.id}/discharged-patients`); + break; + } + } else { + Notification.Error({ msg: "No facility selected" }); } }} handleCancel={() => { setShowDialog(undefined); - setSelectedFacility({ name: "" }); + setSelectedFacility(undefined); }} /> -
-
-
- -
-
-
-
-
- - -
-
- setPhoneNumber(e.value)} - types={["mobile", "landline"]} - className="w-full grow" - error={((phoneNumber || "+91") === "+91" && "") || undefined} - /> - setEmergencyPhoneNumber(e.value)} - types={["mobile", "landline"]} - className="w-full" - error={ - ((emergencyPhoneNumber || "+91") === "+91" && "") || undefined - } - /> -
-
+
+
+
+ +
- {patientData.modified_date - ? relativeDate(patientData.modified_date) + {patientData.created_date + ? relativeDate(patientData.created_date) : "--:--"}
diff --git a/src/components/Patient/PatientRegister.tsx b/src/components/Patient/PatientRegister.tsx index 5828517adf9..a7098c24264 100644 --- a/src/components/Patient/PatientRegister.tsx +++ b/src/components/Patient/PatientRegister.tsx @@ -1,6 +1,4 @@ import careConfig from "@careConfig"; -import { startCase, toLower } from "lodash-es"; -import { debounce } from "lodash-es"; import { navigate } from "raviger"; import { useCallback, useEffect, useReducer, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -56,6 +54,7 @@ import { UserModel } from "@/components/Users/models"; import useAppHistory from "@/hooks/useAppHistory"; import useAuthUser from "@/hooks/useAuthUser"; +import useDebounce from "@/hooks/useDebounce"; import { BLOOD_GROUPS, @@ -656,7 +655,7 @@ export const PatientRegister = (props: PatientRegisterProps) => { ? formData.last_vaccinated_date : null : null, - name: startCase(toLower(formData.name)), + name: formData.name, pincode: formData.pincode ? formData.pincode : undefined, gender: Number(formData.gender), nationality: formData.nationality, @@ -776,7 +775,7 @@ export const PatientRegister = (props: PatientRegisterProps) => { }); }; - const duplicateCheck = debounce(async (phoneNo: string) => { + const duplicateCheck = useDebounce(async (phoneNo: string) => { if ( phoneNo && PhoneNumberValidator()(parsePhoneNumber(phoneNo) ?? "") === undefined @@ -800,6 +799,11 @@ export const PatientRegister = (props: PatientRegisterProps) => { }); } } + } else { + setStatusDialog({ + show: false, + patientList: [], + }); } }, 300); @@ -1022,6 +1026,7 @@ export const PatientRegister = (props: PatientRegisterProps) => { {...field("name")} type="text" label={"Name"} + autoCapitalize="words" />
diff --git a/src/components/Patient/SampleDetails.tsx b/src/components/Patient/SampleDetails.tsx index abf099a6e7d..9a779446d54 100644 --- a/src/components/Patient/SampleDetails.tsx +++ b/src/components/Patient/SampleDetails.tsx @@ -1,10 +1,10 @@ -import { camelCase, capitalize, startCase } from "lodash-es"; -import { navigate } from "raviger"; +import { Link, navigate } from "raviger"; import { useTranslation } from "react-i18next"; -import Card from "@/CAREUI/display/Card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import ButtonV2 from "@/components/Common/ButtonV2"; import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; import { FileUpload } from "@/components/Files/FileUpload"; @@ -270,11 +270,11 @@ export const SampleDetails = ({ id }: DetailRoute) => { {t("status")}:{" "} {" "} - {startCase(camelCase(flow.status))} + {t(`SAMPLE_TEST_HISTORY__${flow.status}`) || "Unknown"}
{t("label")}:{" "} - {capitalize(flow.notes)} + {flow.notes}
@@ -305,169 +305,230 @@ export const SampleDetails = ({ id }: DetailRoute) => { options={ sampleDetails?.patient && (
- - {t("icmr_specimen_referral_form")} - +
) } > - -
-
- - {t("status")}:{" "} - - {sampleDetails?.status} -
-
- - {t("result")}:{" "} - - {sampleDetails?.result} -
-
- - {t("patient")}:{" "} - - {sampleDetails?.patient_name} -
- {sampleDetails?.facility_object && ( -
- - {t("facility")}:{" "} - - {sampleDetails?.facility_object.name} + + +
+
+
+ {t("status")}:{" "} +
+ + {sampleDetails?.status} + +
+
+
+ {t("result")}:{" "} +
+ + {sampleDetails?.result} +
- )} -
- - {t("tested_on")}:{" "} - - {sampleDetails?.date_of_result - ? formatDateTime(sampleDetails.date_of_result) - : "-"} -
-
- - {t("result_on")}:{" "} - - {sampleDetails?.date_of_result - ? formatDateTime(sampleDetails.date_of_result) - : "-"}
- {sampleDetails?.fast_track && ( -
- - {t("fast_track_testing_reason")}:{" "} - - {sampleDetails.fast_track} + + +
+
+
+ {t("patient")}: +
+
+ {sampleDetails?.patient_name || "-"} +
- )} - {sampleDetails?.doctor_name && ( -
- - {t("doctors_name")}:{" "} - - - {startCase(camelCase(sampleDetails.doctor_name))} - + {sampleDetails?.facility_object && ( +
+
+ {t("facility")}:{" "} +
+
+ {sampleDetails?.facility_object.name} +
+
+ )} +
+
+ {t("tested_on")}:{" "} +
+
+ {sampleDetails?.date_of_result + ? formatDateTime(sampleDetails.date_of_result) + : "-"} +
- )} - {sampleDetails?.diagnosis && ( -
- - {t("diagnosis")}:{" "} - - {sampleDetails.diagnosis} +
+
+ {t("result_on")}:{" "} +
+
+ {sampleDetails?.date_of_result + ? formatDateTime(sampleDetails.date_of_result) + : "-"} +
- )} - {sampleDetails?.diff_diagnosis && ( -
- - {t("differential_diagnosis")}:{" "} - - {sampleDetails?.diff_diagnosis} +
+ + {sampleDetails?.doctor_name && ( +
+
+ {t("doctors_name")}: +
+
+ {sampleDetails.doctor_name} +
)} - {sampleDetails?.etiology_identified && ( -
- - {t("etiology_identified")}:{" "} - - - {sampleDetails.etiology_identified} - +
+
+ {sampleDetails?.fast_track && ( +
+
+ {t("fast_track_testing_reason")}:{" "} +
+ {sampleDetails.fast_track} +
+ )} + {sampleDetails?.diagnosis && ( +
+
{t("diagnosis")}:
+ + {" "} + {sampleDetails.diagnosis} + +
+ )} + {sampleDetails?.diff_diagnosis && ( +
+
+ {t("differential_diagnosis")}:{" "} +
+ + {" "} + {sampleDetails?.diff_diagnosis} + +
+ )} + {sampleDetails?.etiology_identified && ( +
+
+ {t("etiology_identified")}:{" "} +
+ + {" "} + {sampleDetails.etiology_identified} + +
+ )} +
+
+ {t("is_atypical_presentation")} +
+ + {" "} + {yesOrNoBadge(sampleDetails?.is_atypical_presentation)} +
- )} -
- - {t("is_atypical_presentation")}{" "} - - {yesOrNoBadge(sampleDetails?.is_atypical_presentation)} -
-
- - {t("is_unusual_course")}{" "} - - {yesOrNoBadge(sampleDetails?.is_unusual_course)} -
- {sampleDetails?.atypical_presentation && ( -
- - {t("atypical_presentation_details")}:{" "} - - {sampleDetails.atypical_presentation} +
+
+ {t("is_unusual_course")} +
+ + {" "} + {yesOrNoBadge(sampleDetails?.is_unusual_course)} +
- )} -
- {t("sari")} - {yesOrNoBadge(sampleDetails?.has_sari)} -
-
- {t("ari")} - {yesOrNoBadge(sampleDetails?.has_ari)} -
-
- - {t("contact_with_confirmed_carrier")}{" "} - - {yesOrNoBadge(sampleDetails?.patient_has_confirmed_contact)} -
-
- - {t("contact_with_suspected_carrier")}{" "} - - {yesOrNoBadge(sampleDetails?.patient_has_suspected_contact)} -
- {sampleDetails?.patient_travel_history && - sampleDetails.patient_travel_history.length !== 0 && ( -
- - {t("countries_travelled")}:{" "} - - {sampleDetails.patient_travel_history} + {sampleDetails?.atypical_presentation && ( +
+
+ {t("atypical_presentation_details")}:{" "} +
+ + {" "} + {sampleDetails.atypical_presentation} +
)} +
+
{t("sari")}
+ + {" "} + {yesOrNoBadge(sampleDetails?.has_sari)} + +
+
+
{t("ari")}
+ + {" "} + {yesOrNoBadge(sampleDetails?.has_ari)} + +
+
+
+ {t("contact_with_confirmed_carrier")}{" "} +
+ + {" "} + {yesOrNoBadge(sampleDetails?.patient_has_confirmed_contact)} + +
+
+
+ {t("contact_with_suspected_carrier")}{" "} +
+ + {" "} + {yesOrNoBadge(sampleDetails?.patient_has_suspected_contact)} + +
+ {sampleDetails?.patient_travel_history && + sampleDetails.patient_travel_history.length !== 0 && ( +
+
+ {t("countries_travelled")}:{" "} +
+ + {" "} + {sampleDetails.patient_travel_history} + +
+ )} +
+
{sampleDetails?.sample_type && ( -
- +
+
{t("sample_type")}:{" "} +
+ + {sampleDetails.sample_type} - {startCase(camelCase(sampleDetails.sample_type))}
)} {sampleDetails?.sample_type === "OTHER TYPE" && ( -
- +
+
{t("sample_type_description")}:{" "} - - {sampleDetails?.sample_type_other} +
+
+ {sampleDetails?.sample_type_other} +
)} -
+
diff --git a/src/components/Patient/SampleTestCard.tsx b/src/components/Patient/SampleTestCard.tsx index c22155c494e..f1d59980c23 100644 --- a/src/components/Patient/SampleTestCard.tsx +++ b/src/components/Patient/SampleTestCard.tsx @@ -1,6 +1,6 @@ -import { camelCase, startCase } from "lodash-es"; import { navigate } from "raviger"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import ButtonV2 from "@/components/Common/ButtonV2"; import RelativeDateUserMention from "@/components/Common/RelativeDateUserMention"; @@ -24,6 +24,7 @@ interface SampleDetailsProps { } export const SampleTestCard = (props: SampleDetailsProps) => { + const { t } = useTranslation(); const { itemData, handleApproval, facilityId, patientId, refetch } = props; const [statusDialog, setStatusDialog] = useState<{ @@ -103,9 +104,9 @@ export const SampleTestCard = (props: SampleDetailsProps) => {
- {startCase(camelCase(itemData.status))} + {t(`SAMPLE_TEST_HISTORY__${itemData.status}`) || "Unknown"}
@@ -144,9 +145,9 @@ export const SampleTestCard = (props: SampleDetailsProps) => {
- {startCase(camelCase(itemData.result))} + {t(`SAMPLE_TEST_RESULT__${itemData.result}`) || "Unknown"}
diff --git a/src/components/Patient/SampleViewAdmin.tsx b/src/components/Patient/SampleViewAdmin.tsx index 37d66bfb815..dcb98d9f28c 100644 --- a/src/components/Patient/SampleViewAdmin.tsx +++ b/src/components/Patient/SampleViewAdmin.tsx @@ -346,7 +346,7 @@ export default function SampleViewAdmin() { text="Total Samples Taken" count={sampeleData?.count || 0} loading={isLoading} - icon="l-thermometer" + icon="d-microscope" className="flex-1" />
diff --git a/src/components/Resource/ResourceBoard.tsx b/src/components/Resource/ResourceBoard.tsx index f76604ff9f6..1f8ccafcb5d 100644 --- a/src/components/Resource/ResourceBoard.tsx +++ b/src/components/Resource/ResourceBoard.tsx @@ -5,7 +5,8 @@ import { useTranslation } from "react-i18next"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { AdvancedFilterButton } from "@/CAREUI/interactive/FiltersSlideover"; -import ButtonV2 from "@/components/Common/ButtonV2"; +import { Button } from "@/components/ui/button"; + import { ExportButton } from "@/components/Common/Export"; import Loading from "@/components/Common/Loading"; import PageTitle from "@/components/Common/PageTitle"; @@ -92,10 +93,10 @@ export default function BoardView() { currentTab={boardFilter !== ACTIVE ? 1 : 0} />
- - + advancedFilter.setShow(true)} /> diff --git a/src/components/Resource/ResourceList.tsx b/src/components/Resource/ResourceList.tsx index 891c2f3378b..3bca8377a6b 100644 --- a/src/components/Resource/ResourceList.tsx +++ b/src/components/Resource/ResourceList.tsx @@ -5,7 +5,8 @@ import Chip from "@/CAREUI/display/Chip"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { AdvancedFilterButton } from "@/CAREUI/interactive/FiltersSlideover"; -import ButtonV2 from "@/components/Common/ButtonV2"; +import { Button } from "@/components/ui/button"; + import { ExportButton } from "@/components/Common/Export"; import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; @@ -34,8 +35,10 @@ export default function ListView() { const { t } = useTranslation(); - const onBoardViewBtnClick = () => + const onBoardViewBtnClick = () => { navigate("/resource/board", { query: qParams }); + localStorage.setItem("defaultResourceView", "board"); + }; const appliedFilters = formatFilter(qParams); const { loading, data, refetch } = useQuery(routes.listResourceRequests, { @@ -216,10 +219,10 @@ export default function ListView() {
- - + advancedFilter.setShow(true)} /> diff --git a/src/components/Shifting/ShiftingBoard.tsx b/src/components/Shifting/ShiftingBoard.tsx index 8486c8dd4d0..cd9f4300fd4 100644 --- a/src/components/Shifting/ShiftingBoard.tsx +++ b/src/components/Shifting/ShiftingBoard.tsx @@ -6,7 +6,8 @@ import { useTranslation } from "react-i18next"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { AdvancedFilterButton } from "@/CAREUI/interactive/FiltersSlideover"; -import ButtonV2 from "@/components/Common/ButtonV2"; +import { Button } from "@/components/ui/button"; + import ConfirmDialog from "@/components/Common/ConfirmDialog"; import { ExportButton } from "@/components/Common/Export"; import Loading from "@/components/Common/Loading"; @@ -75,6 +76,10 @@ export default function BoardView() { const [boardFilter, setBoardFilter] = useState(activeBoards); const { t } = useTranslation(); + const onListViewBtnClick = () => { + navigate("/shifting/list", { query: qParams }); + localStorage.setItem("defaultShiftView", "list"); + }; return (
@@ -119,13 +124,10 @@ export default function BoardView() { />
- navigate("/shifting/list", { query: qParams })} - > - + advancedFilter.setShow(true)} /> diff --git a/src/components/Shifting/ShiftingList.tsx b/src/components/Shifting/ShiftingList.tsx index 7161ea29441..f9727bdd819 100644 --- a/src/components/Shifting/ShiftingList.tsx +++ b/src/components/Shifting/ShiftingList.tsx @@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { AdvancedFilterButton } from "@/CAREUI/interactive/FiltersSlideover"; -import ButtonV2 from "@/components/Common/ButtonV2"; +import { Button } from "@/components/ui/button"; + import { ExportButton } from "@/components/Common/Export"; import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; @@ -32,6 +33,10 @@ export default function ListView() { } = useFilters({ cacheBlacklist: ["patient_name"] }); const { t } = useTranslation(); + const onBoardViewBtnClick = () => { + navigate("/shifting/board", { query: qParams }); + localStorage.setItem("defaultShiftView", "board"); + }; const { data: shiftData, loading, @@ -73,13 +78,10 @@ export default function ListView() {
- navigate("/shifting/board", { query: qParams })} - > - + advancedFilter.setShow(true)} /> diff --git a/src/components/Users/ConfirmFacilityModal.tsx b/src/components/Users/ConfirmFacilityModal.tsx new file mode 100644 index 00000000000..289e576aa54 --- /dev/null +++ b/src/components/Users/ConfirmFacilityModal.tsx @@ -0,0 +1,79 @@ +import { useTranslation } from "react-i18next"; + +import ConfirmDialog from "@/components/Common/ConfirmDialog"; +import { FacilityModel } from "@/components/Facility/models"; + +const ConfirmFacilityModal = ({ + username, + currentFacility, + homeFacility, + handleCancel, + handleOk, + type, +}: { + username: string; + currentFacility?: FacilityModel; + homeFacility?: FacilityModel; + handleCancel: () => void; + handleOk: () => void; + type: string; +}) => { + const { t } = useTranslation(); + const title = t(type); + let action = ""; + let body; + switch (type) { + case "unlink_facility": + action = "Unlink"; + body = ( +
+
+ {t("unlink_facility_confirm")}{" "} + {currentFacility?.name} {t("from_user")}{" "} + {username} ? +
+ {t("unlink_facility_access")} +
+
+ ); + break; + case "clear_home_facility": + action = "Clear"; + body = ( +
+
+ {t("clear_home_facility_confirm")}{" "} + {currentFacility?.name} {t("from_user")}{" "} + {username} ? +
+
+
+ ); + break; + case "replace_home_facility": + action = "Replace"; + body = ( +
+ {t("replace_home_facility_confirm")}{" "} + {homeFacility?.name} {t("with")}{" "} + {currentFacility?.name}{" "} + {t("replace_home_facility_confirm_as")} {username}? +
+ ); + break; + } + return ( + {title}} + show={true} + action={action} + onClose={handleCancel} + onConfirm={handleOk} + variant="danger" + > +
{body}
+
+ ); +}; + +export default ConfirmFacilityModal; diff --git a/src/components/Users/ConfirmHomeFacilityUpdateDialog.tsx b/src/components/Users/ConfirmHomeFacilityUpdateDialog.tsx deleted file mode 100644 index 429e7370366..00000000000 --- a/src/components/Users/ConfirmHomeFacilityUpdateDialog.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useState } from "react"; - -import ConfirmDialog from "@/components/Common/ConfirmDialog"; - -interface ConfirmDialogProps { - previousFacilityName: string; - userName: string; - newFacilityName: string; - handleCancel: () => void; - handleOk: () => void; -} - -const ConfirmHomeFacilityUpdateDialog = (props: ConfirmDialogProps) => { - const { - previousFacilityName, - userName, - newFacilityName, - handleCancel, - handleOk, - } = props; - - const [disable, setDisable] = useState(false); - - const handleSubmit = () => { - handleOk(); - setDisable(true); - }; - return ( - Replace Home Facility} - show={true} - action={"Replace"} - onClose={handleCancel} - onConfirm={handleSubmit} - disabled={disable} - variant="danger" - > -
-
- Are you sure you want to replace{" "} - {previousFacilityName} with{" "} - {newFacilityName} as home facility for user{" "} - {userName} - ? -
-
-
-
- ); -}; - -export default ConfirmHomeFacilityUpdateDialog; diff --git a/src/components/Users/LinkFacilityDialog.tsx b/src/components/Users/LinkFacilityDialog.tsx deleted file mode 100644 index 523965803b2..00000000000 --- a/src/components/Users/LinkFacilityDialog.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useState } from "react"; - -import ConfirmDialog from "@/components/Common/ConfirmDialog"; -import { FacilitySelect } from "@/components/Common/FacilitySelect"; -import { FacilityModel } from "@/components/Facility/models"; - -interface Props { - username: string; - handleOk: ( - username: string, - facility: FacilityModel | FacilityModel[] | null, - ) => void; - handleCancel: () => void; -} - -const LinkFacilityDialog = ({ username, handleOk, handleCancel }: Props) => { - const [facility, setFacility] = useState(null); - - return ( - - Link new facility to {username} - - } - description={ -
- -
- } - action="Link" - variant="primary" - onClose={handleCancel} - onConfirm={() => handleOk(username, facility)} - disabled={!facility} - /> - ); -}; - -export default LinkFacilityDialog; diff --git a/src/components/Users/LinkedFacilities.tsx b/src/components/Users/LinkedFacilities.tsx new file mode 100644 index 00000000000..18d520abca3 --- /dev/null +++ b/src/components/Users/LinkedFacilities.tsx @@ -0,0 +1,338 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +import { FacilitySelect } from "@/components/Common/FacilitySelect"; +import { FacilityModel } from "@/components/Facility/models"; +import ConfirmFacilityModal from "@/components/Users/ConfirmFacilityModal"; +import { UserModel } from "@/components/Users/models"; + +import useAuthUser from "@/hooks/useAuthUser"; +import { useIsAuthorized } from "@/hooks/useIsAuthorized"; + +import AuthorizeFor from "@/Utils/AuthorizeFor"; +import * as Notification from "@/Utils/Notifications"; +import routes from "@/Utils/request/api"; +import request from "@/Utils/request/request"; +import useQuery from "@/Utils/request/useQuery"; + +import ButtonV2 from "../Common/ButtonV2"; + +const initModalProps: { + selectedFacility?: FacilityModel; + type: string; + toggle: boolean; +} = { + toggle: false, + selectedFacility: undefined, + type: "", +}; + +export default function LinkedFacilities({ + userData, + refetchUserData, +}: { + userData: UserModel; + refetchUserData?: () => void; +}) { + const [facility, setFacility] = useState(null); + const [userFacilities, setUserFacilities] = useState< + FacilityModel[] | null + >(); + const [homeFacility, setHomeFacility] = useState(); + const [modalProps, setModalProps] = useState(initModalProps); + const { t } = useTranslation(); + const authUser = useAuthUser(); + + const authorizeForHomeFacility = useIsAuthorized( + AuthorizeFor(["DistrictAdmin", "StateAdmin"]), + ); + + const isCurrentUser = userData.username === authUser.username; + + const { refetch: refetchUserFacilities } = useQuery(routes.userListFacility, { + pathParams: { username: userData.username }, + query: { limit: 36 }, + onResponse({ res, data }) { + if (res?.status === 200 && data) { + let userFacilities = data?.results; + if (userData.home_facility_object) { + const homeFacility = data?.results.find( + (facility) => facility.id === userData.home_facility_object?.id, + ); + userFacilities = userFacilities.filter( + (facility) => facility.id !== homeFacility?.id, + ); + setHomeFacility(homeFacility); + } + setUserFacilities(userFacilities); + } + }, + }); + + const handleOnClick = (type: string, selectedFacility: FacilityModel) => { + switch (type) { + case "clear_home_facility": + case "unlink_facility": + case "replace_home_facility": + setModalProps({ + selectedFacility, + type: type, + toggle: true, + }); + break; + case "set_home_facility": + replaceHomeFacility(selectedFacility); + break; + } + }; + + const handleModalCancel = () => { + setModalProps(initModalProps); + }; + + const handleModalOk = () => { + switch (modalProps.type) { + case "unlink_facility": + unlinkFacility(); + break; + case "clear_home_facility": + clearHomeFacility(); + break; + case "replace_home_facility": + replaceHomeFacility(); + break; + } + setModalProps(initModalProps); + }; + + const replaceHomeFacility = async (facility?: FacilityModel) => { + const selectedFacility = facility ?? modalProps.selectedFacility; + const { res } = await request(routes.partialUpdateUser, { + pathParams: { username: userData.username }, + body: { home_facility: selectedFacility?.id?.toString() }, + }); + if (!res?.ok) { + Notification.Error({ + msg: t("home_facility_updated_error"), + }); + } else { + setHomeFacility(selectedFacility); + Notification.Success({ + msg: t("home_facility_updated_success"), + }); + } + await refetchUserFacilities(); + refetchUserData?.(); + }; + + const clearHomeFacility = async () => { + const { res } = await request(routes.clearHomeFacility, { + pathParams: { username: userData.username }, + }); + + if (!res?.ok) { + Notification.Error({ + msg: t("clear_home_facility_error"), + }); + } else { + userData.home_facility_object = undefined; + setHomeFacility(undefined); + Notification.Success({ + msg: t("home_facility_cleared_success"), + }); + } + await refetchUserFacilities(); + }; + + const unlinkFacility = async () => { + const { res } = await request(routes.deleteUserFacility, { + pathParams: { username: userData.username }, + body: { facility: modalProps.selectedFacility?.id?.toString() }, + }); + if (!res?.ok) { + Notification.Error({ + msg: t("unlink_facility_error"), + }); + } else { + Notification.Success({ + msg: t("unlink_facility_success"), + }); + } + await refetchUserFacilities(); + }; + + const linkFacility = async ( + username: string, + facility: FacilityModel | null, + ) => { + if (!facility) return; + const { res } = await request(routes.addUserFacility, { + pathParams: { username }, + body: { facility: facility.id?.toString() }, + }); + + if (!res?.ok) { + Notification.Error({ + msg: t("link_facility_error"), + }); + } else { + Notification.Success({ + msg: t("facility_linked_success"), + }); + } + await refetchUserFacilities(); + setFacility(null); + }; + + const renderFacilityButtons = (facility: FacilityModel) => { + if (!facility) return; + return ( +
+ +
+
{facility.name}
+ +
+ +
+
+
+ + + {authorizeForHomeFacility && ( + + handleOnClick( + homeFacility + ? "replace_home_facility" + : "set_home_facility", + facility, + ) + } + > + {t("set_home_facility")} + + )} + handleOnClick("unlink_facility", facility)} + > + {t("unlink_this_facility")} + + +
+
+ ); + }; + + const renderHomeFacilityButton = (homeFacility: FacilityModel) => { + return ( +
+
+
+ {homeFacility.name} +
+ {(authorizeForHomeFacility || isCurrentUser) && ( +
+ +
+ )} +
+
+ ); + }; + + return ( + <> + {modalProps.toggle && ( + + )} +
+
+ + linkFacility(userData.username, facility)} + disabled={!authorizeForHomeFacility} + tooltip={ + !authorizeForHomeFacility + ? t("contact_your_admin_to_add_facilities") + : undefined + } + > + {t("add_facility")} + +
+ + {homeFacility && ( +
+

{t("home_facility")}

+
+ {renderHomeFacilityButton(homeFacility)} +
+
+ )} + {userFacilities && userFacilities.length > 0 && ( +
+

{t("linked_facilities")}

+ +
+ {userFacilities.map((facility: FacilityModel) => { + if (homeFacility?.id === facility.id) { + return null; + } + return renderFacilityButtons(facility); + })} +
+
+ )} +
+ + ); +} diff --git a/src/components/Users/LinkedFacilitiesTab.tsx b/src/components/Users/LinkedFacilitiesTab.tsx new file mode 100644 index 00000000000..3f255dacbe1 --- /dev/null +++ b/src/components/Users/LinkedFacilitiesTab.tsx @@ -0,0 +1,43 @@ +import { navigate } from "raviger"; +import { useTranslation } from "react-i18next"; + +import UserColumns from "@/components/Common/UserColumns"; +import LinkedFacilities from "@/components/Users/LinkedFacilities"; +import { UserModel } from "@/components/Users/models"; + +import useAuthUser from "@/hooks/useAuthUser"; + +import * as Notification from "@/Utils/Notifications"; +import { editUserPermissions } from "@/Utils/permissions"; + +type Props = { + userData: UserModel; + username: string; + refetchUserData?: () => void; +}; + +export default function LinkedFacilitiesTab(props: Props) { + const { userData } = props; + const { t } = useTranslation(); + const authUser = useAuthUser(); + const editPermissions = editUserPermissions(authUser, userData); + + if (!userData) { + Notification.Error({ msg: t("username_userdetails_not_found") }); + return <>; + } else if (!editPermissions) { + Notification.Error({ msg: t("no_permission_to_view_page") }); + navigate("/users"); + } + + return ( +
+ +
+ ); +} diff --git a/src/components/Users/LinkedSkills.tsx b/src/components/Users/LinkedSkills.tsx new file mode 100644 index 00000000000..8baa5a1b06b --- /dev/null +++ b/src/components/Users/LinkedSkills.tsx @@ -0,0 +1,173 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import ButtonV2 from "@/components/Common/ButtonV2"; +import { SkillSelect } from "@/components/Common/SkillSelect"; + +import { useIsAuthorized } from "@/hooks/useIsAuthorized"; + +import AuthorizeFor from "@/Utils/AuthorizeFor"; +import * as Notification from "@/Utils/Notifications"; +import routes from "@/Utils/request/api"; +import request from "@/Utils/request/request"; +import useQuery from "@/Utils/request/useQuery"; + +import UnlinkSkillDialog from "./UnlinkSkillDialog"; +import { SkillModel } from "./models"; + +const initModalProps: { + selectedSkill: SkillModel | null; + toggle: boolean; +} = { + toggle: false, + selectedSkill: null, +}; + +export default function LinkedSkills({ username }: { username: string }) { + const [modalProps, setModalProps] = useState(initModalProps); + const [selectedSkill, setSelectedSkill] = useState(null); + const { t } = useTranslation(); + + const { data: skills, refetch: refetchUserSkills } = useQuery( + routes.userListSkill, + { + pathParams: { username }, + }, + ); + + const handleOnClick = (selectedSkill: SkillModel) => { + setModalProps({ + selectedSkill, + toggle: true, + }); + }; + + const handleModalCancel = () => { + setModalProps(initModalProps); + }; + + const handleModalOk = () => { + removeSkill(username, modalProps.selectedSkill?.id.toString() ?? ""); + setModalProps(initModalProps); + }; + + const authorizeForAddSkill = useIsAuthorized( + AuthorizeFor(["DistrictAdmin", "StateAdmin"]), + ); + + const addSkill = async (username: string, skill: SkillModel | null) => { + if (!skill) return; + const { res } = await request(routes.addUserSkill, { + pathParams: { username }, + body: { skill: skill.id }, + }); + if (res?.ok) { + Notification.Success({ + msg: t("skill_added_successfully"), + }); + } else { + Notification.Error({ + msg: t("skill_add_error"), + }); + } + setSelectedSkill(null); + setModalProps(initModalProps); + await refetchUserSkills(); + }; + + const removeSkill = async (username: string, skillId: string) => { + const { res } = await request(routes.deleteUserSkill, { + pathParams: { username, id: skillId }, + }); + if (res?.status !== 204) { + Notification.Error({ + msg: t("unlink_skill_error"), + }); + } else { + Notification.Success({ + msg: t("unlink_skill_success"), + }); + } + await refetchUserSkills(); + }; + + const renderSkillButtons = (skill: SkillModel) => { + return ( +
+
+
{skill.skill_object.name}
+ {authorizeForAddSkill && ( +
+ +
+ )} +
+
+ ); + }; + + return ( + <> + {modalProps.toggle && ( + + )} +
+
+ + addSkill(username, selectedSkill)} + className="mt-1 rounded-lg px-6 py-[11px] text-base" + tooltip={ + !authorizeForAddSkill + ? t("contact_your_admin_to_add_skills") + : undefined + } + > + {t("add_skill")} + +
+ {skills && skills?.count > 0 && ( +
+

{t("linked_skills")}

+ +
+ {skills?.results.map((skill: SkillModel) => { + return renderSkillButtons(skill); + })} +
+
+ )} +
+ + ); +} diff --git a/src/components/Users/LinkedSkillsTab.tsx b/src/components/Users/LinkedSkillsTab.tsx new file mode 100644 index 00000000000..3910f0a9e52 --- /dev/null +++ b/src/components/Users/LinkedSkillsTab.tsx @@ -0,0 +1,44 @@ +import { navigate } from "raviger"; +import { useTranslation } from "react-i18next"; + +import UserColumns from "@/components/Common/UserColumns"; +import LinkedSkills from "@/components/Users/LinkedSkills"; +import { UserModel } from "@/components/Users/models"; + +import useAuthUser from "@/hooks/useAuthUser"; + +import * as Notification from "@/Utils/Notifications"; +import { editUserPermissions } from "@/Utils/permissions"; + +type Props = { + userData: UserModel; + username: string; +}; + +export default function LinkedSkillsTab(props: Props) { + const { userData, username } = props; + const { t } = useTranslation(); + const authUser = useAuthUser(); + const editPermissions = editUserPermissions(authUser, userData); + + if (!userData || !username) { + Notification.Error({ msg: t("username_userdetails_not_found") }); + return <>; + } else if (!editPermissions) { + Notification.Error({ msg: t("no_permission_to_view_page") }); + navigate("/users"); + } + + return ( + <> +
+ +
+ + ); +} diff --git a/src/components/Users/ManageUsers.tsx b/src/components/Users/ManageUsers.tsx index a1c00e5eaef..5b441e76150 100644 --- a/src/components/Users/ManageUsers.tsx +++ b/src/components/Users/ManageUsers.tsx @@ -1,4 +1,3 @@ -import dayjs from "dayjs"; import { navigate } from "raviger"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -6,48 +5,31 @@ import { useTranslation } from "react-i18next"; import CountBlock from "@/CAREUI/display/Count"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { AdvancedFilterButton } from "@/CAREUI/interactive/FiltersSlideover"; -import SlideOverCustom from "@/CAREUI/interactive/SlideOver"; -import { Avatar } from "@/components/Common/Avatar"; -import ButtonV2, { Submit } from "@/components/Common/ButtonV2"; +import ButtonV2 from "@/components/Common/ButtonV2"; import CircularProgress from "@/components/Common/CircularProgress"; import { FacilitySelect } from "@/components/Common/FacilitySelect"; import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; import Pagination from "@/components/Common/Pagination"; -import UserDetails from "@/components/Common/UserDetails"; -import UserDetailComponent from "@/components/Common/UserDetailsComponet"; import { FacilityModel } from "@/components/Facility/models"; -import TextFormField from "@/components/Form/FormFields/TextFormField"; -import SearchInput from "@/components/Form/SearchInput"; -import ConfirmHomeFacilityUpdateDialog from "@/components/Users/ConfirmHomeFacilityUpdateDialog"; -import SkillsSlideOver from "@/components/Users/SkillsSlideOver"; import UnlinkFacilityDialog from "@/components/Users/UnlinkFacilityDialog"; -import UserDeleteDialog from "@/components/Users/UserDeleteDialog"; import UserFilter from "@/components/Users/UserFilter"; +import UserListView from "@/components/Users/UserListAndCard"; import useAuthUser from "@/hooks/useAuthUser"; import useFilters from "@/hooks/useFilters"; -import useWindowDimensions from "@/hooks/useWindowDimensions"; import { USER_TYPES } from "@/common/constants"; import * as Notification from "@/Utils/Notifications"; -import { showUserDelete } from "@/Utils/permissions"; import routes from "@/Utils/request/api"; import request from "@/Utils/request/request"; import useQuery from "@/Utils/request/useQuery"; -import { - classNames, - formatDisplayName, - formatName, - isUserOnline, - relativeTime, -} from "@/Utils/utils"; +import { classNames } from "@/Utils/utils"; export default function ManageUsers() { const { t } = useTranslation(); - const { width } = useWindowDimensions(); const { qParams, updateQuery, @@ -59,55 +41,39 @@ export default function ManageUsers() { limit: 18, cacheBlacklist: ["username"], }); - let manageUsers: any = null; - const [expandSkillList, setExpandSkillList] = useState(false); - const [expandFacilityList, setExpandFacilityList] = useState(false); - const [selectedUser, setSelectedUser] = useState(null); - const [expandWorkingHours, setExpandWorkingHours] = useState(false); + let manageUsers: JSX.Element = <>; const authUser = useAuthUser(); - const [weeklyHours, setWeeklyHours] = useState("0"); const userIndex = USER_TYPES.indexOf(authUser.user_type); const userTypes = authUser.is_superuser ? [...USER_TYPES] : USER_TYPES.slice(0, userIndex + 1); - - const [userData, setUserData] = useState<{ - show: boolean; - username: string; - name: string; - }>({ show: false, username: "", name: "" }); - - const [weeklyHoursError, setWeeklyHoursError] = useState(""); - - const extremeSmallScreenBreakpoint = 320; - const isExtremeSmallScreen = width <= extremeSmallScreenBreakpoint; + const [activeTab, setActiveTab] = useState(0); const { data: homeFacilityData } = useQuery(routes.getAnyFacility, { pathParams: { id: qParams.home_facility }, prefetch: !!qParams.home_facility && qParams.home_facility !== "NONE", }); - const { - data: userListData, - loading: userListLoading, - refetch: refetchUserList, - } = useQuery(routes.userList, { - query: { - limit: resultsPerPage.toString(), - offset: ( - (qParams.page ? qParams.page - 1 : 0) * resultsPerPage - ).toString(), - username: qParams.username, - first_name: qParams.first_name, - last_name: qParams.last_name, - phone_number: qParams.phone_number, - alt_phone_number: qParams.alt_phone_number, - user_type: qParams.user_type, - district_id: qParams.district, - home_facility: qParams.home_facility, - last_active_days: qParams.last_active_days, + const { data: userListData, loading: userListLoading } = useQuery( + routes.userList, + { + query: { + limit: resultsPerPage.toString(), + offset: ( + (qParams.page ? qParams.page - 1 : 0) * resultsPerPage + ).toString(), + username: qParams.username, + first_name: qParams.first_name, + last_name: qParams.last_name, + phone_number: qParams.phone_number, + alt_phone_number: qParams.alt_phone_number, + user_type: qParams.user_type, + district_id: qParams.district, + home_facility: qParams.home_facility, + last_active_days: qParams.last_active_days, + }, }, - }); + ); useEffect(() => { if (!qParams.state && qParams.district) { @@ -137,436 +103,34 @@ export default function ManageUsers() {
); - const handleCancel = () => { - setUserData({ show: false, username: "", name: "" }); - }; - - const handleWorkingHourSubmit = async () => { - const username = selectedUser; - if (!username || !weeklyHours || +weeklyHours < 0 || +weeklyHours > 168) { - setWeeklyHoursError("Value should be between 0 and 168"); - return; - } - const { res, data, error } = await request(routes.partialUpdateUser, { - pathParams: { username }, - body: { weekly_working_hours: weeklyHours }, - }); - if (res && res.status === 200 && data) { - Notification.Success({ - msg: "Working hours updated successfully", - }); - setExpandWorkingHours(false); - setSelectedUser(null); - } else { - Notification.Error({ - msg: "Error while updating working hours: " + (error || ""), - }); - } - setWeeklyHours("0"); - setWeeklyHoursError(""); - await refetchUserList(); - }; - - const handleSubmit = async () => { - const { res, error } = await request(routes.deleteUser, { - pathParams: { username: userData.username }, - }); - if (res?.status === 204) { - Notification.Success({ - msg: "User deleted successfully", - }); - } else { - Notification.Error({ - msg: "Error while deleting User: " + (error || ""), - }); - } - - setUserData({ show: false, username: "", name: "" }); - await refetchUserList(); - }; - - const handleDelete = (user: any) => { - setUserData({ - show: true, - username: user.username, - name: formatName(user), - }); - }; - - let userList: any[] = []; - userListData?.results && - userListData.results.length && - (userList = userListData.results.map((user: any, idx) => { - const cur_online = isUserOnline(user); - return ( -
-
-
-
-
- -
-
- {user.username && ( -
- {user.username} -
- )} -
- {user.last_login && cur_online ? ( - - {" "} - {" "} - Currently Online - - ) : ( - <> - - Last - Online:{" "} - - - - {user.last_login - ? relativeTime(user.last_login) - : "Never"} - - - )} -
-
-
-
- {formatName(user)} -
- - {user.last_login && cur_online ? ( -
- ) : null} - {showUserDelete(authUser, user) && ( -
handleDelete(user)} - > - -
- )} -
- -
- {user.user_type && ( - - )} - {user.district_object && ( - - )} - {user.user_type === "Doctor" && ( - <> -
- - {user.qualification ? ( - - {user.qualification} - - ) : ( - - {t("unknown")} - - )} - -
-
- - {user.doctor_experience_commenced_on ? ( - - {dayjs().diff( - user.doctor_experience_commenced_on, - "years", - false, - )}{" "} - years - - ) : ( - - {t("unknown")} - - )} - -
-
- - {user.doctor_medical_council_registration ? ( - - {user.doctor_medical_council_registration} - - ) : ( - - {t("unknown")} - - )} - -
- - )} -
- {user.local_body_object && ( - -
- {user.local_body_object.name} -
-
- )} - -
- {user.user_type === "Nurse" && ( -
- - {user.qualification ? ( - - {user.qualification} - - ) : ( - - {t("unknown")} - - )} - -
- )} - {user.created_by && ( -
- -
-
- {user.created_by} -
-
-
-
- )} - {user.username && ( -
- - - {user.home_facility_object?.name || - "No Home Facility"} - - -
- )} -
-
- - {user.weekly_working_hours ? ( - - {user.weekly_working_hours} hours - - ) : ( - - - )} - -
-
- {user.username && ( -
-
- { - setExpandFacilityList(!expandFacilityList); - setSelectedUser(user); - }} - > - -

{t("linked_facilities")}

-
- { - setExpandSkillList(true); - setSelectedUser(user.username); - }} - > - -

{t("linked_skills")}

-
-
- {["DistrictAdmin", "StateAdmin"].includes( - authUser.user_type, - ) && ( -
- { - setExpandWorkingHours(true); - setSelectedUser(user.username); - setWeeklyHours(user.weekly_working_hours); - }} - > - -

- Set Average weekly working hours -

-
-
- )} -
- )} -
-
-
- ); - })); - if (userListLoading || districtDataLoading || !userListData?.results) { - manageUsers = ; - } else if (userListData?.results.length) { - manageUsers = ( -
-
- {userList} -
- -
- ); - } else if (userListData?.results && userListData?.results.length === 0) { - manageUsers = ( -
-
-
- No Users Found -
-
-
- ); + return ; } + manageUsers = ( +
+ updateQuery({ username })} + searchValue={qParams.username} + activeTab={activeTab} + onTabChange={setActiveTab} + /> + +
+ ); + return ( - {expandSkillList && ( - - )} - - - - { - setExpandWorkingHours(state); - setWeeklyHours("0"); - setWeeklyHoursError(""); - }} - slideFrom="right" - title={t("average_weekly_working_hours")} - dialogClass="md:w-[400px]" - > -
-
- {t("set_average_weekly_working_hours_for")} {selectedUser} -
- { - setWeeklyHours(e.value); - }} - error={weeklyHoursError} - required - label="" - type="number" - min={0} - max={168} - /> -
- -
-
-
- -
+
-
- updateQuery({ [e.name]: e.value })} - value={qParams.username} - placeholder={t("search_by_username")} - /> -
advancedFilter.setShow(true)} @@ -617,13 +181,6 @@ export default function ManageUsers() {
{manageUsers}
- {userData.show && ( - - )} ); } @@ -645,26 +202,6 @@ export function UserFacilities(props: { user: any }) { isHomeFacility: boolean; }>({ show: false, userName: "", facility: undefined, isHomeFacility: false }); const authUser = useAuthUser(); - - const [replaceHomeFacility, setReplaceHomeFacility] = useState<{ - show: boolean; - userName: string; - previousFacility?: FacilityModel; - newFacility?: FacilityModel; - }>({ - show: false, - userName: "", - previousFacility: undefined, - newFacility: undefined, - }); - const hideReplaceHomeFacilityModal = () => { - setReplaceHomeFacility({ - show: false, - previousFacility: undefined, - userName: "", - newFacility: undefined, - }); - }; const hideUnlinkFacilityModal = () => { setUnlinkFacilityData({ show: false, @@ -890,13 +427,6 @@ export function UserFacilities(props: { user: any }) { onClick={() => { if (user?.home_facility_object) { // has previous home facility - setReplaceHomeFacility({ - show: true, - userName: username, - previousFacility: - user?.home_facility_object, - newFacility: facility, - }); } else { // no previous home facility updateHomeFacility(username, facility); @@ -962,28 +492,6 @@ export function UserFacilities(props: { user: any }) { )}
)} - {replaceHomeFacility.show && ( - { - updateHomeFacility( - replaceHomeFacility.userName, - replaceHomeFacility.newFacility, - ); - setReplaceHomeFacility({ - show: false, - previousFacility: undefined, - userName: "", - newFacility: undefined, - }); - }} - /> - )}
); } diff --git a/src/components/Users/SkillsSlideOver.tsx b/src/components/Users/SkillsSlideOver.tsx deleted file mode 100644 index 57e1c8359bb..00000000000 --- a/src/components/Users/SkillsSlideOver.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { useCallback, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import SlideOverCustom from "@/CAREUI/interactive/SlideOver"; - -import ButtonV2 from "@/components/Common/ButtonV2"; -import CircularProgress from "@/components/Common/CircularProgress"; -import { SkillSelect } from "@/components/Common/SkillSelect"; -import { - AddSkillsPlaceholder, - SkillsArray, -} from "@/components/Users/SkillsSlideOverComponents"; -import UnlinkSkillDialog from "@/components/Users/UnlinkSkillDialog"; -import { SkillModel, SkillObjectModel } from "@/components/Users/models"; - -import { useIsAuthorized } from "@/hooks/useIsAuthorized"; - -import AuthorizeFor from "@/Utils/AuthorizeFor"; -import * as Notification from "@/Utils/Notifications"; -import routes from "@/Utils/request/api"; -import request from "@/Utils/request/request"; -import useQuery from "@/Utils/request/useQuery"; - -interface IProps { - username: string; - show: boolean; - setShow: (show: boolean) => void; -} - -export default ({ show, setShow, username }: IProps) => { - /* added const {t} hook here and relevant text to Common.json to avoid eslint error */ - const { t } = useTranslation(); - const [selectedSkill, setSelectedSkill] = useState( - null, - ); - const [isLoading, setIsLoading] = useState(false); - const [deleteSkill, setDeleteSkill] = useState(null); - - const { - data: skills, - loading: skillsLoading, - refetch: refetchUserSkills, - } = useQuery(routes.userListSkill, { - pathParams: { username }, - }); - - const addSkill = useCallback( - async (username: string, skill: SkillObjectModel | null) => { - if (!skill) return; - setIsLoading(true); - const { res } = await request(routes.addUserSkill, { - pathParams: { username }, - body: { skill: skill.id }, - }); - - if (!res?.ok) { - Notification.Error({ - msg: "Error while adding skill", - }); - } else { - Notification.Success({ - msg: "Skill added successfully", - }); - } - setSelectedSkill(null); - setIsLoading(false); - await refetchUserSkills(); - }, - [refetchUserSkills], - ); - - const removeSkill = useCallback( - async (username: string, skillId: string) => { - const { res } = await request(routes.deleteUserSkill, { - pathParams: { username, id: skillId }, - }); - if (res?.status !== 204) { - Notification.Error({ - msg: "Error while unlinking skill", - }); - } else { - Notification.Success({ - msg: "Skill unlinked successfully", - }); - } - setDeleteSkill(null); - await refetchUserSkills(); - }, - [refetchUserSkills], - ); - - const authorizeForAddSkill = useIsAuthorized( - AuthorizeFor(["DistrictAdmin", "StateAdmin"]), - ); - - const hasSkills = skills?.results?.length || 0 > 0; - - return ( -
- {deleteSkill && ( - setDeleteSkill(null)} - onSubmit={() => removeSkill(username, deleteSkill.id)} - /> - )} - { - !deleteSkill && setShow(openState); - }} - slideFrom="right" - title="Skills" - dialogClass="md:w-[400px]" - > -
-
- {(!isLoading || !skillsLoading) && ( -
- - addSkill(username, selectedSkill)} - className="mt-1 h-[45px] w-[74px] text-base" - > - {t("add")} - - {!authorizeForAddSkill && ( - - {t("contact_your_admin_to_add_skills")} - - )} -
- )} - {isLoading || skillsLoading ? ( -
- -
- ) : ( -
- {hasSkills ? ( - - ) : ( - - )} -
- )} -
-
-
-
- ); -}; diff --git a/src/components/Users/SkillsSlideOverComponents.tsx b/src/components/Users/SkillsSlideOverComponents.tsx deleted file mode 100644 index bb91d4cbfd3..00000000000 --- a/src/components/Users/SkillsSlideOverComponents.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Fragment } from "react"; -import { useTranslation } from "react-i18next"; - -import CareIcon from "@/CAREUI/icons/CareIcon"; - -import ButtonV2 from "@/components/Common/ButtonV2"; -import { SkillModel } from "@/components/Users/models"; - -import { classNames } from "@/Utils/utils"; - -export const AddSkillsPlaceholder = () => { - const { t } = useTranslation(); - return ( -
-
- Error 404 -
-

- {t("select_skills")} -

-
- ); -}; - -type SkillsArrayProps = { - isLoading: boolean; - skills: SkillModel[]; - authorizeForAddSkill: boolean; - setDeleteSkill: (skill: SkillModel) => void; -}; - -export const SkillsArray = ({ - isLoading, - skills, - authorizeForAddSkill, - setDeleteSkill, -}: SkillsArrayProps) => { - return ( - - {skills.map((skill, i) => ( -
-
-
{skill.skill_object.name}
-
- setDeleteSkill(skill)} - > - - -
-
-
- ))} -
- ); -}; diff --git a/src/components/Users/UnlinkSkillDialog.tsx b/src/components/Users/UnlinkSkillDialog.tsx index c52e72b30ca..6ddfcc6e569 100644 --- a/src/components/Users/UnlinkSkillDialog.tsx +++ b/src/components/Users/UnlinkSkillDialog.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import ConfirmDialog from "@/components/Common/ConfirmDialog"; @@ -11,6 +12,7 @@ interface Props { export default function UnlinkSkillDialog(props: Props) { const [disabled, setDisabled] = useState(false); + const { t } = useTranslation(); const handleSubmit = () => { props.onSubmit(); @@ -20,19 +22,21 @@ export default function UnlinkSkillDialog(props: Props) { return ( - Are you sure you want to unlink the skill{" "} - {props.skillName} from user{" "} - {props.userName}? the user will not have the skill - associated anymore. - +
+ + {t("unlink_skill_confirm")} {props.skillName}{" "} + {t("from_user")} {props.userName}?{" "} + {t("unlink_skill_access")} + +
} >
); diff --git a/src/components/Users/UserAdd.tsx b/src/components/Users/UserAdd.tsx index bfd38d2cae2..467cf578fae 100644 --- a/src/components/Users/UserAdd.tsx +++ b/src/components/Users/UserAdd.tsx @@ -1,152 +1,15 @@ -import { Link, navigate } from "raviger"; -import { useEffect, useState } from "react"; +import { Link } from "raviger"; import { useTranslation } from "react-i18next"; -import Card from "@/CAREUI/display/Card"; import CareIcon from "@/CAREUI/icons/CareIcon"; -import { Cancel, Submit } from "@/components/Common/ButtonV2"; -import CircularProgress from "@/components/Common/CircularProgress"; -import { FacilitySelect } from "@/components/Common/FacilitySelect"; -import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; -import { FacilityModel } from "@/components/Facility/models"; -import { PhoneNumberValidator } from "@/components/Form/FieldValidators"; -import CheckBoxFormField from "@/components/Form/FormFields/CheckBoxFormField"; -import DateFormField from "@/components/Form/FormFields/DateFormField"; -import { FieldLabel } from "@/components/Form/FormFields/FormField"; -import PhoneNumberFormField from "@/components/Form/FormFields/PhoneNumberFormField"; -import { SelectFormField } from "@/components/Form/FormFields/SelectFormField"; -import TextFormField from "@/components/Form/FormFields/TextFormField"; -import { FieldChangeEvent } from "@/components/Form/FormFields/Utils"; +import UserAddEditForm from "@/components/Users/UserAddEditForm"; +import { newUserFields } from "@/components/Users/UserFormValidations"; -import useAppHistory from "@/hooks/useAppHistory"; -import useAuthUser from "@/hooks/useAuthUser"; - -import { - GENDER_TYPES, - USER_TYPES, - USER_TYPE_OPTIONS, -} from "@/common/constants"; -import { useAbortableEffect } from "@/common/utils"; -import { - validateEmailAddress, - validateName, - validatePassword, - validateUsername, -} from "@/common/validation"; - -import { DraftSection, useAutoSaveReducer } from "@/Utils/AutoSave"; -import * as Notification from "@/Utils/Notifications"; -import dayjs from "@/Utils/dayjs"; -import routes from "@/Utils/request/api"; -import request from "@/Utils/request/request"; -import useQuery from "@/Utils/request/useQuery"; -import { - classNames, - dateQueryString, - parsePhoneNumber, - scrollTo, -} from "@/Utils/utils"; - -interface UserProps { - userId?: number; -} - -interface StateObj { - id: number; - name: string; -} - -type UserForm = { - user_type: string; - gender: string; - password: string; - c_password: string; - facilities: Array; - home_facility: FacilityModel | null; - username: string; - first_name: string; - last_name: string; - email: string; - phone_number: string; - alt_phone_number: string; - phone_number_is_whatsapp: boolean; - date_of_birth: Date | null; - state: number; - district: number; - local_body: number; - qualification: string | undefined; - doctor_experience_commenced_on: string | undefined; - doctor_medical_council_registration: string | undefined; -}; - -const initForm: UserForm = { - user_type: "", - gender: "", - password: "", - c_password: "", - facilities: [], - home_facility: null, - username: "", - first_name: "", - last_name: "", - email: "", - phone_number: "+91", - alt_phone_number: "+91", - phone_number_is_whatsapp: true, - date_of_birth: null, - state: 0, - district: 0, - local_body: 0, - qualification: undefined, - doctor_experience_commenced_on: undefined, - doctor_medical_council_registration: undefined, -}; - -const STAFF_OR_NURSE_USER = [ - "Staff", - "StaffReadOnly", - "Nurse", - "NurseReadOnly", -]; - -const initError = Object.assign( - {}, - ...Object.keys(initForm).map((k) => ({ [k]: "" })), -); - -const initialState = { - form: { ...initForm }, - errors: { ...initError }, -}; - -const user_create_reducer = (state = initialState, action: any) => { - switch (action.type) { - case "set_form": { - return { - ...state, - form: action.form, - }; - } - case "set_errors": { - return { - ...state, - errors: action.errors, - }; - } - case "set_state": { - if (action.state) return action.state; - return state; - } - default: - return state; - } -}; - -const getDate = (value: any) => - value && dayjs(value).isValid() && dayjs(value).toDate(); +import { classNames } from "@/Utils/utils"; +//Temporary: ABDM plug imports from UserAdd instead of UserAddEditForm export const validateRule = ( condition: boolean, content: JSX.Element | string, @@ -176,465 +39,12 @@ export const validateRule = ( ); }; -export const UserAdd = (props: UserProps) => { +const UserAdd = () => { const { t } = useTranslation(); - const { goBack } = useAppHistory(); - const { userId } = props; - - const [state, dispatch] = useAutoSaveReducer( - user_create_reducer, - initialState, - ); - const [isLoading, setIsLoading] = useState(false); - const [states, setStates] = useState([]); - const [selectedStateId, setSelectedStateId] = useState(0); - const [selectedDistrictId, setSelectedDistrictId] = useState(0); - const [districts, setDistricts] = useState([]); - const [localBodies, setLocalBodies] = useState([]); - const [selectedFacility, setSelectedFacility] = useState([]); - const [usernameInputInFocus, setUsernameInputInFocus] = useState(false); - const [passwordInputInFocus, setPasswordInputInFocus] = useState(false); - const [confirmPasswordInputInFocus, setConfirmPasswordInputInFocus] = - useState(false); - const [usernameInput, setUsernameInput] = useState(""); - - const userExistsEnums = { - idle: 0, - checking: 1, - exists: 2, - available: 3, - }; - - const [usernameExists, setUsernameExists] = useState(0); - - const check_username = async (username: string) => { - setUsernameExists(userExistsEnums.checking); - const { res: usernameCheck } = await request(routes.checkUsername, { - pathParams: { username }, - silent: true, - }); - if (usernameCheck === undefined || usernameCheck.status === 409) - setUsernameExists(userExistsEnums.exists); - else if (usernameCheck.status === 200) - setUsernameExists(userExistsEnums.available); - else - Notification.Error({ - msg: "Some error checking username availabality. Please try again later.", - }); - }; - - useEffect(() => { - setUsernameExists(userExistsEnums.idle); - if (validateUsername(usernameInput)) { - const timeout = setTimeout(() => { - check_username(usernameInput); - }, 500); - return () => clearTimeout(timeout); - } - }, [usernameInput]); - - const authUser = useAuthUser(); - - const userIndex = USER_TYPES.indexOf(authUser.user_type); - const readOnlyUsers = USER_TYPE_OPTIONS.filter((user) => user.readOnly); - - const defaultAllowedUserTypes = USER_TYPE_OPTIONS.slice(0, userIndex + 1); - const userTypes = authUser.is_superuser - ? [...USER_TYPE_OPTIONS] - : authUser.user_type === "StaffReadOnly" - ? readOnlyUsers.slice(0, 1) - : authUser.user_type === "DistrictReadOnlyAdmin" - ? readOnlyUsers.slice(0, 2) - : authUser.user_type === "StateReadOnlyAdmin" - ? readOnlyUsers.slice(0, 3) - : authUser.user_type === "Pharmacist" - ? USER_TYPE_OPTIONS.slice(0, 1) - : // Exception to allow Staff to Create Doctors - defaultAllowedUserTypes; - - // TODO: refactor lines 227 through 248 to be more readable. This is messy. - if (authUser.user_type === "Nurse" || authUser.user_type === "Staff") { - userTypes.push(USER_TYPE_OPTIONS[6]); // Temperorily allows creation of users with elevated permissions due to introduction of new roles. - } - - const headerText = !userId ? "Add User" : "Update User"; - const buttonText = !userId ? "Save User" : "Update Details"; - const showLocalbody = ![ - "Pharmacist", - "Volunteer", - "Doctor", - ...STAFF_OR_NURSE_USER, - ].includes(state.form.user_type); - - const { loading: isDistrictLoading } = useQuery(routes.getDistrictByState, { - prefetch: !!(selectedStateId > 0), - pathParams: { id: selectedStateId.toString() }, - onResponse: (result) => { - if (!result || !result.res || !result.data) return; - if (userIndex <= USER_TYPES.indexOf("DistrictAdmin")) { - setDistricts([authUser.district_object!]); - } else { - setDistricts(result.data); - } - }, - }); - - const { loading: isLocalbodyLoading } = useQuery( - routes.getAllLocalBodyByDistrict, - { - prefetch: !!(selectedDistrictId > 0), - pathParams: { id: selectedDistrictId.toString() }, - onResponse: (result) => { - if (!result || !result.res || !result.data) return; - if (userIndex <= USER_TYPES.indexOf("LocalBodyAdmin")) { - setLocalBodies([authUser.local_body_object!]); - } else { - setLocalBodies(result.data); - } - }, - }, - ); - - const { loading: isStateLoading } = useQuery(routes.statesList, { - onResponse: (result) => { - if (!result || !result.res || !result.data) return; - if (userIndex <= USER_TYPES.indexOf("StateAdmin")) { - setStates([authUser.state_object!]); - } else { - setStates(result.data.results); - } - }, - }); - - const handleDateChange = (e: FieldChangeEvent) => { - if (dayjs(e.value).isValid()) { - const errors = { ...state.errors, [e.name]: "" }; - dispatch({ - type: "set_form", - form: { - ...state.form, - [e.name]: dayjs(e.value).format("YYYY-MM-DD"), - }, - }); - dispatch({ type: "set_errors", errors }); - } - }; - - const handleFieldChange = (event: FieldChangeEvent) => { - const errors = { ...state.errors, [event.name]: "" }; - dispatch({ - type: "set_form", - form: { - ...state.form, - [event.name]: event.value, - }, - }); - dispatch({ type: "set_errors", errors }); - }; - - useAbortableEffect(() => { - if (state.form.phone_number_is_whatsapp) { - handleFieldChange({ - name: "alt_phone_number", - value: state.form.phone_number, - }); - } - }, [state.form.phone_number_is_whatsapp, state.form.phone_number]); - - const setFacility = (selected: FacilityModel | FacilityModel[] | null) => { - const newSelectedFacilities = selected - ? Array.isArray(selected) - ? selected - : [selected] - : []; - setSelectedFacility(newSelectedFacilities as FacilityModel[]); - const form = { ...state.form }; - form.facilities = selected - ? (selected as FacilityModel[]).map((i) => i.id!) - : []; - dispatch({ type: "set_form", form }); - }; - - const validateForm = () => { - const errors = { ...initError }; - let invalidForm = false; - Object.keys(state.form).forEach((field) => { - switch (field) { - case "facilities": - if ( - state.form[field].length === 0 && - STAFF_OR_NURSE_USER.includes(authUser.user_type) && - STAFF_OR_NURSE_USER.includes(state.form.user_type) - ) { - errors[field] = - "Please select atleast one of the facilities you are linked to"; - invalidForm = true; - } - return; - case "user_type": - if (!state.form[field]) { - errors[field] = "Please select the User Type"; - invalidForm = true; - } - return; - case "doctor_experience_commenced_on": - if (state.form.user_type === "Doctor" && !state.form[field]) { - errors[field] = t("field_required"); - invalidForm = true; - } else if ( - state.form.user_type === "Doctor" && - Number(state.form.doctor_experience_commenced_on) > 100 - ) { - errors[field] = "Doctor experience should be less than 100 years"; - invalidForm = true; - } - return; - case "qualification": - if ( - (state.form.user_type === "Doctor" || - state.form.user_type === "Nurse") && - !state.form[field] - ) { - errors[field] = t("field_required"); - invalidForm = true; - } - return; - case "doctor_medical_council_registration": - if (state.form.user_type === "Doctor" && !state.form[field]) { - errors[field] = t("field_required"); - invalidForm = true; - } - return; - case "first_name": - case "last_name": - state.form[field] = state.form[field].trim(); - if (!state.form[field]) { - errors[field] = `${field - .split("_") - .map((word) => word[0].toUpperCase() + word.slice(1)) - .join(" ")} is required`; - invalidForm = true; - } else if (!validateName(state.form[field])) { - errors[field] = t("min_char_length_error", { min_length: 3 }); - invalidForm = true; - } - return; - case "gender": - if (!state.form[field]) { - errors[field] = "Please select the Gender"; - invalidForm = true; - } - return; - case "username": - if (!state.form[field]) { - errors[field] = "Please enter the username"; - invalidForm = true; - } else if (!validateUsername(state.form[field])) { - errors[field] = - "Please enter a 4-16 characters long username with lowercase letters, digits and . _ - only and it should not start or end with . _ -"; - invalidForm = true; - } else if (usernameExists !== userExistsEnums.available) { - errors[field] = "This username already exists"; - invalidForm = true; - } - return; - case "password": - if (!state.form[field]) { - errors[field] = "Please enter the password"; - invalidForm = true; - } else if (!validatePassword(state.form[field])) { - errors.password = - "Password should have 1 lowercase letter, 1 uppercase letter, 1 number, and be at least 8 characters long"; - invalidForm = true; - } - return; - case "c_password": - if (!state.form.password) { - errors.c_password = "Confirm password is required"; - invalidForm = true; - } else if (state.form.password !== state.form.c_password) { - errors.c_password = "Passwords not matching"; - invalidForm = true; - } - return; - case "phone_number": - // eslint-disable-next-line no-case-declarations - const phoneNumber = parsePhoneNumber(state.form[field]); - // eslint-disable-next-line no-case-declarations - let is_valid = false; - if (phoneNumber) { - is_valid = PhoneNumberValidator()(phoneNumber) === undefined; - } - if (!state.form[field] || !is_valid) { - errors[field] = "Please enter valid phone number"; - invalidForm = true; - } - return; - - case "alt_phone_number": - // eslint-disable-next-line no-case-declarations - let alt_is_valid = false; - if (state.form[field] && state.form[field] !== "+91") { - const altPhoneNumber = parsePhoneNumber(state.form[field]); - if (altPhoneNumber) { - alt_is_valid = - PhoneNumberValidator(["mobile"])(altPhoneNumber) === undefined; - } - } - if ( - state.form[field] && - state.form[field] !== "+91" && - !alt_is_valid - ) { - errors[field] = "Please enter valid mobile number"; - invalidForm = true; - } - return; - case "email": - state.form[field] = state.form[field].trim(); - if ( - state.form[field].length === 0 || - !validateEmailAddress(state.form[field]) - ) { - errors[field] = "Please enter a valid email address"; - invalidForm = true; - } - return; - case "date_of_birth": - if (!state.form[field]) { - errors[field] = "Please enter date in DD/MM/YYYY format"; - invalidForm = true; - } else if ( - dayjs(state.form[field]).isAfter(dayjs().subtract(1, "year")) - ) { - errors[field] = "Enter a valid date of birth"; - invalidForm = true; - } - return; - case "state": - if (!Number(state.form[field])) { - errors[field] = "Please select the state"; - invalidForm = true; - } - return; - case "district": - if (!Number(state.form[field])) { - errors[field] = "Please select the district"; - invalidForm = true; - } - return; - case "local_body": - if (showLocalbody && !Number(state.form[field])) { - errors[field] = "Please select the local body"; - invalidForm = true; - } - return; - - default: - return; - } - }); - if (invalidForm) { - dispatch({ type: "set_errors", errors }); - const firstError = Object.keys(errors).find((e) => errors[e]); - if (firstError) { - scrollTo(firstError); - } - return false; - } - dispatch({ type: "set_errors", errors }); - return true; - }; - - const handleSubmit = async (e: any) => { - e.preventDefault(); - const validated = validateForm(); - if (validated) { - setIsLoading(true); - const data = { - user_type: state.form.user_type, - gender: state.form.gender, - password: state.form.password, - facilities: state.form.facilities ? state.form.facilities : undefined, - home_facility: state.form.home_facility ?? undefined, - username: state.form.username, - first_name: state.form.first_name ? state.form.first_name : undefined, - last_name: state.form.last_name ? state.form.last_name : undefined, - email: state.form.email, - state: state.form.state, - district: state.form.district, - local_body: showLocalbody ? state.form.local_body : null, - phone_number: - state.form.phone_number === "+91" - ? "" - : parsePhoneNumber(state.form.phone_number), - alt_phone_number: - parsePhoneNumber( - state.form.phone_number_is_whatsapp - ? state.form.phone_number === "+91" - ? "" - : state.form.phone_number - : state.form.alt_phone_number === "+91" - ? "" - : state.form.alt_phone_number, - ) ?? "", - date_of_birth: dateQueryString(state.form.date_of_birth), - qualification: - state.form.user_type === "Doctor" || state.form.user_type == "Nurse" - ? state.form.qualification - : undefined, - doctor_experience_commenced_on: - state.form.user_type === "Doctor" - ? dayjs() - .subtract( - parseInt(state.form.doctor_experience_commenced_on ?? "0"), - "years", - ) - .format("YYYY-MM-DD") - : undefined, - doctor_medical_council_registration: - state.form.user_type === "Doctor" - ? state.form.doctor_medical_council_registration - : undefined, - }; - - const { res } = await request(routes.addUser, { - body: data, - }); - if (res?.ok) { - dispatch({ type: "set_form", form: initForm }); - if (!userId) { - Notification.Success({ - msg: "User added successfully", - }); - } else { - Notification.Success({ - msg: "User updated successfully", - }); - } - navigate("/users"); - } - setIsLoading(false); - } - }; - - if (isLoading) { - return ; - } - - const field = (name: string) => { - return { - id: name, - name, - onChange: handleFieldChange, - value: (state.form as any)[name], - error: (state.errors as any)[name], - }; - }; return ( { } backUrl="/users" > - -
handleSubmit(e)}> - { - dispatch({ type: "set_state", state: newState }); - }} - formData={state.form} - /> -
-
- Facilities - -
- o.role + (o.readOnly ? " (Read Only)" : "")} - optionValue={(o) => o.id} - /> - - {(state.form.user_type === "Doctor" || - state.form.user_type === "Nurse") && ( - - )} - {state.form.user_type === "Doctor" && ( - <> - - - - - )} - - option.name} - optionValue={(option) => option.id} - onChange={handleFieldChange} - /> - -
- - -
- - - -
- { - handleFieldChange(e); - setUsernameInput(e.value); - }} - onFocus={() => setUsernameInputInFocus(true)} - onBlur={() => { - setUsernameInputInFocus(false); - }} - /> - {usernameInputInFocus && ( -
-
- {usernameExists !== userExistsEnums.idle && ( - <> - {usernameExists === userExistsEnums.checking ? ( - - {" "} - checking... - - ) : ( - <> - {usernameExists === userExistsEnums.exists ? ( -
- {" "} - - Username is not available - -
- ) : ( -
- {" "} - - Username is available - -
- )} - - )} - - )} -
-
- {validateRule( - usernameInput.length >= 4 && usernameInput.length <= 16, - "Username should be 4-16 characters long", - !state.form.username, - )} -
-
- {validateRule( - /^[a-z0-9._-]*$/.test(usernameInput), - "Username can only contain lowercase letters, numbers, and . _ -", - !state.form.username, - )} -
-
- {validateRule( - /^[a-z0-9].*[a-z0-9]$/i.test(usernameInput), - "Username must start and end with a letter or number", - !state.form.username, - )} -
-
- {validateRule( - !/(?:[._-]{2,})/.test(usernameInput), - "Username can't contain consecutive special characters . _ -", - !state.form.username, - )} -
-
- )} -
- - - -
- setPasswordInputInFocus(true)} - onBlur={() => setPasswordInputInFocus(false)} - /> - {passwordInputInFocus && ( -
- {validateRule( - state.form.password?.length >= 8, - "Password should be atleast 8 characters long", - !state.form.password, - )} - {validateRule( - state.form.password !== state.form.password.toUpperCase(), - "Password should contain at least 1 lowercase letter", - !state.form.password, - )} - {validateRule( - state.form.password !== state.form.password.toLowerCase(), - "Password should contain at least 1 uppercase letter", - !state.form.password, - )} - {validateRule( - /\d/.test(state.form.password), - "Password should contain at least 1 number", - !state.form.password, - )} -
- )} -
-
- setConfirmPasswordInputInFocus(true)} - onBlur={() => setConfirmPasswordInputInFocus(false)} - /> - {confirmPasswordInputInFocus && - state.form.c_password.length > 0 && - validateRule( - state.form.c_password === state.form.password, - "Confirm password should match the entered password", - !state.form.password, - )} -
- - - - o.text} - optionValue={(o) => o.text} - /> - - {isStateLoading ? ( - - ) : ( - o.name} - optionValue={(o) => o.id} - onChange={(e) => { - handleFieldChange(e); - if (e) setSelectedStateId(e.value); - }} - /> - )} - - {isDistrictLoading ? ( - - ) : ( - o.name} - optionValue={(o) => o.id} - onChange={(e) => { - handleFieldChange(e); - if (e) setSelectedDistrictId(e.value); - }} - /> - )} - - {showLocalbody && - (isLocalbodyLoading ? ( - - ) : ( - o.name} - optionValue={(o) => o.id} - /> - ))} -
-
- goBack()} /> - -
- -
+
); }; + +export default UserAdd; diff --git a/src/components/Users/UserAddEditForm.tsx b/src/components/Users/UserAddEditForm.tsx new file mode 100644 index 00000000000..0249c955310 --- /dev/null +++ b/src/components/Users/UserAddEditForm.tsx @@ -0,0 +1,1289 @@ +import { navigate } from "raviger"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import CircularProgress from "@/components/Common/CircularProgress"; +import { FacilitySelect } from "@/components/Common/FacilitySelect"; +import Loading from "@/components/Common/Loading"; +import { FacilityModel } from "@/components/Facility/models"; +import { + FieldError, + PhoneNumberValidator, +} from "@/components/Form/FieldValidators"; +import Form from "@/components/Form/Form"; +import { FormContextValue } from "@/components/Form/FormContext"; +import CheckBoxFormField from "@/components/Form/FormFields/CheckBoxFormField"; +import DateFormField from "@/components/Form/FormFields/DateFormField"; +import { FieldLabel } from "@/components/Form/FormFields/FormField"; +import PhoneNumberFormField from "@/components/Form/FormFields/PhoneNumberFormField"; +import { SelectFormField } from "@/components/Form/FormFields/SelectFormField"; +import TextFormField from "@/components/Form/FormFields/TextFormField"; +import { FieldChangeEvent } from "@/components/Form/FormFields/Utils"; +import { + UserForm, + ValidateDoctorExperienceCommencedOn, + ValidateDoctorMedicalCouncilRegistration, + ValidateQualification, + ValidateVideoLink, +} from "@/components/Users/UserFormValidations"; +import { GetUserTypes } from "@/components/Users/UserListAndCard"; +import { GenderType, UserModel } from "@/components/Users/models"; + +import useAppHistory from "@/hooks/useAppHistory"; +import useAuthUser from "@/hooks/useAuthUser"; + +import { GENDER_TYPES, USER_TYPES } from "@/common/constants"; +import { + validateEmailAddress, + validateName, + validateNumber, + validatePassword, + validateUsername, +} from "@/common/validation"; + +import { useAutoSaveReducer } from "@/Utils/AutoSave"; +import * as Notification from "@/Utils/Notifications"; +import dayjs from "@/Utils/dayjs"; +import routes from "@/Utils/request/api"; +import request from "@/Utils/request/request"; +import useQuery from "@/Utils/request/useQuery"; +import { classNames, dateQueryString, parsePhoneNumber } from "@/Utils/utils"; + +interface StateObj { + id: number; + name: string; +} + +const initForm: UserForm = { + user_type: "", + gender: "", + password: "", + c_password: "", + facilities: [], + home_facility: null, + username: "", + first_name: "", + last_name: "", + email: "", + phone_number: "+91", + alt_phone_number: "+91", + phone_number_is_whatsapp: true, + date_of_birth: null, + state: 0, + district: 0, + local_body: 0, + qualification: undefined, + doctor_experience_commenced_on: undefined, + doctor_medical_council_registration: undefined, + weekly_working_hours: undefined, + video_connect_link: undefined, +}; + +interface UserProps { + username?: string; + includedFields?: Array; + onSubmitSuccess?: () => void; +} + +const STAFF_OR_NURSE_USER = [ + "Staff", + "StaffReadOnly", + "Nurse", + "NurseReadOnly", +]; + +const initError = Object.assign( + {}, + ...Object.keys(initForm).map((k) => ({ [k]: "" })), +); + +const initialState = { + form: { ...initForm }, + errors: { ...initError }, +}; + +type UserFormAction = + | { type: "set_form"; form: UserForm } + | { type: "set_state"; state?: typeof initialState }; + +const user_create_reducer = (state = initialState, action: UserFormAction) => { + switch (action.type) { + case "set_form": { + return { + ...state, + form: action.form, + }; + } + case "set_state": { + if (action.state) return action.state; + return state; + } + default: + return state; + } +}; + +const getDate = (value: string | Date | null) => + value && dayjs(value).isValid() ? dayjs(value).toDate() : undefined; + +export const validateRule = ( + condition: boolean, + content: JSX.Element | string, + isInitialState: boolean = false, +) => { + return ( +
+ {isInitialState ? ( + + ) : condition ? ( + + ) : ( + + )}{" "} + + {content} + +
+ ); +}; + +const UserAddEditForm = (props: UserProps) => { + const { t } = useTranslation(); + const { goBack } = useAppHistory(); + const { username, includedFields } = props; + const editUser = username ? true : false; + const formVals = useRef(initForm); + const [facilityErrors, setFacilityErrors] = useState(""); + + const { + loading: userDataLoading, + data: userData, + refetch: refetchUserData, + } = useQuery(routes.getUserDetails, { + pathParams: { + username: username ?? "", + }, + prefetch: editUser && !!username, + onResponse: (result) => { + if (!editUser || !result || !result.res || !result.data) return; + const userData = result.data; + const formData: UserForm = { + first_name: userData.first_name, + last_name: userData.last_name, + date_of_birth: userData.date_of_birth || null, + gender: userData.gender || "Male", + email: userData.email, + video_connect_link: userData.video_connect_link, + phone_number: userData.phone_number?.toString() || "", + alt_phone_number: userData.alt_phone_number?.toString() || "", + weekly_working_hours: userData.weekly_working_hours, + phone_number_is_whatsapp: + userData.phone_number?.toString() === + userData.alt_phone_number?.toString(), + user_type: userData.user_type, + qualification: userData.qualification, + doctor_experience_commenced_on: userData.doctor_experience_commenced_on + ? dayjs() + .diff(dayjs(userData.doctor_experience_commenced_on), "years") + .toString() + : undefined, + doctor_medical_council_registration: + userData.doctor_medical_council_registration, + }; + dispatch({ + type: "set_form", + form: formData, + }); + formVals.current = formData; + }, + }); + + const prepData = ( + formData: UserForm, + isCreate: boolean = false, + ): Partial => { + const fields = includedFields ?? Object.keys(formData); + let baseData: Partial = {}; + const phoneNumber = parsePhoneNumber(formData.phone_number) ?? ""; + const altPhoneNumber = formData.phone_number_is_whatsapp + ? phoneNumber + : (parsePhoneNumber(formData.alt_phone_number) ?? ""); + + let fieldMappings: Partial = { + first_name: formData.first_name, + last_name: formData.last_name, + email: formData.email, + video_connect_link: formData.video_connect_link, + phone_number: phoneNumber, + alt_phone_number: altPhoneNumber, + gender: formData.gender as GenderType, + date_of_birth: dateQueryString(formData.date_of_birth), + qualification: + formData.user_type === "Doctor" || formData.user_type === "Nurse" + ? formData.qualification + : undefined, + doctor_experience_commenced_on: + formData.user_type === "Doctor" + ? dayjs() + .subtract( + parseInt( + (formData.doctor_experience_commenced_on as string) ?? "0", + ), + "years", + ) + .format("YYYY-MM-DD") + : undefined, + doctor_medical_council_registration: + formData.user_type === "Doctor" + ? formData.doctor_medical_council_registration + : undefined, + weekly_working_hours: + formData.weekly_working_hours && formData.weekly_working_hours !== "" + ? formData.weekly_working_hours + : undefined, + }; + + if (isCreate) { + fieldMappings = { + ...fieldMappings, + user_type: formData.user_type, + password: formData.password, + facilities: formData.facilities ? formData.facilities : undefined, + home_facility: formData.home_facility ?? undefined, + username: formData.username, + state: formData.state, + district: formData.district, + local_body: showLocalbody ? formData.local_body : undefined, + }; + } + + for (const field of fields) { + if (field in fieldMappings) { + baseData = { + ...baseData, + [field as keyof UserForm]: fieldMappings[field as keyof UserForm], + }; + } + } + + return baseData; + }; + + const handleEditSubmit = async (formData: UserForm) => { + if (!username) return; + const data = prepData(formData); + const { res, error } = await request(routes.partialUpdateUser, { + pathParams: { username }, + body: data as Partial, + }); + if (res?.ok) { + Notification.Success({ + msg: t("user_details_update_success"), + }); + await refetchUserData(); + } else { + Notification.Error({ + msg: error?.message ?? t("user_details_update_error"), + }); + } + props.onSubmitSuccess?.(); + }; + + const [state, dispatch] = useAutoSaveReducer( + user_create_reducer, + initialState, + ); + const [isLoading, setIsLoading] = useState(false); + const [states, setStates] = useState([]); + const [selectedStateId, setSelectedStateId] = useState(0); + const [selectedDistrictId, setSelectedDistrictId] = useState(0); + const [districts, setDistricts] = useState([]); + const [localBodies, setLocalBodies] = useState([]); + const [selectedFacility, setSelectedFacility] = useState([]); + const [usernameInputInFocus, setUsernameInputInFocus] = useState(false); + const [passwordInputInFocus, setPasswordInputInFocus] = useState(false); + const [confirmPasswordInputInFocus, setConfirmPasswordInputInFocus] = + useState(false); + const [usernameInput, setUsernameInput] = useState(""); + + const userExistsEnums = { + idle: 0, + checking: 1, + exists: 2, + available: 3, + }; + + const [usernameExists, setUsernameExists] = useState(0); + + const check_username = async (username: string) => { + setUsernameExists(userExistsEnums.checking); + const { res: usernameCheck } = await request(routes.checkUsername, { + pathParams: { username }, + silent: true, + }); + if (usernameCheck === undefined || usernameCheck.status === 409) + setUsernameExists(userExistsEnums.exists); + else if (usernameCheck.status === 200) + setUsernameExists(userExistsEnums.available); + else + Notification.Error({ + msg: "Some error checking username availabality. Please try again later.", + }); + }; + + useEffect(() => { + setUsernameExists(userExistsEnums.idle); + if (validateUsername(usernameInput)) { + const timeout = setTimeout(() => { + check_username(usernameInput); + }, 500); + return () => clearTimeout(timeout); + } + }, [usernameInput]); + + const userTypes = GetUserTypes(); + const authUser = useAuthUser(); + const userIndex = USER_TYPES.indexOf(authUser.user_type); + + const showLocalbody = ![ + "Pharmacist", + "Volunteer", + "Doctor", + ...STAFF_OR_NURSE_USER, + ].includes(state.form.user_type ?? ""); + + const { loading: isDistrictLoading } = useQuery(routes.getDistrictByState, { + prefetch: !!(selectedStateId > 0), + pathParams: { id: selectedStateId.toString() }, + onResponse: (result) => { + if (!result || !result.res || !result.data) return; + if (userIndex <= USER_TYPES.indexOf("DistrictAdmin")) { + setDistricts([authUser.district_object!]); + } else { + setDistricts(result.data); + } + }, + }); + + const { loading: isLocalbodyLoading } = useQuery( + routes.getAllLocalBodyByDistrict, + { + prefetch: !!(selectedDistrictId > 0), + pathParams: { id: selectedDistrictId.toString() }, + onResponse: (result) => { + if (!result || !result.res || !result.data) return; + if (userIndex <= USER_TYPES.indexOf("LocalBodyAdmin")) { + setLocalBodies([authUser.local_body_object!]); + } else { + setLocalBodies(result.data); + } + }, + }, + ); + + const { loading: isStateLoading } = useQuery(routes.statesList, { + onResponse: (result) => { + if (!result || !result.res || !result.data) return; + if (userIndex <= USER_TYPES.indexOf("StateAdmin")) { + setStates([authUser.state_object!]); + } else { + setStates(result.data.results); + } + }, + }); + + const handleDateChange = ( + event: FieldChangeEvent, + field?: FormContextValue, + ) => { + if (dayjs(event.value).isValid()) { + dispatch({ + type: "set_form", + form: { + ...state.form, + [event.name]: dayjs(event.value).format("YYYY-MM-DD"), + }, + }); + if (field) field(event.name as keyof UserForm).onChange(event); + } + }; + + const handleFieldChange = ( + event: FieldChangeEvent, + field?: FormContextValue, + ) => { + const fieldName = event.name as keyof UserForm; + dispatch({ + type: "set_form", + form: { + ...state.form, + [fieldName]: event.value, + }, + }); + field?.(fieldName).onChange(event); + }; + + const changePhoneNumber = ( + field: FormContextValue, + fieldName: keyof UserForm, + value: string | boolean, + ) => { + field(fieldName).onChange({ + name: field(fieldName).name, + value: value, + }); + }; + + const updatePhoneNumber = ( + field: FormContextValue, + phoneNumber: string, + ) => { + changePhoneNumber(field, "phone_number", phoneNumber); + return { phone_number: phoneNumber }; + }; + + const updateAltPhoneNumber = ( + field: FormContextValue, + allowUpdate: boolean, + phoneNumber: string, + ) => { + if (allowUpdate) { + changePhoneNumber(field, "alt_phone_number", phoneNumber); + return { alt_phone_number: phoneNumber }; + } + return {}; + }; + + const handlePhoneChange = ( + event: FieldChangeEvent, + field: FormContextValue, + ) => { + let formData = { ...state.form }; + let phoneNumberVal = ""; + switch (event.name) { + case "phone_number": + formData = { + ...formData, + ...updatePhoneNumber(field, event.value as string), + ...updateAltPhoneNumber( + field, + state.form.phone_number_is_whatsapp ?? true, + event.value as string, + ), + }; + break; + case "alt_phone_number": + phoneNumberVal = event.value as string; + formData = { + ...formData, + ...updateAltPhoneNumber( + field, + !(state.form.phone_number_is_whatsapp ?? true), + phoneNumberVal, + ), + }; + break; + case "phone_number_is_whatsapp": + formData = { + ...formData, + ...updateAltPhoneNumber( + field, + event.value as boolean, + state.form.phone_number, + ), + phone_number_is_whatsapp: event.value as boolean, + }; + changePhoneNumber( + field, + "phone_number_is_whatsapp", + event.value as boolean, + ); + break; + } + dispatch({ + type: "set_form", + form: formData, + }); + }; + + const setFacility = (selected: FacilityModel | FacilityModel[] | null) => { + const newSelectedFacilities = selected + ? Array.isArray(selected) + ? selected + : [selected] + : []; + setSelectedFacility(newSelectedFacilities as FacilityModel[]); + const form = { ...state.form }; + form.facilities = selected + ? (selected as FacilityModel[]).map((i) => i.id!) + : []; + dispatch({ type: "set_form", form }); + }; + + const validateFacility = ( + formData: UserForm, + selectedFacility: FacilityModel[], + ) => { + if ( + selectedFacility && + formData.user_type && + selectedFacility.length === 0 && + STAFF_OR_NURSE_USER.includes(authUser.user_type) && + STAFF_OR_NURSE_USER.includes(formData.user_type) + ) { + return "Please select atleast one of the facilities you are linked to"; + } + }; + + const validatePhoneNumber = (phoneNumber: string) => { + const parsedPhoneNumber = parsePhoneNumber(phoneNumber); + if (!parsedPhoneNumber) return false; + return PhoneNumberValidator()(parsedPhoneNumber) === undefined; + }; + + const validateForm = (formData: UserForm) => { + const errors: Partial> = {}; + const fieldsToValidate = includedFields || Object.keys(formData); + const facilityError = fieldsToValidate.includes("facilities") + ? validateFacility(formData, selectedFacility) + : null; + if (facilityError) { + errors.facilities = facilityError; + } + let currentError = null; + fieldsToValidate.forEach((field) => { + switch (field) { + case "user_type": + if (!formData[field]) { + errors[field] = t("please_select_user_type"); + } + break; + case "qualification": + currentError = ValidateQualification(formData, t); + if (currentError) { + errors[field] = currentError; + } + break; + case "doctor_experience_commenced_on": + currentError = ValidateDoctorExperienceCommencedOn(formData, t); + if (currentError) { + errors[field] = currentError; + } + break; + case "doctor_medical_council_registration": + currentError = ValidateDoctorMedicalCouncilRegistration(formData, t); + if (currentError) { + errors[field] = currentError; + } + break; + case "phone_number": + if (!formData[field] || !validatePhoneNumber(formData[field])) { + errors[field] = t("invalid_phone"); + } + break; + case "alt_phone_number": + if ( + formData[field] && + formData[field] !== "+91" && + !validatePhoneNumber(formData[field]) + ) { + errors[field] = t("mobile_number_validation_error"); + } + break; + case "username": + if (!formData[field]) { + errors[field] = t("please_enter_username"); + } else if (!validateUsername(formData[field])) { + errors[field] = t("invalid_username"); + } else if (usernameExists !== userExistsEnums.available) { + errors[field] = t("username_already_exists"); + } + break; + case "password": + if (!formData[field]) { + errors[field] = t("password_required"); + } else if (!validatePassword(formData[field])) { + errors.password = t("password_validation"); + } + break; + case "c_password": + if (!formData.password) { + errors.c_password = t("confirm_password_required"); + } else if (formData.password !== formData.c_password) { + errors.c_password = t("password_mismatch"); + } + break; + case "first_name": + case "last_name": + formData[field] = formData[field].trim(); + if (!formData[field]) { + errors[field] = t(`${field}_required`); + } else if (!validateName(formData[field])) { + errors[field] = t("min_char_length_error", { min_length: 3 }); + } + break; + case "email": + formData[field] = formData[field].trim(); + if ( + formData[field].length === 0 || + !validateEmailAddress(formData[field]) + ) { + errors[field] = t("invalid_email"); + } + break; + case "date_of_birth": + if (!formData[field]) { + errors[field] = t("dob_format"); + } else if ( + dayjs(formData[field]).isAfter(dayjs().subtract(1, "year")) + ) { + errors[field] = t("enter_valid_dob"); + } else if ( + dayjs(formData[field]).isAfter(dayjs().subtract(16, "year")) + ) { + errors[field] = t("enter_valid_dob_age"); + } + break; + case "gender": + if (!formData[field]) { + errors[field] = t("please_select_gender"); + } + break; + case "state": + if (!Number(formData[field])) { + errors[field] = t("please_select_state"); + } + break; + case "district": + if (!Number(formData[field])) { + errors[field] = t("please_select_district"); + } + break; + case "local_body": + if (showLocalbody && !Number(formData[field])) { + errors[field] = t("please_select_localbody"); + } + break; + case "weekly_working_hours": + if (formData[field] !== null && formData[field] !== undefined) { + const hours = Number(formData[field]); + if ( + Number.isNaN(hours) || + hours < 0 || + hours > 168 || + !validateNumber(formData[field] ?? "") + ) { + errors[field] = t("weekly_working_hours_error"); + } + } + break; + case "video_connect_link": + currentError = ValidateVideoLink(formData, t); + if (currentError) { + errors[field] = currentError; + } + break; + default: + break; + } + }); + return errors; + }; + + const handleSubmit = async (formData: UserForm) => { + setIsLoading(true); + const data = prepData(formData, true); + + const { res, error } = await request(routes.addUser, { + body: data, + }); + if (res?.ok) { + dispatch({ type: "set_form", form: initForm }); + Notification.Success({ + msg: t("user_added_successfully"), + }); + navigate("/users"); + } else { + Notification.Error({ + msg: error?.message ?? t("user_add_error"), + }); + } + setIsLoading(false); + }; + + useEffect(() => { + const facilityError = validateFacility(state.form, selectedFacility); + setFacilityErrors(facilityError || ""); + }, [state.form, selectedFacility]); + + if (isLoading || (editUser && userDataLoading)) { + return ; + } + + const handleCancel = () => { + dispatch({ + type: "set_form", + form: formVals.current, + }); + }; + + const renderDoctorOrNurseFields = (field: FormContextValue) => { + return ( + <> + {(state.form.user_type === "Doctor" || + state.form.user_type === "Nurse") && + includedFields?.includes("qualification") && ( + { + handleFieldChange(e, field); + }} + className="flex-1" + aria-label={t("qualification")} + /> + )} + {state.form.user_type === "Doctor" && ( +
+ {includedFields?.includes("doctor_experience_commenced_on") && ( + { + handleFieldChange(e, field); + }} + className="flex-1" + aria-label={t("years_of_experience")} + /> + )} + {includedFields?.includes( + "doctor_medical_council_registration", + ) && ( + { + handleFieldChange(e, field); + }} + className="flex-1" + aria-label={t("medical_council_registration")} + /> + )} +
+ )} + + ); + }; + + const renderPhoneNumberFields = (field: FormContextValue) => { + return ( + <> + {includedFields?.includes("phone_number") && ( +
+
+ { + handlePhoneChange(e, field); + }} + className="" + aria-label={t("phone_number")} + /> + { + handlePhoneChange(e, field); + }} + label={t("is_phone_a_whatsapp_number")} + /> +
+ { + handlePhoneChange(e, field); + }} + className="flex-1" + aria-label={t("whatsapp_number")} + /> +
+ )} + + ); + }; + + const renderUsernameField = (field: FormContextValue) => { + return ( + <> + {includedFields?.includes("username") && ( + <> + { + handleFieldChange(e, field); + setUsernameInput(e.value); + }} + onFocus={() => setUsernameInputInFocus(true)} + onBlur={() => { + setUsernameInputInFocus(false); + }} + aria-label={t("username")} + /> + {usernameInputInFocus && ( +
+
+ {usernameExists !== userExistsEnums.idle && ( + <> + {usernameExists === userExistsEnums.checking ? ( + + {" "} + checking... + + ) : ( + <> + {usernameExists === userExistsEnums.exists ? ( +
+ {" "} + + {t("username_not_available")} + +
+ ) : ( +
+ {" "} + + {t("username_available")} + +
+ )} + + )} + + )} +
+
+ {validateRule( + usernameInput.length >= 4 && usernameInput.length <= 16, + "Username should be 4-16 characters long", + !state.form.username, + )} + {validateRule( + /^[a-z0-9._-]*$/.test(usernameInput), + "Username can only contain lowercase letters, numbers, and . _ -", + !state.form.username, + )} + {validateRule( + /^[a-z0-9].*[a-z0-9]$/i.test(usernameInput), + "Username must start and end with a letter or number", + !state.form.username, + )} + {validateRule( + !/(?:[._-]{2,})/.test(usernameInput), + "Username can't contain consecutive special characters . _ -", + !state.form.username, + )} +
+
+ )} + + )} + + ); + }; + + const renderPasswordFields = (field: FormContextValue) => { + return ( + <> +
+ {includedFields?.includes("password") && ( +
+ setPasswordInputInFocus(true)} + onBlur={() => setPasswordInputInFocus(false)} + onChange={(e) => { + handleFieldChange(e, field); + }} + aria-label={t("password")} + /> + {passwordInputInFocus && state.form.password && ( +
+ {validateRule( + state.form.password.length >= 8, + t("password_length_validation"), + !state.form.password, + )} + {validateRule( + state.form.password !== state.form.password.toUpperCase(), + t("password_lowercase_validation"), + !state.form.password, + )} + {validateRule( + state.form.password !== state.form.password.toLowerCase(), + t("password_uppercase_validation"), + !state.form.password, + )} + {validateRule( + /\d/.test(state.form.password), + t("password_number_validation"), + !state.form.password, + )} +
+ )} +
+ )} + {includedFields?.includes("c_password") && ( +
+ setConfirmPasswordInputInFocus(true)} + onBlur={() => setConfirmPasswordInputInFocus(false)} + onChange={(e) => { + handleFieldChange(e, field); + }} + aria-label={t("confirm_password")} + /> + {confirmPasswordInputInFocus && + state.form.c_password && + state.form.c_password.length > 0 && ( +
+ {validateRule( + state.form.c_password === state.form.password, + t("password_mismatch"), + !state.form.c_password, + )} +
+ )} +
+ )} +
+ + ); + }; + + const renderPersonalInfoFields = (field: FormContextValue) => { + return ( + <> +
+ {includedFields?.includes("first_name") && ( + { + handleFieldChange(e, field); + }} + aria-label={t("first_name")} + /> + )} + {includedFields?.includes("last_name") && ( + { + handleFieldChange(e, field); + }} + aria-label={t("last_name")} + /> + )} +
+ {includedFields?.includes("email") && ( + { + handleFieldChange(e, field); + }} + aria-label={t("email")} + /> + )} +
+ {includedFields?.includes("date_of_birth") && ( + { + handleDateChange(e, field); + }} + disableFuture + className="flex-1" + aria-label={t("date_of_birth")} + /> + )} + {includedFields?.includes("gender") && ( + o.text} + optionValue={(o) => o.text} + onChange={(e) => { + handleFieldChange(e, field); + }} + className="flex-1" + aria-label={t("gender")} + /> + )} +
+ + ); + }; + + const renderHoursAndConferenceLinkFields = ( + field: FormContextValue, + ) => { + return ( + <> +
+ {includedFields?.includes("weekly_working_hours") && ( + { + handleFieldChange(e, field); + }} + aria-label={t("average_weekly_working_hours")} + /> + )} + {includedFields?.includes("video_connect_link") && ( + { + handleFieldChange(e, field); + }} + aria-label={t("video_conference_link")} + /> + )} +
+ + ); + }; + + const renderStateDistrictLocalBodyFields = ( + field: FormContextValue, + ) => { + return ( + <> + {includedFields?.includes("state") && ( + <> + {isStateLoading ? ( + + ) : ( + o.name} + optionValue={(o) => o.id} + onChange={(e) => { + handleFieldChange(e, field); + if (e) setSelectedStateId(e.value); + }} + aria-label={t("state")} + /> + )} + + )} + {includedFields?.includes("district") && ( + <> + {isDistrictLoading ? ( + + ) : ( + o.name} + optionValue={(o) => o.id} + onChange={(e) => { + handleFieldChange(e, field); + if (e) setSelectedDistrictId(e.value); + }} + aria-label={t("district")} + /> + )} + + )} + {showLocalbody && includedFields?.includes("local_body") && ( + <> + {isLocalbodyLoading ? ( + + ) : ( + o.name} + optionValue={(o) => o.id} + onChange={(e) => { + handleFieldChange(e, field); + }} + aria-label={t("local_body")} + /> + )} + + )} + + ); + }; + + const renderFacilityUserTypeHomeFacilityFields = ( + field: FormContextValue, + ) => { + return ( + <> + {includedFields?.includes("facilities") && ( +
+ {t("facilities")} + +
+ )} +
+ {includedFields?.includes("user_type") && ( + o.role + (o.readOnly ? " (Read Only)" : "")} + onChange={(e) => { + handleFieldChange(e, field); + }} + optionValue={(o) => o.id} + className="flex-1" + aria-label={t("user_type")} + /> + )} + {includedFields?.includes("home_facility") && ( + option.name} + optionValue={(option) => option.id} + onChange={(e) => { + handleFieldChange(e, field); + }} + className="flex-1" + aria-label={t("home_facility")} + /> + )} +
+ + ); + }; + + return ( + + disabled={isLoading} + defaults={userData ? state.form : initForm} + validate={validateForm} + onCancel={editUser ? handleCancel : () => goBack()} + onSubmit={editUser ? handleEditSubmit : handleSubmit} + onDraftRestore={(newState) => { + dispatch({ type: "set_state", state: newState }); + }} + hideRestoreDraft={editUser} + noPadding + resetFormValsOnCancel + hideCancelButton={editUser} + > + {(field) => ( + <> +
+ {renderFacilityUserTypeHomeFacilityFields(field)} + {renderDoctorOrNurseFields(field)} + {renderPhoneNumberFields(field)} + {renderUsernameField(field)} + {renderPasswordFields(field)} + {renderPersonalInfoFields(field)} + {renderHoursAndConferenceLinkFields(field)} + {renderStateDistrictLocalBodyFields(field)} +
+ + )} + + ); +}; + +export default UserAddEditForm; diff --git a/src/components/Users/UserAvatar.tsx b/src/components/Users/UserAvatar.tsx new file mode 100644 index 00000000000..c186039951e --- /dev/null +++ b/src/components/Users/UserAvatar.tsx @@ -0,0 +1,125 @@ +import careConfig from "@careConfig"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Avatar } from "@/components/Common/Avatar"; +import AvatarEditModal from "@/components/Common/AvatarEditModal"; +import ButtonV2 from "@/components/Common/ButtonV2"; +import Loading from "@/components/Common/Loading"; + +import useAuthUser from "@/hooks/useAuthUser"; + +import { LocalStorageKeys } from "@/common/constants"; + +import * as Notification from "@/Utils/Notifications"; +import { showAvatarEdit } from "@/Utils/permissions"; +import routes from "@/Utils/request/api"; +import request from "@/Utils/request/request"; +import uploadFile from "@/Utils/request/uploadFile"; +import useQuery from "@/Utils/request/useQuery"; +import { formatDisplayName, sleep } from "@/Utils/utils"; + +export default function UserAvatar({ username }: { username: string }) { + const { t } = useTranslation(); + const [editAvatar, setEditAvatar] = useState(false); + const authUser = useAuthUser(); + + const { + data: userData, + loading: isLoading, + refetch: refetchUserData, + } = useQuery(routes.getUserDetails, { + pathParams: { + username: username, + }, + }); + + if (isLoading || !userData) { + return ; + } + + const handleAvatarUpload = async (file: File, onError: () => void) => { + const formData = new FormData(); + formData.append("profile_picture", file); + const url = `${careConfig.apiUrl}/api/v1/users/${userData.username}/profile_picture/`; + + uploadFile( + url, + formData, + "POST", + { + Authorization: + "Bearer " + localStorage.getItem(LocalStorageKeys.accessToken), + }, + async (xhr: XMLHttpRequest) => { + if (xhr.status === 200) { + await sleep(1000); + refetchUserData(); + Notification.Success({ msg: t("avatar_updated_success") }); + setEditAvatar(false); + } + }, + null, + () => { + onError(); + }, + ); + }; + + const handleAvatarDelete = async (onError: () => void) => { + const { res } = await request(routes.deleteProfilePicture, { + pathParams: { username }, + }); + if (res?.ok) { + Notification.Success({ msg: "Profile picture deleted" }); + await refetchUserData(); + setEditAvatar(false); + } else { + onError(); + } + }; + + return ( + <> + setEditAvatar(false)} + /> +
+
+
+ +
+ setEditAvatar(!editAvatar)} + type="button" + id="change-avatar" + className="border border-gray-200 bg-gray-50 text-black hover:bg-gray-100" + shadow={false} + disabled={!showAvatarEdit(authUser, userData)} + tooltip={ + !showAvatarEdit(authUser, userData) + ? t("edit_avatar_permission_error") + : undefined + } + > + {t("change_avatar")} + +

+ {t("change_avatar_note")} +

+
+
+
+
+ + ); +} diff --git a/src/components/Users/UserBanner.tsx b/src/components/Users/UserBanner.tsx new file mode 100644 index 00000000000..c42c75ce165 --- /dev/null +++ b/src/components/Users/UserBanner.tsx @@ -0,0 +1,94 @@ +import dayjs from "dayjs"; +import { t } from "i18next"; + +import { Avatar } from "@/components/Common/Avatar"; +import UserDetails from "@/components/Common/UserDetails"; +import UserDetailComponent from "@/components/Common/UserDetailsComponet"; +import { UserModel } from "@/components/Users/models"; + +import { formatDisplayName, formatName } from "@/Utils/utils"; + +import { UserStatusIndicator } from "./UserListAndCard"; + +export default function UserBanner({ userData }: { userData: UserModel }) { + if (!userData) { + return; + } + + return ( +
+
+
+ +
+
+

+ {formatName(userData)} +

+
+ +
+
+ + + {userData.username} + +
+
+
+ {userData.user_type && ( + + )} + {userData.district_object && ( + + )} + + + {userData.home_facility_object?.name || t("no_home_facility")} + + + {["Doctor", "Nurse"].includes(userData.user_type) && ( + + {userData.qualification ? ( + {userData.qualification} + ) : ( + {t("unknown")} + )} + + )} + {userData.user_type === "Doctor" && ( + + {userData.doctor_experience_commenced_on ? ( + + {dayjs().diff( + userData.doctor_experience_commenced_on, + "years", + false, + )}{" "} + years + + ) : ( + {t("unknown")} + )} + + )} +
+
+
+ ); +} diff --git a/src/components/Users/UserEditDetails.tsx b/src/components/Users/UserEditDetails.tsx new file mode 100644 index 00000000000..66b9d0b22fd --- /dev/null +++ b/src/components/Users/UserEditDetails.tsx @@ -0,0 +1,177 @@ +import { TFunction } from "i18next"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import UserAddEditForm from "@/components/Users/UserAddEditForm"; +import { + editBasicInfoFields, + editContactInfoFields, + editProfessionalInfoFields, + editProfessionalInfoFieldsForNurseDoctor, +} from "@/components/Users/UserFormValidations"; +import { + BasicInfoDetails, + ContactInfoDetails, + ProfessionalInfoDetails, +} from "@/components/Users/UserViewDetails"; +import { UserModel } from "@/components/Users/models"; + +interface UserEditDetailsProps { + username: string; + userData: UserModel; + onSubmitSuccess?: () => void; +} + +const ViewEditToggle = ({ + isEditing, + setIsEditing, + id, + t, +}: { + isEditing: boolean; + setIsEditing: (value: boolean) => void; + id: string; + t: TFunction; +}) => { + return ( +
+ + +
+ ); +}; + +export function UserBasicInfoView({ + username, + userData, + onSubmitSuccess, +}: UserEditDetailsProps) { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useState(false); + + return ( +
+ + {isEditing ? ( + { + setIsEditing(false); + onSubmitSuccess?.(); + }} + /> + ) : ( + + )} +
+ ); +} + +export function UserContactInfoView({ + username, + userData, + onSubmitSuccess, +}: UserEditDetailsProps) { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useState(false); + + return ( +
+ + {isEditing ? ( + { + setIsEditing(false); + onSubmitSuccess?.(); + }} + /> + ) : ( + + )} +
+ ); +} + +export function UserProfessionalInfoView({ + username, + userData, + onSubmitSuccess, +}: UserEditDetailsProps) { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useState(false); + const editFields = + userData.user_type === "Doctor" || userData.user_type === "Nurse" + ? editProfessionalInfoFieldsForNurseDoctor + : editProfessionalInfoFields; + + return ( +
+ + {isEditing ? ( + { + setIsEditing(false); + onSubmitSuccess?.(); + }} + /> + ) : ( + + )} +
+ ); +} diff --git a/src/components/Users/UserFormValidations.tsx b/src/components/Users/UserFormValidations.tsx new file mode 100644 index 00000000000..c59d4a4bd5c --- /dev/null +++ b/src/components/Users/UserFormValidations.tsx @@ -0,0 +1,159 @@ +import { TFunction } from "i18next"; + +import { validateNumber } from "@/common/validation"; + +import { FacilityModel } from "../Facility/models"; + +export type UserForm = { + user_type?: string; + gender: string; + password?: string; + c_password?: string; + facilities?: Array; + home_facility?: FacilityModel | null; + username?: string; + first_name: string; + last_name: string; + email: string; + phone_number: string; + alt_phone_number: string; + phone_number_is_whatsapp?: boolean; + date_of_birth: Date | null | string; + state?: number; + district?: number; + local_body?: number; + qualification?: string | undefined; + doctor_experience_commenced_on?: string; + doctor_medical_council_registration?: string; + video_connect_link?: string; + weekly_working_hours?: string | null; +}; + +export const newUserFields: Array = [ + "user_type", + "username", + "password", + "c_password", + "first_name", + "last_name", + "email", + "phone_number", + "alt_phone_number", + "phone_number_is_whatsapp", + "gender", + "date_of_birth", + "state", + "district", + "local_body", + "facilities", + "home_facility", + "qualification", + "doctor_experience_commenced_on", + "doctor_medical_council_registration", +]; + +export const editUserFields: Array = [ + "first_name", + "last_name", + "date_of_birth", + "gender", + "email", + "video_connect_link", + "phone_number", + "alt_phone_number", + "phone_number_is_whatsapp", + "qualification", + "doctor_experience_commenced_on", + "doctor_medical_council_registration", + "weekly_working_hours", +]; + +export const editBasicInfoFields: Array = [ + "first_name", + "last_name", + "date_of_birth", + "gender", +]; + +export const editContactInfoFields: Array = [ + "email", + "phone_number", + "alt_phone_number", + "phone_number_is_whatsapp", +]; + +export const editProfessionalInfoFields: Array = [ + "weekly_working_hours", + "video_connect_link", +]; + +export const editProfessionalInfoFieldsForNurseDoctor: Array = [ + "qualification", + "doctor_experience_commenced_on", + "doctor_medical_council_registration", + ...editProfessionalInfoFields, +]; + +export const ValidateQualification = ( + formData: UserForm, + translator: TFunction, +) => { + if ( + (formData.user_type === "Doctor" || formData.user_type === "Nurse") && + !formData["qualification"] + ) { + return translator("qualification_required"); + } + return null; +}; + +export const ValidateDoctorExperienceCommencedOn = ( + formData: UserForm, + translator: TFunction, +) => { + if (formData.user_type === "Doctor") { + if (!formData["doctor_experience_commenced_on"]) { + return translator("doctor_experience_required"); + } else if ( + !validateNumber(formData["doctor_experience_commenced_on"] ?? "") || + Number(formData["doctor_experience_commenced_on"]) < 0 || + Number(formData["doctor_experience_commenced_on"]) > 100 + ) { + return translator("doctor_experience_error"); + } + } + return null; +}; + +export const ValidateDoctorMedicalCouncilRegistration = ( + formData: UserForm, + translator: TFunction, +) => { + if ( + formData.user_type === "Doctor" && + !formData["doctor_medical_council_registration"] + ) { + return translator("medical_council_registration_required"); + } + return null; +}; + +export const ValidateVideoLink = ( + formData: UserForm, + translator: TFunction, +) => { + if (!formData["video_connect_link"]) return null; + + try { + const parsed = new URL(formData["video_connect_link"]); + if (!["https:", "http:"].includes(parsed.protocol)) { + return translator("invalid_url_http_https"); + } + if (parsed.href.toLowerCase().includes("javascript:")) { + return translator("invalid_url_javascript"); + } + } catch { + return translator("invalid_url"); + } + return null; +}; diff --git a/src/components/Users/UserHome.tsx b/src/components/Users/UserHome.tsx new file mode 100644 index 00000000000..dea75598ec9 --- /dev/null +++ b/src/components/Users/UserHome.tsx @@ -0,0 +1,149 @@ +import { Link, navigate } from "raviger"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Loading from "@/components/Common/Loading"; +import Page from "@/components/Common/Page"; +import { userChildProps } from "@/components/Common/UserColumns"; +import Error404 from "@/components/ErrorPages/404"; +import LinkedFacilitiesTab from "@/components/Users/LinkedFacilitiesTab"; +import LinkedSkillsTab from "@/components/Users/LinkedSkillsTab"; +import UserBanner from "@/components/Users/UserBanner"; +import UserSummaryTab from "@/components/Users/UserSummary"; +import { UserModel } from "@/components/Users/models"; + +import useAuthUser from "@/hooks/useAuthUser"; + +import * as Notification from "@/Utils/Notifications"; +import { editUserPermissions } from "@/Utils/permissions"; +import routes from "@/Utils/request/api"; +import useQuery from "@/Utils/request/useQuery"; +import { classNames, formatName, keysOf } from "@/Utils/utils"; + +export interface UserHomeProps { + username?: string; + tab: string; +} +export interface tabChildProp { + body: (childProps: userChildProps) => JSX.Element | undefined; + hidden?: boolean; +} + +export default function UserHome(props: UserHomeProps) { + const { tab } = props; + let { username } = props; + const [userData, setUserData] = useState(); + const { t } = useTranslation(); + const authUser = useAuthUser(); + if (!username) { + username = authUser.username; + } + + const { loading, refetch: refetchUserDetails } = useQuery( + routes.getUserDetails, + { + pathParams: { + username: username, + }, + onResponse: ({ res, data, error }) => { + if (res?.status === 200 && data) { + setUserData(data); + } else if (res?.status === 400) { + navigate("/users"); + } else if (error) { + Notification.Error({ + msg: "Error while fetching user details: " + (error?.message || ""), + }); + } + }, + }, + ); + + if (loading || !userData) { + return ; + } + + const editPermissions = editUserPermissions(authUser, userData); + + const TABS: { + PROFILE: tabChildProp; + SKILLS: tabChildProp; + FACILITIES: tabChildProp; + } = { + PROFILE: { body: UserSummaryTab }, + SKILLS: { + body: LinkedSkillsTab, + hidden: !editPermissions, + }, + FACILITIES: { + body: LinkedFacilitiesTab, + hidden: !editPermissions, + }, + }; + + const normalizedTab = tab.toUpperCase(); + const isValidTab = (tab: string): tab is keyof typeof TABS => + Object.keys(TABS).includes(tab as keyof typeof TABS); + const currentTab = isValidTab(normalizedTab) ? normalizedTab : undefined; + + if (!currentTab) { + return ; + } + + const SelectedTab = TABS[currentTab].body; + + return ( + <> + + { + <> + +
+
+
+ +
+
+
+ + + } +
+ + ); +} diff --git a/src/components/Users/UserListAndCard.tsx b/src/components/Users/UserListAndCard.tsx new file mode 100644 index 00000000000..ee1ac5e9874 --- /dev/null +++ b/src/components/Users/UserListAndCard.tsx @@ -0,0 +1,419 @@ +import { TFunction } from "i18next"; +import { navigate } from "raviger"; +import { useTranslation } from "react-i18next"; + +import Card from "@/CAREUI/display/Card"; +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import { Avatar } from "@/components/Common/Avatar"; +import Tabs from "@/components/Common/Tabs"; +import SearchInput from "@/components/Form/SearchInput"; +import { UserAssignedModel, UserModel } from "@/components/Users/models"; + +import useAuthUser from "@/hooks/useAuthUser"; +import useWindowDimensions from "@/hooks/useWindowDimensions"; + +import { USER_TYPES, USER_TYPE_OPTIONS } from "@/common/constants"; + +import { + classNames, + formatName, + isUserOnline, + relativeTime, +} from "@/Utils/utils"; + +export const GetUserTypes = (editForm = false) => { + const authUser = useAuthUser(); + + const userIndex = USER_TYPES.indexOf(authUser.user_type); + const readOnlyUsers = USER_TYPE_OPTIONS.filter((user) => user.readOnly); + const defaultAllowedUserTypes = USER_TYPE_OPTIONS.slice(0, userIndex + 1); + + // Superuser gets all options + if (authUser.is_superuser) { + return [...USER_TYPE_OPTIONS]; + } + + switch (authUser.user_type) { + case "StaffReadOnly": + return readOnlyUsers.slice(0, 1); + case "DistrictReadOnlyAdmin": + return readOnlyUsers.slice(0, 2); + case "StateReadOnlyAdmin": + return readOnlyUsers.slice(0, 3); + case "Pharmacist": + return USER_TYPE_OPTIONS.slice(0, 1); + case "Nurse": + case "Staff": + if (editForm) return [...defaultAllowedUserTypes]; + // Temporarily allows creation of users with elevated permissions due to introduction of new roles. + return [...defaultAllowedUserTypes, USER_TYPE_OPTIONS[6]]; + default: + return defaultAllowedUserTypes; + } +}; +export const CanUserAccess = (user: UserModel | UserAssignedModel) => { + const allowedTypes = GetUserTypes(true).map((type) => type.id); + return allowedTypes.includes(user.user_type); +}; +const GetDetailsButton = (username: string) => { + const { t } = useTranslation(); + return ( +
+ +
+ ); +}; +const getNameAndStatusCard = ( + user: UserModel | UserAssignedModel, + cur_online: boolean, + showDetailsButton = false, +) => { + return ( +
+
+
+
+

+ {formatName(user)} +

+
+ +
+
+ + {user.username} + +
+
{showDetailsButton && GetDetailsButton(user.username)}
+
+
+ ); +}; + +const getDistrict = (user: UserModel | UserAssignedModel, t: TFunction) => { + const district = + "district_object" in user && user.district_object + ? user.district_object?.name + : "district" in user && user.district + ? user.district + : undefined; + + if (!district) return <>; + return ( +
+
{t("district")}
+
+ {district} +
+
+ ); +}; + +export const UserStatusIndicator = ({ + user, + className, + addPadding = false, +}: { + user: UserModel | UserAssignedModel; + className?: string; + addPadding?: boolean; +}) => { + const cur_online = isUserOnline(user); + const { t } = useTranslation(); + return ( +
+ + + {cur_online + ? t("online") + : user.last_login + ? relativeTime(user.last_login) + : t("never")} + +
+ ); +}; +const UserCard = ({ user }: { user: UserModel | UserAssignedModel }) => { + const userOnline = isUserOnline(user); + const { width } = useWindowDimensions(); + const mediumScreenBreakpoint = 640; + const isMediumScreen = width <= mediumScreenBreakpoint; + const isLessThanXLargeScreen = width <= 1280; + const { t } = useTranslation(); + + return ( + +
+
+
+
+ + {isMediumScreen && getNameAndStatusCard(user, userOnline)} +
+
+ {!isMediumScreen && + getNameAndStatusCard(user, userOnline, !isLessThanXLargeScreen)} +
+
+
{t("role")}
+
+ {user.user_type} +
+
+
+
{t("home_facility")}
+
+ {user.home_facility_object?.name || t("no_home_facility")} +
+
+ {getDistrict(user, t)} +
+
+ {t("average_weekly_working_hours")} +
+
+ {user.weekly_working_hours ?? "-"} +
+
+
+
+
+
+ {isLessThanXLargeScreen && ( +
{GetDetailsButton(user.username)}
+ )} +
+
+ ); +}; +export const UserGrid = ({ + users, +}: { + users?: UserModel[] | UserAssignedModel[]; +}) => ( +
+ {users?.map((user) => )} +
+); + +const UserListHeader = ({ + showDistrictColumn, +}: { + showDistrictColumn: boolean; +}) => { + const { t } = useTranslation(); + return ( + + + + {t("name")} + + {t("status")} + {t("role")} + {t("home_facility")} + {showDistrictColumn && ( + {t("district")} + )} + + + + ); +}; + +const UserListRow = ({ + user, + showDistrictColumn, +}: { + user: UserModel | UserAssignedModel; + showDistrictColumn: boolean; +}) => { + const { t } = useTranslation(); + return ( + + +
+ +
+

+ {formatName(user)} +

+ + @{user.username} + +
+
+ + + + + + {user.user_type} + + + {user.home_facility_object?.name || t("no_home_facility")} + + {showDistrictColumn && ( + + {"district_object" in user && user.district_object + ? user.district_object?.name + : "district" in user && user.district + ? user.district + : ""} + + )} + {GetDetailsButton(user.username)} + + ); +}; +export const UserList = ({ + users, +}: { + users?: UserModel[] | UserAssignedModel[]; +}) => { + const showDistrictColumn = users?.some( + (user) => "district_object" in user || "district" in user, + ); + return ( +
+ + + + {users?.map((user) => ( + + ))} + +
+
+ ); +}; +interface UserListViewProps { + users: UserModel[] | UserAssignedModel[]; + onSearch: (username: string) => void; + searchValue: string; + activeTab: number; + onTabChange: (tab: number) => void; +} + +export default function UserListView({ + users, + onSearch, + searchValue, + activeTab, + onTabChange, +}: UserListViewProps) { + const { t } = useTranslation(); + + return ( + <> +
+
+ onSearch(e.value)} + value={searchValue} + placeholder={t("search_by_username")} + /> +
+ + + Card +
+ ), + value: 0, + id: "user-card-view", + }, + { + text: ( +
+ + List +
+ ), + value: 1, + id: "user-list-view", + }, + ]} + currentTab={activeTab} + onTabChange={(tab) => onTabChange(tab as number)} + className="float-right" + /> +
+ {users.length > 0 ? ( + <> + {activeTab === 0 ? ( + + ) : ( + + )} + + ) : ( +
+
+ No Users Found +
+
+ )} + + ); +} diff --git a/src/components/Users/UserProfile.tsx b/src/components/Users/UserProfile.tsx index 7b0df12cac0..7e8dff2aa84 100644 --- a/src/components/Users/UserProfile.tsx +++ b/src/components/Users/UserProfile.tsx @@ -17,7 +17,7 @@ import PhoneNumberFormField from "@/components/Form/FormFields/PhoneNumberFormFi import { SelectFormField } from "@/components/Form/FormFields/SelectFormField"; import TextFormField from "@/components/Form/FormFields/TextFormField"; import { FieldChangeEvent } from "@/components/Form/FormFields/Utils"; -import { validateRule } from "@/components/Users/UserAdd"; +import { validateRule } from "@/components/Users/UserAddEditForm"; import { GenderType, SkillModel, diff --git a/src/components/Users/UserResetPassword.tsx b/src/components/Users/UserResetPassword.tsx new file mode 100644 index 00000000000..2d61f2bda73 --- /dev/null +++ b/src/components/Users/UserResetPassword.tsx @@ -0,0 +1,213 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import Form from "@/components/Form/Form"; +import TextFormField from "@/components/Form/FormFields/TextFormField"; +import { validateRule } from "@/components/Users/UserAddEditForm"; +import { UpdatePasswordForm, UserModel } from "@/components/Users/models"; + +import * as Notification from "@/Utils/Notifications"; +import routes from "@/Utils/request/api"; +import request from "@/Utils/request/request"; + +import ButtonV2 from "../Common/ButtonV2"; + +interface PasswordForm { + username: string; + old_password: string; + new_password_1: string; + new_password_2: string; +} + +export default function UserResetPassword({ + userData, +}: { + userData: UserModel; +}) { + const { t } = useTranslation(); + const [isSubmitting, setisSubmitting] = useState(false); + const [isEditing, setIsEditing] = useState(false); + + const initForm: PasswordForm = { + username: userData.username, + old_password: "", + new_password_1: "", + new_password_2: "", + }; + + const validateNewPassword = (password: string) => { + if ( + password.length < 8 || + !/\d/.test(password) || + password === password.toUpperCase() || + password === password.toLowerCase() + ) { + return false; + } + return true; + }; + + const validateForm = (formData: PasswordForm) => { + const errors: Partial> = {}; + + if (!formData.old_password) { + errors.old_password = t("please_enter_current_password"); + } + + if (!formData.new_password_1) { + errors.new_password_1 = t("please_enter_new_password"); + } else if (!validateNewPassword(formData.new_password_1)) { + errors.new_password_1 = t("new_password_validation"); + } + + if (!formData.new_password_2) { + errors.new_password_2 = t("please_confirm_password"); + } else if (formData.new_password_1 !== formData.new_password_2) { + errors.new_password_2 = t("password_mismatch"); + } + + if (formData.new_password_1 === formData.old_password) { + errors.new_password_1 = t("new_password_same_as_old"); + } + + return errors; + }; + + const handleSubmit = async (formData: PasswordForm) => { + setisSubmitting(true); + const form: UpdatePasswordForm = { + old_password: formData.old_password, + username: userData.username, + new_password: formData.new_password_1, + }; + + const { res, data, error } = await request(routes.updatePassword, { + body: form, + }); + + if (res?.ok) { + Notification.Success({ msg: data?.message }); + } else { + Notification.Error({ + msg: error?.message ?? t("password_update_error"), + }); + } + setisSubmitting(false); + }; + + const renderPasswordForm = () => { + return ( + + defaults={initForm} + validate={validateForm} + onSubmit={handleSubmit} + resetFormValsOnCancel + resetFormValsOnSubmit + hideRestoreDraft + noPadding + disabled={isSubmitting} + hideCancelButton + > + {(field) => ( +
+ +
+ +
+ {validateRule( + field("new_password_1").value?.length >= 8, + t("password_length_validation"), + !field("new_password_1").value, + )} + {validateRule( + field("new_password_1").value !== + field("new_password_1").value?.toUpperCase(), + t("password_lowercase_validation"), + !field("new_password_1").value, + )} + {validateRule( + field("new_password_1").value !== + field("new_password_1").value?.toLowerCase(), + t("password_uppercase_validation"), + !field("new_password_1").value, + )} + {validateRule( + /\d/.test(field("new_password_1").value ?? ""), + t("password_number_validation"), + !field("new_password_1").value, + )} +
+
+
+ + {field("new_password_2").value?.length > 0 && ( +
+ {validateRule( + field("new_password_1").value === + field("new_password_2").value, + t("password_mismatch"), + !field("new_password_2").value, + )} +
+ )} +
+
+ )} + + ); + }; + + const editButton = () => ( +
+ setIsEditing(!isEditing)} + type="button" + id="change-edit-password-button" + className="flex items-center gap-2 rounded-sm border border-gray-100 bg-white px-3 py-1.5 text-sm text-[#009D48] shadow-sm hover:bg-gray-50" + shadow={false} + > + + {isEditing ? t("cancel") : t("change_password")} + +
+ ); + + return ( +
+ {editButton()} + {isEditing && renderPasswordForm()} +
+ ); +} diff --git a/src/components/Users/UserSummary.tsx b/src/components/Users/UserSummary.tsx new file mode 100644 index 00000000000..f75aa066e65 --- /dev/null +++ b/src/components/Users/UserSummary.tsx @@ -0,0 +1,236 @@ +import { navigate } from "raviger"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import ButtonV2 from "@/components/Common/ButtonV2"; +import LanguageSelector from "@/components/Common/LanguageSelector"; +import UserColumns from "@/components/Common/UserColumns"; +import UserAvatar from "@/components/Users/UserAvatar"; +import UserDeleteDialog from "@/components/Users/UserDeleteDialog"; +import { + UserBasicInfoView, + UserContactInfoView, + UserProfessionalInfoView, +} from "@/components/Users/UserEditDetails"; +import UserResetPassword from "@/components/Users/UserResetPassword"; +import { + BasicInfoDetails, + ContactInfoDetails, + ProfessionalInfoDetails, +} from "@/components/Users/UserViewDetails"; +import { UserModel } from "@/components/Users/models"; + +import useAuthUser from "@/hooks/useAuthUser"; + +import * as Notification from "@/Utils/Notifications"; +import { + editUserPermissions, + showAvatarEdit, + showUserDelete, + showUserPasswordReset, +} from "@/Utils/permissions"; +import routes from "@/Utils/request/api"; +import request from "@/Utils/request/request"; + +export default function UserSummaryTab({ + userData, + refetchUserData, +}: { + userData?: UserModel; + refetchUserData?: () => void; +}) { + const { t } = useTranslation(); + const [showDeleteDialog, setshowDeleteDialog] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const authUser = useAuthUser(); + + if (!userData) { + return <>; + } + + const handleSubmit = async () => { + setIsDeleting(true); + const { res, error } = await request(routes.deleteUser, { + pathParams: { username: userData.username }, + }); + setIsDeleting(false); + if (res?.status === 204) { + Notification.Success({ + msg: t("user_deleted_successfully"), + }); + setshowDeleteDialog(!showDeleteDialog); + navigate("/users"); + } else { + Notification.Error({ + msg: t("user_delete_error") + ": " + (error || ""), + }); + setshowDeleteDialog(!showDeleteDialog); + } + }; + + const userColumnsData = { + userData, + username: userData.username, + refetchUserData, + }; + const deletePermitted = showUserDelete(authUser, userData); + const passwordResetPermitted = showUserPasswordReset(authUser, userData); + const avatarPermitted = showAvatarEdit(authUser, userData); + const editPermissions = editUserPermissions(authUser, userData); + + const renderBasicInformation = () => { + if (editPermissions) { + return ( + + ); + } + return ( +
+ +
+ ); + }; + + const renderContactInformation = () => { + if (editPermissions) { + return ( + + ); + } + return ( +
+ +
+ ); + }; + + const renderProfessionalInformation = () => { + if (editPermissions) { + return ( + + ); + } + return ( +
+ +
+ ); + }; + + return ( + <> + {showDeleteDialog && ( + { + setshowDeleteDialog(false); + }} + /> + )} +
+ {avatarPermitted && ( + + )} + + + + {passwordResetPermitted && ( + + )} + {authUser.username === userData.username && ( + + )} + {deletePermitted && ( +
+
+
+

{t("delete_account")}

+

{t("delete_account_note")}

+
+
+
+ deletePermitted} + onClick={() => setshowDeleteDialog(true)} + variant="danger" + data-testid="user-delete-button" + className="my-1 inline-flex" + disabled={isDeleting} + > + + {t("delete_account_btn")} + +
+
+ )} +
+ + ); +} diff --git a/src/components/Users/UserViewDetails.tsx b/src/components/Users/UserViewDetails.tsx new file mode 100644 index 00000000000..8b6fb40a1d3 --- /dev/null +++ b/src/components/Users/UserViewDetails.tsx @@ -0,0 +1,155 @@ +import { useTranslation } from "react-i18next"; + +import { UserModel } from "./models"; + +interface UserViewDetailsProps { + user: UserModel; +} + +const LabelValue = ({ + label, + value, + id, +}: { + label: string; + value?: string | null; + id?: string; +}) => ( +
+ {label} + + {value || "-"} + +
+); + +interface BadgeProps { + text: string; + bgColor?: string; + textColor?: string; + className?: string; +} + +export const Badge = ({ + text, + textColor = "text-black", + className = "", +}: BadgeProps) => { + return ( +
+
+ + {text} + +
+ ); +}; + +export const BasicInfoDetails = ({ user }: UserViewDetailsProps) => { + const { t } = useTranslation(); + + return ( +
+ +
+ + + + + + +
+
+ ); +}; + +export const ContactInfoDetails = ({ user }: UserViewDetailsProps) => { + const { t } = useTranslation(); + + return ( +
+ +
+ + + +
+
+ ); +}; + +export const ProfessionalInfoDetails = ({ user }: UserViewDetailsProps) => { + const { t } = useTranslation(); + + return ( +
+ +
+ {(user.user_type === "Doctor" || user.user_type === "Nurse") && ( + + )} + {user.user_type === "Doctor" && ( + <> + + + + )} + + +
+
+ ); +}; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 00000000000..99b8f3bbda4 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,43 @@ +import { type VariantProps, cva } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border border-gray-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2 dark:border-gray-800 dark:focus:ring-gray-300", + { + variants: { + variant: { + default: + "border-transparent bg-gray-900 text-gray-50 shadow hover:bg-gray-900/80 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50/80", + secondary: + "border-transparent bg-gray-100 text-gray-900 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80", + destructive: + "border-transparent bg-red-500 text-gray-50 shadow hover:bg-red-500/80 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/80", + warning: + "border-transparent bg-yellow-400 text-gray-900 shadow hover:bg-yellow-500 dark:bg-yellow-400 dark:text-gray-900 dark:hover:bg-yellow-500", + outline: "text-gray-950 dark:text-gray-50", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 4555a7a97af..0125726ce73 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -14,7 +14,7 @@ const buttonVariants = cva( destructive: "bg-red-500 text-gray-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/90", outline: - "border border-gray-200 bg-white shadow-sm hover:bg-gray-100 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:hover:bg-gray-800 dark:hover:text-gray-50", + "border border-gray-200 text-gray-900 bg-white shadow-sm hover:bg-gray-100 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:hover:bg-gray-800 dark:hover:text-gray-50", primary: "bg-primary-700 text-white shadow hover:bg-primary-700/90 dark:bg-primary-100 dark:text-primary-900 dark:hover:bg-primary-100/90", secondary: diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 00000000000..29703e26108 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,154 @@ +import { type DialogProps } from "@radix-ui/react-dialog"; +import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; +import { Command as CommandPrimitive } from "cmdk"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +import { Dialog, DialogContent } from "@/components/ui/dialog"; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 00000000000..94698f9b5e7 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Cross2Icon } from "@radix-ui/react-icons"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 00000000000..dade8c7d2ee --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export interface InputProps + extends Omit, "onChange"> {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 00000000000..a115d28af1e --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as LabelPrimitive from "@radix-ui/react-label"; +import { type VariantProps, cva } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 00000000000..466e7226e36 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,31 @@ +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000000..d0c4d3ac63b --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,46 @@ +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 00000000000..66ee398f962 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from "react"; + +export default function useDebounce( + callback: (...args: T) => void, + delay: number, +) { + const callbackRef = useRef(callback); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + const debouncedCallback = (...args: T) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + callbackRef.current(...args); + }, delay); + }; + return debouncedCallback; +} diff --git a/src/hooks/useFilters.tsx b/src/hooks/useFilters.tsx index b67474045ef..63ffa58822e 100644 --- a/src/hooks/useFilters.tsx +++ b/src/hooks/useFilters.tsx @@ -34,6 +34,10 @@ export default function useFilters({ const hasPagination = limit > 0; const [showFilters, setShowFilters] = useState(false); const [qParams, _setQueryParams] = useQueryParams(); + const [clearSearch, setClearSearch] = useState<{ + value: boolean; + params?: string[]; + }>({ value: false }); const updateCache = (query: QueryParam) => { const blacklist = FILTERS_CACHE_BLACKLIST.concat(cacheBlacklist); @@ -63,6 +67,7 @@ export default function useFilters({ const updateQuery = (filter: FilterState) => { filter = hasPagination ? { page: 1, limit, ...filter } : filter; setQueryParams(Object.assign({}, qParams, filter), { replace: true }); + setClearSearch({ value: false }); }; const updatePage = (page: number) => { if (!hasPagination) return; @@ -71,6 +76,7 @@ export default function useFilters({ const removeFilters = (params?: string[]) => { params ??= Object.keys(qParams); setQueryParams(removeFromQuery(qParams, params)); + setClearSearch({ value: true, params: params }); }; const removeFilter = (param: string) => removeFilters([param]); @@ -203,7 +209,7 @@ export default function useFilters({ return (
{compiledBadges.map((props) => ( @@ -268,6 +274,7 @@ export default function useFilters({ * @param param is the key of the filter to be removed. */ removeFilter, + clearSearch, /** * Removes multiple filters from query param