diff --git a/.example.env b/.example.env index 862f05eb5c4..79e826ad65f 100644 --- a/.example.env +++ b/.example.env @@ -62,3 +62,6 @@ REACT_ALLOWED_LOCALES="en,hi,ta,ml,mr,kn" # ISO 3166-1 Alpha-2 code for the default country code (default: "IN") REACT_DEFAULT_COUNTRY= + +# Maps fallback URL template (default:"https://www.openstreetmap.org/?mlat={lat}&mlon={long}&zoom=15") +REACT_MAPS_FALLBACK_URL_TEMPLATE= \ No newline at end of file diff --git a/.github/workflows/issue-automation.yml b/.github/workflows/issue-automation.yml deleted file mode 100644 index 59f164781b8..00000000000 --- a/.github/workflows/issue-automation.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Automate Issues to Project -on: - issues: - types: - - opened - - reopened - - closed - - assigned -jobs: - issue_opened_and_reopened: - name: issue_opened_and_reopened - runs-on: ubuntu-24.04-arm - if: github.repository == 'ohcnetwork/care_fe' && github.event_name == 'issues' && github.event.action == 'opened' || github.event.action == 'reopened' - steps: - - name: 'Move issue to "Triage"' - uses: leonsteinhaeuser/project-beta-automations@v2.2.1 - with: - gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - organization: ohcnetwork - project_id: 4 - resource_node_id: ${{ github.event.issue.node_id }} - status_value: "Triage" - issue_closed: - name: issue_closed - runs-on: ubuntu-24.04-arm - if: github.repository == 'ohcnetwork/care_fe' && github.event_name == 'issues' && github.event.action == 'closed' - steps: - - name: 'Moved issue to "Done"' - uses: leonsteinhaeuser/project-beta-automations@v2.2.1 - with: - gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - organization: ohcnetwork - project_id: 4 - resource_node_id: ${{ github.event.issue.node_id }} - status_value: "Done" - issue_assigned: - name: issue_assigned - runs-on: ubuntu-24.04-arm - if: github.repository == 'ohcnetwork/care_fe' && github.event_name == 'issues' && github.event.action == 'assigned' - steps: - - name: 'Move issue to "In Progress"' - uses: leonsteinhaeuser/project-beta-automations@v2.2.1 - with: - gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - organization: ohcnetwork - project_id: 4 - resource_node_id: ${{ github.event.issue.node_id }} - status_value: "In Progress" diff --git a/care.config.ts b/care.config.ts index d99d6fe73cd..9f646dbab0a 100644 --- a/care.config.ts +++ b/care.config.ts @@ -55,6 +55,10 @@ const careConfig = { defaultEncounterType: (env.REACT_DEFAULT_ENCOUNTER_TYPE || "hh") as EncounterClass, + mapFallbackUrlTemplate: + env.REACT_MAPS_FALLBACK_URL_TEMPLATE || + "https://www.openstreetmap.org/?mlat={lat}&mlon={long}&zoom=15", + gmapsApiKey: env.REACT_GMAPS_API_KEY || "AIzaSyDsBAc3y7deI5ZO3NtK5GuzKwtUzQNJNUk", diff --git a/cypress/e2e/patient_spec/patient_creation.cy.ts b/cypress/e2e/patient_spec/patient_creation.cy.ts index 3a8e8b77153..be0b1962115 100644 --- a/cypress/e2e/patient_spec/patient_creation.cy.ts +++ b/cypress/e2e/patient_spec/patient_creation.cy.ts @@ -101,6 +101,7 @@ describe("Patient Management", () => { ]; beforeEach(() => { + cy.viewport(1920, 1080); cy.loginByApi("doctor"); cy.visit("/"); }); diff --git a/cypress/e2e/patient_spec/patient_details.cy.ts b/cypress/e2e/patient_spec/patient_details.cy.ts index d96baf9bfef..dcbfe35accf 100644 --- a/cypress/e2e/patient_spec/patient_details.cy.ts +++ b/cypress/e2e/patient_spec/patient_details.cy.ts @@ -8,6 +8,7 @@ const patientDetails = new PatientDetails(); describe("Patient Management", () => { beforeEach(() => { + cy.viewport(1920, 1080); cy.loginByApi("devdoctor"); cy.visit("/"); }); diff --git a/cypress/e2e/patient_spec/patient_encounter.cy.ts b/cypress/e2e/patient_spec/patient_encounter.cy.ts index 2569b9567bc..7df0ff4866f 100644 --- a/cypress/e2e/patient_spec/patient_encounter.cy.ts +++ b/cypress/e2e/patient_spec/patient_encounter.cy.ts @@ -6,6 +6,7 @@ const patientEncounter = new PatientEncounter(); describe("Patient Encounter Questionnaire", () => { beforeEach(() => { + cy.viewport(1920, 1080); cy.loginByApi("devnurse"); cy.visit("/"); }); diff --git a/cypress/e2e/users_spec/user_avatar.cy.ts b/cypress/e2e/users_spec/user_avatar.cy.ts new file mode 100644 index 00000000000..830dcb1b40c --- /dev/null +++ b/cypress/e2e/users_spec/user_avatar.cy.ts @@ -0,0 +1,22 @@ +import { UserAvatar } from "@/pageObject/Users/UserAvatar"; + +describe("User Profile Avatar Modification", () => { + const userAvatar = new UserAvatar("teststaff4"); + beforeEach(() => { + cy.loginByApi("teststaff4"); + cy.visit("/"); + }); + it("should modify an avatar", () => { + userAvatar + .navigateToProfile() + .interceptUploadAvatarRequest() + .clickChangeAvatarButton() + .uploadAvatar() + .clickSaveAvatarButton() + .verifyUploadAvatarApiCall() + .interceptDeleteAvatarRequest() + .clickChangeAvatarButton() + .clickDeleteAvatarButton() + .verifyDeleteAvatarApiCall(); + }); +}); diff --git a/cypress/fixtures/avatar.jpg b/cypress/fixtures/avatar.jpg new file mode 100644 index 00000000000..464ca73c65c Binary files /dev/null and b/cypress/fixtures/avatar.jpg differ diff --git a/cypress/fixtures/users.json b/cypress/fixtures/users.json index 1c3799e32ba..db27e9969f7 100644 --- a/cypress/fixtures/users.json +++ b/cypress/fixtures/users.json @@ -22,5 +22,9 @@ "devdoctor": { "username": "developdoctor", "password": "Test@123" + }, + "teststaff4": { + "username": "teststaff4", + "password": "Test@123" } } diff --git a/cypress/pageObject/Users/UserAvatar.ts b/cypress/pageObject/Users/UserAvatar.ts new file mode 100644 index 00000000000..ad8d59ef330 --- /dev/null +++ b/cypress/pageObject/Users/UserAvatar.ts @@ -0,0 +1,59 @@ +export class UserAvatar { + username: string; + constructor(username: string) { + this.username = username; + } + + navigateToProfile() { + cy.visit(`/users/${this.username}`); + return this; + } + + interceptUploadAvatarRequest() { + cy.intercept("POST", `/api/v1/users/${this.username}/profile_picture/`).as( + "uploadAvatar", + ); + return this; + } + + clickChangeAvatarButton() { + cy.verifyAndClickElement('[data-cy="change-avatar"]', "Change Avatar"); + return this; + } + + uploadAvatar() { + cy.get('input[title="changeFile"]').selectFile( + "cypress/fixtures/avatar.jpg", + { force: true }, + ); + return this; + } + + clickSaveAvatarButton() { + cy.verifyAndClickElement('[data-cy="save-cover-image"]', "Save"); + return this; + } + + verifyUploadAvatarApiCall() { + cy.wait("@uploadAvatar").its("response.statusCode").should("eq", 200); + return this; + } + + interceptDeleteAvatarRequest() { + cy.intercept( + "DELETE", + `/api/v1/users/${this.username}/profile_picture/`, + ).as("deleteAvatar"); + return this; + } + + clickDeleteAvatarButton() { + cy.verifyAndClickElement('[data-cy="delete-avatar"]', "Delete"); + return this; + } + + verifyDeleteAvatarApiCall() { + cy.wait("@deleteAvatar").its("response.statusCode").should("eq", 204); + return this; + } +} diff --git a/public/locale/en.json b/public/locale/en.json index 1418c94c8ec..9d6df393904 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -282,6 +282,7 @@ "active": "Active", "active_encounters": "Active Encounters", "active_files": "Active Files", + "active_location_cannot_be_in_future": "Active location cannot be in the future", "active_prescriptions": "Active Prescriptions", "add": "Add", "add_another_session": "Add another session", @@ -289,8 +290,12 @@ "add_attachments": "Add Attachments", "add_beds": "Add Bed(s)", "add_beds_to_configure_presets": "Add beds to this location to configure presets for them.", + "add_consultation": "Add consultation", + "add_consultation_update": "Add Consultation Update", + "add_contact_point": "Add Contact Point", "add_department_team": "Add Department/Team", "add_details_of_patient": "Add Details of Patient", + "add_device": "Add Device", "add_encounter": "Add Encounter", "add_exception": "Add Exception", "add_facility": "Add Facility", @@ -300,6 +305,7 @@ "add_location_description": "Create a Location such as Rooms/Beds", "add_new_beds": "Add New Bed(s)", "add_new_facility": "Add New Facility", + "add_new_location": "Add New Location", "add_new_patient": "Add New Patient", "add_new_user": "Add New User", "add_notes": "Add notes", @@ -327,6 +333,7 @@ "address": "Address", "address_is_required": "Address is required", "adjust_resource_filters": "Try adjusting your filters or create a new resource", + "admin_dashboard": "Admin Dashboard", "administer": "Administer", "administer_medicine": "Administer Medicine", "administer_medicines": "Administer Medicines", @@ -393,6 +400,7 @@ "archived_reason": "Archived reason", "are_non_editable_fields": "are non-editable fields", "are_you_still_watching": "Are you still watching?", + "are_you_sure": "Are you sure?", "are_you_sure_want_to_delete": "Are you sure you want to delete {{name}}?", "are_you_sure_want_to_delete_this_record": "Are you sure want to delete this record?", "are_you_sure_want_to_remove": "Are you sure you want to remove {{name}} from the patient? This action cannot be undone", @@ -418,6 +426,10 @@ "assigned_facility": "Facility assigned", "assigned_to": "Assigned to", "assigned_volunteer": "Assigned Volunteer", + "associate": "Associate", + "associate_location": "Associate Location", + "associate_location_description": "Select a location to associate with this device", + "associating": "Associating...", "at_least_one_department_is_required": "At least one department is required", "at_time": "at {{time}}", "atypical_presentation_details": "Atypical presentation details", @@ -441,6 +453,7 @@ "auto_generated_for_care": "Auto Generated for Care", "autofilled_fields": "Autofilled Fields", "availabilities": "Availabilities", + "availability_status": "Availability Status", "available": "Available", "available_features": "Available Features", "available_in": "Available in", @@ -513,9 +526,11 @@ "category_description": "Choose the category ", "caution": "Caution", "central_nursing_station": "Central Nursing Station", + "change": "Change", "change_avatar": "Change Avatar", "change_avatar_note": "JPG, GIF or PNG. 1MB max.", "change_file": "Change File", + "change_location": "Change Location", "change_phone_number": "Change Phone Number", "change_status": "Change Status", "chat_on_whatsapp": "Chat on Whatsapp", @@ -578,8 +593,17 @@ "clear_search": "Clear search", "clear_selection": "Clear selection", "clear_skill": "Clear Skill", + "click": "Click", + "click_add_department_team": "Click Add Department/Team to create a new department/team.", + "click_add_main_location": "Click Add Location to add a main location.", + "click_manage_create_users": "Click See Details to create or manage users and departments/teams within the corresponding dept/team.", + "click_manage_create_users_mobile": "Click to create or manage users and departments/teams within the corresponding dept/team.", + "click_manage_sub_locations": "Click See Details to manage sub-locations.", + "click_manage_sub_locations_mobile": "Click to edit and to manage sub-locations.", + "click_on": "Click on", "close": "Close", "close_scanner": "Close Scanner", + "collapse_all": "Collapse All", "collapse_sidebar": "Collapse Sidebar", "combine_files_pdf": "Combine Files To PDF", "comment_added_successfully": "Comment added successfully", @@ -620,6 +644,26 @@ "contact_person_number": "Contact person number", "contact_phone": "Contact Person Number", "contact_phone_description": "Phone number to reach the contact person.", + "contact_point_placeholder__email": "Enter email address", + "contact_point_placeholder__fax": "Enter fax number", + "contact_point_placeholder__other": "Enter contact value", + "contact_point_placeholder__pager": "Enter pager number", + "contact_point_placeholder__phone": "Enter phone number", + "contact_point_placeholder__sms": "Enter SMS number", + "contact_point_placeholder__url": "Enter URL", + "contact_points": "Contact Points", + "contact_system_email": "Email", + "contact_system_fax": "Fax", + "contact_system_other": "Other", + "contact_system_pager": "Pager", + "contact_system_phone": "Phone", + "contact_system_sms": "SMS", + "contact_system_url": "URL", + "contact_use_home": "Home", + "contact_use_mobile": "Mobile", + "contact_use_old": "Old", + "contact_use_temp": "Temporary", + "contact_use_work": "Work", "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", @@ -648,6 +692,7 @@ "create_department_team_description": "Create a new department/team in this facility.", "create_encounter": "Create Encounter", "create_facility": "Create Facility", + "create_location_association": "Create Location Association", "create_new": "Create New", "create_new_asset": "Create New Asset", "create_new_encounter": "Create a new encounter to get started", @@ -674,6 +719,7 @@ "criticality": "Criticality", "csv_file_in_the_specified_format": "Select a CSV file in the specified format", "current_address": "Current Address", + "current_location_description": "The current location of this device", "current_organizations": "Current Organizations", "current_password": "Current Password", "current_role": "Current Role", @@ -688,6 +734,7 @@ "dashboard": "Dashboard", "date": "Date", "date_and_time": "Date and Time", + "date_and_time_of_death": "Date and Time of Death", "date_declared_positive": "Date of declaring positive", "date_of_admission": "Date of Admission", "date_of_birth": "Date of Birth", @@ -700,12 +747,16 @@ "date_of_return": "Date of Return", "date_of_test": "Date of sample collection for Covid testing", "date_range": "Date Range", + "dates_and_identifiers": "Dates & Identifiers", "day": "Day", - "death_report": "Death Report", + "deceased_disclaimer": "Please provide the date and time of death for record-keeping purposes. This information is handled with utmost sensitivity and respect.", + "deceased_status": "Deceased Status", "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_device": "Delete Device", + "delete_device_confirmation": "Are you sure you want to delete this device? This action cannot be undone.", "delete_facility": "Delete Facility", "delete_facility_confirmation": "Are you sure you want to delete {{name}}? This action cannot be undone.", "delete_item": "Delete {{name}}", @@ -725,6 +776,17 @@ "details_of_origin_facility": "Details of origin facility", "details_of_patient": "Details of patient", "details_of_shifting_approving_facility": "Details of shifting approving facility", + "device_availability_status_available": "Available", + "device_availability_status_damaged": "Damaged", + "device_availability_status_destroyed": "Destroyed", + "device_availability_status_lost": "Lost", + "device_contact_description": "Contact points associated with this device", + "device_information": "Device Information", + "device_not_found": "Device not found", + "device_status_active": "Active", + "device_status_entered_in_error": "Entered in Error", + "device_status_inactive": "Inactive", + "devices": "Devices", "diagnoses": "Diagnoses", "diagnosis": "Diagnosis", "diagnosis__confirmed": "Confirmed", @@ -804,6 +866,8 @@ "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_device": "Edit Device", + "edit_device_description": "Edit the details of the device", "edit_facility": "Edit Facility", "edit_facility_details": "Edit Facility Details", "edit_history": "Edit History", @@ -892,6 +956,7 @@ "encounter_manage_organization_description": "Add or remove organizations from this encouter", "encounter_marked_as_complete": "Encounter Completed", "encounter_notes__all_discussions": "All Discussions", + "encounter_notes__all_discussions_description": "View and manage encounternotes discussion threads", "encounter_notes__be_first_to_send": "Be the first to send a message", "encounter_notes__choose_template": "Choose a template or enter a custom title", "encounter_notes__create_discussion": "Create a new discussion thread to organize your conversation topics.", @@ -951,21 +1016,31 @@ "end_time": "End Time", "end_time_before_start_error": "End time cannot be before start time", "end_time_future_error": "End time cannot be in the future", + "end_time_required": "End time is required", "ended": "Ended", + "enter_contact_value": "Enter contact value", "enter_department_team_description": "Enter department/team description (optional)", "enter_department_team_name": "Enter department/team name", "enter_dosage_instructions": "Enter Dosage Instructions", "enter_file_name": "Enter File Name", + "enter_identifier": "Enter device identifier", + "enter_lot_number": "Enter lot number", + "enter_manufacturer": "Enter manufacturer name", "enter_message": "Start typing...", "enter_mobile_number": "Enter Mobile Number", "enter_mobile_otp": "Enter OTP sent to the given mobile number", + "enter_model_number": "Enter model number", "enter_otp": "Enter OTP sent to the registered mobile with the respective ID", + "enter_part_number": "Enter part number", "enter_phone_number": "Enter phone number", "enter_phone_number_to_login_register": "Enter phone number to login/register", + "enter_registered_name": "Enter the registered name of the device", + "enter_serial_number": "Enter serial number", "enter_tag_name": "Enter tag name", "enter_tag_slug": "Enter tag slug", "enter_the_file_name": "Enter the file name", "enter_the_verification_code": "Enter the verification code sent to your phone", + "enter_user_friendly_name": "Enter a user friendly name for the device", "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", @@ -999,10 +1074,14 @@ "exception_created": "Exception created successfully", "exception_deleted": "Exception deleted", "exceptions": "Exceptions", + "expand_all": "Expand All", "expand_sidebar": "Expand Sidebar", "expected_burn_rate": "Expected Burn Rate", + "expiration_date": "Expiration Date", + "expiration_date_must_be_after_manufacture_date": "Expiration date must be after manufacture date", "expired": "Expired", "expired_on": "Expired On", + "expires": "Expires", "expires_on": "Expires On", "export": "Export", "export_live_patients": "Export Live Patients", @@ -1082,6 +1161,8 @@ "filter_by": "Filter By", "filter_by_category": "Filter by category", "filter_by_date": "Filter by Date", + "filter_by_department_or_team_name": "Filter by department or team name", + "filter_by_locations": "Filter by Locations", "filters": "Filters", "first_name": "First Name", "food": "Food", @@ -1143,10 +1224,12 @@ "hospital_identifier": "Hospital Identifier", "hospitalisation_details": "Hospitalization Details", "hospitalization_details": "Hospitalization Details", + "hover_focus_reveal": "Hover or focus to reveal", "hubs": "Hub Facilities", "i_declare": "I hereby declare that:", "icd11_as_recommended": "As per ICD-11 recommended by WHO", "icmr_specimen_referral_form": "ICMR Specimen Referral Form", + "identifier": "Identifier", "immunisation-records": "Immunisation", "in_consultation": "In-Consultation", "in_progress": "In Progress", @@ -1217,6 +1300,7 @@ "is_it_upshift": "is it upshift", "is_phone_a_whatsapp_number": "Is the phone number a WhatsApp number?", "is_pregnant": "Is pregnant", + "is_the_patient_deceased": "Is the patient deceased", "is_this_administration_for_a_past_time": "Is this administration for a past time", "is_this_an_emergency": "Is this an Emergency?", "is_this_an_emergency_request": "Is this an emergency request?", @@ -1278,12 +1362,36 @@ "local_ip_address": "Local IP Address", "local_ip_address_example": "e.g. 192.168.0.123", "location": "Location", + "location_associated_successfully": "Location associated successfully", + "location_association_created_successfully": "Location association created successfully", + "location_association_updated_successfully": "Location association updated successfully", "location_beds_empty": "No beds available in this location", "location_created": "Location Created", + "location_description": "Location Description", "location_details": "Location Details", "location_form": "Location Form", + "location_form__area": "Area", + "location_form__bd": "Bed", + "location_form__bu": "Building", + "location_form__ca": "Cabinet", + "location_form__co": "Corridor", + "location_form__ho": "House", + "location_form__jdn": "Jurisdiction", + "location_form__lvl": "Level", + "location_form__rd": "Road", + "location_form__ro": "Room", + "location_form__si": "Site", + "location_form__ve": "Vehicle", + "location_form__vi": "Virtual", + "location_form__wa": "Ward", + "location_form__wi": "Wing", "location_history": "Location History", "location_management": "Location Management", + "location_name": "Location Name", + "location_status": "Location Status", + "location_status__active": "Active", + "location_status__inactive": "Inactive", + "location_status__unknown": "Unknown", "location_updated": "Location Updated", "location_updated_successfully": "Location updated successfully", "locations": "Locations", @@ -1297,6 +1405,7 @@ "logout": "Log Out", "longitude": "Longitude", "longitude_invalid": "Longitude must be between -180 and 180", + "lot_number": "Lot Number", "low": "Low", "lsg": "Lsg", "make_facility_public": "Make this facility public", @@ -1308,12 +1417,17 @@ "manage_my_schedule": "Manage my schedule", "manage_organizations": "Manage Organizations", "manage_organizations_description": "Add or remove organizations from this questionnaire", + "manage_patient_location_and_transfers": "Manage patient location and transfers", "manage_prescriptions": "Manage Prescriptions", "manage_preset": "Manage preset {{ name }}", "manage_tags": "Manage Tags", "manage_tags_description": "Add or remove tags for this questionnaire", "manage_user": "Manage User", + "manufacture_date": "Manufacture Date", + "manufacture_date_cannot_be_in_future": "Manufacture date cannot be in future", + "manufactured": "Manufactured", "manufacturer": "Manufacturer", + "map": "Map", "map_acronym": "M.A.P.", "mark_active": "Mark Active", "mark_all_as_read": "Mark all as Read", @@ -1369,6 +1483,7 @@ "mobile_otp_send_success": "OTP has been sent to the given mobile number.", "mobile_otp_verify_error": "Failed to verify mobile number. Please try again later.", "mobile_otp_verify_success": "Mobile number has been verified successfully.", + "model_number": "Model Number", "moderate": "Moderate", "modification_caution_note": "No modifications possible once added", "modified": "Modified", @@ -1407,7 +1522,7 @@ "next_sessions": "Next Sessions", "next_week_short": "Next wk", "no": "No", - "no_active_medications": "No active medications", + "no_active_medication_recorded": "No Active Medication Recorded", "no_address_provided": "No address provided", "no_allergies_recorded": "No allergies recorded", "no_appointments": "No appointments found", @@ -1425,6 +1540,8 @@ "no_country_found": "No country found", "no_data_found": "No data found", "no_departments_teams_found": "No Departments or Teams found", + "no_devices_available": "No devices available", + "no_devices_found": "No devices found", "no_diagnoses_recorded": "No diagnoses recorded", "no_discharge_summaries_found": "No Discharge Summaries found", "no_doctors_found": "No Doctors Found", @@ -1440,11 +1557,15 @@ "no_investigation": "No investigation Reports found", "no_investigation_suggestions": "No Investigation Suggestions", "no_linked_facilities": "No Linked Facilities", + "no_location": "No location assigned", + "no_location_description": "This device is not currently assigned to any location", "no_locations_available": "No locations available", "no_locations_found": "No locations found", "no_log_update_delta": "No changes since previous log update", "no_log_updates": "No log updates found", "no_medical_history_available": "No Medical History Available", + "no_medication_recorded": "No Medication Recorded", + "no_medications": "No Medications", "no_medications_found_for_this_encounter": "No medications found for this encounter.", "no_medications_to_administer": "No medications to administer", "no_notices_for_you": "No notices for you.", @@ -1534,6 +1655,7 @@ "ongoing_medications": "Ongoing Medications", "online": "Online", "only_indian_mobile_numbers_supported": "Currently only Indian numbers are supported", + "only_mark_if_applicable": "Only mark if applicable", "onset": "Onset", "op_encounter": "OP Encounter", "op_file_closed": "OP file closed", @@ -1566,6 +1688,7 @@ "page_not_found": "Page Not Found", "pain": "Pain", "pain_chart_description": "Mark region and intensity of pain", + "part_number": "Part Number", "participants": "Participants", "passport_number": "Passport Number", "password": "Password", @@ -1611,6 +1734,7 @@ "patient_face": "Patient Face", "patient_files": "Patient Files", "patient_information": "Patient Information", + "patient_is_deceased": "Patient is deceased", "patient_name": "Patient Name", "patient_name_uhid": "Patient Name/UHID", "patient_no": "OP/IP No", @@ -1662,6 +1786,8 @@ "pincode_district_auto_fill_error": "Failed to auto-fill district information", "pincode_must_be_6_digits": "Pincode must be a 6-digit number", "pincode_state_auto_fill_error": "Failed to auto-fill state and district information", + "planned": "Planned", + "planned_reserved_cannot_be_in_past": "Planned/Reserved cannot be in the past", "play": "Play", "play_audio": "Play Audio", "please_assign_bed_to_patient": "Please assign a bed to this patient", @@ -1802,6 +1928,7 @@ "register_hospital": "Register Hospital", "register_page_title": "Register As Hospital Administrator", "register_patient": "Register Patient", + "registered_name": "Registered Name", "reject": "Reject", "rejected": "Rejected", "relapse": "Relapse", @@ -1854,6 +1981,7 @@ "rescheduled": "Rescheduled", "rescheduling": "Rescheduling...", "resend_otp": "Resend OTP", + "reserved": "Reserved", "reset": "Reset", "reset_password": "Reset Password", "reset_password_note_self": "Enter your current password, then create and confirm your new password", @@ -1912,6 +2040,7 @@ "save": "Save", "save_and_continue": "Save and Continue", "save_investigation": "Save Investigation", + "save_valueset": "Save ValueSet", "saving": "Saving...", "scan_asset_qr": "Scan Asset QR!", "schedule": "Schedule", @@ -1948,7 +2077,7 @@ "search_by_department_team_name": "Search by department/team name", "search_by_emergency_contact_phone_number": "Search by Emergency Contact Phone Number", "search_by_emergency_phone_number": "Search by Emergency Phone Number", - "search_by_name": "Search by Name", + "search_by_name": "Search by name", "search_by_patient_name": "Search by Patient Name", "search_by_patient_no": "Search by Patient Number", "search_by_phone_number": "Search by Phone Number", @@ -1956,6 +2085,7 @@ "search_by_user_name": "Search by user name", "search_by_username": "Search by username", "search_country": "Search country...", + "search_devices": "Search Devices", "search_encounters": "Search Encounters", "search_for_allergies_to_add": "Search for allergies to add", "search_for_diagnoses_to_add": "Search for diagnoses to add", @@ -1981,12 +2111,16 @@ "see_details": "See Details", "see_note": "See Note", "select": "Select", + "select_a_status": "Select a status", "select_a_value_set": "Select a Value Set", "select_additional_instructions": "Select additional instructions", "select_admit_source": "Select Admit Source", "select_all": "Select All", + "select_availability_status": "Select availability status", "select_category": "Select a category", "select_class": "Select Class", + "select_contact_system": "Select contact type", + "select_contact_use": "Select contact use", "select_date": "Select date", "select_department": "Select Department", "select_diet_preference": "Select diet preference", @@ -2026,7 +2160,7 @@ "select_seven_day_period": "Select a seven day period", "select_site": "Select site", "select_skills": "Select and add some skills", - "select_status": "Select Status", + "select_status": "Select status", "select_sub_department": "Select sub-department", "select_subject_type": "Select Subject Type", "select_tags": "Select Tags", @@ -2084,10 +2218,13 @@ "show_all_notifications": "Show All", "show_all_slots": "Show all slots", "show_default_presets": "Show Default Presets", + "show_on_map": "Show on Map", "show_patient_presets": "Show Patient Presets", "show_unread_notifications": "Show Unread", "showing_all_appointments": "Showing all appointments", "showing_x_of_y": "Showing {{x}} of {{y}}", + "sidebar": "sidebar", + "sidebar_description": "sidebar provides navigation to different sections", "sign_in": "Sign in", "sign_out": "Sign out", "site": "Site", @@ -2125,6 +2262,7 @@ "start_time_before_authored_error": "Start time cannot be before the medication was prescribed", "start_time_future_error": "Start time cannot be in the future", "start_time_must_be_before_end_time": "Start time must be before end time", + "start_time_required": "Start time is required", "start_typing_to_search": "Start typing to search...", "state": "State", "state_reason_for_archiving": "State reason for archiving {{name}} file?", @@ -2174,6 +2312,9 @@ "third_party_software_licenses": "Third Party Software Licenses", "this_action_is_irreversible": "This action is irreversible. Once a file is archived it cannot be unarchived.", "this_file_has_been_archived": "This file has been archived and cannot be unarchived.", + "this_will_permanently_remove_the_exception_and_cannot_be_undone": "This will permanently remove the exception and cannot be undone", + "this_will_permanently_remove_the_scheduled_template_and_cannot_be_undone": "This will permanently remove the scheduled template and cannot be undone", + "this_will_permanently_remove_the_session_and_cannot_be_undone": "This will permanently remove the session and cannot be undone", "thread_already_exists": "Thread with this title already exists", "time": "Time", "time_slot": "Time Slot", @@ -2182,6 +2323,7 @@ "titrate_dosage": "Titrate Dosage", "to": "to", "to_be_conducted": "To be conducted", + "to_edit": "to edit", "to_proceed_with_registration": "To proceed with registration, please create a new patient.", "to_view_available_slots_select_resource_and_date": "To view available slots, select a preferred resource and date.", "today": "Today", @@ -2276,6 +2418,7 @@ "update_available": "Update Available", "update_bed": "Update Bed", "update_department": "Update Department", + "update_device": "Update Device", "update_encounter": "Update Encounter", "update_encounter_details": "Update Encounter Details", "update_existing_facility": "Update the details of the existing facility.", @@ -2314,6 +2457,7 @@ "upload_headings__supporting_info": "Upload Supporting Info", "upload_report": "Upload Report", "uploading": "Uploading", + "use": "Use", "use_address_as_permanent": "Use this address for permanent address", "use_phone_number_for_emergency": "Use this phone number for emergency contact", "user_add_error": "Error while adding User", @@ -2324,6 +2468,7 @@ "user_details": "User Details", "user_details_update_error": "Error while updating user details", "user_details_update_success": "User details updated successfully", + "user_friendly_name": "User Friendly Name", "user_management": "User Management", "user_not_available_for_appointments": "This user is not available for appointments", "user_qualifications": "Qualifications", @@ -2354,6 +2499,7 @@ "valid_otp_found": "Valid OTP found, Navigating to Appointments", "valid_to": "Valid Till", "valid_year_of_birth": "Please enter a valid year of birth (YYYY)", + "value": "Value", "value_set": "Value Set", "valuesets": "Valuesets", "vehicle_preference": "Vehicle preference", @@ -2393,6 +2539,7 @@ "view_details": "View Details", "view_encounter": "View Encounter", "view_facility": "View Facility", + "view_facility_details": "View facility details", "view_files": "View Files", "view_patient": "View Patient", "view_patients": "View Patients", @@ -2420,6 +2567,7 @@ "we_ve_sent_you_a_code_to": "We've sent you a code to", "weekly_schedule": "Weekly Schedule", "weekly_working_hours_error": "Average weekly working hours must be a number between 0 and 168", + "welcome_back_name": "Welcome Back {{name}} !", "welcome_back_to_hospital_dashboard": "Welcome back to the overview ", "what_facility_assign_the_patient_to": "What facility would you like to assign the patient to", "whatsapp_number": "Whatsapp Number", @@ -2439,6 +2587,8 @@ "yesterday": "Yesterday", "yet_to_be_decided": "Yet to be decided", "you_need_at_least_a_location_to_create_an_assest": "You need at least a location to create an assest.", + "your_facilities": "Your Facilities", + "your_organizations": "Your Organizations", "zoom_in": "Zoom In", "zoom_out": "Zoom Out" } diff --git a/public/locale/ml.json b/public/locale/ml.json index 871e9cba741..c8c6be67ce5 100644 --- a/public/locale/ml.json +++ b/public/locale/ml.json @@ -378,7 +378,7 @@ "appointment_details": "അപ്പോയിൻ്റ്മെൻ്റ് വിശദാംശങ്ങൾ", "appointment_not_found": "അപ്പോയിൻ്റ്മെൻ്റ് കണ്ടെത്തിയില്ല", "appointment_type": "അപ്പോയിൻ്റ്മെൻ്റ് തരം", - "appointments": "നിയമനങ്ങൾ", + "appointments": "അപ്പോയ്ന്റ്മെന്റ്സ്", "approve": "അംഗീകരിക്കുക", "approved": "അംഗീകരിച്ചു", "approved_by_district_covid_control_room": "ജില്ലാ കോവിഡ് കൺട്രോൾ റൂം അംഗീകരിച്ചു", @@ -676,7 +676,6 @@ "date_of_return": "മടങ്ങിവരുന്ന തീയതി", "date_of_test": "ടെസ്റ്റ് തീയതി", "day": "ദിവസം", - "death_report": "മരണ റിപ്പോർട്ട്", "delete": "ഇല്ലാതാക്കുക", "delete_account": "അക്കൗണ്ട് ഇല്ലാതാക്കുക", "delete_account_btn": "അതെ, ഈ അക്കൗണ്ട് ഇല്ലാതാക്കുക", @@ -787,7 +786,7 @@ "emergency_contact_volunteer": "അടിയന്തര കോൺടാക്റ്റ് (വോളണ്ടിയർ)", "emergency_phone_number": "എമർജൻസി ഫോൺ നമ്പർ", "empty_date_time": "--:-- --; ------------", - "encounter": "ഏറ്റുമുട്ടൽ", + "encounter": "എൻകൗണ്ടർ", "encounter_admit_sources__born": "ആശുപത്രിയിൽ ജനിച്ചു", "encounter_admit_sources__emd": "അപകട/അടിയന്തര വിഭാഗത്തിൽ നിന്ന്", "encounter_admit_sources__gp": "ജനറൽ പ്രാക്ടീഷണറുടെ റഫറൽ", @@ -1218,6 +1217,7 @@ "moving_camera": "ചലിക്കുന്ന ക്യാമറ", "my_doctors": "എൻ്റെ ഡോക്ടർമാർ", "my_profile": "എൻ്റെ പ്രൊഫൈൽ", + "my_schedules": "എന്റെ ഷെഡ്യൂൾസ്", "name": "പേര്", "name_of_hospital": "ആശുപത്രിയുടെ പേര്", "name_of_shifting_approving_facility": "ഷിഫ്റ്റിംഗ് അപ്രൂവിംഗ് സൗകര്യത്തിൻ്റെ പേര്", @@ -1678,6 +1678,7 @@ "set_home_facility": "ഹോം സൗകര്യമായി സജ്ജമാക്കുക", "set_your_local_language": "നിങ്ങളുടെ പ്രാദേശിക ഭാഷ സജ്ജമാക്കുക", "settings_and_filters": "ക്രമീകരണങ്ങളും ഫിൽട്ടറുകളും", + "settings": "സെറ്റിംഗ്സ്", "severity_of_breathlessness": "ശ്വാസതടസ്സത്തിൻ്റെ തീവ്രത", "sex": "ലൈംഗികത", "shared_by": "പങ്കിട്ടത്", @@ -1876,7 +1877,7 @@ "username_available": "ഉപയോക്തൃനാമം ലഭ്യമാണ്", "username_not_available": "ഉപയോക്തൃനാമം ലഭ്യമല്ല", "username_userdetails_not_found": "ഉപയോക്തൃനാമമോ ഉപയോക്തൃ വിശദാംശങ്ങളോ കണ്ടെത്താനായില്ല", - "users": "ഉപയോക്താക്കൾ", + "users": "യൂസേഴ്സ്", "vacant": "ഒഴിഞ്ഞുകിടക്കുന്നു", "vaccinated": "വാക്സിനേഷൻ നൽകി", "vaccine_name": "വാക്സിൻ പേര്", diff --git a/src/App.tsx b/src/App.tsx index 51e33c2209d..7452d7afb23 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,13 +18,23 @@ import AuthUserProvider from "@/Providers/AuthUserProvider"; import HistoryAPIProvider from "@/Providers/HistoryAPIProvider"; import Routers from "@/Routers"; import { handleHttpError } from "@/Utils/request/errorHandler"; +import { HTTPError } from "@/Utils/request/types"; import { PubSubProvider } from "./Utils/pubsubContext"; const queryClient = new QueryClient({ defaultOptions: { queries: { - retry: 2, + retry: (failureCount, error) => { + // Only retry network errors or server errors (502, 503, 504) up to 3 times + if ( + error.message === "Network Error" || + (error instanceof HTTPError && [502, 503, 504].includes(error.status)) + ) { + return failureCount < 3; + } + return false; + }, refetchOnWindowFocus: false, }, }, diff --git a/src/CAREUI/interactive/Zoom.tsx b/src/CAREUI/interactive/Zoom.tsx index d97300d36cb..18a73b9dd40 100644 --- a/src/CAREUI/interactive/Zoom.tsx +++ b/src/CAREUI/interactive/Zoom.tsx @@ -68,7 +68,7 @@ export const ZoomControls = (props: { disabled?: boolean }) => { } return ( -
+
@@ -352,6 +363,7 @@ const AvatarEditModal = ({ variant="outline" onClick={uploadAvatar} disabled={isProcessing || !selectedFile} + data-cy="save-cover-image" > {isProcessing ? ( -
+        
           {JSON.stringify(data, null, 2)}
         
diff --git a/src/components/Common/FilePreviewDialog.tsx b/src/components/Common/FilePreviewDialog.tsx index f5c44865549..7aaabf84dd5 100644 --- a/src/components/Common/FilePreviewDialog.tsx +++ b/src/components/Common/FilePreviewDialog.tsx @@ -13,6 +13,8 @@ import { import { useTranslation } from "react-i18next"; import useKeyboardShortcut from "use-keyboard-shortcut"; +import { cn } from "@/lib/utils"; + import CareIcon, { IconName } from "@/CAREUI/icons/CareIcon"; import { Button } from "@/components/ui/button"; @@ -27,7 +29,6 @@ import CircularProgress from "@/components/Common/CircularProgress"; import { FileUploadModel } from "@/components/Patient/models"; const PDFViewer = lazy(() => import("@/components/Common/PDFViewer")); - export const zoom_values = [ "scale-25", "scale-50", @@ -38,7 +39,6 @@ export const zoom_values = [ "scale-175", "scale-200", ]; - export interface StateInterface { open: boolean; isImage: boolean; @@ -51,7 +51,6 @@ export interface StateInterface { id?: string; associating_id?: string; } - type FilePreviewProps = { title?: ReactNode; description?: ReactNode; @@ -68,7 +67,6 @@ type FilePreviewProps = { loadFile?: (file: FileUploadModel, associating_id: string) => void; currentIndex: number; }; - const previewExtensions = [ ".html", ".htm", @@ -81,7 +79,6 @@ const previewExtensions = [ ".gif", ".webp", ]; - const FilePreviewDialog = (props: FilePreviewProps) => { const { show, @@ -95,18 +92,15 @@ const FilePreviewDialog = (props: FilePreviewProps) => { currentIndex, } = props; const { t } = useTranslation(); - const [page, setPage] = useState(1); const [numPages, setNumPages] = useState(1); const [index, setIndex] = useState(currentIndex); const [scale, setScale] = useState(1.0); - useEffect(() => { if (uploadedFiles && show) { setIndex(currentIndex); } }, [uploadedFiles, show, currentIndex]); - const handleZoomIn = () => { const checkFull = file_state.zoom === zoom_values.length; setFileState({ @@ -115,7 +109,6 @@ const FilePreviewDialog = (props: FilePreviewProps) => { }); setScale((prevScale) => Math.min(prevScale + 0.25, 2)); }; - const handleZoomOut = () => { const checkFull = file_state.zoom === 1; setFileState({ @@ -124,6 +117,29 @@ const FilePreviewDialog = (props: FilePreviewProps) => { }); setScale((prevScale) => Math.max(prevScale - 0.25, 0.5)); }; + const handleRotate = (angle: number) => { + setFileState((prev: any) => { + const newRotation = (prev.rotation + angle + 360) % 360; + return { + ...prev, + rotation: newRotation, + }; + }); + }; + + function getRotationClass(rotation: number) { + const normalizedRotation = rotation % 360; + switch (normalizedRotation) { + case 90: + return "rotate-90"; + case 180: + return "rotate-180"; + case 270: + return "-rotate-90"; + default: + return ""; + } + } const fileName = file_state?.name ? file_state.name + "." + file_state.extension @@ -138,12 +154,11 @@ const FilePreviewDialog = (props: FilePreviewProps) => { !loadFile || newIndex < 0 || newIndex >= uploadedFiles.length - ) + ) { return; - + } const nextFile = uploadedFiles[newIndex]; if (!nextFile?.id) return; - const associating_id = nextFile.associating_id || ""; loadFile(nextFile, associating_id); setIndex(newIndex); @@ -157,24 +172,8 @@ const FilePreviewDialog = (props: FilePreviewProps) => { onClose?.(); }; - const handleRotate = (rotation: number) => { - setFileState((prev: any) => ({ - ...prev, - rotation: prev.rotation + rotation, - })); - }; - - function getRotationClass(rotation: number) { - let normalizedRotation = ((rotation % 360) + 360) % 360; - if (normalizedRotation > 180) { - normalizedRotation -= 360; - } - return normalizedRotation === -90 - ? "-rotate-90" - : `rotate-${normalizedRotation}`; - } - useKeyboardShortcut(["ArrowLeft"], () => index > 0 && handleNext(index - 1)); + useKeyboardShortcut( ["ArrowRight"], () => index < (uploadedFiles?.length || 0) - 1 && handleNext(index + 1), @@ -188,7 +187,6 @@ const FilePreviewDialog = (props: FilePreviewProps) => { {t("file_preview")} - {fileUrl ? ( <>
@@ -251,7 +249,7 @@ const FilePreviewDialog = (props: FilePreviewProps) => { )} -
+
{file_state.isImage ? ( { sandbox="" title={t("source_file")} src={fileUrl} - className="h-[75vh] w-full" + className="h-[50vh] md:h-[75vh] w-full" /> ) : (
@@ -289,7 +287,6 @@ const FilePreviewDialog = (props: FilePreviewProps) => {
)}
- {uploadedFiles && uploadedFiles.length > 1 && ( )}
-
-
+
+
{file_state.isImage && ( <> {[ @@ -344,7 +341,10 @@ const FilePreviewDialog = (props: FilePreviewProps) => { variant="ghost" key={index} onClick={button[2] as () => void} - className="z-50 rounded bg-white/60 px-4 py-2 text-black backdrop-blur transition hover:bg-white/70" + className={cn( + "z-50 rounded bg-white/60 px-4 py-2 text-black backdrop-blur transition hover:bg-white/70", + index > 2 ? "max-md:col-span-3" : "max-md:col-span-2", + )} disabled={button[3] as boolean} > {button[1] && ( @@ -387,7 +387,10 @@ const FilePreviewDialog = (props: FilePreviewProps) => { variant="ghost" key={index} onClick={button[2] as () => void} - className="z-50 rounded bg-white/60 px-4 py-2 text-black backdrop-blur transition hover:bg-white/70" + className={cn( + "z-50 rounded bg-white/60 px-4 py-2 text-black backdrop-blur transition hover:bg-white/70", + index > 2 ? "max-md:col-span-3" : "max-md:col-span-2", + )} disabled={button[3] as boolean} > {button[1] && ( @@ -405,7 +408,7 @@ const FilePreviewDialog = (props: FilePreviewProps) => {
) : ( -
+
)} @@ -413,5 +416,4 @@ const FilePreviewDialog = (props: FilePreviewProps) => { ); }; - export default FilePreviewDialog; diff --git a/src/components/Common/Page.tsx b/src/components/Common/Page.tsx index 0bb287d8637..a4a2ba48a3e 100644 --- a/src/components/Common/Page.tsx +++ b/src/components/Common/Page.tsx @@ -31,7 +31,7 @@ export default function Page(props: PageProps) { // }, [props.collapseSidebar]); return ( -
+
; +interface GenericTableProps { + headers: HeaderRow[]; + rows: TableRowType[] | undefined; +} + +export default function PrintTable({ headers, rows }: GenericTableProps) { + return ( +
+ + + + {headers.map(({ key, width }, index) => ( + + {t(key)} + + ))} + + + + {!!rows && + rows.map((row, index) => ( + + {headers.map(({ key }) => ( + + {row[key] || "-"} + + ))} + + ))} + +
+
+ ); +} diff --git a/src/components/Encounter/CreateEncounterForm.tsx b/src/components/Encounter/CreateEncounterForm.tsx index a51de24038d..32d7d600fd6 100644 --- a/src/components/Encounter/CreateEncounterForm.tsx +++ b/src/components/Encounter/CreateEncounterForm.tsx @@ -17,6 +17,8 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import * as z from "zod"; +import { cn } from "@/lib/utils"; + import { Button } from "@/components/ui/button"; import { Form, @@ -226,10 +228,12 @@ export default function CreateEncounterForm({ key={value} type="button" data-cy={`encounter-type-${value}`} - className="h-24 w-full justify-start text-lg" - variant={ - field.value === value ? "default" : "outline" - } + className={cn( + "h-24 w-full justify-start text-lg", + field.value === value && + "ring-2 ring-primary text-primary", + )} + variant="outline" onClick={() => field.onChange(value)} >
diff --git a/src/components/Facility/ConsultationDetails/QuestionnaireResponsesList.tsx b/src/components/Facility/ConsultationDetails/QuestionnaireResponsesList.tsx index 2514c7bbdfe..c6fc5667222 100644 --- a/src/components/Facility/ConsultationDetails/QuestionnaireResponsesList.tsx +++ b/src/components/Facility/ConsultationDetails/QuestionnaireResponsesList.tsx @@ -197,12 +197,23 @@ function StructuredResponseBadge({ ); } -function ResponseCard({ item }: { item: QuestionnaireResponse }) { +function ResponseCard({ + item, + isPrintPreview, +}: { + item: QuestionnaireResponse; + isPrintPreview?: boolean; +}) { const isStructured = !item.questionnaire; const structuredType = Object.keys(item.structured_responses || {})[0]; return ( - +
@@ -317,7 +328,12 @@ export default function QuestionnaireResponsesList({ ) : (
{questionnarieResponses?.results?.length === 0 ? ( - +
{t("no_questionnaire_responses")}
@@ -327,7 +343,11 @@ export default function QuestionnaireResponsesList({ {questionnarieResponses?.results?.map( (item: QuestionnaireResponse) => (
  • - +
  • ), )} diff --git a/src/components/Facility/FacilityForm.tsx b/src/components/Facility/FacilityForm.tsx index c6cabe1de6a..4acae303aaf 100644 --- a/src/components/Facility/FacilityForm.tsx +++ b/src/components/Facility/FacilityForm.tsx @@ -144,7 +144,7 @@ export default function FacilityForm({ const handleFeatureChange = (value: string[]) => { const features = value.map((val) => Number(val)); - form.setValue("features", features); + form.setValue("features", features, { shouldDirty: true }); }; const handleGetCurrentLocation = () => { @@ -152,8 +152,12 @@ export default function FacilityForm({ setIsGettingLocation(true); navigator.geolocation.getCurrentPosition( (position) => { - form.setValue("latitude", position.coords.latitude); - form.setValue("longitude", position.coords.longitude); + form.setValue("latitude", position.coords.latitude, { + shouldDirty: true, + }); + form.setValue("longitude", position.coords.longitude, { + shouldDirty: true, + }); setIsGettingLocation(false); toast.success(t("location_updated_successfully")); }, @@ -308,7 +312,6 @@ export default function FacilityForm({ @@ -346,7 +349,9 @@ export default function FacilityForm({ value={form.watch("geo_organization")} selected={selectedLevels} onChange={(value) => - form.setValue("geo_organization", value) + form.setValue("geo_organization", value, { + shouldDirty: true, + }) } required /> @@ -418,6 +423,7 @@ export default function FacilityForm({ form.setValue( "latitude", e.target.value ? Number(e.target.value) : undefined, + { shouldDirty: true }, ); }} data-cy="facility-latitude" @@ -445,6 +451,7 @@ export default function FacilityForm({ form.setValue( "longitude", e.target.value ? Number(e.target.value) : undefined, + { shouldDirty: true }, ); }} data-cy="facility-longitude" @@ -493,7 +500,9 @@ export default function FacilityForm({ type="submit" className="w-full" variant="primary" - disabled={facilityId ? isUpdatePending : isPending} + disabled={ + facilityId ? isUpdatePending || !form.formState.isDirty : isPending + } data-cy={facilityId ? "update-facility" : "submit-facility"} > {facilityId ? ( diff --git a/src/components/Facility/FacilityHome.tsx b/src/components/Facility/FacilityHome.tsx index 7af7f0407fa..2c3e121afd8 100644 --- a/src/components/Facility/FacilityHome.tsx +++ b/src/components/Facility/FacilityHome.tsx @@ -8,7 +8,6 @@ import { toast } from "sonner"; import CareIcon from "@/CAREUI/icons/CareIcon"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Markdown } from "@/components/ui/markdown"; @@ -27,6 +26,7 @@ import query from "@/Utils/request/query"; import uploadFile from "@/Utils/request/uploadFile"; import { getAuthorizationHeader } from "@/Utils/request/utils"; import { sleep } from "@/Utils/utils"; +import { FeatureBadge } from "@/pages/Facility/Utils"; import EditFacilitySheet from "@/pages/Organization/components/EditFacilitySheet"; import { FacilityData } from "@/types/facility/facility"; import type { @@ -35,6 +35,8 @@ import type { } from "@/types/organization/organization"; import { getOrgLabel } from "@/types/organization/organization"; +import { FacilityMapsLink } from "./FacilityMapLink"; + type Props = { facilityId: string; }; @@ -200,12 +202,12 @@ export const FacilityHome = ({ facilityId }: Props) => {
    )}
    - +
    @@ -221,10 +223,6 @@ export const FacilityHome = ({ facilityId }: Props) => {
    - {/* TODO: add delete facility @@ -267,7 +265,7 @@ export const FacilityHome = ({ facilityId }: Props) => { aria-label={t("edit_cover_photo")} >
    -
    +
    + - + } @@ -322,8 +321,13 @@ export const FacilityHome = ({ facilityId }: Props) => { {t("location_details")} - - {/* Add Location Link Here */} + + {facilityData.latitude && facilityData.longitude && ( + + )}
    @@ -355,33 +359,18 @@ export const FacilityHome = ({ facilityId }: Props) => { ) && ( - + {t("features")}
    - {facilityData?.features?.map( - (feature: number) => - FACILITY_FEATURE_TYPES.some( - (f) => f.id === feature, - ) && ( - - {getFacilityFeatureIcon(feature)} - - { - FACILITY_FEATURE_TYPES.find( - (f) => f.id === feature, - )?.name - } - - - ), - )} + {facilityData.features?.map((featureId) => ( + + ))}
    diff --git a/src/components/Facility/FacilityMapLink.tsx b/src/components/Facility/FacilityMapLink.tsx new file mode 100644 index 00000000000..47a76082fbf --- /dev/null +++ b/src/components/Facility/FacilityMapLink.tsx @@ -0,0 +1,42 @@ +import { SquareArrowOutUpRight } from "lucide-react"; +import { Link } from "raviger"; +import { useTranslation } from "react-i18next"; + +import { getMapUrl, isAndroidDevice } from "@/Utils/utils"; + +const isValidLatitude = (latitude: string) => { + const lat = parseFloat(latitude.trim()); + return Number.isFinite(lat) && lat >= -90 && lat <= 90; +}; + +const isValidLongitude = (longitude: string) => { + const long = parseFloat(longitude.trim()); + return Number.isFinite(long) && long >= -180 && long <= 180; +}; + +export const FacilityMapsLink = ({ + latitude, + longitude, +}: { + latitude: string; + longitude: string; +}) => { + const { t } = useTranslation(); + + if (!isValidLatitude(latitude) || !isValidLongitude(longitude)) { + return null; + } + const target = isAndroidDevice ? "_self" : "_blank"; + + return ( + + {t("show_on_map")} + + + ); +}; diff --git a/src/components/Files/FilesTab.tsx b/src/components/Files/FilesTab.tsx index 50fb179cd36..3c1abcbfb45 100644 --- a/src/components/Files/FilesTab.tsx +++ b/src/components/Files/FilesTab.tsx @@ -251,54 +251,52 @@ export const FilesTab = (props: FilesTabProps) => { const filetype = getFileType(file); return ( <> - {editPermission() && ( -
    - {filetype === "AUDIO" && !file.is_archived && ( - - )} - {fileManager.isPreviewable(file) && ( - + )} + {fileManager.isPreviewable(file) && ( + + )} + + + - )} - { - - - - - - - - + + + + + + {editPermission() && ( + <> - - - } -
    - )} + + )} + + +
    ); }; diff --git a/src/components/Location/LocationHistorySheet.tsx b/src/components/Location/LocationHistorySheet.tsx deleted file mode 100644 index de0ddb25abb..00000000000 --- a/src/components/Location/LocationHistorySheet.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useTranslation } from "react-i18next"; - -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; - -import { LocationHistory } from "@/types/emr/encounter"; - -import { LocationTree } from "./LocationTree"; - -interface LocationHistorySheetProps { - trigger: React.ReactNode; - history: LocationHistory[]; -} - -export function LocationHistorySheet({ - trigger, - history, -}: LocationHistorySheetProps) { - const { t } = useTranslation(); - - return ( - - {trigger} - - - {t("location_history")} - - - {history.map((item, index) => ( -
    - -
    - ))} -
    -
    -
    - ); -} diff --git a/src/components/Location/LocationSearch.tsx b/src/components/Location/LocationSearch.tsx index a27d8e8d1ec..3e741c7c03f 100644 --- a/src/components/Location/LocationSearch.tsx +++ b/src/components/Location/LocationSearch.tsx @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { t } from "i18next"; import { useState } from "react"; import { @@ -15,6 +16,7 @@ import { } from "@/components/ui/popover"; import query from "@/Utils/request/query"; +import { stringifyNestedObject } from "@/Utils/utils"; import { LocationList } from "@/types/location/location"; import locationApi from "@/types/location/locationApi"; @@ -40,7 +42,7 @@ export function LocationSearch({ queryKey: ["locations", facilityId, mode, search], queryFn: query(locationApi.list, { pathParams: { facility_id: facilityId }, - queryParams: { mode, name: search }, + queryParams: { mode, name: search, form: "bd", available: "true" }, }), enabled: facilityId !== "preview", }); @@ -52,7 +54,7 @@ export function LocationSearch({ role="combobox" aria-expanded={open} > - {value?.name || "Select location..."} + {stringifyNestedObject(value || { name: "" }) || "Select location..."}
    @@ -60,9 +62,10 @@ export function LocationSearch({ - No locations found. + {t("no_locations_found")} {locations?.results.map((location) => ( - {location.name} + {stringifyNestedObject(location)} ))} diff --git a/src/components/Location/LocationSheet.tsx b/src/components/Location/LocationSheet.tsx new file mode 100644 index 00000000000..047f9d2d7e7 --- /dev/null +++ b/src/components/Location/LocationSheet.tsx @@ -0,0 +1,456 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { format, isAfter, isBefore, parseISO } from "date-fns"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; + +import mutate from "@/Utils/request/mutate"; +import { stringifyNestedObject } from "@/Utils/utils"; +import { LocationHistory } from "@/types/emr/encounter"; +import { + LocationAssociationStatus, + LocationAssociationUpdate, +} from "@/types/location/association"; +import { LocationList } from "@/types/location/location"; +import locationApi from "@/types/location/locationApi"; + +import { LocationSearch } from "./LocationSearch"; +import { LocationTree } from "./LocationTree"; + +interface LocationSheetProps { + trigger: React.ReactNode; + history: LocationHistory[]; + facilityId: string; + encounterId: string; +} + +interface LocationState extends LocationHistory { + displayStatus: LocationAssociationStatus; +} + +interface ValidationError { + message: string; + field: "start_datetime" | "end_datetime"; +} + +// Omit id field for creation +type LocationAssociationCreate = Omit; + +export function LocationSheet({ + trigger, + history, + facilityId, + encounterId, +}: LocationSheetProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const initialState = { + location: "", + status: "active", + start_datetime: format(new Date(), "yyyy-MM-dd'T'HH:mm"), + end_datetime: format(new Date(), "yyyy-MM-dd'T'HH:mm"), + encounter: encounterId, + }; + const [newLocation, setNewLocation] = useState(initialState); + + const [locations, setLocations] = useState([]); + + useEffect(() => { + setLocations( + history.map((loc) => ({ + ...loc, + displayStatus: loc.status, + end_datetime: loc.status === "active" ? undefined : loc.end_datetime, + })), + ); + }, [history]); + + function validateTimes( + status: LocationAssociationStatus, + startTime: string, + endTime?: string, + ): ValidationError | null { + const now = new Date(); + const start = parseISO(startTime); + + if (!startTime) { + return { message: t("start_time_required"), field: "start_datetime" }; + } + + if (status !== "active" && !endTime) { + return { message: t("end_time_required"), field: "end_datetime" }; + } + + if (endTime) { + const end = parseISO(endTime); + if (isBefore(end, start)) { + return { + message: t("start_time_must_be_before_end_time"), + field: "end_datetime", + }; + } + } + + if ( + (status === "planned" || status === "reserved") && + isBefore(start, now) + ) { + return { + message: t("planned_reserved_cannot_be_in_past"), + field: "start_datetime", + }; + } + + if (status === "active" && isAfter(start, now)) { + return { + message: t("active_location_cannot_be_in_future"), + field: "start_datetime", + }; + } + + return null; + } + + const handleLocationUpdate = (updatedLocation: LocationState) => { + setLocations((prevLocations) => + prevLocations.map((loc) => + loc.id === updatedLocation.id + ? { + ...updatedLocation, + end_datetime: + updatedLocation.status === "active" + ? undefined + : updatedLocation.end_datetime, + } + : loc, + ), + ); + }; + + const [selectedLocation, setSelectedLocation] = useState( + null, + ); + + const updateAssociation = useMutation({ + mutationFn: (location: LocationAssociationUpdate) => { + const validationError = validateTimes( + location.status, + location.start_datetime, + location.end_datetime, + ); + + if (validationError) { + throw new Error(validationError.message); + } + + return mutate(locationApi.updateAssociation, { + pathParams: { + facility_external_id: facilityId, + location_external_id: location.location, + external_id: location.id, + }, + })(location); + }, + onSuccess: () => { + toast.success(t("location_association_updated_successfully")); + queryClient.invalidateQueries({ queryKey: ["encounter", encounterId] }); + }, + }); + + const { mutate: createAssociation, isPending } = useMutation({ + mutationFn: (data: LocationAssociationCreate) => { + const validationError = validateTimes( + data.status, + data.start_datetime, + data.end_datetime, + ); + + if (validationError) { + throw new Error(validationError.message); + } + + return mutate(locationApi.createAssociation, { + pathParams: { + facility_external_id: facilityId, + location_external_id: selectedLocation?.id, + }, + })(data); + }, + onSuccess: () => { + toast.success(t("location_association_created_successfully")); + queryClient.invalidateQueries({ queryKey: ["encounter", encounterId] }); + setNewLocation(initialState); + setSelectedLocation(null); + }, + }); + + const renderLocation = (location: LocationState) => ( +
    +
    + +
    + + +
    +
    + + {stringifyNestedObject(location.location, " < ")} + +
    + {(location.status === "active" || + location.status === "planned" || + location.status === "reserved") && ( +
    + + + handleLocationUpdate({ + ...location, + start_datetime: e.target.value, + }) + } + className="h-9 w-auto" + /> +
    + )} + {location.status !== "active" && ( +
    + + + handleLocationUpdate({ + ...location, + end_datetime: e.target.value, + }) + } + className="h-9" + /> +
    + )} +
    +
    + ); + + // Get locations by their original display status + const activeLocation = locations.find( + (loc) => loc.displayStatus === "active", + ); + const plannedLocations = locations.filter( + (loc) => loc.displayStatus === "planned", + ); + const reservedLocations = locations.filter( + (loc) => loc.displayStatus === "reserved", + ); + + return ( + + {trigger} + + + + {t("update_location")} + +

    + {t("manage_patient_location_and_transfers")} +

    +
    + +
    + {/* Active Location */} + {activeLocation && renderLocation(activeLocation)} + + {/* Reserved Locations */} + {reservedLocations.map((location) => renderLocation(location))} + + {/* Planned Locations */} + {plannedLocations.map((location) => renderLocation(location))} + +
    +
    +
    + +
    +
    + setSelectedLocation(location)} + value={selectedLocation} + /> + {selectedLocation && ( +
    +
    + + +
    + {(newLocation.status === "active" || + newLocation.status === "planned" || + newLocation.status === "reserved") && ( +
    + + + setNewLocation((prev) => ({ + ...prev, + start_datetime: e.target.value, + })) + } + className="h-9" + /> +
    + )} + {newLocation.status !== "active" && ( +
    + + + setNewLocation((prev) => ({ + ...prev, + end_datetime: e.target.value, + })) + } + className="h-9" + /> +
    + )} + +
    + )} +
    +
    + {history.map((item, index) => ( +
    + +
    + ))} +
    + + + + ); +} diff --git a/src/components/Medicine/MedicationAdministration/AdministrationTab.tsx b/src/components/Medicine/MedicationAdministration/AdministrationTab.tsx index 4a0bc9d63ac..cb53325fd11 100644 --- a/src/components/Medicine/MedicationAdministration/AdministrationTab.tsx +++ b/src/components/Medicine/MedicationAdministration/AdministrationTab.tsx @@ -1,14 +1,15 @@ "use client"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { format, formatDistanceToNow } from "date-fns"; import { t } from "i18next"; -import React, { useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, @@ -257,15 +258,30 @@ const MedicationRow: React.FC = ({ return (
    onEditAdministration(medication, admin)} > -
    +
    +
    +
    + {admin.note && ( + + )} +
    +
    +
    + {new Date( admin.occurrence_period_start, ).toLocaleTimeString("en-US", { @@ -273,11 +289,11 @@ const MedicationRow: React.FC = ({ minute: "2-digit", hour12: true, })} -
    -
    - {admin.occurrence_period_end && ( - <> - {"- "} + + {admin.occurrence_period_end && ( + <> + {"-"} + {new Date( admin.occurrence_period_end, ).toLocaleTimeString("en-US", { @@ -285,19 +301,10 @@ const MedicationRow: React.FC = ({ minute: "2-digit", hour12: true, })} - - )} -
    + + + )}
    - {admin.note && ( - - )}
    ); })} @@ -361,7 +368,7 @@ export const AdministrationTab: React.FC = ({ Math.floor(currentDate.getHours() / 6), ); // Calculate visible slots based on end slot - const visibleSlots = React.useMemo(() => { + const visibleSlots = useMemo(() => { const slots = []; let currentIndex = endSlotIndex; let currentDate = new Date(endSlotDate); @@ -382,8 +389,10 @@ export const AdministrationTab: React.FC = ({ return slots; }, [endSlotDate, endSlotIndex]); + const queryClient = useQueryClient(); + // Queries - const { data: activeMedications, refetch: refetchActive } = useQuery({ + const { data: activeMedications } = useQuery({ queryKey: ["medication_requests_active", patientId], queryFn: query(medicationRequestApi.list, { pathParams: { patientId }, @@ -396,7 +405,7 @@ export const AdministrationTab: React.FC = ({ enabled: !!patientId, }); - const { data: stoppedMedications, refetch: refetchStopped } = useQuery({ + const { data: stoppedMedications } = useQuery({ queryKey: ["medication_requests_stopped", patientId], queryFn: query(medicationRequestApi.list, { pathParams: { patientId }, @@ -409,7 +418,7 @@ export const AdministrationTab: React.FC = ({ enabled: !!patientId, }); - const { data: administrations, refetch: refetchAdministrations } = useQuery({ + const { data: administrations } = useQuery({ queryKey: ["medication_administrations", patientId, visibleSlots], queryFn: query(medicationAdministrationApi.list, { pathParams: { patientId }, @@ -438,7 +447,7 @@ export const AdministrationTab: React.FC = ({ }); // Get last administered date and last administered by for each medication - const lastAdministeredDetails = React.useMemo(() => { + const lastAdministeredDetails = useMemo(() => { return administrations?.results?.reduce<{ dates: Record; performers: Record; @@ -473,7 +482,7 @@ export const AdministrationTab: React.FC = ({ }; // Calculate if we can go back further based on the earliest slot and authored date - const canGoBack = React.useMemo(() => { + const canGoBack = useMemo(() => { const medications = showStopped ? [ ...(activeMedications?.results || []), @@ -500,7 +509,7 @@ export const AdministrationTab: React.FC = ({ useState(null); // Calculate last modified date - const lastModifiedDate = React.useMemo(() => { + const lastModifiedDate = useMemo(() => { if (!administrations?.results?.length) return null; const sortedAdmins = [...administrations.results].sort( @@ -518,13 +527,17 @@ export const AdministrationTab: React.FC = ({ pathParams: { patientId }, }), onSuccess: () => { - refetchActive(); - refetchStopped(); + queryClient.invalidateQueries({ + queryKey: ["medication_requests_active"], + }); + queryClient.invalidateQueries({ + queryKey: ["medication_requests_stopped"], + }); }, }); // Handlers - const handlePreviousSlot = React.useCallback(() => { + const handlePreviousSlot = useCallback(() => { if (!canGoBack) return; const newEndSlotIndex = endSlotIndex - 1; @@ -538,7 +551,7 @@ export const AdministrationTab: React.FC = ({ } }, [endSlotDate, endSlotIndex, canGoBack]); - const handleNextSlot = React.useCallback(() => { + const handleNextSlot = useCallback(() => { const newEndSlotIndex = endSlotIndex + 1; if (newEndSlotIndex > 3) { setEndSlotIndex(0); @@ -550,7 +563,7 @@ export const AdministrationTab: React.FC = ({ } }, [endSlotDate, endSlotIndex]); - const handleAdminister = React.useCallback( + const handleAdminister = useCallback( (medication: MedicationRequestRead) => { setAdministrationRequest( createMedicationAdministrationRequest(medication, encounterId), @@ -561,7 +574,7 @@ export const AdministrationTab: React.FC = ({ [encounterId], ); - const handleEditAdministration = React.useCallback( + const handleEditAdministration = useCallback( (medication: MedicationRequestRead, admin: MedicationAdministration) => { setAdministrationRequest({ id: admin.id, @@ -580,7 +593,7 @@ export const AdministrationTab: React.FC = ({ [], ); - const handleDiscontinue = React.useCallback( + const handleDiscontinue = useCallback( (medication: MedicationRequestRead) => { discontinueMedication({ datapoints: [ @@ -602,14 +615,16 @@ export const AdministrationTab: React.FC = ({ ] : activeMedications?.results || []; - const filteredMedications = medications.filter( - (med: MedicationRequestRead) => { - if (!searchQuery.trim()) return true; - const searchTerm = searchQuery.toLowerCase().trim(); - const medicationName = med.medication?.display?.toLowerCase() || ""; - return medicationName.includes(searchTerm); - }, - ); + const filteredMedications = !searchQuery.trim() + ? medications + : [ + ...(activeMedications?.results || []), + ...(stoppedMedications?.results || []), + ].filter((med: MedicationRequestRead) => + med.medication?.display + ?.toLowerCase() + .includes(searchQuery.toLowerCase().trim()), + ); let content; if (!activeMedications || !stoppedMedications) { @@ -618,10 +633,13 @@ export const AdministrationTab: React.FC = ({
    ); - } else if (!medications?.length) { + } else if ( + !activeMedications?.results?.length && + !stoppedMedications?.results?.length + ) { content = ( ); @@ -629,124 +647,134 @@ export const AdministrationTab: React.FC = ({ content = ; } else { content = ( - - -
    - {/* Top row without vertical borders */} -
    -
    -
    - {lastModifiedDate && ( -
    - {t("last_modified")}{" "} - {formatDistanceToNow(lastModifiedDate)} {t("ago")} -
    - )} + <> + {!filteredMedications.length && ( + +

    + {t("no_active_medication_recorded")} +

    +
    + )} + + +
    + {/* Top row without vertical borders */} +
    +
    +
    + {lastModifiedDate && ( +
    + {t("last_modified")}{" "} + {formatDistanceToNow(lastModifiedDate)} {t("ago")} +
    + )} +
    +
    + +
    -
    + {visibleSlots.map((slot) => ( + + ))} +
    - {visibleSlots.map((slot) => ( - - ))} -
    - -
    -
    - {/* Main content with borders */} -
    - {/* Headers */} -
    - {t("medicine")}: -
    - {visibleSlots.map((slot, i) => ( -
    - {i === endSlotIndex && - slot.date.getTime() === currentDate.getTime() && ( -
    -
    -
    - )} - {slot.label} + {/* Main content with borders */} +
    + {/* Headers */} +
    + {t("medicine")}:
    - ))} -
    - - {/* Medication rows */} - {filteredMedications?.map((medication) => ( - - ))} + {visibleSlots.map((slot, i) => ( +
    + {i === endSlotIndex && + slot.date.getTime() === currentDate.getTime() && ( +
    +
    +
    + )} + {slot.label} +
    + ))} +
    + + {/* Medication rows */} + {filteredMedications?.map((medication) => ( + + ))} +
    -
    - {stoppedMedications?.results?.length > 0 && ( -
    setShowStopped(!showStopped)} - > - - - {showStopped ? t("hide") : t("show")}{" "} - {`${stoppedMedications?.results?.length} ${t("stopped")}`}{" "} - {t("prescriptions")} - -
    - )} - - - + {stoppedMedications?.results?.length > 0 && !searchQuery.trim() && ( +
    setShowStopped(!showStopped)} + > + + + {showStopped ? t("hide") : t("show")}{" "} + {`${stoppedMedications?.results?.length} ${t("stopped")}`}{" "} + {t("prescriptions")} + +
    + )} + + + + ); } return (
    -
    +
    - setSearchQuery(e.target.value)} @@ -766,8 +794,9 @@ export const AdministrationTab: React.FC = ({