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 a00b8fa6f0e..f8bd48db6b5 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", @@ -305,6 +306,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", @@ -700,6 +702,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", @@ -741,6 +744,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", @@ -755,7 +759,8 @@ "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", @@ -962,6 +967,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.", @@ -1021,6 +1027,7 @@ "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)", @@ -1305,6 +1312,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?", @@ -1367,6 +1375,8 @@ "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", @@ -1419,6 +1429,7 @@ "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", @@ -1525,6 +1536,7 @@ "next_sessions": "Next Sessions", "next_week_short": "Next wk", "no": "No", + "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", @@ -1566,6 +1578,7 @@ "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", @@ -1657,6 +1670,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", @@ -1735,6 +1749,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", @@ -1786,6 +1801,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", @@ -1982,6 +1999,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", @@ -2111,6 +2129,7 @@ "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", @@ -2217,10 +2236,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", @@ -2258,6 +2280,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?", diff --git a/public/locale/ml.json b/public/locale/ml.json index 871e9cba741..8ae49d208d8 100644 --- a/public/locale/ml.json +++ b/public/locale/ml.json @@ -676,7 +676,6 @@ "date_of_return": "മടങ്ങിവരുന്ന തീയതി", "date_of_test": "ടെസ്റ്റ് തീയതി", "day": "ദിവസം", - "death_report": "മരണ റിപ്പോർട്ട്", "delete": "ഇല്ലാതാക്കുക", "delete_account": "അക്കൗണ്ട് ഇല്ലാതാക്കുക", "delete_account_btn": "അതെ, ഈ അക്കൗണ്ട് ഇല്ലാതാക്കുക", 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 ( -
+
@@ -358,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 edd84bdf56b..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; }; @@ -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 288a283773e..3e741c7c03f 100644 --- a/src/components/Location/LocationSearch.tsx +++ b/src/components/Location/LocationSearch.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; +import { t } from "i18next"; import { useState } from "react"; -import { useTranslation } from "react-i18next"; import { Command, @@ -16,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"; @@ -34,7 +35,6 @@ export function LocationSearch({ disabled, value, }: LocationSearchProps) { - const { t } = useTranslation(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -42,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", }); @@ -54,7 +54,7 @@ export function LocationSearch({ role="combobox" aria-expanded={open} > - {value?.name || "Select location..."} + {stringifyNestedObject(value || { name: "" }) || "Select location..."}
    @@ -65,7 +65,7 @@ export function LocationSearch({ className="outline-none border-none ring-0 shadow-none" onValueChange={setSearch} /> - No locations found. + {t("no_locations_found")} {locations?.results.map((location) => ( - {location.name} - - {t(`location_form__${location.form}`)} - {" in "} - {formatLocationParent(location)} - - - {t(`location_status__${location.status}`)} - + {stringifyNestedObject(location)} ))} @@ -93,12 +85,3 @@ export function LocationSearch({ ); } - -const formatLocationParent = (location: LocationList) => { - const parents: string[] = []; - while (location.parent?.name) { - parents.push(location.parent?.name); - location = location.parent; - } - return parents.reverse().join(" > "); -}; 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 93d8c430717..cb53325fd11 100644 --- a/src/components/Medicine/MedicationAdministration/AdministrationTab.tsx +++ b/src/components/Medicine/MedicationAdministration/AdministrationTab.tsx @@ -8,7 +8,7 @@ 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, @@ -647,119 +647,130 @@ 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 && !searchQuery.trim() && ( -
    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 (
    -
    +
    @@ -783,8 +794,9 @@ export const AdministrationTab: React.FC = ({