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 (
-
-
-
-
-
+
+ );
+}
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.
+
+ ) : (
+