diff --git a/.env b/.env index 4928b31e2ba..2c8b73fb65a 100644 --- a/.env +++ b/.env @@ -7,7 +7,7 @@ REACT_APP_COVER_IMAGE_ALT=https://cdn.ohc.network/care_logo.svg REACT_PUBLIC_URL=https://care.ohc.network # Care API URL without the /api prefix -REACT_CARE_API_URL=https://care-api.do.ohc.network +REACT_CARE_API_URL=https://careapi.ohc.network # Dev envs ESLINT_NO_DEV_ERRORS=true diff --git a/care.config.ts b/care.config.ts index f48d842d262..0721552b888 100644 --- a/care.config.ts +++ b/care.config.ts @@ -103,6 +103,14 @@ const careConfig = { }, appointments: { + /** + * Relative number of days to show in the appointments page by default. + * 0 means today, positive for future days, negative for past days. + */ + defaultDateFilter: env.REACT_APPOINTMENTS_DEFAULT_DATE_FILTER + ? parseInt(env.REACT_APPOINTMENTS_DEFAULT_DATE_FILTER) + : 7, + // Kill switch in-case the heatmap API doesn't scale as expected useAvailabilityStatsAPI: boolean( "REACT_APPOINTMENTS_USE_AVAILABILITY_STATS_API", diff --git a/cypress/docs/best-practices.md b/cypress/docs/best-practices.md index f567b8a9ea8..30a746ce8bc 100644 --- a/cypress/docs/best-practices.md +++ b/cypress/docs/best-practices.md @@ -1,32 +1,52 @@ # Best Practices ## Test Independence + - Each test should be independent and isolated - Clean up test data after tests - Don't rely on the state from other tests ## API Testing + - Use cy.intercept() for API verification - Use waitUntil() for API completion - Avoid cy.wait() except for API responses ## Element Interaction + - Always verify element state before interaction - Use data-cy attributes for selectors - Verify button text before clicking +- Always verify loading states before and after interactions ## Code Organization + - Keep tests focused and concise - Follow AAA pattern (Arrange, Act, Assert) - Use meaningful test descriptions ## Common Pitfalls to Avoid + - Redundant visibility checks with verifyAndClickElement - Hardcoded values in page objects - Unnecessary waits - Test interdependencies +- Skipping API verifications +- Using arbitrary timeouts instead of proper waits ## Performance Considerations + - Minimize unnecessary API calls - Use efficient selectors -- Batch similar operations \ No newline at end of file +- Batch similar operations + +## Testing Checklist + +Before submitting your test, verify: + +- [ ] All API calls are intercepted and verified +- [ ] Loading states are handled properly +- [ ] Success/error states are verified +- [ ] No arbitrary timeouts used +- [ ] Search operations include debounce handling +- [ ] Form submissions verify both request and response diff --git a/cypress/docs/file-structure.md b/cypress/docs/file-structure.md index 8edee0d93f6..0526a478bd5 100644 --- a/cypress/docs/file-structure.md +++ b/cypress/docs/file-structure.md @@ -1,6 +1,7 @@ # File Structure and Organization ## Directory Structure + ``` cypress/ ├── docs/ @@ -9,31 +10,42 @@ cypress/ │ ├── patterns.md │ └── best-practices.md ├── e2e/ # Test files grouped by modules -│ ├── patient/ -│ ├── facility/ -│ └── user/ -├── fixtures/ -├── pageObject/ -└── support/ +│ ├── patient/ +│ ├── facility/ +│ └── user/ +├── fixtures/ +├── pageObject/ # Page Objects grouped by modules +│ ├── patient/ +│ ├── facility/ +│ └── user/ +├── utils/ # Utility functions and helpers +│ ├── facilityData.ts # Facility-related utility functions +│ └── commonUtils.ts # Shared utility functions +└── support/ ``` ## Module Organization + Each module (patient, facility, user, etc.) should have: + - Test files in `e2e//` - Page Object in `pageObject//` - Fixtures in `fixtures//` ## File Naming Conventions + - Test files: `feature-name.cy.ts` - Page Object: `FeatureNamePage.ts` - Custom Commands: `feature-name.ts` - Fixtures: `feature-name-data.json` ## Support Files + - `commands.ts`: Custom Cypress commands - `e2e.ts`: e2e configurations - `index.ts`: Main support file ## Storage Management + - Use cy.saveLocalStorage() and cy.restoreLocalStorage() -- Manage test data cleanup \ No newline at end of file +- Manage test data cleanup diff --git a/cypress/e2e/facility_spec/facility_creation.cy.ts b/cypress/e2e/facility_spec/facility_creation.cy.ts index 15288719ba8..670af8993d6 100644 --- a/cypress/e2e/facility_spec/facility_creation.cy.ts +++ b/cypress/e2e/facility_spec/facility_creation.cy.ts @@ -45,6 +45,9 @@ describe("Facility Management", () => { facilityPage.submitFacilityCreationForm(); facilityPage.verifySuccessMessage(); + // Wait for facility cards to load + facilityPage.waitForFacilityCardsToLoad(); + // Search for the facility and verify in card facilityPage.searchFacility(testFacility.name); facilityPage.verifyFacilityNameInCard(testFacility.name); diff --git a/cypress/pageObject/facility/FacilityCreation.ts b/cypress/pageObject/facility/FacilityCreation.ts index 2762f29fab8..0ef7cb918e8 100644 --- a/cypress/pageObject/facility/FacilityCreation.ts +++ b/cypress/pageObject/facility/FacilityCreation.ts @@ -95,10 +95,22 @@ export class FacilityCreation { } searchFacility(facilityName: string) { - cy.typeIntoField('[data-cy="search-facility"]', facilityName); + cy.intercept("GET", `**/api/v1/facility/?**`).as("searchFacility"); + + cy.get('[data-cy="search-facility"]') + .focus() + .type(facilityName, { force: true }); + + cy.wait("@searchFacility").its("response.statusCode").should("eq", 200); } verifyFacilityNameInCard(facilityName: string) { cy.get('[data-cy="facility-cards"]').should("contain", facilityName); } + + waitForFacilityCardsToLoad(timeout = 10000) { + cy.get('[data-cy="facility-cards"]', { timeout }) + .should("be.visible") + .should("not.be.empty"); + } } diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index c8c0dbbd3d8..540e1571228 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -220,20 +220,32 @@ Cypress.Commands.add( ( selector: string, value: string, - options: { clearBeforeTyping?: boolean; skipVerification?: boolean } = {}, + options: { + clearBeforeTyping?: boolean; + skipVerification?: boolean; + delay?: number; + } = {}, ) => { - const { clearBeforeTyping = false, skipVerification = false } = options; + const { + clearBeforeTyping = false, + skipVerification = false, + delay = 0, + } = options; const inputField = cy.get(selector); if (clearBeforeTyping) { - inputField.clear(); // Clear the input field if specified + inputField.clear(); } - inputField.scrollIntoView().should("be.visible").click().type(value); - - // Conditionally skip verification based on the skipVerification flag - if (!skipVerification) { - inputField.should("have.value", value); // Verify the value if skipVerification is false - } + inputField + .scrollIntoView() + .should("be.visible") + .click() + .type(value, { delay }) + .then(() => { + if (!skipVerification) { + cy.get(selector).should("have.value", value); + } + }); }, ); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index cfa6266439c..da4fd19dfad 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -48,7 +48,11 @@ declare global { typeIntoField( selector: string, value: string, - options?: { clearBeforeTyping?: boolean; skipVerification?: boolean }, + options?: { + clearBeforeTyping?: boolean; + skipVerification?: boolean; + delay?: number; + }, ): Chainable; } } diff --git a/package-lock.json b/package-lock.json index 69b50fa1713..d6b9aa5aca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,10 +38,9 @@ "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-tooltip": "^1.1.6", - "@rollup/rollup-linux-x64-gnu": "4.30.1", "@sentry/browser": "^8.48.0", - "@tanstack/react-query": "^5.62.8", - "@tanstack/react-query-devtools": "^5.63.0", + "@tanstack/react-query": "^5.64.0", + "@tanstack/react-query-devtools": "^5.64.0", "@vitejs/plugin-react": "^4.3.4", "@yudiel/react-qr-scanner": "^2.1.0", "bowser": "^2.11.0", @@ -6290,9 +6289,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.62.16", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.16.tgz", - "integrity": "sha512-9Sgft7Qavcd+sN0V25xVyo0nfmcZXBuODy3FVG7BMWTg1HMLm8wwG5tNlLlmSic1u7l1v786oavn+STiFaPH2g==", + "version": "5.64.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.64.0.tgz", + "integrity": "sha512-/MPJt/AaaMzdWJZTafgMyYhEX/lGjQrNz8+NDQSk8fNoU5PHqh05FhQaBrEQafW2PeBHsRbefEf//qKMiSAbQQ==", "license": "MIT", "funding": { "type": "github", @@ -6310,12 +6309,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.63.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.63.0.tgz", - "integrity": "sha512-QWizLzSiog8xqIRYmuJRok9VELlXVBAwtINgVCgW1SNvamQwWDO5R0XFSkjoBEj53x9Of1KAthLRBUC5xmtVLQ==", + "version": "5.64.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.64.0.tgz", + "integrity": "sha512-tBMzlROROUcTDMpDt1NC3n9ndKnJHPB3RCpa6Bf9f31TFvqhLz879x8jldtKU+6IwMSw1Pn4K1AKA+2SYyA6TA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.62.16" + "@tanstack/query-core": "5.64.0" }, "funding": { "type": "github", @@ -6326,9 +6325,9 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.63.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.63.0.tgz", - "integrity": "sha512-j3+22r6srSJVy8oiLUpOOupI4g7IHwbISeEGM+5ASIzzOnVUUSsY6e4nu5pxxj7ODJbiag3GpkHU/otG9B9sAA==", + "version": "5.64.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.64.0.tgz", + "integrity": "sha512-XORJjlbcBwPJaNbWBfZudaVVMi5TtlN1lYkHYU71hlG2c/jYpceO2yfAhZfgeyTNtqmTJ7jXOitgoGqtunsBAA==", "license": "MIT", "dependencies": { "@tanstack/query-devtools": "5.62.16" @@ -6338,7 +6337,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.63.0", + "@tanstack/react-query": "^5.64.0", "react": "^18 || ^19" } }, diff --git a/package.json b/package.json index a38a3053753..7b3c3a33f78 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,8 @@ "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-tooltip": "^1.1.6", "@sentry/browser": "^8.48.0", - "@tanstack/react-query": "^5.62.8", - "@tanstack/react-query-devtools": "^5.63.0", + "@tanstack/react-query": "^5.64.0", + "@tanstack/react-query-devtools": "^5.64.0", "@vitejs/plugin-react": "^4.3.4", "@yudiel/react-qr-scanner": "^2.1.0", "bowser": "^2.11.0", diff --git a/public/locale/en.json b/public/locale/en.json index 57c951c3f81..4d005dc8151 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -349,6 +349,7 @@ "additional_information": "Additional Information", "additional_instructions": "Additional Instructions", "address": "Address", + "address_is_required": "Address is required", "administer": "Administer", "administer_medicine": "Administer Medicine", "administer_medicines": "Administer Medicines", @@ -364,6 +365,9 @@ "age_input_warning": "While entering a patient's age is an option, please note that only the year of birth will be captured from this information.", "age_input_warning_bold": "Recommended only when the patient's date of birth is unknown", "age_less_than_0": "Age cannot be less than 0", + "age_must_be_below_120": "Age must be below 120", + "age_must_be_positive": "Age must be greater than 0", + "age_must_be_present": "Age must be present", "age_notice": "Only year of birth will be stored for age.", "ago": "ago", "all": "All", @@ -461,6 +465,7 @@ "back_to_login": "Back to login", "base_dosage": "Dosage", "basic_info": "Basic Information", + "basic_information": "Basic Information", "bed_capacity": "Bed Capacity", "bed_created_notification_one": "{{count}} Bed created successfully", "bed_created_notification_other": "{{count}} Beds created successfully", @@ -477,6 +482,7 @@ "beta": "beta", "bladder": "Bladder", "blood_group": "Blood Group", + "blood_group_is_required": "Blood group is required", "blood_pressure_error": { "missing": "Field is required. Either specify both or clear both.", "exceed": "Value cannot exceed 250 mmHg.", @@ -507,6 +513,7 @@ "card": "Card", "care": "CARE", "category": "Category", + "category_description": "Choose the category that best describes the resource needed.", "caution": "Caution", "central_nursing_station": "Central Nursing Station", "change_avatar": "Change Avatar", @@ -634,11 +641,15 @@ "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_information": "Contact Information", + "contact_information_description": "Provide contact details for follow-up communication.", "contact_number": "Contact Number", "contact_person": "Name of Contact Person at Facility", "contact_person_at_the_facility": "Contact person at the current facility", + "contact_person_description": "Name of the person to contact regarding this request.", "contact_person_number": "Contact person number", "contact_phone": "Contact Person Number", + "contact_phone_description": "Phone number to reach the contact person.", "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", @@ -696,6 +707,8 @@ "date_of_admission": "Date of Admission", "date_of_birth": "Date of Birth", "date_of_birth_age": "Date of Birth/Age", + "date_of_birth_format": "Date of birth must be in YYYY-MM-DD format", + "date_of_birth_must_be_present": "Date of birth must be present", "date_of_birth_or_age": "Date of Birth or Age", "date_of_positive_covid_19_swab": "Date of Positive Covid 19 Swab", "date_of_result": "Covid confirmation date", @@ -816,6 +829,7 @@ "emergency_contact_person_name_details": "Emergency contact person (Father, Mother, Spouse, Sibling, Friend)", "emergency_contact_person_name_volunteer": "Emergency Contact Person Name (Volunteer)", "emergency_contact_volunteer": "Emergency Contact (Volunteer)", + "emergency_description": "Mark as emergency if immediate attention is required.", "emergency_phone_number": "Emergency Phone Number", "empty_date_time": "--:-- --; --/--/----", "encounter": "Encounter", @@ -967,6 +981,7 @@ "facility_consent_requests_page_title": "Patient Consent List", "facility_district_name": "Facility/District Name", "facility_district_pincode": "Facility/District/Pincode", + "facility_for_care_support": "Facility for Care Support", "facility_linked_success": "Facility linked successfully", "facility_name": "Facility Name", "facility_not_found": "Facility Not Found", @@ -1007,10 +1022,15 @@ "file_preview": "File Preview", "file_preview_not_supported": "Can't preview this file. Try downloading it.", "file_type": "File Type", + "file_upload_error": "Error uploading file", + "file_upload_success": "File uploaded successfully", "file_uploaded": "File Uploaded Successfully", + "files": "Files", + "fill_my_details": "Fill My Details", "filter": "Filter", "filter_by": "Filter By", "filter_by_category": "Filter by category", + "filter_by_date": "Filter by Date", "filters": "Filters", "first_name": "First Name", "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.", @@ -1024,12 +1044,15 @@ "full_name": "Full Name", "full_screen": "Full Screen", "gender": "Gender", + "gender_is_required": "Gender is required", "general_info_detail": "Provide the patient's personal details, including name, date of birth, gender, and contact information for accurate identification and communication.", "generate_link_abha": "Generate/Link ABHA Number", "generate_report": "Generate Report", "generated_summary_caution": "This is a computer generated summary using the information captured in the CARE system.", "generating": "Generating", "generating_discharge_summary": "Generating discharge summary", + "geo_organization_is_required": "Geo organization is required when nationality is India", + "geo_organization_required": "Geo organization is required", "geolocation_is_not_supported_by_this_browser": "Geolocation is not supported by this browser", "get_auth_methods": "Get Available Authentication Methods", "get_auth_mode_error": "Could not find any supported authentication methods, Please try again with a different authentication method", @@ -1144,7 +1167,7 @@ "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_emergency": "Is this an Emergency?", "is_this_an_emergency_request": "Is this an emergency request?", "is_this_an_upshift": "Is this an upshift?", "is_unusual_course": "Is unusual course", @@ -1161,7 +1184,6 @@ "last_administered": "Last administered", "last_discharge_reason": "Last Discharge Reason", "last_edited": "Last Edited", - "last_fortnight_short": "Last 2wk", "last_login": "Last Login", "last_modified": "Last Modified", "last_modified_by": "Last Modified By", @@ -1181,6 +1203,7 @@ "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_patient": "Linked Patient", "linked_patient_details": "Linked Patient Details", "linked_skills": "Linked Skills", "linked_skills_note": "Search and select skills to add to the skill set", @@ -1277,9 +1300,11 @@ "my_profile": "My Profile", "my_schedules": "My Schedules", "name": "Name", + "name_is_required": "Name is required", "name_of_hospital": "Name of Hospital", "name_of_shifting_approving_facility": "Name of shifting approving facility", "nationality": "Nationality", + "nationality_is_required": "Nationality is required", "nearby_facilities": "Nearby Facilities", "network_failure": "Network Failure. Please check your internet connectivity.", "never": "never", @@ -1290,7 +1315,7 @@ "new_password_same_as_old": "Your new password must not match the old password.", "new_password_validation": "New password is not valid.", "new_session": "New Session", - "next_fortnight_short": "Next 2wk", + "next_month": "Next month", "next_sessions": "Next Sessions", "next_week_short": "Next wk", "no": "No", @@ -1416,7 +1441,7 @@ "pain_chart_description": "Mark region and intensity of pain", "passport_number": "Passport Number", "password": "Password", - "password_length_met": "It’s at least 8 characters long", + "password_length_met": "It's at least 8 characters long", "password_length_validation": "Use at least 8 characters", "password_lowercase_met": "It includes at least one lowercase letter", "password_lowercase_validation": "Include at least one lowercase letter", @@ -1489,6 +1514,7 @@ "patients": "Patients", "patients_per_slot": "Patients per Slot", "pending": "Pending", + "permanant_address_is_required": "Permanant address is required", "permanent_address": "Permanent Address", "permission_denied": "You do not have permission to perform this action", "personal_information": "Personal Information", @@ -1500,11 +1526,13 @@ "phone_number": "Phone Number", "phone_number_at_current_facility": "Phone Number of Contact person at current Facility", "phone_number_min_error": "Phone number must be at least 10 characters long", + "phone_number_must_be_10_digits": "Phone number must be a 10-digit mobile number", "phone_number_not_found": "Phone number not found", "phone_number_validation": "Phone number must start with +91 followed by 10 digits", "phone_number_verified": "Phone Number Verified", "pincode": "Pincode", "pincode_autofill": "State and District auto-filled from Pincode", + "pincode_must_be_6_digits": "Pincode must be a 6-digit number", "play": "Play", "play_audio": "Play Audio", "please_assign_bed_to_patient": "Please assign a bed to this patient", @@ -1517,6 +1545,7 @@ "please_enter_username": "Please enter the username", "please_fix_errors": "Please fix the errors in the highlighted fields and try submitting again.", "please_select_a_facility": "Please select a facility", + "please_select_blood_group": "Please select the blood group", "please_select_breathlessness_level": "Please select Breathlessness Level", "please_select_district": "Please select the district", "please_select_facility_type": "Please select Facility Type", @@ -1647,14 +1676,17 @@ "request": "Request", "request-sample-test": "Service Request", "request_consent": "Request Consent", + "request_details": "Request Details", "request_for": "Request for", "request_letter": "Request Letter", "request_reason": "Reason of Request", + "request_reason_description": "Provide a detailed explanation of why this resource is needed.", "request_reason_placeholder": "Type your description here", "request_sample_test": "Request Sample Test", "request_the_following_resource": "This is to request the following resource", "request_title": "Request Title", - "request_title_placeholder": "Type your title here", + "request_title_description": "A brief title that describes what resource is needed.", + "request_title_placeholder": "Enter a clear, concise title for your request", "request_updated_successfully": "Request updated successfully", "requested_by": "Requested By", "required": "Required", @@ -1671,6 +1703,8 @@ "resource_details": "Resource details", "resource_origin_facility": "Origin Facility", "resource_request": "Request", + "resource_request_basic_info_description": "Provide the basic details about the resource request including the facility and urgency.", + "resource_request_details_description": "Provide detailed information about what resource is needed and why.", "resource_requests": "Requests", "resource_status": "Request Status", "resource_type": "Request Type", @@ -1757,11 +1791,13 @@ "select": "Select", "select_additional_instructions": "Select additional instructions", "select_all": "Select All", + "select_category": "Select a category", "select_date": "Select date", "select_department": "Select Department", "select_diff_role": "Please select a different role", "select_eligible_policy": "Select an Eligible Insurance Policy", "select_facility": "Select Facility", + "select_facility_description": "Select the healthcare facility that will provide the requested resource.", "select_facility_for_discharged_patients_warning": "Facility needs to be selected to view discharged patients.", "select_for_administration": "Select for Administration", "select_frequency": "Select frequency", @@ -2116,6 +2152,8 @@ "working_status": "Working Status", "year": "Year", "year_of_birth": "Year of Birth", + "year_of_birth_format": "Year of birth must be in YYYY format", + "year_of_birth_must_be_present": "Year of birth must be present", "years": "years", "years_of_experience": "Years of Experience", "years_of_experience_of_the_doctor": "Years of Experience of the Doctor", diff --git a/scripts/setup-care-apps.ts b/scripts/setup-care-apps.ts index f03c07f6cad..714d12f5a5a 100644 --- a/scripts/setup-care-apps.ts +++ b/scripts/setup-care-apps.ts @@ -92,6 +92,7 @@ const pluginMapContent = `// Use type assertion for the static import\n${plugins `// @ts-expect-error Remote module will be available at runtime\nimport ${plugin.camelCaseName}Manifest from "${plugin.repo}/manifest";`, ) .join("\n")} + import type { PluginManifest } from "./pluginTypes"; const pluginMap: PluginManifest[] = [${plugins.map((plugin) => `${plugin.camelCaseName}Manifest as PluginManifest`).join(",\n ")}]; diff --git a/src/Utils/request/api.tsx b/src/Utils/request/api.tsx index 88468e2cf79..eafc48e3c81 100644 --- a/src/Utils/request/api.tsx +++ b/src/Utils/request/api.tsx @@ -186,11 +186,6 @@ const routes = { TRes: Type>(), }, - getAllFacilities: { - path: "/api/v1/getallfacilities/", - TRes: Type>(), - }, - createFacility: { path: "/api/v1/facility/", method: "POST", diff --git a/src/components/Common/FacilitySelect.tsx b/src/components/Common/FacilitySelect.tsx index 663fa6d70d8..74eb8ac4231 100644 --- a/src/components/Common/FacilitySelect.tsx +++ b/src/components/Common/FacilitySelect.tsx @@ -4,8 +4,8 @@ import { useCallback } from "react"; import { FacilityModel } from "@/components/Facility/models"; import AutoCompleteAsync from "@/components/Form/AutoCompleteAsync"; -import routes from "@/Utils/request/api"; import request from "@/Utils/request/request"; +import facilityApi from "@/types/facility/facilityApi"; interface BaseFacilitySelectProps { name: string; @@ -57,8 +57,6 @@ export const FacilitySelect = ({ showNOptions, className = "", facilityType, - district, - state, allowNone = false, freeText = false, errors = "", @@ -75,19 +73,9 @@ export const FacilitySelect = ({ all: searchAll, facility_type: facilityType, exclude_user: exclude_user, - district, - state, }; - const { data } = await request( - showAll ? routes.getAllFacilities : routes.getPermittedFacilities, - { query }, - ); - - if (freeText) - data?.results?.push({ - name: text, - }); + const { data } = await request(facilityApi.getAllFacilities, { query }); if (allowNone) return [ @@ -97,7 +85,7 @@ export const FacilitySelect = ({ return data?.results; }, - [searchAll, showAll, facilityType, district, exclude_user, freeText], + [searchAll, showAll, facilityType, exclude_user, freeText], ); return ( diff --git a/src/components/Facility/ConsultationDetails/EncounterContext.tsx b/src/components/Facility/ConsultationDetails/EncounterContext.tsx index e0ba1d6228a..07d76a7f538 100644 --- a/src/components/Facility/ConsultationDetails/EncounterContext.tsx +++ b/src/components/Facility/ConsultationDetails/EncounterContext.tsx @@ -1,6 +1,5 @@ import { ReactNode, createContext, useContext, useState } from "react"; -import { PLUGIN_Component } from "@/PluginEngine"; import { Encounter } from "@/types/emr/encounter"; import { Patient } from "@/types/emr/newPatient"; @@ -65,7 +64,6 @@ export const EncounterProvider = ({ } as EncounterContextType } > - {children} ); diff --git a/src/components/Patient/PatientHome.tsx b/src/components/Patient/PatientHome.tsx index 1d4f942f900..f41137221fe 100644 --- a/src/components/Patient/PatientHome.tsx +++ b/src/components/Patient/PatientHome.tsx @@ -11,6 +11,7 @@ import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; import { patientTabs } from "@/components/Patient/PatientDetailsTab"; +import { PLUGIN_Component } from "@/PluginEngine"; import routes from "@/Utils/request/api"; import query from "@/Utils/request/query"; import { formatDateTime, formatPatientAge, relativeDate } from "@/Utils/utils"; @@ -134,8 +135,8 @@ export const PatientHome = (props: { {t("actions")}
-
-
+
+
+ +
diff --git a/src/components/Patient/PatientInfoCard.tsx b/src/components/Patient/PatientInfoCard.tsx index 2c716095400..eeca3049a45 100644 --- a/src/components/Patient/PatientInfoCard.tsx +++ b/src/components/Patient/PatientInfoCard.tsx @@ -30,6 +30,7 @@ import { import { Avatar } from "@/components/Common/Avatar"; +import { PLUGIN_Component } from "@/PluginEngine"; import routes from "@/Utils/request/api"; import mutate from "@/Utils/request/mutate"; import { formatDateTime, formatPatientAge } from "@/Utils/utils"; @@ -342,6 +343,10 @@ export default function PatientInfoCard(props: PatientInfoCardProps) { {t("mark_as_complete")} +
diff --git a/src/components/Patient/PatientRegistration.tsx b/src/components/Patient/PatientRegistration.tsx index 36ac706c23b..cdecd959e5a 100644 --- a/src/components/Patient/PatientRegistration.tsx +++ b/src/components/Patient/PatientRegistration.tsx @@ -1,16 +1,26 @@ -import careConfig from "@careConfig"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery } from "@tanstack/react-query"; import { navigate, useQueryParams } from "raviger"; -import { Fragment, useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; +import { z } from "zod"; import SectionNavigator from "@/CAREUI/misc/SectionNavigator"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Select, @@ -34,18 +44,15 @@ import { //RATION_CARD_CATEGORY, // SOCIOECONOMIC_STATUS_CHOICES , } from "@/common/constants"; import countryList from "@/common/static/countries.json"; -import { validatePincode } from "@/common/validation"; +import { PLUGIN_Component } from "@/PluginEngine"; import routes from "@/Utils/request/api"; import mutate from "@/Utils/request/mutate"; import query from "@/Utils/request/query"; -import { - dateQueryString, - getPincodeDetails, - parsePhoneNumber, -} from "@/Utils/utils"; +import { parsePhoneNumber } from "@/Utils/utils"; import OrganizationSelector from "@/pages/Organization/components/OrganizationSelector"; -import { PatientModel, validatePatient } from "@/types/emr/patient"; +import { PatientModel } from "@/types/emr/patient"; +import { Organization } from "@/types/organization/organization"; import Autocomplete from "../ui/autocomplete"; @@ -54,6 +61,14 @@ interface PatientRegistrationPageProps { patientId?: string; } +export const GENDERS = GENDER_TYPES.map((gender) => gender.id) as [ + (typeof GENDER_TYPES)[number]["id"], +]; + +export const BLOOD_GROUPS = BLOOD_GROUP_CHOICES.map((bg) => bg.id) as [ + (typeof BLOOD_GROUP_CHOICES)[number]["id"], +]; + export default function PatientRegistration( props: PatientRegistrationPageProps, ) { @@ -62,65 +77,88 @@ export default function PatientRegistration( const { t } = useTranslation(); const { goBack } = useAppHistory(); - const [samePhoneNumber, setSamePhoneNumber] = useState(false); - const [sameAddress, setSameAddress] = useState(true); - const [ageDob, setAgeDob] = useState<"dob" | "age">("dob"); - const [_showAutoFilledPincode, setShowAutoFilledPincode] = useState(false); - const [form, setForm] = useState>({ - nationality: "India", - phone_number: phone_number || "+91", - emergency_phone_number: "+91", - }); - const [feErrors, setFeErrors] = useState< - Partial> - >({}); const [suppressDuplicateWarning, setSuppressDuplicateWarning] = useState(!!patientId); const [debouncedNumber, setDebouncedNumber] = useState(); - const sidebarItems = [ - { label: t("patient__general-info"), id: "general-info" }, - // { label: t("social_profile"), id: "social-profile" }, - //{ label: t("volunteer_contact"), id: "volunteer-contact" }, - //{ label: t("patient__insurance-details"), id: "insurance-details" }, - ]; - - const mutationFields: (keyof PatientModel)[] = [ - "name", - "phone_number", - "emergency_phone_number", - "geo_organization", - "gender", - "blood_group", - "date_of_birth", - "age", - "address", - "permanent_address", - "pincode", - "nationality", - "meta_info", - "ration_card_category", - ]; + const formSchema = useMemo( + () => + z + .object({ + name: z.string().nonempty(t("name_is_required")), + phone_number: z + .string() + .regex(/^\+\d{12}$/, t("phone_number_must_be_10_digits")), + same_phone_number: z.boolean(), + emergency_phone_number: z + .string() + .regex(/^\+\d{12}$/, t("phone_number_must_be_10_digits")), + gender: z.enum(GENDERS, { required_error: t("gender_is_required") }), + blood_group: z.enum(BLOOD_GROUPS, { + required_error: t("blood_group_is_required"), + }), + age_or_dob: z.enum(["dob", "age"]), + date_of_birth: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, t("date_of_birth_format")) + .optional(), + age: z + .number() + .int() + .positive() + .min(1, t("age_must_be_positive")) + .max(120, t("age_must_be_below_120")) + .optional(), + address: z.string().nonempty(t("address_is_required")), + same_address: z.boolean(), + permanent_address: z + .string() + .nonempty(t("permanent_address_is_required")), + pincode: z + .number() + .int() + .positive() + .min(100000, t("pincode_must_be_6_digits")) + .max(999999, t("pincode_must_be_6_digits")), + nationality: z.string().nonempty(t("nationality_is_required")), + geo_organization: z.string().uuid().optional(), + }) + .refine( + (data) => (data.age_or_dob === "dob" ? !!data.date_of_birth : true), + { + message: t("date_of_birth_must_be_present"), + path: ["date_of_birth"], + }, + ) + .refine((data) => (data.age_or_dob === "age" ? !!data.age : true), { + message: t("age_must_be_present"), + path: ["age"], + }) + .refine( + (data) => + data.nationality === "India" ? !!data.geo_organization : true, + { + message: t("geo_organization_required"), + path: ["geo_organization"], + }, + ), + [], // eslint-disable-line react-hooks/exhaustive-deps + ); - const mutationData: Partial = { - ...Object.fromEntries( - Object.entries(form).filter(([key]) => - mutationFields.includes(key as keyof PatientModel), - ), - ), - date_of_birth: - ageDob === "dob" ? dateQueryString(form.date_of_birth) : undefined, - age: ageDob === "age" ? form.age : undefined, - meta_info: { - ...(form.meta_info as any), - occupation: - form.meta_info?.occupation === "" - ? undefined - : form.meta_info?.occupation, + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + nationality: "India", + phone_number: phone_number || "+91", + emergency_phone_number: "+91", + age_or_dob: "dob", + same_phone_number: false, + same_address: true, }, - }; + }); - const createPatientMutation = useMutation({ + const { mutate: createPatient, isPending: isCreatingPatient } = useMutation({ + mutationKey: ["create_patient"], mutationFn: mutate(routes.addPatient), onSuccess: (resp: PatientModel) => { toast.success(t("patient_registration_success")); @@ -129,7 +167,7 @@ export default function PatientRegistration( query: { phone_number: resp.phone_number, year_of_birth: - ageDob === "dob" + form.getValues("age_or_dob") === "dob" ? new Date(resp.date_of_birth!).getFullYear() : new Date().getFullYear() - Number(resp.age!), partial_id: resp?.id?.slice(0, 5), @@ -141,7 +179,7 @@ export default function PatientRegistration( }, }); - const updatePatientMutation = useMutation({ + const { mutate: updatePatient, isPending: isUpdatingPatient } = useMutation({ mutationFn: mutate(routes.updatePatient, { pathParams: { id: patientId || "" }, }), @@ -154,86 +192,32 @@ export default function PatientRegistration( }, }); - const patientQuery = useQuery({ - queryKey: ["patient", patientId], - queryFn: query(routes.getPatient, { - pathParams: { id: patientId || "" }, - }), - enabled: !!patientId, - }); - - useEffect(() => { - if (patientQuery.data) { - setForm(patientQuery.data); - if (patientQuery.data.year_of_birth && !patientQuery.data.date_of_birth) { - setAgeDob("age"); - } - if ( - patientQuery.data.phone_number === - patientQuery.data.emergency_phone_number - ) - setSamePhoneNumber(true); - if (patientQuery.data.address === patientQuery.data.permanent_address) - setSameAddress(true); + function onSubmit(values: z.infer) { + if (patientId) { + updatePatient({ ...values, ward_old: undefined }); + return; } - }, [patientQuery.data]); - - const handlePincodeChange = async (value: string) => { - if (!validatePincode(value)) return; - if (form.state && form.district) return; - - const pincodeDetails = await getPincodeDetails( - value, - careConfig.govDataApiKey, - ); - if (!pincodeDetails) return; - - const { statename: _stateName, districtname: _districtName } = - pincodeDetails; - setShowAutoFilledPincode(true); - setTimeout(() => { - setShowAutoFilledPincode(false); - }, 2000); - }; + createPatient({ + ...values, + facility: facilityId, + ward_old: undefined, + }); + } - useEffect(() => { - const timeout = setTimeout( - () => handlePincodeChange(form.pincode?.toString() || ""), - 1000, - ); - return () => clearTimeout(timeout); - }, [form.pincode]); + const sidebarItems = [ + { label: t("patient__general-info"), id: "general-info" }, + ]; const title = !patientId ? t("add_details_of_patient") : t("update_patient_details"); - const errors = { - ...feErrors, - ...(createPatientMutation.error as unknown as string[]), - }; - - const fieldProps = (field: keyof typeof form) => ({ - value: form[field] as string, - onChange: (e: React.ChangeEvent) => - setForm((f) => ({ - ...f, - [field]: e.target.value === "" ? undefined : e.target.value, - })), - }); - - const selectProps = (field: keyof typeof form) => ({ - value: (form[field] as string)?.toString(), - onValueChange: (value: string) => - setForm((f) => ({ ...f, [field]: value })), - }); - const handleDialogClose = (action: string) => { if (action === "transfer") { navigate(`/facility/${facilityId}/patients`, { query: { - phone_number: form.phone_number, + phone_number: form.getValues("phone_number"), }, }); } else { @@ -241,53 +225,59 @@ export default function PatientRegistration( } }; - const handleFormSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const validate = validatePatient(form, ageDob === "dob"); - if (typeof validate !== "object") { - patientId - ? updatePatientMutation.mutate({ ...mutationData, ward_old: undefined }) - : createPatientMutation.mutate({ - ...mutationData, - facility: facilityId, - ward_old: undefined, - }); - } else { - const firstErrorField = document.querySelector("[data-input-error]"); - if (firstErrorField) { - firstErrorField.scrollIntoView({ behavior: "smooth", block: "center" }); - } - toast.error(t("please_fix_errors")); - setFeErrors(validate); + const patientPhoneSearch = useQuery({ + queryKey: ["patients", "phone-number", debouncedNumber], + queryFn: query(routes.searchPatient, { + body: { + phone_number: parsePhoneNumber(debouncedNumber || "") || "", + }, + }), + enabled: !!parsePhoneNumber(debouncedNumber || ""), + }); + + const duplicatePatients = useMemo(() => { + return patientPhoneSearch.data?.results.filter((p) => p.id !== patientId); + }, [patientPhoneSearch.data, patientId]); + + const patientQuery = useQuery({ + queryKey: ["patient", patientId], + queryFn: query(routes.getPatient, { + pathParams: { id: patientId || "" }, + }), + enabled: !!patientId, + }); + + useEffect(() => { + if (patientQuery.data) { + form.reset({ + ...patientQuery.data, + same_phone_number: + patientQuery.data.phone_number === + patientQuery.data.emergency_phone_number, + same_address: + patientQuery.data.address === patientQuery.data.permanent_address, + age_or_dob: patientQuery.data.date_of_birth ? "dob" : "age", + geo_organization: ( + patientQuery.data.geo_organization as unknown as Organization + )?.id, + } as unknown as z.infer); } - }; + }, [patientQuery.data]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const handler = setTimeout(() => { - if (!patientId || patientQuery.data?.phone_number !== form.phone_number) { + const phoneNumber = form.getValues("phone_number"); + if (!patientId || patientQuery.data?.phone_number !== phoneNumber) { setSuppressDuplicateWarning(false); } - setDebouncedNumber(form.phone_number); + setDebouncedNumber(phoneNumber); }, 500); return () => { clearTimeout(handler); }; - }, [form.phone_number]); - - const patientPhoneSearch = useQuery({ - queryKey: ["patients", "phone-number", debouncedNumber], - queryFn: query(routes.searchPatient, { - body: { - phone_number: parsePhoneNumber(debouncedNumber || "") || "", - }, - }), - enabled: !!parsePhoneNumber(debouncedNumber || ""), - }); + }, [form.watch("phone_number")]); // eslint-disable-line react-hooks/exhaustive-deps - const duplicatePatients = patientPhoneSearch.data?.results.filter( - (p) => p.id !== patientId, - ); if (patientId && patientQuery.isLoading) { return ; } @@ -297,558 +287,473 @@ export default function PatientRegistration(
-
-
-

- {t("patient__general-info")} -

-
{t("general_info_detail")}
-
- - -
- {errors["name"] && - errors["name"].map((error, i) => ( -
- {error} -
- ))} -
-
- - { - if (e.target.value.length > 13) return; - setForm((f) => ({ - ...f, - phone_number: e.target.value, - emergency_phone_number: samePhoneNumber - ? e.target.value - : f.emergency_phone_number, - })); - }} + + + + -
- {errors["phone_number"] && - errors["phone_number"]?.map((error, i) => ( -
- {error} -
- ))} -
-
- { - const newValue = !samePhoneNumber; - setSamePhoneNumber(newValue); - if (newValue) { - setForm((f) => ({ - ...f, - emergency_phone_number: f.phone_number, - })); - } - }} - id="same-phone-number" +
+
+

+ {t("patient__general-info")} +

+
{t("general_info_detail")}
+
+ + ( + + {t("name")} + + + + + + )} /> - -
-
- - - { - if (e.target.value.length > 13) return; - setForm((f) => ({ - ...f, - emergency_phone_number: e.target.value, - })); - }} - disabled={samePhoneNumber} - /> -
- {errors["emergency_phone_number"] && - errors["emergency_phone_number"]?.map((error, i) => ( -
- {error} -
- ))} -
- {/*
- */} -
- - - - setForm((f) => ({ ...f, gender: value })) - } - className="flex items-center gap-4" - > - {GENDER_TYPES.map((g) => ( - - - - - ))} - -
- {errors["gender"] && - errors["gender"]?.map((error, i) => ( -
- {error} -
- ))} -
-
- - - -
- {errors["blood_group"] && - errors["blood_group"]?.map((error, i) => ( -
- {error} -
- ))} -
+ ( + + {t("phone_number")} + + { + form.setValue("phone_number", e.target.value); + if (form.watch("same_phone_number")) { + form.setValue( + "emergency_phone_number", + e.target.value, + ); + } + }} + data-cy="patient-phone-input" + /> + + + ( + + + { + field.onChange(v); + if (v) { + form.setValue( + "emergency_phone_number", + form.watch("phone_number"), + ); + } + }} + data-cy="same-phone-number-checkbox" + /> + + + {t("use_phone_number_for_emergency")} + + + )} + /> + + + + )} + /> -
- - setAgeDob(value as typeof ageDob) - } - > - - {[ - ["dob", t("date_of_birth")], - ["age", t("age")], - ].map(([key, label]) => ( - {label} - ))} - - -
-
- - - setForm((f) => ({ - ...f, - date_of_birth: `${form.date_of_birth?.split("-")[0] || ""}-${form.date_of_birth?.split("-")[1] || ""}-${e.target.value}`, - })) - } - /> -
-
- - - setForm((f) => ({ - ...f, - date_of_birth: `${form.date_of_birth?.split("-")[0] || ""}-${e.target.value}-${form.date_of_birth?.split("-")[2] || ""}`, - })) - } - /> -
-
- - - setForm((f) => ({ - ...f, - date_of_birth: `${e.target.value}-${form.date_of_birth?.split("-")[1] || ""}-${form.date_of_birth?.split("-")[2] || ""}`, - })) - } - /> -
-
- {errors["date_of_birth"] && ( -
- {errors["date_of_birth"].map((error, i) => ( -
- {error} -
- ))} -
+ ( + + + {t("emergency_phone_number")} + + + + + + + )} + /> + + ( + + {t("sex")} + + + {GENDER_TYPES.map((g) => ( + + + + + + {t(`GENDER__${g.id}`)} + + + ))} + + + + + )} + /> + + ( + + {t("blood_group")} + + + )} -
- -
- {t("age_input_warning")} -
- {t("age_input_warning_bold")} -
-
- - - setForm((f) => ({ - ...f, - age: e.target.value, - year_of_birth: e.target.value - ? new Date().getFullYear() - Number(e.target.value) - : undefined, - })) - } - type="number" + /> + + { + form.setValue("age_or_dob", v as "dob" | "age"); + if (v === "age") { + form.setValue("date_of_birth", undefined); + } else { + form.setValue("age", undefined); + } + }} + > + + {t("date_of_birth")} + {t("age")} + + + ( + + +
+
+ {t("day")} + + + form.setValue( + "date_of_birth", + `${form.watch("date_of_birth")?.split("-")[0]}-${form.watch("date_of_birth")?.split("-")[1]}-${e.target.value}`, + ) + } + data-cy="dob-day-input" + /> +
+ +
+ {t("month")} + + + form.setValue( + "date_of_birth", + `${form.watch("date_of_birth")?.split("-")[0]}-${e.target.value}-${form.watch("date_of_birth")?.split("-")[2]}`, + ) + } + data-cy="dob-month-input" + /> +
+ +
+ {t("year")} + + + form.setValue( + "date_of_birth", + `${e.target.value}-${form.watch("date_of_birth")?.split("-")[1]}-${form.watch("date_of_birth")?.split("-")[2]}`, + ) + } + data-cy="dob-year-input" + /> +
+
+
+ +
+ )} /> -
- {errors["year_of_birth"] && - errors["year_of_birth"]?.map((error, i) => ( -
- {error} -
- ))} + + +
+ {t("age_input_warning")} +
+ {t("age_input_warning_bold")}
- {form.year_of_birth && ( -
- {t("year_of_birth")} : {form.year_of_birth} -
- )} -
-
-
-
- - -