diff --git a/.env b/.env index 6c1f64a7ce2..6dd78d32548 100644 --- a/.env +++ b/.env @@ -9,7 +9,7 @@ HEADERS="Strict-Transport-Security: max-age=31536000; includeSubDomains; preload REACT_RECAPTCHA_SITE_KEY=6LcedK8qAAAAAM2PpuqlqhZUxQpmIqHqluL74dDs # Care API URL without the /api prefix -REACT_CARE_API_URL=https://careapi.ohc.network +REACT_CARE_API_URL=https://develop-api.ohc.network REACT_SBOM_BASE_URL=https://sbom.ohc.network # Default payment terms for invoices diff --git a/.github/instructions/hooks.instructions.md b/.github/instructions/hooks.instructions.md index 3e9afeff6fc..5ab26117683 100644 --- a/.github/instructions/hooks.instructions.md +++ b/.github/instructions/hooks.instructions.md @@ -9,7 +9,6 @@ applyTo: "src/hooks/**/*.{ts,tsx}" ### Authentication & User Management - useAuthUser: Current user state and permissions - usePatientUser: Patient authentication for public appointments -- usePatientSignOut: Secure patient session termination ### Medical Data Management - useFilters: Medical record filtering and search diff --git a/care.config.ts b/care.config.ts index dc09c10f398..87c6fd6f2c6 100644 --- a/care.config.ts +++ b/care.config.ts @@ -79,8 +79,7 @@ const careConfig = { : undefined), defaultDischargeDisposition: env.REACT_DEFAULT_DISCHARGE_DISPOSITION as - | EncounterDischargeDisposition - | undefined, + EncounterDischargeDisposition | undefined, mapFallbackUrlTemplate: env.REACT_MAPS_FALLBACK_URL_TEMPLATE || @@ -246,6 +245,29 @@ const careConfig = { ? parseInt(env.REACT_APP_RESEND_OTP_TIMEOUT, 10) : 30, + /** + * Number of digits in the OTP sent for patient login and password reset. + * Must match the backend's `OTP_LENGTH` setting, which defaults to 5. + */ + otpLength: env.REACT_OTP_LENGTH ? parseInt(env.REACT_OTP_LENGTH, 10) : 5, + + /** + * How long a patient login token is treated as still valid without hitting + * the API, so a returning patient can skip straight past the OTP screen. + * Must stay under the backend's `PatientToken.lifetime` (currently 1 hour, + * see `config/patient_otp_token.py`) — kept well under to leave a safety + * margin, since the real expiry is only enforced server-side. + */ + patientTokenFreshnessMinutes: env.REACT_PATIENT_TOKEN_FRESHNESS_MINUTES + ? parseInt(env.REACT_PATIENT_TOKEN_FRESHNESS_MINUTES, 10) + : 15, + + /** + * Health helpline shown to patients who cannot sign in. Deployment specific, + * so the prompt is hidden entirely when this is unset. + */ + patientSupportPhone: env.REACT_PATIENT_SUPPORT_PHONE, + imageUploadMaxSizeInMB: env.REACT_APP_MAX_IMAGE_UPLOAD_SIZE_MB ? parseInt(env.REACT_APP_MAX_IMAGE_UPLOAD_SIZE_MB, 10) : 2, diff --git a/public/images/care_patient_login.svg b/public/images/care_patient_login.svg new file mode 100644 index 00000000000..2a3a9046c2e --- /dev/null +++ b/public/images/care_patient_login.svg @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/locale/en.json b/public/locale/en.json index 499f24f8a9d..5c1460ca1a3 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -30,6 +30,15 @@ "BLOOD_GROUP_LONG__O_negative": "O Negative", "BLOOD_GROUP_LONG__O_positive": "O Positive", "BLOOD_GROUP_LONG__unknown": "Unknown", + "BLOOD_GROUP__AB_negative": "AB-", + "BLOOD_GROUP__AB_positive": "AB+", + "BLOOD_GROUP__A_negative": "A-", + "BLOOD_GROUP__A_positive": "A+", + "BLOOD_GROUP__B_negative": "B-", + "BLOOD_GROUP__B_positive": "B+", + "BLOOD_GROUP__O_negative": "O-", + "BLOOD_GROUP__O_positive": "O+", + "BLOOD_GROUP__unknown": "Unknown", "BOWEL_ISSUE__CONSTIPATION": "Constipation", "BOWEL_ISSUE__DIARRHOEA": "Diarrhoea", "BOWEL_ISSUE__NO_DIFFICULTY": "No difficulty", @@ -971,6 +980,32 @@ "batch_no": "Batch #", "batch_number_required_at_row": "Batch number is required at row {{row}}", "batch_select": "Batch", + "beckn_appointment_booked": "Appointment booked.", + "beckn_booking_appointment": "Booking appointment...", + "beckn_choose_slot": "Choose a slot", + "beckn_confirm_appointment": "Confirm appointment", + "beckn_confirm_booking": "Confirm booking", + "beckn_confirming_referral": "Confirming referral...", + "beckn_find_providers": "Find providers", + "beckn_loading_slots": "Loading available slots...", + "beckn_network_not_configured": "The coordination network is not configured.", + "beckn_no_abha_on_file": "This patient has no ABHA on file. The booking will be confirmed using their name only.", + "beckn_no_providers_returned": "No providers were returned.", + "beckn_no_slots_returned": "No slots were returned.", + "beckn_option_fallback_label": "Option {{index}}", + "beckn_provider_offer": "Provider offer", + "beckn_referral_booking": "Referral booking", + "beckn_referral_created": "Referral request created.", + "beckn_request_failed": "The request could not be completed. Please try again.", + "beckn_resource_request_id": "Resource request ID: {{id}}", + "beckn_searching_providers": "Searching for providers...", + "beckn_select_provider": "Select provider", + "beckn_select_provider_offer": "Select a provider offer", + "beckn_slot": "Slot", + "beckn_slot_fallback_label": "Slot {{index}}", + "beckn_start_over": "Start over", + "beckn_submitting_referral": "Submitting referral request...", + "beckn_working": "Working...", "bed_active_removed_due_to_error": "Active bed assignment has been marked as an error and removed successfully.", "bed_assigned_successfully": "Bed Assigned Successfully", "bed_association": "Bed Association", @@ -1049,6 +1084,7 @@ "book_appointment": "Book Appointment", "booked": "Booked", "booked_by": "Booked by", + "booked_on": "Booked on", "bookings": "Bookings", "bookmark_page": "Bookmark Page", "boolean": "Boolean", @@ -1138,6 +1174,49 @@ "category_required_for_item": "Category is required for {{item}}", "category_updated_successfully": "Category updated successfully", "caution": "Caution", + "cc_console": "CC Console", + "ccn_abha_label": "ABHA", + "ccn_back_to_form": "Back to form", + "ccn_booked_banner": "Appointment booked for this referral.", + "ccn_booking_available_once_approved": "Appointment booking is available once the request is approved.", + "ccn_booking_cta_description": "Search the care network for providers and book a slot for this patient.", + "ccn_confirm_referral": "Confirm referral", + "ccn_coordination_desk": "Coordination desk", + "ccn_empty_incoming_description": "Referrals to be coordinated at your facility will appear here.", + "ccn_empty_incoming_title": "No incoming requests", + "ccn_empty_outgoing_description": "Requests your facility sends to other facilities will appear here.", + "ccn_empty_outgoing_title": "No outgoing requests", + "ccn_network_activity": "Network activity", + "ccn_outgoing_read_only": "This request was sent by your facility. The receiving facility coordinates the appointment.", + "ccn_ready_to_book": "Ready to book", + "ccn_referral_id": "Referral ID", + "ccn_select_request_title": "Select a request", + "ccn_select_request_to_begin": "Select a resource request to begin.", + "ccn_service_type": "Service type", + "ccn_service_type__lab_test": "Lab test", + "ccn_service_type__physical_consultation": "Physical consultation", + "ccn_transaction_label": "Transaction", + "ccn_untitled_request": "Untitled request", + "ccn_wizard_abandon_cancel": "Keep booking", + "ccn_wizard_abandon_confirm": "Discard booking", + "ccn_wizard_abandon_description": "The appointment hasn't been confirmed yet. Leaving now discards the current selection.", + "ccn_wizard_abandon_title": "Discard this booking?", + "ccn_wizard_choose_provider": "Choose a provider", + "ccn_wizard_choose_provider_description": "Providers on the care network offering {{service}}", + "ccn_wizard_choose_service": "What does this patient need?", + "ccn_wizard_choose_slot": "Pick a time", + "ccn_wizard_choose_slot_description": "Available slots at {{provider}}", + "ccn_wizard_done_description": "The appointment is confirmed on the care network.", + "ccn_wizard_done_title": "Appointment booked", + "ccn_wizard_review_description": "Check everything before confirming on the care network.", + "ccn_wizard_review_title": "Review and confirm", + "ccn_wizard_slots_any_day": "Available slots", + "ccn_wizard_slow_hint": "This is taking longer than usual. The network may be busy — you can keep waiting or start over.", + "ccn_wizard_step__provider": "Provider", + "ccn_wizard_step__review": "Review", + "ccn_wizard_step__service": "Service", + "ccn_wizard_step__slot": "Slot", + "ccn_wizard_try_again": "Try again", "center": "Center", "central": "Central", "central_nursing_station": "Central Nursing Station", @@ -1152,6 +1231,7 @@ "change_interpretation_type": "Change Interpretation Type", "change_location": "Change Location", "change_location_confirm": "Change Location?", + "change_number": "Change Number", "change_payment_account_description_one": "Move {{count}} payment to a different account", "change_payment_account_description_other": "Move {{count}} payments to a different account", "change_phone_number": "Change Phone Number", @@ -1872,6 +1952,7 @@ "diagnostic_report_not_found": "Diagnostic Report Not Found", "diagnostic_report_one": "Diagnostic Report", "diagnostic_report_other": "Diagnostic Reports", + "diagnostic_reports": "Diagnostic Reports", "diagnostic_results_confirm_approval_description": "Are you sure you want to approve these diagnostic results? This action cannot be undone.", "diastolic": "Diastolic", "didnt_receive_a_message": "Didn't receive a message?", @@ -3098,6 +3179,7 @@ "landline": "Indian landline", "landmark": "Landmark", "landscape": "Landscape", + "language": "Language", "language_selection": "Language Selection", "languages": "Languages", "last_administered": "Last administered", @@ -4241,6 +4323,27 @@ "patient_basics": "Patient Basics", "patient_birth_year_for_identity": "Please enter the patient's year of birth to verify their identity", "patient_body": "Patient Body", + "patient_booking__add_to_calendar": "Add to calendar", + "patient_booking__all_facilities": "All facilities", + "patient_booking__arrive_early_description": "Carry any earlier prescriptions. Tokens are called in order of arrival within the slot.", + "patient_booking__arrive_early_heading": "Reach 15 minutes early", + "patient_booking__back_to_home": "Back to home", + "patient_booking__booking_for": "Booking for", + "patient_booking__booking_reference": "Booking reference", + "patient_booking__calendar_title": "Appointment with {{name}}", + "patient_booking__choose_practitioner": "Choose a doctor", + "patient_booking__confirm_appointment": "Confirm appointment", + "patient_booking__facilities_in": "Facilities in {{area}}", + "patient_booking__no_practitioners": "No doctors at this facility are open for online booking yet. You can still visit as a walk-in.", + "patient_booking__pick_a_slot": "Pick a slot", + "patient_booking__practitioner_hint": "Tokens are called in order of arrival within your slot.", + "patient_booking__reason_for_visit": "Reason for visit", + "patient_booking__reason_hint": "Optional, but it helps the doctor prepare.", + "patient_booking__sms_sent": "A confirmation SMS has been sent to {{phone}}", + "patient_booking__step_of": "Step {{step}} of {{total}}", + "patient_booking__switch_patient_hint": "Switch patient from the home screen.", + "patient_booking__what_brings_you_in": "What brings you in?", + "patient_booking__your_token": "Your token", "patient_care": "Patient Care", "patient_category": "Patient Category", "patient_clinical_history_page_title": "{{ name }}'s clinical history", @@ -4269,6 +4372,16 @@ "patient_files": "Patient Files", "patient_gender": "Gender", "patient_home": "Patient Home", + "patient_home__book_first_appointment": "Book the first appointment", + "patient_home__empty_description": "Once {{name}} visits a health facility, appointments, prescriptions and reports will show up here automatically.", + "patient_home__empty_heading": "Nothing here yet", + "patient_home__greeting": "Hello, {{name}}", + "patient_home__quick_book_op": "Book OP", + "patient_home__quick_rx": "Rx", + "patient_home__recent_prescriptions": "Recent prescriptions", + "patient_home__recent_reports": "Recent reports", + "patient_home__records_digitisation_note": "Records older than 2024 may not be digitised. Ask the facility for a physical copy.", + "patient_home__upcoming_appointment": "Upcoming appointment", "patient_id": "Patient ID", "patient_id_abha": "Patient Id (ABHA)", "patient_identifier_config": "Patient Identifier Config", @@ -4277,7 +4390,21 @@ "patient_instruction": "Patient Instruction", "patient_is_deceased": "Patient is deceased", "patient_location": "Patient Location", - "patient_login": "Log in as Patient", + "patient_login": "Sign in as Patient", + "patient_login__back_to_login": "Back to login", + "patient_login__consent": "I consent to the terms and conditions and agree to sign in securely to access my healthcare account.", + "patient_login__consent_why": "Why we ask this", + "patient_login__consent_why_description": "care uses your mobile number to match you with the health records your facility has on file. We only look up records linked to this number after you turn this on.", + "patient_login__heading": "Sign in to your health records", + "patient_login__helpline": "Trouble signing in? Call the health helpline", + "patient_login__otp_heading": "Enter the OTP", + "patient_login__otp_security_note": "care never asks for your OTP over a call. Do not share it with anyone.", + "patient_login__otp_sent_to": "Sent to {{phone}}", + "patient_login__phone_helper": "Enter all the digits.", + "patient_login__phone_valid": "That looks right — one more step below.", + "patient_login__resend_in": "Resend OTP in", + "patient_login__subheading": "Enter the mobile number registered at your health facility. We'll send a {{length}}-digit OTP.", + "patient_login__verify_continue": "Verify & continue", "patient_login_description": "For patients to book and manage their appointments.", "patient_name": "Patient Name", "patient_name_uhid": "Patient Name/UHID", @@ -4290,7 +4417,27 @@ "patient_prep": "Patient Prep", "patient_preparation": "Patient Preparation", "patient_profile": "Patient Profile", + "patient_profile__account_description": "This account belongs to this mobile number", + "patient_profile__account_owner": "account owner", + "patient_profile__patients_on_this_number": "Patients on this number", "patient_profile_created_by": "Patient profile created by", + "patient_records__book_first_appointment": "Book their first appointment", + "patient_records__doctors_advice": "Doctor's advice", + "patient_records__earlier": "Earlier", + "patient_records__flagged_count_one": "{{count}} flagged", + "patient_records__flagged_count_other": "{{count}} flagged", + "patient_records__no_prescriptions_description": "Medicines prescribed at any linked facility will appear here.", + "patient_records__no_prescriptions_for_name": "No prescriptions for {{name}} yet", + "patient_records__no_reports": "No reports yet", + "patient_records__no_reports_description": "Lab and imaging results from any linked facility will appear here once they are reported.", + "patient_records__no_reports_for_name": "No reports for {{name}} yet", + "patient_records__prescription_summary": "{{total}} total · {{active}} active", + "patient_records__processing": "Processing", + "patient_records__ready": "Ready", + "patient_records__reference": "Reference", + "patient_records__report_summary": "{{total}} total · {{processing}} processing", + "patient_records__switch_patient": "Switch to another patient", + "patient_records__verified_by": "Verified by", "patient_records_found": "Patient Records Found", "patient_records_found_description": "It appears that there are patient records that contain the same phone number as the one you just entered. ", "patient_registration": "Patient Registration", @@ -4304,13 +4451,37 @@ "patient_registration__name": "Name", "patient_registration_error": "Could not register patient", "patient_registration_success": "Patient Registered Successfully", + "patient_select__add_family_member": "Add a family member", + "patient_select__continue_as": "Continue as {{name}}", + "patient_select__description_one": "{{count}} profile is linked to this number. You can switch any time from the home screen.", + "patient_select__description_other": "{{count}} profiles are linked to this number. You can switch any time from the home screen.", + "patient_select__empty_description": "Add a profile to start booking appointments and viewing health records for this number.", + "patient_select__empty_heading": "No profiles linked yet", + "patient_select__heading": "Choose a patient", + "patient_shell__navigation": "Patient navigation", + "patient_shell__now_showing": "Now showing records for {{name}}", + "patient_shell__viewing": "Viewing", "patient_state": "Patient State", "patient_status": "Patient Status", "patient_summary": "Patient Summary", + "patient_switcher__description": "Appointments, prescriptions and reports all follow the patient you pick.", + "patient_switcher__heading": "Switch patient", "patient_tags": "Patient Tags", "patient_transfer_birth_match_note": "Note: Year of birth must match the patient to process the transfer request.", "patient_update_error": "Could not update patient", "patient_update_success": "Patient Updated Successfully", + "patient_visits__book_follow_up": "Book a follow-up", + "patient_visits__cancel_confirmation": "Cancel your appointment with {{name}} on {{datetime}}? This cannot be undone — you would need to book a new slot.", + "patient_visits__earlier_this_year": "Earlier this year", + "patient_visits__from_this_visit": "From this visit", + "patient_visits__keep_appointment": "Keep appointment", + "patient_visits__no_history": "No past visits recorded", + "patient_visits__no_history_description": "No appointment history is available. Schedule your first appointment to begin.", + "patient_visits__no_upcoming_description": "Book an appointment to see it listed here.", + "patient_visits__not_found": "This visit could not be found.", + "patient_visits__reason": "Reason for visit", + "patient_visits__show_at_counter": "Show this at the counter", + "patient_visits__summary": "Visit summary", "patients": "Patients", "patients_per_slot": "Patients per Slot", "pause_request": "Pause Request", @@ -4832,8 +5003,10 @@ "recording_payment_for_invoice": "Recording payment for invoice {{id}}", "recording_refund": "Recording refund", "recording_refund_for_invoice": "Recording refund for invoice #{{id}}", + "records": "Records", "recurrence": "Recurrence", "redirected_to_create_consultation": "Note: You will be redirected to create consultation form. Please complete the form to finish the transfer process", + "refer_patient": "Refer Patient", "reference": "Reference", "reference_details": "Reference Details", "reference_no": "Reference No", @@ -4945,6 +5118,7 @@ "report_preview": "Report Preview", "report_template_description": "Report templates help you create standardized formats for patient reports and clinical documentation.", "report_type": "Report Type", + "reported": "Reported", "reported_by": "Reported by", "reports": "Reports", "repository": "Repository", @@ -5019,6 +5193,8 @@ "resource": "Resource", "resource_approving_facility": "Resource approving facility", "resource_category": "Resource Category", + "resource_category_direct_hint": "This request is created for your facility to coordinate directly.", + "resource_category_network_hint": "Submitting refers this request to a coordination desk on the care network.", "resource_created_successfully": "Request created successfully", "resource_details": "Resource details", "resource_id_is_required": "Resource is required", @@ -5030,7 +5206,7 @@ "resource_request_category__comfort_devices": "Comfort Devices", "resource_request_category__financial": "Financial", "resource_request_category__medicines": "Medicines", - "resource_request_category__other": "Other", + "resource_request_category__other": "Diagnostic", "resource_request_category__patient_care": "Clinical Care and Social Support", "resource_request_details_description": "Provide detailed information about what resource is needed and why.", "resource_request_status__approved": "Approved", @@ -5382,6 +5558,7 @@ "sections_description": "Configure, reorder and customize report sections", "sections_error": "There are errors in the Sections tab. Please fix the errors before saving.", "secure_connection": "Secure connection • Your privacy is protected", + "see_all": "See all", "see_attachments": "See Attachments", "see_clinical_history": "See Clinical History", "see_details": "See Details", @@ -5743,6 +5920,7 @@ "severity_of_breathlessness": "Severity of Breathlessness", "sex": "Sex", "sgst": "SGST", + "share": "Share", "shared_by": "Shared By", "shift": "Shift Patient", "shift_key": "shift", @@ -6621,6 +6799,7 @@ "virtual": "Virtual", "virtual_nursing_assistant": "Virtual Nursing Assistant", "visibility_settings": "Visibility Settings", + "visits": "Visits", "vital_signs": "Vital Signs", "vitals": "Vitals", "vitals_monitor": "Vitals Monitor", diff --git a/src/Providers/AuthUserProvider.tsx b/src/Providers/AuthUserProvider.tsx index 45e17d9c3f6..7e0a15ed8e3 100644 --- a/src/Providers/AuthUserProvider.tsx +++ b/src/Providers/AuthUserProvider.tsx @@ -163,10 +163,13 @@ export default function AuthUserProvider({ localStorage.removeItem(LocalStorageKeys.accessToken); localStorage.removeItem(LocalStorageKeys.refreshToken); localStorage.removeItem(LocalStorageKeys.patientTokenKey); + localStorage.removeItem(LocalStorageKeys.selectedPatient); setAccessToken(null); setPatientToken(null); - await queryClient.resetQueries({ queryKey: ["currentUser"] }); + // Wipe all cached data so a subsequent sign-in with the same credentials + // cannot receive stale data from the previous session. + queryClient.clear(); const redirectURL = getRedirectURL(); navigate(redirectURL ? `/login?redirect=${redirectURL}` : "/login"); diff --git a/src/Providers/PatientUserProvider.tsx b/src/Providers/PatientUserProvider.tsx index 8bf6a317ec3..aec198cd589 100644 --- a/src/Providers/PatientUserProvider.tsx +++ b/src/Providers/PatientUserProvider.tsx @@ -1,6 +1,14 @@ import { useQuery } from "@tanstack/react-query"; -import { navigate } from "raviger"; -import { createContext, useEffect, useState } from "react"; +import { navigate, usePath } from "raviger"; +import { + createContext, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; + +import { LocalStorageKeys } from "@/common/constants"; import { useAuthContext } from "@/hooks/useAuthUser"; @@ -13,6 +21,7 @@ export type PatientUserContextType = { patients?: PublicPatientRead[]; selectedPatient: PublicPatientRead | null; setSelectedPatient: (patient: PublicPatientRead) => void; + isLoadingPatients: boolean; tokenData: TokenData; }; @@ -20,18 +29,22 @@ export const PatientUserContext = createContext( null, ); +const APPOINTMENT_REGISTRATION_PATH_REGEX = + /^\/facility\/[^/]+\/appointments\/[^/]+\/patient-registration$/; + interface Props { children: React.ReactNode; } export default function PatientUserProvider({ children }: Props) { - const [patients, setPatients] = useState([]); - const [selectedPatient, setSelectedPatient] = - useState(null); + const [selectedPatientId, setSelectedPatientId] = useState( + () => localStorage.getItem(LocalStorageKeys.selectedPatient), + ); + const path = usePath(); const { patientToken: tokenData } = useAuthContext(); - const { data: userData } = useQuery({ + const { data: userData, isLoading } = useQuery({ queryKey: ["patients", tokenData], queryFn: query(publicPatientApi.list, { headers: { @@ -41,15 +54,50 @@ export default function PatientUserProvider({ children }: Props) { enabled: !!tokenData?.token, }); + const patients = useMemo(() => userData?.results ?? [], [userData]); + + // A stored id can go stale (profile unlinked, or a different number signed + // in), so resolution always falls back to the first linked profile. The + // stored value is only rewritten on an explicit switch. + const selectedPatient = useMemo( + () => + patients.find((patient) => patient.id === selectedPatientId) ?? + patients[0] ?? + null, + [patients, selectedPatientId], + ); + + const setSelectedPatient = useCallback((patient: PublicPatientRead) => { + setSelectedPatientId(patient.id); + localStorage.setItem(LocalStorageKeys.selectedPatient, patient.id); + }, []); + + // Every screen under this provider needs a patient session, so send an + // unauthenticated (or signed-out) visitor to the patient login rather than + // the public landing page. + useEffect(() => { + if (!tokenData) { + navigate("/patient/login", { replace: true }); + } + }, [tokenData]); + + const isProfileSetupPath = + path === "/patient/select-profile" || + path === "/patient/add-profile" || + APPOINTMENT_REGISTRATION_PATH_REGEX.test(path || ""); + useEffect(() => { - if (userData?.results && userData.results.length > 0) { - setPatients(userData.results); - setSelectedPatient(userData.results[0]); + if ( + tokenData && + !isLoading && + patients.length === 0 && + !isProfileSetupPath + ) { + navigate("/patient/select-profile", { replace: true }); } - }, [userData]); + }, [tokenData, isLoading, patients.length, isProfileSetupPath]); if (!tokenData) { - navigate("/"); return null; } @@ -59,7 +107,8 @@ export default function PatientUserProvider({ children }: Props) { patients, selectedPatient, setSelectedPatient, - tokenData: tokenData, + isLoadingPatients: isLoading, + tokenData, }} > {children} diff --git a/src/Routers/PatientRouter.tsx b/src/Routers/PatientRouter.tsx index b23c34fb025..ebf3f39bf05 100644 --- a/src/Routers/PatientRouter.tsx +++ b/src/Routers/PatientRouter.tsx @@ -1,45 +1,73 @@ -import careConfig from "@careConfig"; -import { useRoutes } from "raviger"; - -import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; -import { AppSidebar, SidebarFor } from "@/components/ui/sidebar/app-sidebar"; +import { Redirect, useRoutes } from "raviger"; import ErrorBoundary from "@/components/Common/ErrorBoundary"; import BrowserWarning from "@/components/ErrorPages/BrowserWarning"; import ErrorPage from "@/components/ErrorPages/DefaultErrorPage"; -import { patientTabs } from "@/components/Patient/PatientDetailsTab"; -import { PatientProfile } from "@/components/Patient/PatientProfile"; - -import useSidebarState from "@/hooks/useSidebarState"; import PatientUserProvider from "@/Providers/PatientUserProvider"; -import { FacilitiesPage } from "@/pages/Facility/FacilitiesPage"; +import DiagnosticReportDetail from "@/pages/Patient/DiagnosticReportDetail"; +import PatientProfileSettings from "@/pages/Patient/PatientProfileSettings"; +import PatientRecords from "@/pages/Patient/PatientRecords"; +import PatientVisits from "@/pages/Patient/PatientVisits"; +import PrescriptionDetail from "@/pages/Patient/PrescriptionDetail"; +import SelectProfile from "@/pages/Patient/SelectProfile"; +import VisitSummary from "@/pages/Patient/VisitSummary"; import PatientIndex from "@/pages/Patient/index"; +import BookFacility from "@/pages/PublicAppointments/BookFacility"; +import BookPractitioner from "@/pages/PublicAppointments/BookPractitioner"; import PublicPatientRegistration from "@/pages/PublicAppointments/PatientRegistration"; -import PatientSelect from "@/pages/PublicAppointments/PatientSelect"; import { ScheduleAppointment } from "@/pages/PublicAppointments/Schedule"; import { AppointmentSuccess } from "@/pages/PublicAppointments/Success"; import PublicRouter from "./PublicRouter"; +/** Routes available to a patient signed in with an OTP session. */ const DashboardRoutes = { - "/nearby_facilities": () => , + "/nearby_facilities": () => , "/facility/:facilityId/appointments/:appointmentId/success": ({ appointmentId, }: { appointmentId: string; }) => , + + "/patient/select-profile": () => , + "/patient/add-profile": () => , "/patient/home": () => , - "/patient/:id": ({ id }: { id: string }) => ( - - ), - "/patient/:id/:tab": ({ - id, - tab, + + "/patient/visits": () => , + "/patient/visits/:appointmentId": ({ + appointmentId, }: { - id: string; - tab: (typeof patientTabs)[number]["route"]; - }) => , + appointmentId: string; + }) => , + + "/patient/profile": () => , + + "/patient/book/:facilityId": ({ facilityId }: { facilityId: string }) => ( + + ), + + "/patient/records": () => , + "/patient/records/prescriptions/:id": ({ id }: { id: string }) => ( + + ), + "/patient/records/reports/:id": ({ id }: { id: string }) => ( + + ), + + // Superseded by the records hub; kept so existing links keep working. + "/patient/medications": () => ( + + ), + "/patient/diagnostic_reports": () => ( + + ), + + // `/patient/:id` is deliberately NOT routed here. PatientProfile is a + // staff-facing component backed by authenticated facility APIs, so under an + // OTP session it only ever rendered an error page — and its wildcard swallowed + // unmatched paths like `/patient/profile`. The staff app serves those routes + // through AppRouter (src/Routers/routes/PatientRoutes.tsx) instead. }; const AppointmentRoutes = { @@ -65,13 +93,6 @@ const AppointmentRoutes = { appointmentId={appointmentId} /> ), - "/facility/:facilityId/appointments/:staffId/patient-select": ({ - facilityId, - staffId, - }: { - facilityId: string; - staffId: string; - }) => , "/facility/:facilityId/appointments/:staffId/patient-registration": ({ facilityId, staffId, @@ -86,49 +107,26 @@ export default function PatientRouter() { const appointmentPages = useRoutes(AppointmentRoutes); - const sidebarOpen = useSidebarState(); - if (!pages) { if (appointmentPages) { - return {appointmentPages}; + return ( + + }> + {appointmentPages} + + + ); } return ; } + // Each patient page owns its own chrome via PatientAppShell. return ( - - -
- -
-
- -
- - care logo - -
-
- }> - {pages} - -
-
-
+ + }> + {pages} +
); } diff --git a/src/Routers/PublicRouter.tsx b/src/Routers/PublicRouter.tsx index a9094c32d44..040baa0ca22 100644 --- a/src/Routers/PublicRouter.tsx +++ b/src/Routers/PublicRouter.tsx @@ -1,5 +1,5 @@ import careConfig from "@careConfig"; -import { Redirect, useRoutes } from "raviger"; +import { Redirect, usePath, useRoutes } from "raviger"; import { Authenticate } from "@/components/Auth/Authenticate"; import Login from "@/components/Auth/Login"; @@ -29,19 +29,25 @@ export const routes = { ) : ( ), + "/patient/login": () => + careConfig.disablePatientLogin ? ( + + ) : ( + + ), "/facility/:facilityId/appointments/:staffId/otp/:page": ({ facilityId, staffId, - page, }: { facilityId: string; staffId: string; - page: string; }) => careConfig.disablePatientLogin ? ( ) : ( - + ), "/login": () => , "/2fa": () => , @@ -56,11 +62,16 @@ export const routes = { export default function PublicRouter() { const routeResult = useRoutes(routes); + const path = usePath(); + + const isPatientPath = + !!path && path.startsWith("/patient") && path !== "/patient/login"; return ( <> - {routeResult || } + {routeResult || + (isPatientPath ? : )} ); } diff --git a/src/Routers/routes/ResourceRoutes.tsx b/src/Routers/routes/ResourceRoutes.tsx index d82b17d1672..b28697b555b 100644 --- a/src/Routers/routes/ResourceRoutes.tsx +++ b/src/Routers/routes/ResourceRoutes.tsx @@ -1,3 +1,4 @@ +import CcnConsole from "@/components/Resource/CcnConsole"; import PrintResourceLetter from "@/components/Resource/PrintResourceLetter"; import ResourceDetails from "@/components/Resource/ResourceDetails"; import ResourceForm from "@/components/Resource/ResourceForm"; @@ -9,6 +10,12 @@ const ResourceRoutes: AppRoutes = { "/facility/:facilityId/resource": ({ facilityId }) => ( ), + "/facility/:facilityId/ccn": ({ facilityId }) => ( + + ), + "/facility/:facilityId/ccn/:resourceId": ({ facilityId, resourceId }) => ( + + ), "/facility/:facilityId/resource/:id": ({ facilityId, id }) => ( ), diff --git a/src/Utils/observationRange.ts b/src/Utils/observationRange.ts new file mode 100644 index 00000000000..43bc09e3159 --- /dev/null +++ b/src/Utils/observationRange.ts @@ -0,0 +1,111 @@ +import { + ObservationRead, + ObservationReferenceRange, +} from "@/types/emr/observation/observation"; + +export type ObservationResult = Pick< + ObservationRead, + "value" | "interpretation" | "reference_range" +>; + +/** Interpretation labels that mean "nothing to worry about". */ +const NORMAL_INTERPRETATION = /^norm/i; + +/** + * `reference_range` bounds come back as strings from the API even though the + * shared type declares numbers, so coerce defensively. + */ +const toNumber = (value: unknown): number | undefined => { + if (value === null || value === undefined || value === "") { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +/** + * `reference_range` is a set of labelled bands spanning the whole scale — Low, + * Normal, High — not a list of acceptable ranges. Find the band the result + * actually falls into. + */ +function matchingBand( + result: ObservationResult, +): ObservationReferenceRange | undefined { + const value = toNumber(result.value?.value); + if (value === undefined) { + return undefined; + } + + return (result.reference_range ?? []).find((range) => { + const min = toNumber(range.min); + const max = toNumber(range.max); + return ( + (min === undefined || value >= min) && (max === undefined || value <= max) + ); + }); +} + +/** + * The lab's own wording for the result, e.g. `High` — either stated directly + * or, lacking that, implied by the band the value falls into. + */ +export function observationInterpretation( + result: ObservationResult, +): string | undefined { + return ( + result.interpretation?.display ?? + matchingBand(result)?.interpretation?.display + ); +} + +/** + * A result is flagged when the lab's interpretation — either stated on the + * observation or implied by the band the value lands in — is not "normal". + */ +export function isObservationFlagged(result: ObservationResult): boolean { + if (result.interpretation?.highlight) { + return true; + } + + const interpretation = observationInterpretation(result); + + return !!interpretation && !NORMAL_INTERPRETATION.test(interpretation); +} + +/** + * Renders a min/max pair the way lab reports do: "12 - 20", "> 12", "< 20", + * or "" when neither bound is present. Shared so every reference-range + * listing (qualified ranges, observation bands) formats bounds identically. + */ +export function formatRangeBounds( + min: string | number | undefined | null, + max: string | number | undefined | null, +): string { + const hasMin = min !== null && min !== undefined; + const hasMax = max !== null && max !== undefined; + if (hasMin && hasMax) return `${min} - ${max}`; + if (hasMin) return `> ${min}`; + if (hasMax) return `< ${max}`; + return ""; +} + +export interface ReferenceRangeEntry { + label?: string; + range: string; +} + +/** + * `reference_range` is a set of labelled bands spanning the whole scale — + * Low, Normal, High — not a single acceptable range. Returns every band that + * has a label or bounds to show, dropping empty ones. + */ +export function formatReferenceRanges( + ranges: ObservationReferenceRange[] | undefined, +): ReferenceRangeEntry[] { + return (ranges ?? []) + .map((range) => ({ + label: range.interpretation?.display, + range: formatRangeBounds(range.min, range.max), + })) + .filter((entry) => entry.label || entry.range); +} diff --git a/src/common/constants.tsx b/src/common/constants.tsx index 73640337fa5..887fdf19d03 100644 --- a/src/common/constants.tsx +++ b/src/common/constants.tsx @@ -8,6 +8,7 @@ export const LocalStorageKeys = { refreshToken: "care_refresh_token", patientTokenKey: "care_patient_token", loginPreference: "care_login_preference", + selectedPatient: "care_selected_patient", }; export const GENDER_TYPES = [ diff --git a/src/components/Auth/Login.tsx b/src/components/Auth/Login.tsx index ed5046126ef..e7ffffa086e 100644 --- a/src/components/Auth/Login.tsx +++ b/src/components/Auth/Login.tsx @@ -1,11 +1,9 @@ import careConfig from "@careConfig"; import { useMutation } from "@tanstack/react-query"; -import { REGEXP_ONLY_DIGITS } from "input-otp"; -import { useQueryParams } from "raviger"; +import { navigate, useQueryParams } from "raviger"; import { useEffect, useState } from "react"; import ReCaptcha from "react-google-recaptcha"; import { useTranslation } from "react-i18next"; -import { isValidPhoneNumber } from "react-phone-number-input"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -19,14 +17,8 @@ import { CardTitle, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from "@/components/ui/input-otp"; import { PasswordInput } from "@/components/ui/input-password"; import { Label } from "@/components/ui/label"; -import { PhoneInput } from "@/components/ui/phone-input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ForgotPasswordPanel } from "@/components/Auth/ForgotPasswordPanel"; @@ -42,35 +34,12 @@ import ViewCache from "@/Utils/ViewCache"; import mutate from "@/Utils/request/mutate"; import { HTTPError } from "@/Utils/request/types"; import authApi from "@/types/auth/authApi"; -import { TokenData } from "@/types/otp/otp"; -import otpApi from "@/types/otp/otpApi"; import { clearQueryPersistenceCache } from "@/Utils/request/queryClient"; import { invalidateAllPaymentReconcilationLocationCaches } from "@/atoms/paymentReconcilationLocationAtom"; import { clearQueuePractitionerCache } from "@/atoms/queuePractitionerAtom"; import { AuthHero } from "./AuthHero"; -interface OtpLoginData { - phone_number: string; - otp: string; -} - -interface OtpError { - type: string; - loc: string[]; - msg: string; - input: string; - ctx: { - error: string; - }; - url: string; -} - -interface OtpValidationError { - otp?: string; - [key: string]: string | undefined; -} - type LoginMode = "staff" | "patient"; interface LoginProps { @@ -78,14 +47,13 @@ interface LoginProps { } const Login = (props: LoginProps) => { - const { signIn, patientLogin, isAuthenticating } = useAuthContext(); + const { signIn, isAuthenticating } = useAuthContext(); const { reCaptchaSiteKey, urls, stateLogo, customLogo, customLogoAlt, - resendOtpTimeout, disablePatientLogin, } = careConfig; const initForm: any = { @@ -101,92 +69,12 @@ const Login = (props: LoginProps) => { const [isCaptchaEnabled, setCaptcha] = useState(false); const { t } = useTranslation(); const [forgotPassword, setForgotPassword] = useState(forgot); - const [isOtpSent, setIsOtpSent] = useState(false); - const [phone, setPhone] = useState(""); - const [otp, setOtp] = useState(""); - const [otpError, setOtpError] = useState(""); - const [otpValidationError, setOtpValidationError] = useState(""); - const [resendOtpCountdown, setResendOtpCountdown] = useState(0); - - // Timer Function for resend OTP - useEffect(() => { - if (resendOtpCountdown <= 0) { - return; - } - - const timer = setInterval(() => { - setResendOtpCountdown((prevTime) => prevTime - 1); - }, 1000); - - return () => clearInterval(timer); - }, [resendOtpCountdown]); // Remember the last login mode useEffect(() => { localStorage.setItem(LocalStorageKeys.loginPreference, mode); }, [mode]); - // Send OTP Mutation - const { mutate: sendOtp, isPending: sendOtpPending } = useMutation({ - mutationFn: mutate(otpApi.send), - onSuccess: () => { - setIsOtpSent(true); - setOtpError(""); - toast.success(t("send_otp_success")); - }, - onError: (error: any) => { - const errors = error?.data || []; - if (Array.isArray(errors) && errors.length > 0) { - const firstError = errors[0] as OtpError; - setOtpError(firstError.msg); - } else { - setOtpError("send_otp_error"); - } - }, - }); - - // Verify OTP Mutation - const { mutate: verifyOtp, isPending: verifyOtpPending } = useMutation({ - mutationFn: async (data: OtpLoginData) => { - const response = await mutate(otpApi.login, { silent: true })(data); - if ("errors" in response) { - throw response; - } - return response; - }, - onSuccess: async (response: { access: string }) => { - const { access } = response; - if (access) { - setOtpValidationError(""); - const tokenData: TokenData = { - token: access, - phoneNumber: phone, - createdAt: new Date().toISOString(), - }; - patientLogin(tokenData, `/patient/home`); - } - }, - onError: (error: any) => { - let errorMessage = "invalid_otp"; - if ( - error.cause && - Array.isArray(error.cause.errors) && - error.cause.errors.length > 0 - ) { - const otpError = error.cause.errors.find( - (e: OtpValidationError) => e.otp, - ); - if (otpError && otpError.otp) { - errorMessage = otpError.otp; - } - } else if (error.message) { - errorMessage = error.message; - } - setOtpValidationError(errorMessage); - toast.error(errorMessage); - }, - }); - // Forgot Password Mutation const { mutate: submitForgetPassword, isPending: forgotPasswordPending } = useMutation({ @@ -294,28 +182,8 @@ const Login = (props: LoginProps) => { } }; - // Handle OTP flow - const handlePatientLogin = async (e: React.SubmitEvent) => { - e.preventDefault(); - - if (!isOtpSent) { - sendOtp({ phone_number: phone }); - setResendOtpCountdown(resendOtpTimeout); - } else { - verifyOtp({ phone_number: phone, otp }); - } - }; - - const resetPatientLogin = () => { - setIsOtpSent(false); - setPhone(""); - setOtp(""); - setOtpError(""); - setOtpValidationError(""); - }; - // Loading state derived from mutations - const isLoading = isAuthenticating || sendOtpPending || verifyOtpPending; + const isLoading = isAuthenticating; const logos = [stateLogo, customLogo].filter( (logo) => logo?.light || logo?.dark, @@ -458,22 +326,21 @@ const Login = (props: LoginProps) => { ) : ( { - setQueryParams({ mode: value as LoginMode }); - if (value === "staff") { - resetPatientLogin(); - } else { - setForgotPassword(false); - } - }} + value="staff" + onValueChange={(value) => + setQueryParams({ mode: value as LoginMode }) + } > {t("staff_login")} {!disablePatientLogin && ( - + navigate("/patient/login")} + > {t("patient_login")} )} @@ -565,130 +432,6 @@ const Login = (props: LoginProps) => { /> )} - - {/* Patient Login */} - -
-
- - { - setPhone(value ?? ""); - setOtpError(""); - setOtpValidationError(""); - }} - disabled={isOtpSent} - placeholder={t("enter_phone_number")} - /> - {otpError && ( -

- {t(otpError)} -

- )} -
- - {isOtpSent && ( -
- -
- { - setOtp(value); - setOtpValidationError(""); - }} - > - - {[...Array(5)].map((_, index) => ( - - ))} - - -
- {otpValidationError && ( -

- {t(otpValidationError)} -

- )} -
- )} - - - {isOtpSent && ( -
- {resendOtpCountdown <= 0 ? ( - - ) : ( -

- {t("resend_otp_timer", { - time: resendOtpCountdown, - })} -

- )} -
- -
-
- )} -
-
)} diff --git a/src/components/Common/InfiniteScrollSentinel.tsx b/src/components/Common/InfiniteScrollSentinel.tsx new file mode 100644 index 00000000000..5752118b1dd --- /dev/null +++ b/src/components/Common/InfiniteScrollSentinel.tsx @@ -0,0 +1,42 @@ +import { useEffect } from "react"; +import { useInView } from "react-intersection-observer"; + +import { cn } from "@/lib/utils"; + +import { Skeleton } from "@/components/ui/skeleton"; + +interface InfiniteScrollSentinelProps { + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; + /** Applied to the placeholder so the sentinel matches the list's row height. */ + className?: string; +} + +/** + * Fetches the next page once scrolled into view, and renders a placeholder in + * the space the incoming rows will occupy. Renders nothing once the list is + * exhausted. + */ +export function InfiniteScrollSentinel({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, + className, +}: InfiniteScrollSentinelProps) { + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (!hasNextPage) return null; + + return ( +
+ +
+ ); +} diff --git a/src/components/Common/LoginHeader.tsx b/src/components/Common/LoginHeader.tsx index 30d329b580a..fcd6a6d4d45 100644 --- a/src/components/Common/LoginHeader.tsx +++ b/src/components/Common/LoginHeader.tsx @@ -1,91 +1,91 @@ +import careConfig from "@careConfig"; import dayjs from "dayjs"; +import { ChevronDown, LogOut, User } from "lucide-react"; import { navigate } from "raviger"; import { useTranslation } from "react-i18next"; - -import CareIcon from "@/CAREUI/icons/CareIcon"; +import { formatPhoneNumberIntl } from "react-phone-number-input"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Avatar } from "@/components/Common/Avatar"; - -import { usePatientSignOut } from "@/hooks/usePatientSignOut"; - -import { LocalStorageKeys } from "@/common/constants"; - -import { TokenData } from "@/types/otp/otp"; +import { useAuthContext } from "@/hooks/useAuthUser"; export const LoginHeader = () => { const { t } = useTranslation(); - const signOut = usePatientSignOut(); - - const tokenData: TokenData = JSON.parse( - localStorage.getItem(LocalStorageKeys.patientTokenKey) || "{}", - ); + const { signOut, patientToken } = useAuthContext(); const isLoggedIn = - tokenData.token && - Object.keys(tokenData).length > 0 && - dayjs(tokenData.createdAt).isAfter(dayjs().subtract(14, "minutes")); + !!patientToken?.token && + dayjs(patientToken.createdAt).isAfter( + dayjs().subtract(careConfig.patientTokenFreshnessMinutes, "minutes"), + ); + + const phone = patientToken?.phoneNumber + ? formatPhoneNumberIntl(patientToken.phoneNumber) || + patientToken.phoneNumber + : ""; if (isLoggedIn) { return ( -
-
- - - - - - - - {tokenData.phoneNumber} - - + + + +
+ + + + + + + + {phone} + + + + + {t("sign_out")} + + +
); } return ( -
-
- -
+
+
); }; diff --git a/src/components/Patient/AppointmentTokenPass.tsx b/src/components/Patient/AppointmentTokenPass.tsx new file mode 100644 index 00000000000..e821d975ec6 --- /dev/null +++ b/src/components/Patient/AppointmentTokenPass.tsx @@ -0,0 +1,102 @@ +import { QRCodeSVG } from "qrcode.react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; + +import { formatSlotTimeRange } from "@/pages/Appointments/utils"; +import { + PublicAppointment, + formatScheduleResourceName, +} from "@/types/scheduling/schedule"; +import { renderTokenNumber } from "@/types/tokens/token/token"; +import { formatPatientAge } from "@/Utils/utils"; + +import dayjs from "@/Utils/dayjs"; + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +export function AppointmentTokenPass({ + appointment, + className, +}: { + appointment: PublicAppointment; + className?: string; +}) { + const { t } = useTranslation(); + const start = dayjs(appointment.token_slot.start_datetime); + + return ( +
+
+ + {appointment.patient.name} + + {formatPatientAge(appointment.patient, true)} ·{" "} + {t(`GENDER__${appointment.patient.gender}`)} + + + + + + {formatScheduleResourceName(appointment)} + + + +
+ + {appointment.token_slot.availability.name} + + + {start.format("ddd, D MMM")} ·{" "} + {formatSlotTimeRange(appointment.token_slot)} + +
+ + + {appointment.facility.name} + +
+ +
+ {appointment.token ? ( +
+ + {t("token_no")} + + + {renderTokenNumber(appointment.token)} + +
+ ) : ( +
+ + {t("patient_booking__booking_reference")} + + + {appointment.id.slice(0, 8)} + +
+ )} + +
+
+ ); +} diff --git a/src/components/Patient/CancelAppointmentButton.tsx b/src/components/Patient/CancelAppointmentButton.tsx new file mode 100644 index 00000000000..ec8e6687685 --- /dev/null +++ b/src/components/Patient/CancelAppointmentButton.tsx @@ -0,0 +1,94 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import dayjs from "dayjs"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import ConfirmActionDialog from "@/components/Common/ConfirmActionDialog"; +import { Button } from "@/components/ui/button"; + +import { usePatientContext } from "@/hooks/usePatientUser"; + +import mutate from "@/Utils/request/mutate"; +import PublicAppointmentApi from "@/types/scheduling/PublicAppointmentApi"; +import { + AppointmentFinalStatuses, + PublicAppointment, + formatScheduleResourceName, +} from "@/types/scheduling/schedule"; + +export function isAppointmentCancellable( + appointment: PublicAppointment, +): boolean { + return !AppointmentFinalStatuses.includes(appointment.status); +} + +interface CancelAppointmentButtonProps { + appointment: PublicAppointment; + size?: React.ComponentProps["size"]; + variant?: React.ComponentProps["variant"]; + className?: string; + onCancelled?: () => void; +} + +export function CancelAppointmentButton({ + appointment, + size, + variant = "outline", + className, + onCancelled, +}: CancelAppointmentButtonProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { tokenData } = usePatientContext(); + const [open, setOpen] = useState(false); + + const { mutate: cancelAppointment, isPending } = useMutation({ + mutationFn: mutate(PublicAppointmentApi.cancelAppointment, { + headers: { Authorization: `Bearer ${tokenData?.token}` }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["appointment", tokenData?.phoneNumber], + }); + toast.success(t("appointment_cancelled")); + setOpen(false); + onCancelled?.(); + }, + }); + + const start = dayjs(appointment.token_slot.start_datetime); + + return ( + <> + + + + cancelAppointment({ + appointment: appointment.id, + patient: appointment.patient.id, + }) + } + /> + + ); +} diff --git a/src/components/Patient/DiagnosticReportRow.tsx b/src/components/Patient/DiagnosticReportRow.tsx new file mode 100644 index 00000000000..8e33f8257db --- /dev/null +++ b/src/components/Patient/DiagnosticReportRow.tsx @@ -0,0 +1,97 @@ +import dayjs from "dayjs"; +import { ChevronRight, Clock } from "lucide-react"; +import { Link } from "raviger"; +import { createElement } from "react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; + +import { PatientBadge } from "@/components/Patient/PatientBadge"; + +import { READY_REPORT_STATUSES } from "@/hooks/usePatientPortalData"; +import { + reportFlagSummary, + reportIcon, + reportTitle, +} from "@/pages/Patient/records/reportUtils"; +import { DiagnosticReportRead } from "@/types/emr/diagnosticReport/diagnosticReport"; + +/** + * One diagnostic report in a list — shared by the records hub and the home + * preview so the same record does not describe itself two different ways. + */ +export function DiagnosticReportRow({ + report, + className, +}: { + report: DiagnosticReportRead; + className?: string; +}) { + const { t } = useTranslation(); + const isReady = READY_REPORT_STATUSES.includes(report.status); + const flags = reportFlagSummary(report); + + const content = ( + <> + + {/* createElement, not JSX: reportIcon() picks between icons at runtime */} + {createElement(isReady ? reportIcon(report) : Clock, { + className: cn("size-5", isReady ? "text-gray-900" : "text-gray-400"), + strokeWidth: isReady ? 1.8 : 1.9, + })} + +
+ + {reportTitle(report, t)} + + + {dayjs(report.created_date).format("DD MMM YYYY")} + {report.encounter?.facility?.name && + ` · ${report.encounter.facility.name}`} + +
+
+ {!isReady ? ( + + {t("patient_records__processing")} + + ) : ( + flags > 0 && ( + + {t("patient_records__flagged_count", { count: flags })} + + ) + )} + {isReady && ( + + )} +
+ + ); + + const rowClassName = cn( + "flex items-center gap-3 rounded-2xl border bg-white p-4", + !isReady && "border-dashed border-gray-300 bg-gray-50", + isReady && "hover:border-gray-300", + isReady && (flags > 0 ? "border-warning-200" : "border-gray-200"), + className, + ); + + if (!isReady) { + return
{content}
; + } + + return ( + + {content} + + ); +} diff --git a/src/components/Patient/PatientAppShell.tsx b/src/components/Patient/PatientAppShell.tsx new file mode 100644 index 00000000000..f8f6f179895 --- /dev/null +++ b/src/components/Patient/PatientAppShell.tsx @@ -0,0 +1,315 @@ +import careConfig from "@careConfig"; +import { + ArrowLeft, + CalendarDays, + Check, + ChevronDown, + FolderClosed, + Home, + User, +} from "lucide-react"; +import { Link, navigate, usePath } from "raviger"; +import { createContext, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; + +import { PatientAvatar } from "@/components/Patient/PatientProfileCard"; +import { PatientSwitcherSheet } from "@/components/Patient/PatientSwitcherSheet"; + +import { Button } from "@/components/ui/button"; +import { usePatientContext } from "@/hooks/usePatientUser"; + +/** How long the "now showing records for X" confirmation stays up. */ +const SWITCH_NOTICE_MS = 3000; + +/** Lets descendant screens open the shell's shared patient switcher sheet. */ +const PatientShellContext = createContext<{ + openSwitcher: () => void; + canSwitch: boolean; +}>({ openSwitcher: () => {}, canSwitch: false }); + +const TABS = [ + { key: "home", href: "/patient/home", icon: Home, label: "home" }, + { + key: "visits", + href: "/patient/visits", + icon: CalendarDays, + label: "visits", + }, + { + key: "records", + href: "/patient/records", + icon: FolderClosed, + label: "records", + }, + { key: "profile", href: "/patient/profile", icon: User, label: "profile" }, +] as const; + +/** `Anitha Ravindran` → `Anitha R.` so the header chip stays on one line. */ +function abbreviateName(name: string): string { + const parts = name.trim().split(/\s+/); + if (parts.length < 2) { + return parts[0] ?? ""; + } + return `${parts[0]} ${parts[parts.length - 1][0]}.`; +} + +interface PatientAppShellProps { + children: React.ReactNode; + title?: string; + /** Renders a back affordance instead of the tab bar's implicit navigation. */ + backTo?: string; + /** Detail screens hide the tab bar so the page owns the full height. */ + hideTabs?: boolean; + /** Trailing header content, e.g. a download action on detail screens. */ + headerAction?: React.ReactNode; + /** Underline tabs rendered inside the header — use `PatientHeaderTabs`. */ + headerTabs?: React.ReactNode; +} + +/** + * The section switcher used by Records and Visits. Lives here so both screens + * share one implementation and stay visually identical. + */ +export function PatientHeaderTabs({ + tabs, + value, + onChange, +}: { + tabs: { key: T; label: string }[]; + value: T; + onChange: (key: T) => void; +}) { + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); +} + +export function PatientAppShell({ + children, + title, + backTo, + hideTabs, + headerAction, + headerTabs, +}: PatientAppShellProps) { + const { t } = useTranslation(); + const path = usePath(); + const { selectedPatient, patients } = usePatientContext(); + + const [switcherOpen, setSwitcherOpen] = useState(false); + const [switchedTo, setSwitchedTo] = useState(null); + + useEffect(() => { + if (!switchedTo) { + return; + } + const timer = setTimeout(() => setSwitchedTo(null), SWITCH_NOTICE_MS); + return () => clearTimeout(timer); + }, [switchedTo]); + + const canSwitch = (patients?.length ?? 0) > 1; + + const switcherChip = selectedPatient && ( + + ); + + const tabBar = ( + <> + {TABS.map((tab) => { + const isActive = path?.startsWith(tab.href); + const Icon = tab.icon; + return ( + + + + {t(tab.label)} + + + ); + })} + + ); + + // Desktop navigation + const sideNav = ( + + ); + + // Mobile is a centred card + return ( + setSwitcherOpen(true), canSwitch }} + > +
+ {sideNav} +
+
+
+
+ {backTo && ( + + )} + {title ? ( +

+ {title} +

+ ) : ( + + {t("care")} + + )} +
+ {headerAction} + {!hideTabs && switcherChip} +
+
+ {headerTabs} +
+ +
+ {switchedTo && ( +
+ + + {t("patient_shell__now_showing", { name: switchedTo })} + +
+ )} +
+ +
+ {children} +
+ + {!hideTabs && ( + + )} +
+
+ + setSwitchedTo(patient.name)} + /> +
+
+ ); +} diff --git a/src/components/Patient/PatientBadge.tsx b/src/components/Patient/PatientBadge.tsx new file mode 100644 index 00000000000..397f490e67b --- /dev/null +++ b/src/components/Patient/PatientBadge.tsx @@ -0,0 +1,50 @@ +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +/** + * The patient portal's status pill. + * + * The shared `Badge` primitive bottoms out at ~24px tall, which reads as a + * button next to the 12–14px rows these pills annotate. This keeps the same + * palette but at the 18px height the portal screens are laid out around, and + * names the tones by meaning (`success`, `warning`) rather than by hue so a + * status never has to pick a colour at the call site. + */ +const patientBadgeVariants = cva( + "inline-flex shrink-0 items-center gap-1 rounded-md border px-2 text-[11px] font-bold leading-4 whitespace-nowrap", + { + variants: { + tone: { + primary: "border-primary-200 bg-primary-100 text-primary-800", + solid: "border-primary-700 bg-primary-700 text-white", + neutral: "border-gray-200 bg-gray-100 text-gray-700", + success: "border-green-200 bg-green-100 text-green-800", + warning: "border-yellow-200 bg-yellow-100 text-yellow-800", + info: "border-blue-200 bg-blue-100 text-blue-800", + danger: "border-red-200 bg-red-100 text-red-800", + }, + }, + defaultVariants: { + tone: "neutral", + }, + }, +); + +export type PatientBadgeTone = NonNullable< + VariantProps["tone"] +>; + +export function PatientBadge({ + className, + tone, + ...props +}: React.ComponentProps<"span"> & VariantProps) { + return ( + + ); +} diff --git a/src/components/Patient/PatientProfileCard.tsx b/src/components/Patient/PatientProfileCard.tsx new file mode 100644 index 00000000000..49b11e3ec50 --- /dev/null +++ b/src/components/Patient/PatientProfileCard.tsx @@ -0,0 +1,111 @@ +import { Check } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; + +import { formatPatientAge } from "@/Utils/utils"; +import { + BloodGroupChoices, + PublicPatientRead, +} from "@/types/emr/patient/patient"; + +export function patientMetaLine( + patient: PublicPatientRead, + t: (key: string) => string, +): string { + const parts = [ + formatPatientAge(patient, true), + t(`GENDER__${patient.gender}`), + ]; + if ( + patient.blood_group && + patient.blood_group !== BloodGroupChoices.Unknown + ) { + parts.push(t(`BLOOD_GROUP__${patient.blood_group}`)); + } + return parts.join(" · "); +} + +export function patientInitials(name: string): string { + return ( + name + .trim() + .split(/\s+/) + .slice(0, 2) + .map((word) => word[0] ?? "") + .join("") + .toUpperCase() || "-" + ); +} + +export function PatientAvatar({ + name, + active, + className, +}: { + name: string; + active?: boolean; + className?: string; +}) { + return ( + + {patientInitials(name)} + + ); +} + +interface PatientProfileCardProps { + patient: PublicPatientRead; + selected: boolean; + onSelect: (patient: PublicPatientRead) => void; + subtitle?: React.ReactNode; +} + +export function PatientProfileCard({ + patient, + selected, + onSelect, + subtitle, +}: PatientProfileCardProps) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/src/components/Patient/PatientSwitcherSheet.tsx b/src/components/Patient/PatientSwitcherSheet.tsx new file mode 100644 index 00000000000..f499432d6aa --- /dev/null +++ b/src/components/Patient/PatientSwitcherSheet.tsx @@ -0,0 +1,86 @@ +import { Plus } from "lucide-react"; +import { Link } from "raviger"; +import { useTranslation } from "react-i18next"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; + +import { PatientProfileCard } from "@/components/Patient/PatientProfileCard"; + +import { usePatientContext } from "@/hooks/usePatientUser"; + +import { PublicPatientRead } from "@/types/emr/patient/patient"; + +interface PatientSwitcherSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSwitched?: (patient: PublicPatientRead) => void; +} + +/** + * Bottom sheet for changing which linked profile the app is showing. + * Appointments, prescriptions and reports all follow this selection. + */ +export function PatientSwitcherSheet({ + open, + onOpenChange, + onSwitched, +}: PatientSwitcherSheetProps) { + const { t } = useTranslation(); + const { patients, selectedPatient, setSelectedPatient } = usePatientContext(); + + const handleSelect = (patient: PublicPatientRead) => { + onOpenChange(false); + if (patient.id === selectedPatient?.id) { + return; + } + setSelectedPatient(patient); + onSwitched?.(patient); + }; + + return ( + + + + + + {t("patient_switcher__heading")} + + + {t("patient_switcher__description")} + + + +
+ {patients?.map((patient) => ( + + ))} +
+ + + + {t("patient_select__add_family_member")} + +
+
+ ); +} diff --git a/src/components/Patient/PrescriptionRow.tsx b/src/components/Patient/PrescriptionRow.tsx new file mode 100644 index 00000000000..6366326d5c4 --- /dev/null +++ b/src/components/Patient/PrescriptionRow.tsx @@ -0,0 +1,46 @@ +import dayjs from "dayjs"; +import { ChevronRight } from "lucide-react"; +import { Link } from "raviger"; + +import { cn } from "@/lib/utils"; + +import { formatName } from "@/Utils/utils"; +import { PrescritionList } from "@/types/emr/prescription/prescription"; + +export function PrescriptionRow({ + prescription, + className, +}: { + prescription: PrescritionList; + className?: string; +}) { + return ( + +
+ + {formatName(prescription.prescribed_by)} + + + {[ + dayjs(prescription.created_date).format("DD MMM YYYY"), + prescription.encounter?.facility?.name, + prescription.name, + ] + .filter(Boolean) + .join(" · ")} + +
+ + + ); +} diff --git a/src/components/Patient/VisitCard.tsx b/src/components/Patient/VisitCard.tsx new file mode 100644 index 00000000000..13d93d2dac6 --- /dev/null +++ b/src/components/Patient/VisitCard.tsx @@ -0,0 +1,163 @@ +import { ChevronRight } from "lucide-react"; +import { Link } from "raviger"; +import { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; + +import { + PatientBadge, + type PatientBadgeTone, +} from "@/components/Patient/PatientBadge"; + +import dayjs from "@/Utils/dayjs"; +import { formatSlotTimeRange } from "@/pages/Appointments/utils"; + +import { + AppointmentStatus, + PublicAppointment, + formatScheduleResourceName, +} from "@/types/scheduling/schedule"; +import { renderTokenNumber } from "@/types/tokens/token/token"; + +export const VISIT_STATUS_TONES: Record = { + proposed: "neutral", + pending: "neutral", + booked: "primary", + arrived: "primary", + checked_in: "primary", + waitlist: "neutral", + in_consultation: "primary", + fulfilled: "success", + noshow: "neutral", + cancelled: "neutral", + entered_in_error: "neutral", + rescheduled: "neutral", +}; + +/** + * One visit — highlighted with slot time and a relative-time eyebrow for + * `upcoming`, or a flatter row with a status pill for `past`. Reschedule and + * cancel are deliberately absent from either: both actions want the full + * appointment in front of you, which lives on the visit screen this card opens. + */ +export function VisitCard({ + appointment, + variant, + eyebrow, +}: { + appointment: PublicAppointment; + variant: "upcoming" | "past"; + eyebrow?: ReactNode; +}) { + const { t } = useTranslation(); + const start = dayjs(appointment.token_slot.start_datetime); + const isUpcoming = variant === "upcoming"; + const isMissed = + appointment.status === AppointmentStatus.NO_SHOW || + appointment.status === AppointmentStatus.CANCELLED; + + return ( + + {isUpcoming && ( +
+ + {eyebrow ?? start.fromNow()} + + {appointment.token && ( + + {t("token")} {renderTokenNumber(appointment.token)} + + )} +
+ )} + +
+ {isUpcoming ? ( +
+
+ {start.format("ddd")} +
+
+ {start.format("DD")} +
+
+ {start.format("MMM")} +
+
+ ) : ( +
+
+ {start.format("MMM")} +
+
+ {start.format("DD")} +
+
+ )} + +
+
+ + {formatScheduleResourceName(appointment)} + + {!isUpcoming && + appointment.status !== AppointmentStatus.FULFILLED && ( + + {t(appointment.status)} + + )} +
+ + {isUpcoming ? ( + <> + + {appointment.token_slot.availability.name} ·{" "} + {formatSlotTimeRange(appointment.token_slot)} + + + {appointment.facility.name} + + + ) : ( + <> + + {appointment.facility.name} + + + {[ + start.format("h:mm A"), + appointment.token && + `${t("token")} ${renderTokenNumber(appointment.token)}`, + ] + .filter(Boolean) + .join(" · ")} + + + )} +
+ + +
+ + ); +} diff --git a/src/components/Resource/CcnConsole.tsx b/src/components/Resource/CcnConsole.tsx new file mode 100644 index 00000000000..174e64eabfa --- /dev/null +++ b/src/components/Resource/CcnConsole.tsx @@ -0,0 +1,404 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, navigate, useQueryParams } from "raviger"; +import { ComponentProps, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import Loading from "@/components/Common/Loading"; +import PageTitle from "@/components/Common/PageTitle"; +import AppointmentWizard from "@/components/Resource/beckn/AppointmentWizard"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import query from "@/Utils/request/query"; +import { relativeTime } from "@/Utils/utils"; +import { becknPatientFrom } from "@/types/beckn/becknModels"; +import { + getResourceRequestCategoryEnum, + ResourceRequestListRead, + ResourceRequestRead, + ResourceRequestStatus, +} from "@/types/resourceRequest/resourceRequest"; +import resourceRequestApi from "@/types/resourceRequest/resourceRequestApi"; + +interface CcnConsoleProps { + facilityId: string; + resourceId?: string; +} + +/** Which side of the referral this facility is on. */ +type CcnTab = "incoming" | "outgoing"; + +type BadgeVariant = ComponentProps["variant"]; + +const STATUS_BADGE_VARIANTS: Record = { + [ResourceRequestStatus.PENDING]: "yellow", + [ResourceRequestStatus.APPROVED]: "blue", + [ResourceRequestStatus.REJECTED]: "destructive", + [ResourceRequestStatus.CANCELLED]: "destructive", + [ResourceRequestStatus.TRANSPORTATION_TO_BE_ARRANGED]: "orange", + [ResourceRequestStatus.TRANSFER_IN_PROGRESS]: "sky", + [ResourceRequestStatus.COMPLETED]: "green", +}; + +/** + * A request is Incoming when its origin facility is also its assigned facility. + * Everything else in the result set is Outgoing. + */ +function isIncomingRequest(request: ResourceRequestListRead): boolean { + return ( + !!request.origin_facility?.id && + request.origin_facility.id === request.assigned_facility?.id + ); +} + +/** + * Care-Coordinator console: ResourceRequests on the left, and on selection the + * request detail on the right. + * + * A single fetch (`origin_facility=self`) returns every request raised by this + * facility; the two tabs are then split client-side, avoiding a second query + * for a filter the coordinator can derive itself. **Incoming** is the working + * tab — requests whose origin facility is also the assigned facility, i.e. + * raised and fulfilled here; the origin facility places and confirms the + * referral itself (it is the BAP consumer), so requests reach this console + * already `approved` and the desk can run the Beckn appointment-booking flow. + * **Outgoing** is every other request (routed elsewhere) and is read-only, + * since booking is the receiving desk's job. The FE never speaks Beckn + * directly — it calls Care BE's BAP REST endpoints (see AppointmentWizard / + * the hook). + */ +export default function CcnConsole({ + facilityId, + resourceId, +}: CcnConsoleProps) { + const { t } = useTranslation(); + // Kept in the URL so the tab survives selecting a request (which navigates) + // and stays shareable. Anything unrecognised falls back to incoming. + const [{ tab }] = useQueryParams<{ tab?: string }>(); + const activeTab: CcnTab = tab === "outgoing" ? "outgoing" : "incoming"; + const isOutgoing = activeTab === "outgoing"; + + // One fetch for both tabs — everything raised by this facility. + const { data: list, isLoading: listLoading } = useQuery({ + queryKey: ["ccn-resource-list", facilityId], + queryFn: query(resourceRequestApi.list, { + queryParams: { limit: 100, origin_facility: facilityId }, + }), + }); + + // Incoming = origin facility is the assigned facility; Outgoing = the rest. + const results = list?.results ?? []; + const incoming = results.filter(isIncomingRequest); + const outgoing = results.filter((item) => !isIncomingRequest(item)); + const requests = isOutgoing ? outgoing : incoming; + + // A request only exists in one of the two lists, so switching tabs drops the + // current selection rather than leaving the detail pane out of step. + const selectTab = (next: CcnTab) => + navigate( + `/facility/${facilityId}/ccn${next === "outgoing" ? "?tab=outgoing" : ""}`, + ); + + return ( +
+ +
+ + +
+ {resourceId ? ( + // Keyed so per-request state (the booked banner, wizard progress) + // can never leak across selections via a cached detail query. + + ) : ( +
+ + } + title={t("ccn_select_request_title")} + description={t("ccn_select_request_to_begin")} + /> +
+ )} +
+
+
+ ); +} + +function RequestListItem({ + item, + facilityId, + isOutgoing, + active, +}: { + item: ResourceRequestListRead; + facilityId: string; + isOutgoing: boolean; + active: boolean; +}) { + const { t } = useTranslation(); + const readyToBook = + !isOutgoing && item.status === ResourceRequestStatus.APPROVED; + + return ( +
  • + +
    + + {item.title || t("ccn_untitled_request")} + + + {t(`resource_request_status__${item.status}`)} + +
    +
    + {item.origin_facility?.name ?? ""} + {relativeTime(item.modified_date)} +
    + {readyToBook ? ( +

    + + {t("ccn_ready_to_book")} +

    + ) : null} + +
  • + ); +} + +function ResourceDetail({ + facilityId, + resourceId, + readOnly, +}: { + facilityId: string; + resourceId: string; + readOnly: boolean; +}) { + const { t } = useTranslation(); + const { data: resource, isLoading } = useQuery({ + queryKey: ["ccn-resource", resourceId], + queryFn: query(resourceRequestApi.get, { + pathParams: { resourceRequestId: resourceId }, + }), + }); + + if (isLoading || !resource) { + return ; + } + + const isApproved = resource.status === ResourceRequestStatus.APPROVED; + + return ( +
    + + + + {resource.title || t("ccn_untitled_request")} + + {t(`resource_request_status__${resource.status}`)} + + + + + + + + {resource.reason ? ( + + ) : null} + + + + {readOnly ? ( +

    {t("ccn_outgoing_read_only")}

    + ) : isApproved ? ( + + ) : ( +

    + {t("ccn_booking_available_once_approved")} +

    + )} +
    + ); +} + +/** + * Entry point to the appointment flow for an approved request: a CTA card that + * opens the full-screen wizard, and — once the wizard confirms — a booked + * banner in its place. + */ +function BookingLauncher({ + facilityId, + resource, +}: { + facilityId: string; + resource: ResourceRequestRead; +}) { + const { t } = useTranslation(); + const [wizardOpen, setWizardOpen] = useState(false); + const [booked, setBooked] = useState(false); + + return ( + <> + {booked ? ( +
    + +

    {t("ccn_booked_banner")}

    +
    + ) : ( + + +
    +

    {t("book_appointment")}

    +

    + {t("ccn_booking_cta_description")} +

    +
    + +
    +
    + )} + + setBooked(true)} + /> + + ); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
    + {label} + {value} +
    + ); +} diff --git a/src/components/Resource/ResourceForm.tsx b/src/components/Resource/ResourceForm.tsx index 9054c2472c3..822d8674504 100644 --- a/src/components/Resource/ResourceForm.tsx +++ b/src/components/Resource/ResourceForm.tsx @@ -7,11 +7,11 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import * as z from "zod"; -import CareIcon from "@/CAREUI/icons/CareIcon"; +import CareIcon, { IconName } from "@/CAREUI/icons/CareIcon"; -import RadioInput from "@/components/ui/RadioInput"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import Autocomplete from "@/components/ui/autocomplete"; +import BackButton from "@/components/Common/BackButton"; +import Loading from "@/components/Common/Loading"; +import PageTitle from "@/components/Common/PageTitle"; import { Button } from "@/components/ui/button"; import { Form, @@ -23,7 +23,6 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { PhoneInput } from "@/components/ui/phone-input"; import { Select, SelectContent, @@ -32,21 +31,17 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { Textarea } from "@/components/ui/textarea"; - -import Loading from "@/components/Common/Loading"; -import PageTitle from "@/components/Common/PageTitle"; -import UserSelector from "@/components/Common/UserSelector"; - -import useAuthUser from "@/hooks/useAuthUser"; import mutate from "@/Utils/request/mutate"; import query from "@/Utils/request/query"; -import { formatName, mergeAutocompleteOptions, valuesOf } from "@/Utils/utils"; -import validators from "@/Utils/validators"; -import BackButton from "@/components/Common/BackButton"; +import { valuesOf } from "@/Utils/utils"; +import BecknFlow from "@/components/Resource/beckn/BecknFlow"; +import { cn } from "@/lib/utils"; +import { + becknPatientFrom, + healthServiceTypeForCategory, +} from "@/types/beckn/becknModels"; import patientApi from "@/types/emr/patient/patientApi"; -import publicFacilityApi from "@/types/facility/publicFacilityApi"; import { getResourceRequestCategoryEnum, RESOURCE_REQUEST_STATUS_OPTIONS, @@ -55,41 +50,39 @@ import { ResourceRequestStatus, } from "@/types/resourceRequest/resourceRequest"; import resourceRequestApi from "@/types/resourceRequest/resourceRequestApi"; -import { UserReadMinimal } from "@/types/user/user"; interface ResourceProps { - facilityId: number; + facilityId: string; id?: string; } +// Consultation discover searches the network for Care Coordination Desks; the +// user then picks which desk/facility to assign the referral to. +const COORDINATION_DESK_SEARCH = "Care Coordination Desk"; + +const CATEGORY_ICONS: Record = { + [ResourceRequestCategory.PATIENT_CARE]: "l-heart-medical", + [ResourceRequestCategory.COMFORT_DEVICES]: "l-bed", + [ResourceRequestCategory.MEDICINES]: "l-capsule", + [ResourceRequestCategory.FINANCIAL]: "l-money-bill", + [ResourceRequestCategory.OTHER]: "l-microscope", +}; + +/** + * Minimal resource-request form for the Care Coordination Network flow. + * + * Exposes only the linked patient (read-only, taken from the + * `related_patient` query param), status, category and title. All other fields the backend accepts as optional are + * sent with safe defaults so the create/update flow continues unchanged. + */ export default function ResourceForm({ facilityId, id }: ResourceProps) { - const [facilitySearch, setFacilitySearch] = useState(""); const { t } = useTranslation(); const [{ related_patient }] = useQueryParams(); - const [assignedToUser, setAssignedToUser] = useState(); - const authUser = useAuthUser(); const resourceFormSchema = z.object({ status: z.enum(ResourceRequestStatus), category: z.enum(ResourceRequestCategory), - assigned_facility: z - .object({ - id: z.string(), - name: z.string(), - }) - .nullable(), - emergency: z.enum(["true", "false"]), title: z.string().min(1, { message: t("field_required") }), - reason: z - .string() - .trim() - .min(1, { message: t("field_required") }), - referring_facility_contact_name: z - .string() - .min(1, { message: t("field_required") }), - referring_facility_contact_number: validators().phoneNumber.required, - priority: z.number().default(1), - assigned_to: z.string().optional(), }); type ResourceFormValues = z.infer; @@ -114,13 +107,7 @@ export default function ResourceForm({ facilityId, id }: ResourceProps) { resolver: zodResolver(resourceFormSchema), defaultValues: { status: ResourceRequestStatus.PENDING, - assigned_to: "", - emergency: "false" as const, title: "", - reason: "", - referring_facility_contact_name: "", - referring_facility_contact_number: "", - priority: 1, }, }); @@ -129,34 +116,21 @@ export default function ResourceForm({ facilityId, id }: ResourceProps) { form.reset({ status: resourceData.status, category: getResourceRequestCategoryEnum(resourceData.category), - assigned_facility: resourceData.assigned_facility, - assigned_to: resourceData.assigned_to?.id, - emergency: resourceData.emergency ? "true" : "false", title: resourceData.title, - reason: resourceData.reason, - referring_facility_contact_name: - resourceData.referring_facility_contact_name, - referring_facility_contact_number: - resourceData.referring_facility_contact_number, - priority: resourceData.priority, }); - if (resourceData.assigned_to) { - setAssignedToUser(resourceData.assigned_to); - } else { - setAssignedToUser(undefined); - } } }, [resourceData, form]); - const { mutate: createResource, isPending } = useMutation({ - mutationFn: mutate(resourceRequestApi.create), - onSuccess: (data: ResourceRequestRead) => { - toast.success(t("resource_created_successfully")); - navigate(`/facility/${facilityId}/resource/${data.id}`, { - replace: true, - }); - }, - }); + // Consultation referrals are created by the backend during the Beckn flow (not + // by a direct POST) to avoid duplicating the ResourceRequest: `init` mints the + // referral and creates the request, and the origin facility then fires + // `confirm` to finalise it. Submitting a NEW request starts that flow; + // `flowIntent` holds the discover parameters. + const [flowIntent, setFlowIntent] = useState<{ + textSearch?: string; + healthServiceType?: string; + title?: string; + } | null>(null); const { mutate: updateResource, isPending: isUpdatePending } = useMutation({ mutationFn: mutate(resourceRequestApi.update, { @@ -170,387 +144,228 @@ export default function ResourceForm({ facilityId, id }: ResourceProps) { }, }); + // Clinical Care and Social Support (`patient_care`) is fulfilled by the + // facility itself, so it posts the request directly — no network referral. + const { mutate: createResource, isPending: isCreatePending } = useMutation({ + mutationFn: mutate(resourceRequestApi.create), + onSuccess: (data: ResourceRequestRead) => { + toast.success(t("resource_created_successfully")); + navigate( + related_patient + ? `/facility/${facilityId}/patient/${related_patient}/resource_requests` + : `/facility/${facilityId}/resource/${data.id}`, + { replace: true }, + ); + }, + }); + + const isPending = isUpdatePending || isCreatePending; + + // New network-referral requests continue into the coordination-desk flow + // after submit, so the button says Continue; direct creates and updates + // finish in one step. + const watchedCategory = form.watch("category"); + const submitLabel = + !id && + watchedCategory && + watchedCategory !== ResourceRequestCategory.PATIENT_CARE + ? t("continue") + : t("submit"); + const onSubmit = (data: ResourceFormValues) => { const resourcePayload = { status: data.status, category: data.category, + title: data.title, origin_facility: String(facilityId), - assigned_facility: data.assigned_facility?.id || null, - assigned_to: assignedToUser?.id || null, + assigned_facility: null, + assigned_to: null, approving_facility: null, - emergency: data.emergency === "true", - title: data.title, - reason: data.reason, - referring_facility_contact_name: data.referring_facility_contact_name, - referring_facility_contact_number: data.referring_facility_contact_number, - related_patient: related_patient, - priority: data.priority, + emergency: false, + reason: "", + referring_facility_contact_name: "", + referring_facility_contact_number: "", + related_patient: related_patient ?? null, + priority: 1, }; if (id) { updateResource({ ...resourcePayload }); - } else { + } else if (data.category === ResourceRequestCategory.PATIENT_CARE) { createResource(resourcePayload); - } - }; - const { data: facilities } = useQuery({ - queryKey: ["facilities", facilitySearch], - queryFn: query.debounced(publicFacilityApi.getAll, { - queryParams: { - search_text: facilitySearch ? facilitySearch : undefined, - limit: 50, - }, - }), - }); - - const facilityOptions = facilities?.results.map((facility) => ({ - label: facility.name, - value: facility.id, - })); - - const handleUserChange = (user: UserReadMinimal) => { - form.setValue("assigned_to", user.id, { shouldDirty: true }); - setAssignedToUser(user); - }; - - const fillMyDetails = () => { - form.setValue("referring_facility_contact_name", formatName(authUser), { - shouldDirty: true, - shouldValidate: true, - }); - if (authUser.phone_number) { - form.setValue( - "referring_facility_contact_number", - authUser.phone_number, - { - shouldDirty: true, - shouldValidate: true, - }, - ); + } else { + setFlowIntent({ + textSearch: COORDINATION_DESK_SEARCH, + healthServiceType: healthServiceTypeForCategory(data.category), + title: data.title, + }); } }; - if (isPending || isUpdatePending) { + if (isPending) { return ; } + if (flowIntent && !id) { + return ( +
    + + + {patientData?.name ? : null} + { + toast.success(t("resource_created_successfully")); + navigate( + `/facility/${facilityId}/patient/${related_patient}/resource_requests`, + ); + }} + /> +
    + +
    +
    + ); + } + return ( -
    +
    - {patientData && ( - -
    - - - - - {t("linked_patient")}:{" "} - - {patientData.name} - - - - -
    -
    - )} - -
    -
    -

    {t("basic_information")}

    -

    - {t("resource_request_basic_info_description")} -

    -
    - -
    - ( - - - {t("facility_for_care_support")} - - - + ) : null} + + ( + + {t("category")} +
    + {valuesOf(ResourceRequestCategory).map((category) => { + const selected = field.value === category; + return ( +
    -
    - - -
    -
    -

    {t("request_details")}

    -

    - {t("resource_request_details_description")} -

    -
    - - ( - - {t("request_title")} - - field.onChange(value)} + + {t(`resource_request_category__${category}`)} + + {selected ? ( + + ) : null} + + ); + })} +
    + {!id && field.value ? ( + + - - - {t("request_title_description")} + {field.value === ResourceRequestCategory.PATIENT_CARE + ? t("resource_category_direct_hint") + : t("resource_category_network_hint")} - - - )} - /> - - ( - - {t("request_reason")} + ) : null} + + + )} + /> + + ( + + {t("request_title")} + + field.onChange(value)} + /> + + + + )} + /> + + ( + + {t("status")} +